diff --git a/.gitignore b/.gitignore index 5823721c3..84631ee8a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ mkdocs-site-manifest.csv !test/admissible-report-wallet.json !test/admissible-report.json -!test/config.json \ No newline at end of file +!test/config.json + +/*.md \ No newline at end of file diff --git a/erlang_ls.config b/erlang_ls.config index 097464093..c4b00cba2 100644 --- a/erlang_ls.config +++ b/erlang_ls.config @@ -2,15 +2,13 @@ diagnostics: enabled: - crossref - dialyzer - - eunit apps_dirs: - "src" - "src/*" -include_dirs: - - "src/include" include_dirs: - "src" - "src/include" + - "_build/default/lib" lenses: enabled: - ct-run-test diff --git a/rebar.config b/rebar.config index 30723757a..28af013b9 100644 --- a/rebar.config +++ b/rebar.config @@ -123,7 +123,8 @@ {prometheus, "4.11.0"}, {prometheus_cowboy, "0.1.8"}, {gun, "2.2.0"}, - {luerl, "1.3.0"} + {luerl, "1.3.0"}, + {ssl_cert, "1.0.1"} ]}. {shell, [ @@ -138,7 +139,7 @@ {eunit_opts, [verbose, {scale_timeouts, 10}]}. {relx, [ - {release, {'hb', "0.0.1"}, [hb, b64fast, cowboy, gun, luerl, prometheus, prometheus_cowboy, elmdb]}, + {release, {'hb', "0.0.1"}, [hb, b64fast, cowboy, gun, luerl, prometheus, prometheus_cowboy, elmdb, ssl_cert]}, {include_erts, true}, {extended_start_script, true}, {overlay, [ @@ -149,7 +150,7 @@ ]}. {dialyzer, [ - {plt_extra_apps, [public_key, ranch, cowboy, prometheus, prometheus_cowboy, b64fast, eunit, gun]}, + {plt_extra_apps, [public_key, ranch, cowboy, prometheus, prometheus_cowboy, b64fast, eunit, gun, ssl_cert]}, incremental, {warnings, [no_improper_lists, no_unused]} ]}. diff --git a/rebar.lock b/rebar.lock index 1000c4f50..d3d5702e1 100644 --- a/rebar.lock +++ b/rebar.lock @@ -14,10 +14,13 @@ 1}, {<<"elmdb">>, {git,"https://github.com/twilson63/elmdb-rs.git", - {ref,"90c8857cd4ccff341fbe415b96bc5703d17ff7f0"}}, + {ref,"5ac27143b44f4f19175fc0179b33c707300f1d44"}}, 0}, {<<"graphql">>,{pkg,<<"graphql_erl">>,<<"0.17.1">>},0}, - {<<"gun">>,{pkg,<<"gun">>,<<"2.2.0">>},0}, + {<<"gun">>, + {git,"https://github.com/ninenines/gun", + {ref,"627b8f9ed65da255afaddd166b1b9d102e0fa512"}}, + 0}, {<<"luerl">>,{pkg,<<"luerl">>,<<"1.3.0">>},0}, {<<"prometheus">>,{pkg,<<"prometheus">>,<<"4.11.0">>},0}, {<<"prometheus_cowboy">>,{pkg,<<"prometheus_cowboy">>,<<"0.1.8">>},0}, @@ -26,24 +29,25 @@ {<<"ranch">>, {git,"https://github.com/ninenines/ranch", {ref,"10b51304b26062e0dbfd5e74824324e9a911e269"}}, - 1}]}. + 1}, + {<<"ssl_cert">>,{pkg,<<"ssl_cert">>,<<"1.0.1">>},0}]}. [ {pkg_hash,[ {<<"accept">>, <<"CD6E34A2D7E28CA38B2D3CB233734CA0C221EFBC1F171F91FEC5F162CC2D18DA">>}, {<<"graphql">>, <<"EB59FCBB39F667DC1C78C950426278015C3423F7A6ED2A121D3DB8B1D2C5F8B4">>}, - {<<"gun">>, <<"B8F6B7D417E277D4C2B0DC3C07DFDF892447B087F1CC1CAFF9C0F556B884E33D">>}, {<<"luerl">>, <<"B56423DDB721432AB980B818FEECB84ADBAB115E2E11522CF94BCD0729CAA501">>}, {<<"prometheus">>, <<"B95F8DE8530F541BD95951E18E355A840003672E5EDA4788C5FA6183406BA29A">>}, {<<"prometheus_cowboy">>, <<"CFCE0BC7B668C5096639084FCD873826E6220EA714BF60A716F5BD080EF2A99C">>}, {<<"prometheus_httpd">>, <<"8F767D819A5D36275EAB9264AFF40D87279151646776069BF69FBDBBD562BD75">>}, - {<<"quantile_estimator">>, <<"EF50A361F11B5F26B5F16D0696E46A9E4661756492C981F7B2229EF42FF1CD15">>}]}, + {<<"quantile_estimator">>, <<"EF50A361F11B5F26B5F16D0696E46A9E4661756492C981F7B2229EF42FF1CD15">>}, + {<<"ssl_cert">>, <<"5E4133E7D524141836C045838C98E69964E188707DF12032CE5DA902BB40C9A3">>}]}, {pkg_hash_ext,[ {<<"accept">>, <<"CA69388943F5DAD2E7232A5478F16086E3C872F48E32B88B378E1885A59F5649">>}, {<<"graphql">>, <<"4D0F08EC57EF0983E2596763900872B1AB7E94F8EE3817B9F67EEC911FF7C386">>}, - {<<"gun">>, <<"76022700C64287FEB4DF93A1795CFF6741B83FB37415C40C34C38D2A4645261A">>}, {<<"luerl">>, <<"6B3138AA829F0FBC4CD0F083F273B4030A2B6CE99155194A6DB8C67B2C3480A4">>}, {<<"prometheus">>, <<"719862351AABF4DF7079B05DC085D2BBCBE3AC0AC3009E956671B1D5AB88247D">>}, {<<"prometheus_cowboy">>, <<"BA286BECA9302618418892D37BCD5DC669A6CC001F4EB6D6AF85FF81F3F4F34C">>}, {<<"prometheus_httpd">>, <<"67736D000745184D5013C58A63E947821AB90CB9320BC2E6AE5D3061C6FFE039">>}, - {<<"quantile_estimator">>, <<"282A8A323CA2A845C9E6F787D166348F776C1D4A41EDE63046D72D422E3DA946">>}]} + {<<"quantile_estimator">>, <<"282A8A323CA2A845C9E6F787D166348F776C1D4A41EDE63046D72D422E3DA946">>}, + {<<"ssl_cert">>, <<"2E37259313514B854EE0BC5B0696250883568CD1A5FC9EC338D78E27C521E65D">>}]} ]. diff --git a/src/dev_green_zone.erl b/src/dev_green_zone.erl index 78ce51ee8..4ab6ab154 100644 --- a/src/dev_green_zone.erl +++ b/src/dev_green_zone.erl @@ -5,11 +5,83 @@ %%% and node identity cloning. All operations are protected by hardware %%% commitment and encryption. -module(dev_green_zone). + +%% Device API exports -export([info/1, info/3, join/3, init/3, become/3, key/3, is_trusted/3]). +%% Encryption helper functions +-export([encrypt_data/2, decrypt_data/3]). + -include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). -include_lib("public_key/include/public_key.hrl"). +%%% =================================================================== +%%% Type Specifications +%%% =================================================================== + +%% Device API function specs +-spec info(term()) -> #{exports := [atom()]}. +-spec info(term(), term(), map()) -> {ok, map()}. +-spec init(term(), term(), map()) -> {ok, binary()} | {error, binary()}. +-spec join(term(), term(), map()) -> {ok, map()} | {error, map() | binary()}. +-spec key(term(), term(), map()) -> {ok, map()} | {error, binary()}. +-spec become(term(), term(), map()) -> {ok, map()} | {error, binary()}. + +%% Helpers for init/3 +-spec setup_green_zone_config(map()) -> {ok, map()}. +-spec ensure_wallet(map()) -> term(). +-spec ensure_aes_key(map()) -> binary(). + +%% Helpers for join/3 +-spec extract_peer_info(map()) -> + {binary() | undefined, binary() | undefined, boolean()}. +-spec should_join_peer( + binary() | undefined, binary() | undefined, boolean() +) -> boolean(). + +%% Helpers for join_peer/5 +-spec join_peer(binary(), binary(), term(), term(), map()) -> + {ok, map()} | {error, map() | binary()}. +-spec prepare_join_request(map()) -> {ok, map()} | {error, term()}. +-spec verify_peer_response(map(), binary(), map()) -> boolean(). +-spec extract_and_decrypt_zone_key(map(), map()) -> + {ok, binary()} | {error, term()}. +-spec finalize_join_success(binary(), map()) -> {ok, map()}. + +%% Helpers for validate_join/3 +-spec validate_join(term(), map(), map()) -> {ok, map()} | {error, binary()}. +-spec extract_join_request_data(map(), map()) -> + {ok, {binary(), term()}} | {error, term()}. +-spec process_successful_join(binary(), term(), map(), map()) -> {ok, map()}. +-spec validate_peer_opts(map(), map()) -> boolean(). +-spec add_trusted_node(binary(), map(), term(), map()) -> ok. + +%% Helpers for key/3 +-spec get_appropriate_wallet(map()) -> term(). +-spec build_key_response(binary(), binary()) -> {ok, map()}. + +%% Helpers for become/3 +-spec validate_become_params(map()) -> + {ok, {binary(), binary()}} | {error, atom()}. +-spec request_and_verify_peer_key(binary(), binary(), map()) -> + {ok, map()} | {error, atom()}. +-spec finalize_become(map(), binary(), binary(), map()) -> {ok, map()}. +-spec update_node_identity(term(), map()) -> ok. + +%% General/Shared helpers +-spec default_zone_required_opts(map()) -> map(). +-spec replace_self_values(map(), map()) -> map(). +-spec is_trusted(term(), map(), map()) -> {ok, binary()}. +-spec encrypt_payload(binary(), term()) -> binary(). +-spec decrypt_zone_key(binary(), map()) -> {ok, binary()} | {error, binary()}. +-spec try_mount_encrypted_volume(term(), map()) -> ok. + +%% Encryption helper specs +-spec encrypt_data(term(), map()) -> + {ok, {binary(), binary()}} | {error, term()}. +-spec decrypt_data(binary(), binary(), map()) -> + {ok, binary()} | {error, term()}. + %% @doc Controls which functions are exposed via the device API. %% %% This function defines the security boundary for the green zone device by @@ -18,16 +90,14 @@ %% @param _ Ignored parameter %% @returns A map with the `exports' key containing a list of allowed functions info(_) -> - #{ - exports => - [ - <<"info">>, - <<"init">>, - <<"join">>, - <<"become">>, - <<"key">>, - <<"is_trusted">> - ] + #{ + exports => [ + <<"info">>, + <<"init">>, + <<"join">>, + <<"become">>, + <<"key">> + ] }. %% @doc Provides information about the green zone device and its API. @@ -44,7 +114,10 @@ info(_) -> info(_Msg1, _Msg2, _Opts) -> InfoBody = #{ <<"description">> => - <<"Green Zone secure communication and identity management for trusted nodes">>, + << + "Green Zone secure communication", + "and identity management for trusted nodes" + >>, <<"version">> => <<"1.0">>, <<"api">> => #{ <<"info">> => #{ @@ -53,109 +126,57 @@ info(_Msg1, _Msg2, _Opts) -> <<"init">> => #{ <<"description">> => <<"Initialize the green zone">>, <<"details">> => - <<"Sets up the node's cryptographic identity with wallet and AES key">> + << + "Sets up the node's cryptographic", + "identity with wallet and AES key" + >> }, <<"join">> => #{ <<"description">> => <<"Join an existing green zone">>, <<"required_node_opts">> => #{ - <<"green_zone_peer_location">> => <<"Target peer's address">>, - <<"green_zone_peer_id">> => <<"Target peer's unique identifier">> + <<"green_zone_peer_location">> => + <<"Target peer's address">>, + <<"green_zone_peer_id">> => + <<"Target peer's unique identifier">> } }, <<"key">> => #{ - <<"description">> => <<"Retrieve and encrypt the node's private key">>, + <<"description">> => + <<"Retrieve and encrypt the node's private key">>, <<"details">> => - <<"Returns the node's private key encrypted with the shared AES key">> + << + "Returns the node's private key encrypted", + "with the shared AES key" + >> }, <<"become">> => #{ <<"description">> => <<"Clone the identity of a target node">>, <<"required_node_opts">> => #{ - <<"green_zone_peer_location">> => <<"Target peer's address">>, - <<"green_zone_peer_id">> => <<"Target peer's unique identifier">> + <<"green_zone_peer_location">> => + <<"Target peer's address">>, + <<"green_zone_peer_id">> => + <<"Target peer's unique identifier">> } } } }, {ok, #{<<"status">> => 200, <<"body">> => InfoBody}}. -%% @doc Provides the default required options for a green zone. -%% -%% This function defines the baseline security requirements for nodes in a green zone: -%% 1. Restricts loading of remote devices and only allows trusted signers -%% 2. Limits to preloaded devices from the initiating machine -%% 3. Enforces specific store configuration -%% 4. Prevents route changes from the defaults -%% 5. Requires matching hooks across all peers -%% 6. Disables message scheduling to prevent conflicts -%% 7. Enforces a permanent state to prevent further configuration changes -%% -%% @param Opts A map of configuration options from which to derive defaults -%% @returns A map of required configuration options for the green zone --spec default_zone_required_opts(Opts :: map()) -> map(). -default_zone_required_opts(Opts) -> - #{ - % trusted_device_signers => hb_opts:get(trusted_device_signers, [], Opts), - % load_remote_devices => hb_opts:get(load_remote_devices, false, Opts), - % preload_devices => hb_opts:get(preload_devices, [], Opts), - % % store => hb_opts:get(store, [], Opts), - % routes => hb_opts:get(routes, [], Opts), - % on => hb_opts:get(on, undefined, Opts), - % scheduling_mode => disabled, - % initialized => permanent - }. - -%% @doc Replace values of <<"self">> in a configuration map with corresponding values from Opts. -%% -%% This function iterates through all key-value pairs in the configuration map. -%% If a value is <<"self">>, it replaces that value with the result of -%% hb_opts:get(Key, not_found, Opts) where Key is the corresponding key. -%% -%% @param Config The configuration map to process -%% @param Opts The options map to fetch replacement values from -%% @returns A new map with <<"self">> values replaced --spec replace_self_values(Config :: map(), Opts :: map()) -> map(). -replace_self_values(Config, Opts) -> - maps:map( - fun(Key, Value) -> - case Value of - <<"self">> -> - hb_opts:get(Key, not_found, Opts); - _ -> - Value - end - end, - Config - ). - -%% @doc Returns `true' if the request is signed by a trusted node. -is_trusted(_M1, Req, Opts) -> - Signers = hb_message:signers(Req, Opts), - {ok, - hb_util:bin( - lists:any( - fun(Signer) -> - lists:member( - Signer, - maps:keys(hb_opts:get(trusted_nodes, #{}, Opts)) - ) - end, - Signers - ) - ) - }. %% @doc Initialize the green zone for a node. %% %% This function performs the following operations: -%% 1. Validates the node's history to ensure this is a valid initialization -%% 2. Retrieves or creates a required configuration for the green zone +%% 1. Checks if the green zone is already initialized +%% 2. Sets up and processes the required configuration for the green zone %% 3. Ensures a wallet (keypair) exists or creates a new one %% 4. Generates a new 256-bit AES key for secure communication %% 5. Updates the node's configuration with these cryptographic identities +%% 6. Attempts to mount an encrypted volume using the AES key %% %% Config options in Opts map: %% - green_zone_required_config: (Optional) Custom configuration requirements -%% - priv_wallet: (Optional) Existing wallet to use instead of creating a new one +%% - priv_wallet: (Optional) Existing wallet to use instead of creating +%% a new one %% - priv_green_zone_aes: (Optional) Existing AES key, if already part of a zone %% %% @param _M1 Ignored parameter @@ -163,66 +184,46 @@ is_trusted(_M1, Req, Opts) -> %% @param Opts A map of configuration options %% @returns `{ok, Binary}' on success with confirmation message, or %% `{error, Binary}' on failure with error message. --spec init(M1 :: term(), M2 :: term(), Opts :: map()) -> {ok, binary()} | {error, binary()}. init(_M1, _M2, Opts) -> ?event(green_zone, {init, start}), - case hb_opts:get(green_zone_initialized, false, Opts) of + maybe + % Check if already initialized + false ?= hb_opts:get(green_zone_initialized, false, Opts), + % Setup configuration + {ok, ProcessedRequiredConfig} ?= setup_green_zone_config(Opts), + % Ensure wallet and AES key exist + NodeWallet = ensure_wallet(Opts), + GreenZoneAES = ensure_aes_key(Opts), + % Store configuration and finalize setup + NewOpts = Opts#{ + priv_wallet => NodeWallet, + priv_green_zone_aes => GreenZoneAES, + trusted_nodes => #{}, + green_zone_required_opts => ProcessedRequiredConfig, + green_zone_initialized => true + }, + hb_http_server:set_opts(NewOpts), + try_mount_encrypted_volume(GreenZoneAES, NewOpts), + ?event(green_zone, {init, complete}), + {ok, <<"Green zone initialized successfully.">>} + else true -> {error, <<"Green zone already initialized.">>}; - false -> - RequiredConfig = hb_opts:get( - <<"green_zone_required_config">>, - default_zone_required_opts(Opts), - Opts - ), - % Process RequiredConfig to replace <<"self">> values with actual values from Opts - ProcessedRequiredConfig = replace_self_values(RequiredConfig, Opts), - ?event(green_zone, {init, required_config, ProcessedRequiredConfig}), - % Check if a wallet exists; create one if absent. - NodeWallet = case hb_opts:get(priv_wallet, undefined, Opts) of - undefined -> - ?event(green_zone, {init, wallet, missing}), - hb:wallet(); - ExistingWallet -> - ?event(green_zone, {init, wallet, found}), - ExistingWallet - end, - % Generate a new 256-bit AES key if we have not already joined - % a green zone. - GreenZoneAES = - case hb_opts:get(priv_green_zone_aes, undefined, Opts) of - undefined -> - ?event(green_zone, {init, aes_key, generated}), - crypto:strong_rand_bytes(32); - ExistingAES -> - ?event(green_zone, {init, aes_key, found}), - ExistingAES - end, - % Store the wallet, AES key, and an empty trusted nodes map. - hb_http_server:set_opts(NewOpts =Opts#{ - priv_wallet => NodeWallet, - priv_green_zone_aes => GreenZoneAES, - trusted_nodes => #{}, - green_zone_required_opts => ProcessedRequiredConfig, - green_zone_initialized => true - }), - try_mount_encrypted_volume(GreenZoneAES, NewOpts), - ?event(green_zone, {init, complete}), - {ok, <<"Green zone initialized successfully.">>} + Error -> + ?event(green_zone, {init, error, Error}), + {error, <<"Failed to initialize green zone">>} end. %% @doc Initiates the join process for a node to enter an existing green zone. %% -%% This function performs the following operations depending on the state: -%% 1. Validates the node's history to ensure proper initialization -%% 2. Checks for target peer information (location and ID) -%% 3. If target peer is specified: -%% a. Generates a commitment report for the peer -%% b. Prepares and sends a POST request to the target peer -%% c. Verifies the response and decrypts the returned zone key -%% d. Updates local configuration with the shared AES key -%% 4. If no peer is specified, processes the join request locally +%% This function determines the appropriate join strategy and routes to the +%% correct handler: +%% 1. Extracts peer information from configuration options +%% 2. Determines whether to join a specific peer or validate a local request +%% 3. Routes to join_peer/5 if peer details are provided and node has +%% no identity +%% 4. Routes to validate_join/3 for local join request processing %% %% Config options in Opts map: %% - green_zone_peer_location: Target peer's address @@ -235,29 +236,30 @@ init(_M1, _M2, Opts) -> %% @param Opts A map of configuration options for join operations %% @returns `{ok, Map}' on success with join response details, or %% `{error, Binary}' on failure with error message. --spec join(M1 :: term(), M2 :: term(), Opts :: map()) -> - {ok, map()} | {error, binary()}. join(M1, M2, Opts) -> ?event(green_zone, {join, start}), - PeerLocation = hb_opts:get(<<"green_zone_peer_location">>, undefined, Opts), - PeerID = hb_opts:get(<<"green_zone_peer_id">>, undefined, Opts), - Identities = hb_opts:get(identities, #{}, Opts), - HasGreenZoneIdentity = maps:is_key(<<"green-zone">>, Identities), - ?event(green_zone, {join_peer, PeerLocation, PeerID, HasGreenZoneIdentity}), - if (not HasGreenZoneIdentity) andalso (PeerLocation =/= undefined) andalso (PeerID =/= undefined) -> - join_peer(PeerLocation, PeerID, M1, M2, Opts); - true -> - validate_join(M1, M2, hb_cache:ensure_all_loaded(Opts, Opts)) + maybe + % Extract peer information and determine join strategy + {PeerLocation, PeerID, HasGreenZoneIdentity} = extract_peer_info(Opts), + ?event(green_zone, + {join_peer, PeerLocation, PeerID, HasGreenZoneIdentity} + ), + % Route to appropriate join handler based on configuration + case should_join_peer(PeerLocation, PeerID, HasGreenZoneIdentity) of + true -> + join_peer(PeerLocation, PeerID, M1, M2, Opts); + false -> + validate_join(M1, M2, hb_cache:ensure_all_loaded(Opts, Opts)) + end end. %% @doc Encrypts and provides the node's private key for secure sharing. %% %% This function performs the following operations: -%% 1. Retrieves the shared AES key and the node's wallet -%% 2. Verifies that the node is part of a green zone (has a shared AES key) -%% 3. Generates a random initialization vector (IV) for encryption -%% 4. Encrypts the node's private key using AES-256-GCM with the shared key -%% 5. Returns the encrypted key and IV for secure transmission +%% 1. Determines the appropriate wallet to use (green-zone identity or default) +%% 2. Extracts the private key components from the wallet +%% 3. Encrypts the private key using the green zone AES key via helper function +%% 4. Builds and returns a standardized response with encrypted key and IV %% %% Required configuration in Opts map: %% - priv_green_zone_aes: The shared AES key for the green zone @@ -268,56 +270,36 @@ join(M1, M2, Opts) -> %% @param Opts A map of configuration options %% @returns `{ok, Map}' containing the encrypted key and IV on success, or %% `{error, Binary}' if the node is not part of a green zone --spec key(M1 :: term(), M2 :: term(), Opts :: map()) -> - {ok, map()} | {error, binary()}. key(_M1, _M2, Opts) -> ?event(green_zone, {get_key, start}), - % Retrieve the shared AES key and the node's wallet. - GreenZoneAES = hb_opts:get(priv_green_zone_aes, undefined, Opts), - Identities = hb_opts:get(identities, #{}, Opts), - Wallet = case maps:find(<<"green-zone">>, Identities) of - {ok, #{priv_wallet := GreenZoneWallet}} -> GreenZoneWallet; - _ -> hb_opts:get(priv_wallet, undefined, Opts) - end, - {{KeyType, Priv, Pub}, _PubKey} = Wallet, - ?event(green_zone, - {get_key, wallet, hb_util:human_id(ar_wallet:to_address(Pub))}), - case GreenZoneAES of - undefined -> - % Log error if no shared AES key is found. + maybe + % Get appropriate wallet (green-zone identity or default) + Wallet = get_appropriate_wallet(Opts), + {{KeyType, Priv, Pub}, _PubKey} = Wallet, + ?event(green_zone, + {get_key, wallet, hb_util:human_id(ar_wallet:to_address(Pub))}), + % Encrypt the node's private key using the helper function + {ok, {EncryptedData, IV}} ?= encrypt_data({KeyType, Priv, Pub}, Opts), + ?event(green_zone, {get_key, encrypt, complete}), + build_key_response(EncryptedData, IV) + else + {error, no_green_zone_aes_key} -> ?event(green_zone, {get_key, error, <<"no aes key">>}), {error, <<"Node not part of a green zone.">>}; - _ -> - % Generate an IV and encrypt the node's private key using AES-256-GCM. - IV = crypto:strong_rand_bytes(16), - {EncryptedKey, Tag} = crypto:crypto_one_time_aead( - aes_256_gcm, - GreenZoneAES, - IV, - term_to_binary({KeyType, Priv, Pub}), - <<>>, - true - ), - - % Log successful encryption of the private key. - ?event(green_zone, {get_key, encrypt, complete}), - {ok, #{ - <<"status">> => 200, - <<"encrypted_key">> => - base64:encode(<>), - <<"iv">> => base64:encode(IV) - }} + {error, EncryptError} -> + ?event(green_zone, {get_key, encrypt_error, EncryptError}), + {error, <<"Encryption failed">>}; + Error -> + ?event(green_zone, {get_key, unexpected_error, Error}), + {error, <<"Failed to retrieve key">>} end. %% @doc Clones the identity of a target node in the green zone. %% %% This function performs the following operations: -%% 1. Retrieves target node location and ID from the configuration -%% 2. Verifies that the local node has a valid shared AES key -%% 3. Requests the target node's encrypted key via its key endpoint -%% 4. Verifies the response is from the expected peer -%% 5. Decrypts the target node's private key using the shared AES key -%% 6. Updates the local node's wallet with the target node's identity +%% 1. Validates required parameters and green zone membership +%% 2. Requests and verifies the target node's encrypted key +%% 3. Finalizes the identity adoption process through helper functions %% %% Required configuration in Opts map: %% - green_zone_peer_location: Target node's address @@ -330,102 +312,137 @@ key(_M1, _M2, Opts) -> %% @returns `{ok, Map}' on success with confirmation details, or %% `{error, Binary}' if the node is not part of a green zone or %% identity adoption fails. --spec become(M1 :: term(), M2 :: term(), Opts :: map()) -> - {ok, map()} | {error, binary()}. become(_M1, _M2, Opts) -> ?event(green_zone, {become, start}), - % 1. Retrieve the target node's address from the incoming message. - NodeLocation = hb_opts:get(<<"green_zone_peer_location">>, undefined, Opts), - NodeID = hb_opts:get(<<"green_zone_peer_id">>, undefined, Opts), - % 2. Check if the local node has a valid shared AES key. - GreenZoneAES = hb_opts:get(priv_green_zone_aes, undefined, Opts), - case GreenZoneAES of - undefined -> - % Shared AES key not found: node is not part of a green zone. + maybe + % Validate required parameters and green zone membership + {ok, {NodeLocation, NodeID}} ?= validate_become_params(Opts), + % Request and verify peer's encrypted key + {ok, KeyResp} ?= + request_and_verify_peer_key(NodeLocation, NodeID, Opts), + % Finalize identity adoption + finalize_become(KeyResp, NodeLocation, NodeID, Opts) + else + {error, no_green_zone_aes_key} -> ?event(green_zone, {become, error, <<"no aes key">>}), {error, <<"Node not part of a green zone.">>}; - _ -> - % 3. Request the target node's encrypted key from its key endpoint. - ?event(green_zone, {become, getting_key, NodeLocation, NodeID}), - {ok, KeyResp} = hb_http:get(NodeLocation, - <<"/~greenzone@1.0/key">>, Opts), - Signers = hb_message:signers(KeyResp, Opts), - case hb_message:verify(KeyResp, Signers, Opts) and - lists:member(NodeID, Signers) of - false -> - % The response is not from the expected peer. - {error, <<"Received incorrect response from peer!">>}; - true -> - finalize_become(KeyResp, NodeLocation, NodeID, - GreenZoneAES, Opts) - end + {error, missing_peer_location} -> + {error, <<"green_zone_peer_location required">>}; + {error, missing_peer_id} -> + {error, <<"green_zone_peer_id required">>}; + {error, invalid_peer_response} -> + {error, <<"Received incorrect response from peer!">>}; + Error -> + ?event(green_zone, {become, unexpected_error, Error}), + {error, <<"Failed to adopt target node identity">>} end. -finalize_become(KeyResp, NodeLocation, NodeID, GreenZoneAES, Opts) -> - % 4. Decode the response to obtain the encrypted key and IV. - Combined = - base64:decode( - hb_ao:get(<<"encrypted_key">>, KeyResp, Opts)), - IV = base64:decode(hb_ao:get(<<"iv">>, KeyResp, Opts)), - % 5. Separate the ciphertext and the authentication tag. - CipherLen = byte_size(Combined) - 16, - <> = Combined, - % 6. Decrypt the ciphertext using AES-256-GCM with the shared AES - % key and IV. - DecryptedBin = crypto:crypto_one_time_aead( - aes_256_gcm, - GreenZoneAES, - IV, - Ciphertext, - <<>>, - Tag, - false + +%%% =================================================================== +%%% Internal Helper Functions +%%% =================================================================== + +%%% ------------------------------------------------------------------- +%%% Helpers for init/3 +%%% ------------------------------------------------------------------- + +%% @doc Setup and process green zone configuration. +%% +%% This function retrieves the required configuration, processes any +%% "self" placeholder values, and returns the processed configuration. +%% +%% @param Opts Configuration options +%% @returns {ok, ProcessedConfig} with processed configuration +setup_green_zone_config(Opts) -> + RequiredConfig = hb_opts:get( + <<"green_zone_required_config">>, + default_zone_required_opts(Opts), + Opts ), - OldWallet = hb_opts:get(priv_wallet, undefined, Opts), - OldWalletAddr = hb_util:human_id(ar_wallet:to_address(OldWallet)), - ?event(green_zone, {become, old_wallet, OldWalletAddr}), - % Print the decrypted binary - ?event(green_zone, {become, decrypted_bin, DecryptedBin}), - % 7. Convert the decrypted binary into the target node's keypair. - {KeyType, Priv, Pub} = binary_to_term(DecryptedBin), - % Print the keypair - ?event(green_zone, {become, keypair, Pub}), - % 8. Add the target node's keypair to the local node's identities. - GreenZoneWallet = {{KeyType, Priv, Pub}, {KeyType, Pub}}, + ProcessedRequiredConfig = replace_self_values(RequiredConfig, Opts), + ?event(green_zone, {init, required_config, ProcessedRequiredConfig}), + {ok, ProcessedRequiredConfig}. + +%% @doc Ensure a wallet exists, creating one if necessary. +%% +%% This function checks if a wallet already exists in the configuration +%% and creates a new one if needed. +%% +%% @param Opts Configuration options +%% @returns Wallet (existing or newly created) +ensure_wallet(Opts) -> + case hb_opts:get(priv_wallet, undefined, Opts) of + undefined -> + ?event(green_zone, {init, wallet, missing}), + hb:wallet(); + ExistingWallet -> + ?event(green_zone, {init, wallet, found}), + ExistingWallet + end. + +%% @doc Ensure an AES key exists, generating one if necessary. +%% +%% This function checks if a green zone AES key already exists and +%% generates a new 256-bit key if needed. +%% +%% @param Opts Configuration options +%% @returns AES key (existing or newly generated) +ensure_aes_key(Opts) -> + case hb_opts:get(priv_green_zone_aes, undefined, Opts) of + undefined -> + ?event(green_zone, {init, aes_key, generated}), + crypto:strong_rand_bytes(32); + ExistingAES -> + ?event(green_zone, {init, aes_key, found}), + ExistingAES + end. + +%%% ------------------------------------------------------------------- +%%% Helpers for join/3 +%%% ------------------------------------------------------------------- + +%% @doc Extract peer information from configuration options. +%% +%% This function extracts the peer location, peer ID, and checks if the +%% node already has a green zone identity. +%% +%% @param Opts Configuration options +%% @returns {PeerLocation, PeerID, HasGreenZoneIdentity} tuple +extract_peer_info(Opts) -> + PeerLocation = hb_opts:get(<<"green_zone_peer_location">>, undefined, Opts), + PeerID = hb_opts:get(<<"green_zone_peer_id">>, undefined, Opts), Identities = hb_opts:get(identities, #{}, Opts), - UpdatedIdentities = Identities#{ - <<"green-zone">> => #{ - priv_wallet => GreenZoneWallet - } - }, - NewOpts = Opts#{ - identities => UpdatedIdentities - }, - ok = - hb_http_server:set_opts( - NewOpts - ), - try_mount_encrypted_volume(GreenZoneWallet, NewOpts), - ?event(green_zone, {become, update_wallet, complete}), - {ok, #{ - <<"body">> => #{ - <<"message">> => <<"Successfully adopted target node identity">>, - <<"peer-location">> => NodeLocation, - <<"peer-id">> => NodeID - } - }}. + HasGreenZoneIdentity = maps:is_key(<<"green-zone">>, Identities), + {PeerLocation, PeerID, HasGreenZoneIdentity}. + +%% @doc Determine whether to join a specific peer or validate locally. +%% +%% This function implements the decision logic for join strategy: +%% - Join peer if: no existing identity AND peer location AND peer ID provided +%% - Validate locally otherwise +%% +%% @param PeerLocation Target peer location (may be undefined) +%% @param PeerID Target peer ID (may be undefined) +%% @param HasGreenZoneIdentity Whether node already has green zone identity +%% @returns true if should join peer, false if should validate locally +should_join_peer(PeerLocation, PeerID, HasGreenZoneIdentity) -> + (not HasGreenZoneIdentity) andalso + (PeerLocation =/= undefined) andalso + (PeerID =/= undefined). + +%%% ------------------------------------------------------------------- +%%% Helpers for join_peer/5 +%%% ------------------------------------------------------------------- %% @doc Processes a join request to a specific peer node. %% %% This function handles the client-side join flow when connecting to a peer: %% 1. Verifies the node is not already in a green zone -%% 2. Optionally adopts configuration from the target peer -%% 3. Generates a hardware-backed commitment report -%% 4. Sends a POST request to the peer's join endpoint -%% 5. Verifies the response signature -%% 6. Decrypts the returned AES key -%% 7. Updates local configuration with the shared key -%% 8. Optionally mounts an encrypted volume using the shared key +%% 2. Prepares a join request with commitment report and public key +%% 3. Sends the join request to the target peer +%% 4. Verifies the response is from the expected peer +%% 5. Extracts and decrypts the zone key from the response +%% 6. Finalizes the join by updating configuration with the shared key %% %% @param PeerLocation The target peer's address %% @param PeerID The target peer's unique identifier @@ -434,172 +451,222 @@ finalize_become(KeyResp, NodeLocation, NodeID, GreenZoneAES, Opts) -> %% @param InitOpts A map of initial configuration options %% @returns `{ok, Map}' on success with confirmation message, or %% `{error, Map|Binary}' on failure with error details --spec join_peer( - PeerLocation :: binary(), - PeerID :: binary(), - M1 :: term(), - M2 :: term(), - Opts :: map()) -> {ok, map()} | {error, map() | binary()}. join_peer(PeerLocation, PeerID, _M1, _M2, InitOpts) -> - % Check here if the node is already part of a green zone. - GreenZoneAES = hb_opts:get(priv_green_zone_aes, undefined, InitOpts), - case GreenZoneAES == undefined of - true -> - Wallet = hb_opts:get(priv_wallet, undefined, InitOpts), - {ok, Report} = dev_snp:generate(#{}, #{}, InitOpts), - WalletPub = element(2, Wallet), - ?event(green_zone, {remove_uncommitted, Report}), - MergedReq = hb_ao:set( - Report, - <<"public_key">>, - base64:encode(term_to_binary(WalletPub)), - InitOpts - ), - % Create an committed join request using the wallet. - Req = hb_cache:ensure_all_loaded( - hb_message:commit(MergedReq, Wallet), + maybe + % Verify node is not already in a green zone + undefined ?= hb_opts:get(priv_green_zone_aes, undefined, InitOpts), + % Prepare join request + {ok, Req} ?= prepare_join_request(InitOpts), + % Send join request to peer + ?event(green_zone, + {join, sending_commitment, PeerLocation, PeerID, Req} + ), + {ok, Resp} ?= + hb_http:post( + PeerLocation, + <<"/~greenzone@1.0/join">>, + Req, InitOpts ), - ?event({join_req, {explicit, Req}}), - ?event({verify_res, hb_message:verify(Req)}), - % Log that the commitment report is being sent to the peer. - ?event(green_zone, {join, sending_commitment, PeerLocation, PeerID, Req}), - case hb_http:post(PeerLocation, <<"/~greenzone@1.0/join">>, Req, InitOpts) of - {ok, Resp} -> - % Log the response received from the peer. - ?event(green_zone, {join, join_response, PeerLocation, PeerID, Resp}), - % Ensure that the response is from the expected peer, avoiding - % the risk of a man-in-the-middle attack. - Signers = hb_message:signers(Resp, InitOpts), - ?event(green_zone, {join, signers, Signers}), - IsVerified = hb_message:verify(Resp, Signers, InitOpts), - ?event(green_zone, {join, verify, IsVerified}), - IsPeerSigner = lists:member(PeerID, Signers), - ?event(green_zone, {join, peer_is_signer, IsPeerSigner, PeerID}), - case IsPeerSigner andalso IsVerified of - false -> - % The response is not from the expected peer. - {error, <<"Received incorrect response from peer!">>}; - true -> - % Extract the encrypted shared AES key (zone-key) - % from the response. - ZoneKey = hb_ao:get(<<"zone-key">>, Resp, InitOpts), - % Decrypt the zone key using the local node's - % private key. - {ok, AESKey} = decrypt_zone_key(ZoneKey, InitOpts), - % Update local configuration with the retrieved - % shared AES key. - ?event(green_zone, {opts, {explicit, InitOpts}}), - NewOpts = InitOpts#{ - priv_green_zone_aes => AESKey - }, - hb_http_server:set_opts(NewOpts), - {ok, #{ - <<"body">> => - <<"Node joined green zone successfully.">>, - <<"status">> => 200 - }} - end; - {error, Reason} -> - {error, #{<<"status">> => 400, <<"reason">> => Reason}}; - {unavailable, Reason} -> - ?event(green_zone, { - join_error, - peer_unavailable, - PeerLocation, - PeerID, - Reason - }), - {error, #{ - <<"status">> => 503, - <<"body">> => <<"Peer node is unreachable.">> - }} - end; - false -> + % Verify response from expected peer + true ?= verify_peer_response(Resp, PeerID, InitOpts), + % Extract and decrypt zone key + {ok, AESKey} ?= extract_and_decrypt_zone_key(Resp, InitOpts), + % Update configuration with shared key + finalize_join_success(AESKey, InitOpts) + else + {error, already_joined} -> ?event(green_zone, {join, already_joined}), {error, <<"Node already part of green zone.">>}; {error, Reason} -> - % Log the error and return the initial options. - ?event(green_zone, {join, error, Reason}), - {error, Reason} + {error, #{<<"status">> => 400, <<"reason">> => Reason}}; + {unavailable, Reason} -> + ?event(green_zone, { + join_error, peer_unavailable, PeerLocation, PeerID, Reason + }), + {error, #{ + <<"status">> => 503, + <<"body">> => <<"Peer node is unreachable.">> + }}; + false -> + {error, <<"Received incorrect response from peer!">>}; + Error -> + ?event(green_zone, {join, error, Error}), + {error, Error} end. -%%%-------------------------------------------------------------------- -%%% Internal Functions -%%%-------------------------------------------------------------------- +%% @doc Prepare a join request with commitment report and public key. +%% +%% This function creates a hardware-backed commitment report and prepares +%% the join request message with the node's public key. +%% +%% @param InitOpts Initial configuration options +%% @returns {ok, Req} with prepared request, or {error, Reason} +prepare_join_request(InitOpts) -> + maybe + Wallet = hb_opts:get(priv_wallet, undefined, InitOpts), + {ok, Report} ?= dev_snp:generate(#{}, #{}, InitOpts), + WalletPub = element(2, Wallet), + ?event(green_zone, {remove_uncommitted, Report}), + MergedReq = hb_ao:set( + Report, + <<"public_key">>, + base64:encode(term_to_binary(WalletPub)), + InitOpts + ), + % Create committed join request using the wallet + Req = hb_cache:ensure_all_loaded( + hb_message:commit(MergedReq, Wallet), + InitOpts + ), + ?event({join_req, {explicit, Req}}), + ?event({verify_res, hb_message:verify(Req)}), + {ok, Req} + end. + +%% @doc Verify that response is from expected peer. +%% +%% This function verifies the response signature and ensures it comes +%% from the expected peer to prevent man-in-the-middle attacks. +%% +%% @param Resp Response from peer +%% @param PeerID Expected peer identifier +%% @param InitOpts Configuration options +%% @returns true if verified, false otherwise +verify_peer_response(Resp, PeerID, InitOpts) -> + ?event(green_zone, {join, join_response, Resp}), + Signers = hb_message:signers(Resp, InitOpts), + ?event(green_zone, {join, signers, Signers}), + IsVerified = hb_message:verify(Resp, Signers, InitOpts), + ?event(green_zone, {join, verify, IsVerified}), + IsPeerSigner = lists:member(PeerID, Signers), + ?event(green_zone, {join, peer_is_signer, IsPeerSigner, PeerID}), + IsPeerSigner andalso IsVerified. + +%% @doc Extract and decrypt zone key from peer response. +%% +%% This function extracts the encrypted zone key from the peer's response +%% and decrypts it using the local node's private key. +%% +%% @param Resp Response containing encrypted zone key +%% @param InitOpts Configuration options +%% @returns {ok, AESKey} with decrypted key, or {error, Reason} +extract_and_decrypt_zone_key(Resp, InitOpts) -> + ZoneKey = hb_ao:get(<<"zone-key">>, Resp, InitOpts), + decrypt_zone_key(ZoneKey, InitOpts). + +%% @doc Finalize successful join by updating configuration. +%% +%% This function updates the node's configuration with the shared AES key +%% and returns a success response. +%% +%% @param AESKey Decrypted shared AES key +%% @param InitOpts Initial configuration options +%% @returns {ok, Map} with success response +finalize_join_success(AESKey, InitOpts) -> + ?event(green_zone, {opts, {explicit, InitOpts}}), + NewOpts = InitOpts#{priv_green_zone_aes => AESKey}, + hb_http_server:set_opts(NewOpts), + {ok, #{ + <<"body">> => <<"Node joined green zone successfully.">>, + <<"status">> => 200 + }}. + +%%% ------------------------------------------------------------------- +%%% Helpers for validate_join/3 +%%% ------------------------------------------------------------------- %% @doc Validates an incoming join request from another node. %% %% This function handles the server-side join flow when receiving a connection %% request: %% 1. Validates the peer's configuration meets required standards -%% 2. Extracts the commitment report and public key from the request +%% 2. Extracts join request data (node address and public key) %% 3. Verifies the hardware-backed commitment report -%% 4. Adds the joining node to the trusted nodes list -%% 5. Encrypts the shared AES key with the peer's public key -%% 6. Returns the encrypted key to the requesting node +%% 4. Processes the successful join through helper functions %% %% @param M1 Ignored parameter %% @param Req The join request containing commitment report and public key %% @param Opts A map of configuration options %% @returns `{ok, Map}' on success with encrypted AES key, or %% `{error, Binary}' on failure with error message --spec validate_join(M1 :: term(), Req :: map(), Opts :: map()) -> - {ok, map()} | {error, binary()}. validate_join(M1, Req, Opts) -> - case validate_peer_opts(Req, Opts) of - true -> do_nothing; - false -> throw(invalid_join_request) - end, - ?event(green_zone, {join, start}), - % Retrieve the commitment report and address from the join request. - Report = hb_ao:get(<<"report">>, Req, Opts), - NodeAddr = hb_ao:get(<<"address">>, Req, Opts), - ?event(green_zone, {join, extract, {node_addr, NodeAddr}}), - % Retrieve and decode the joining node's public key. - ?event(green_zone, {m1, {explicit, M1}}), - ?event(green_zone, {req, {explicit, Req}}), - EncodedPubKey = hb_ao:get(<<"public_key">>, Req, Opts), - ?event(green_zone, {encoded_pub_key, {explicit, EncodedPubKey}}), - RequesterPubKey = case EncodedPubKey of - not_found -> not_found; - Encoded -> binary_to_term(base64:decode(Encoded)) - end, - ?event(green_zone, {public_key, {explicit, RequesterPubKey}}), - % Verify the commitment report provided in the join request. - case dev_snp:verify(M1, Req, Opts) of - {ok, <<"true">>} -> - % Commitment verified. - ?event(green_zone, {join, commitment, verified}), - % Retrieve the shared AES key used for encryption. - GreenZoneAES = hb_opts:get(priv_green_zone_aes, undefined, Opts), - ?event(green_zone, {green_zone_aes, {explicit, GreenZoneAES}}), - % Retrieve the local node's wallet to extract its public key. - {WalletPubKey, _} = hb_opts:get(priv_wallet, undefined, Opts), - % Add the joining node's details to the trusted nodes list. - add_trusted_node(NodeAddr, Report, RequesterPubKey, Opts), - % Log the update of trusted nodes. - ?event(green_zone, {join, update, trusted_nodes, ok}), - % Encrypt the shared AES key with the joining node's public key. - EncryptedPayload = encrypt_payload(GreenZoneAES, RequesterPubKey), - % Log completion of AES key encryption. - ?event(green_zone, {join, encrypt, aes_key, complete}), - {ok, #{ - <<"body">> => <<"Node joined green zone successfully.">>, - <<"node-address">> => NodeAddr, - <<"zone-key">> => base64:encode(EncryptedPayload), - <<"public_key">> => WalletPubKey - }}; + maybe + ?event(green_zone, {join, start}), + % Validate peer configuration + true ?= validate_peer_opts(Req, Opts), + % Extract join request data + {ok, {NodeAddr, RequesterPubKey}} ?= + extract_join_request_data(Req, Opts), + % Verify commitment report + {ok, <<"true">>} ?= dev_snp:verify(M1, Req, Opts), + ?event(green_zone, {join, commitment, verified}), + % Process successful join + process_successful_join(NodeAddr, RequesterPubKey, Req, Opts) + else + false -> + throw(invalid_join_request); {ok, <<"false">>} -> - % Commitment failed. ?event(green_zone, {join, commitment, failed}), {error, <<"Received invalid commitment report.">>}; Error -> - % Error during commitment verification. ?event(green_zone, {join, commitment, error, Error}), Error end. +%% @doc Extract join request data including node address and public key. +%% +%% This function extracts and processes the essential data from a join request, +%% including the node address and decoded public key. +%% +%% @param Req Join request message +%% @param Opts Configuration options +%% @returns {ok, {NodeAddr, RequesterPubKey}} or {error, Reason} +extract_join_request_data(Req, Opts) -> + maybe + % Extract basic request data + NodeAddr = hb_ao:get(<<"address">>, Req, Opts), + ?event(green_zone, {join, extract, {node_addr, NodeAddr}}), + % Extract and decode public key + EncodedPubKey = hb_ao:get(<<"public_key">>, Req, Opts), + ?event(green_zone, {encoded_pub_key, {explicit, EncodedPubKey}}), + RequesterPubKey = case EncodedPubKey of + not_found -> not_found; + Encoded -> binary_to_term(base64:decode(Encoded)) + end, + ?event(green_zone, {public_key, {explicit, RequesterPubKey}}), + {ok, {NodeAddr, RequesterPubKey}} + end. + +%% @doc Process a successful join by adding node and encrypting zone key. +%% +%% This function handles the final steps of a successful join request, +%% including adding the node to trusted list and encrypting the zone key. +%% +%% @param NodeAddr Address of joining node +%% @param RequesterPubKey Public key of joining node +%% @param Req Original join request (for Report) +%% @param Opts Configuration options +%% @returns {ok, Map} with success response +process_successful_join(NodeAddr, RequesterPubKey, Req, Opts) -> + % Get required data + Report = hb_ao:get(<<"report">>, Req, Opts), + GreenZoneAES = hb_opts:get(priv_green_zone_aes, undefined, Opts), + ?event(green_zone, {green_zone_aes, {explicit, GreenZoneAES}}), + {WalletPubKey, _} = hb_opts:get(priv_wallet, undefined, Opts), + % Add joining node to trusted nodes + add_trusted_node(NodeAddr, Report, RequesterPubKey, Opts), + ?event(green_zone, {join, update, trusted_nodes, ok}), + % Encrypt shared AES key for the joining node + EncryptedPayload = encrypt_payload(GreenZoneAES, RequesterPubKey), + ?event(green_zone, {join, encrypt, aes_key, complete}), + {ok, #{ + <<"body">> => <<"Node joined green zone successfully.">>, + <<"node-address">> => NodeAddr, + <<"zone-key">> => base64:encode(EncryptedPayload), + <<"public_key">> => WalletPubKey + }}. + %% @doc Validates that a peer's configuration matches required options. %% %% This function ensures the peer node meets configuration requirements: @@ -612,7 +679,6 @@ validate_join(M1, Req, Opts) -> %% @param Req The request message containing the peer's configuration %% @param Opts A map of the local node's configuration options %% @returns true if the peer's configuration is valid, false otherwise --spec validate_peer_opts(Req :: map(), Opts :: map()) -> boolean(). validate_peer_opts(Req, Opts) -> ?event(green_zone, {validate_peer_opts, start, Req}), % Get the required config from the local node's configuration. @@ -626,7 +692,9 @@ validate_peer_opts(Req, Opts) -> Opts ) ), - ?event(green_zone, {validate_peer_opts, required_config, ConvertedRequiredConfig}), + ?event(green_zone, + {validate_peer_opts, required_config, ConvertedRequiredConfig} + ), PeerOpts = hb_ao:normalize_keys( hb_ao:get(<<"node-message">>, Req, undefined, Opts)), @@ -634,10 +702,18 @@ validate_peer_opts(Req, Opts) -> Result = try case hb_opts:ensure_node_history(PeerOpts, ConvertedRequiredConfig) of {ok, _} -> - ?event(green_zone, {validate_peer_opts, history_items_check, valid}), + ?event(green_zone, + {validate_peer_opts, history_items_check, valid} + ), true; {error, ErrorMsg} -> - ?event(green_zone, {validate_peer_opts, history_items_check, {invalid, ErrorMsg}}), + ?event(green_zone, + { + validate_peer_opts, + history_items_check, + {invalid, ErrorMsg} + } + ), false end catch @@ -661,10 +737,6 @@ validate_peer_opts(Req, Opts) -> %% @param RequesterPubKey The joining node's public key %% @param Opts A map of configuration options %% @returns ok --spec add_trusted_node( - NodeAddr :: binary(), - Report :: map(), - RequesterPubKey :: term(), Opts :: map()) -> ok. add_trusted_node(NodeAddr, Report, RequesterPubKey, Opts) -> % Retrieve the current trusted nodes map. TrustedNodes = hb_opts:get(trusted_nodes, #{}, Opts), @@ -678,6 +750,233 @@ add_trusted_node(NodeAddr, Report, RequesterPubKey, Opts) -> trusted_nodes => UpdatedTrustedNodes }). +%%% ------------------------------------------------------------------- +%%% Helpers for key/3 +%%% ------------------------------------------------------------------- + +%% @doc Get the appropriate wallet for the current context. +%% +%% This function determines which wallet to use based on whether the node +%% has a green-zone identity or should use the default wallet. +%% +%% @param Opts Configuration options containing identities and wallet info +%% @returns Wallet to use for encryption operations +get_appropriate_wallet(Opts) -> + Identities = hb_opts:get(identities, #{}, Opts), + case maps:find(<<"green-zone">>, Identities) of + {ok, #{priv_wallet := GreenZoneWallet}} -> GreenZoneWallet; + _ -> hb_opts:get(priv_wallet, undefined, Opts) + end. + +%% @doc Build successful key response with encrypted data. +%% +%% This function constructs the standard response format for successful +%% key encryption operations. +%% +%% @param EncryptedData Base64-encoded encrypted key data +%% @param IV Base64-encoded initialization vector +%% @returns {ok, Map} with standardized response format +build_key_response(EncryptedData, IV) -> + {ok, #{ + <<"status">> => 200, + <<"encrypted_key">> => base64:encode(EncryptedData), + <<"iv">> => base64:encode(IV) + }}. + +%%% ------------------------------------------------------------------- +%%% Helpers for become/3 +%%% ------------------------------------------------------------------- + +%% @doc Validate parameters required for become operation. +%% +%% This function validates that all required parameters are present for +%% the become operation and that the node is part of a green zone. +%% +%% @param Opts Configuration options +%% @returns {ok, {NodeLocation, NodeID}} if valid, or {error, Reason} +validate_become_params(Opts) -> + maybe + % Check if node is part of a green zone + GreenZoneAES = hb_opts:get(priv_green_zone_aes, undefined, Opts), + case GreenZoneAES of + undefined -> {error, no_green_zone_aes_key}; + _ -> ok + end, + % Extract and validate peer parameters + NodeLocation = + hb_opts:get(<<"green_zone_peer_location">>, undefined, Opts), + NodeID = hb_opts:get(<<"green_zone_peer_id">>, undefined, Opts), + case {NodeLocation, NodeID} of + {undefined, _} -> {error, missing_peer_location}; + {_, undefined} -> {error, missing_peer_id}; + {_, _} -> {ok, {NodeLocation, NodeID}} + end + end. + +%% @doc Request peer's key and verify the response. +%% +%% This function handles the HTTP request to get the peer's encrypted key +%% and verifies that the response is authentic and from the expected peer. +%% +%% @param NodeLocation Target node's address +%% @param NodeID Target node's identifier +%% @param Opts Configuration options +%% @returns {ok, KeyResp} if successful, or {error, Reason} +request_and_verify_peer_key(NodeLocation, NodeID, Opts) -> + maybe + ?event(green_zone, {become, getting_key, NodeLocation, NodeID}), + % Request encrypted key from target node + {ok, KeyResp} ?= + hb_http:get(NodeLocation, <<"/~greenzone@1.0/key">>, Opts), + % Verify response signature + Signers = hb_message:signers(KeyResp, Opts), + true ?= (hb_message:verify(KeyResp, Signers, Opts) and + lists:member(NodeID, Signers)), + {ok, KeyResp} + else + false -> + {error, invalid_peer_response}; + Error -> + Error + end. + +%% @doc Finalize the become process by decrypting and adopting target identity. +%% +%% This function completes the identity adoption process by: +%% 1. Extracting and decrypting the target node's encrypted key data +%% 2. Converting the decrypted data back into a keypair structure +%% 3. Creating a new green zone wallet with the target's identity +%% 4. Updating the node's identity configuration +%% 5. Mounting an encrypted volume with the new identity +%% 6. Returning confirmation of successful identity adoption +%% +%% @param KeyResp Response containing encrypted key data from target node +%% @param NodeLocation URL of the target node for logging +%% @param NodeID ID of the target node for logging +%% @param Opts Configuration options containing decryption keys +%% @returns {ok, Map} with success confirmation and peer details +finalize_become(KeyResp, NodeLocation, NodeID, Opts) -> + maybe + % Decode and decrypt the encrypted key + Combined = base64:decode(hb_ao:get(<<"encrypted_key">>, KeyResp, Opts)), + IV = base64:decode(hb_ao:get(<<"iv">>, KeyResp, Opts)), + {ok, DecryptedBin} ?= decrypt_data(Combined, IV, Opts), + % Log current wallet info + OldWallet = hb_opts:get(priv_wallet, undefined, Opts), + OldWalletAddr = hb_util:human_id(ar_wallet:to_address(OldWallet)), + ?event(green_zone, {become, old_wallet, OldWalletAddr}), + % Extract and process target node's keypair + {KeyType, Priv, Pub} = binary_to_term(DecryptedBin), + ?event(green_zone, {become, decrypted_bin, DecryptedBin}), + ?event(green_zone, {become, keypair, Pub}), + % Update node identity with target's keypair + GreenZoneWallet = {{KeyType, Priv, Pub}, {KeyType, Pub}}, + ok ?= update_node_identity(GreenZoneWallet, Opts), + % Mount encrypted volume and finalize + try_mount_encrypted_volume(GreenZoneWallet, Opts), + ?event(green_zone, {become, update_wallet, complete}), + {ok, #{ + <<"body">> => #{ + <<"message">> => + <<"Successfully adopted target node identity">>, + <<"peer-location">> => NodeLocation, + <<"peer-id">> => NodeID + } + }} + end. + +%% @doc Update node identity with new green zone wallet. +%% +%% This function updates the node's identity configuration to include +%% the new green zone wallet and commits the changes. +%% +%% @param GreenZoneWallet New wallet to use for green zone identity +%% @param Opts Current configuration options +%% @returns ok if successful +update_node_identity(GreenZoneWallet, Opts) -> + Identities = hb_opts:get(identities, #{}, Opts), + UpdatedIdentities = Identities#{ + <<"green-zone">> => #{ + priv_wallet => GreenZoneWallet + } + }, + NewOpts = Opts#{identities => UpdatedIdentities}, + hb_http_server:set_opts(NewOpts). + +%%% ------------------------------------------------------------------- +%%% General/Shared helpers +%%% ------------------------------------------------------------------- + +%% @doc Prepare a join request with commitment report and public key. +%% +%% This function creates a hardware-backed commitment report and prepares +%% the join request message with the node's public key. +%% +%% @param InitOpts Initial configuration options +%% @returns {ok, Req} with prepared request, or {error, Reason} +default_zone_required_opts(_Opts) -> + #{ + % trusted_device_signers => hb_opts:get(trusted_device_signers, [], Opts), + % load_remote_devices => hb_opts:get(load_remote_devices, false, Opts), + % preload_devices => hb_opts:get(preload_devices, [], Opts), + % % store => hb_opts:get(store, [], Opts), + % routes => hb_opts:get(routes, [], Opts), + % on => hb_opts:get(on, undefined, Opts), + % scheduling_mode => disabled, + % initialized => permanent + }. + +%% @doc Replace values of <<"self">> in a configuration map with +%% corresponding values from Opts. +%% +%% This function iterates through all key-value pairs in the configuration map. +%% If a value is <<"self">>, it replaces that value with the result of +%% hb_opts:get(Key, not_found, Opts) where Key is the corresponding key. +%% +%% @param Config The configuration map to process +%% @param Opts The options map to fetch replacement values from +%% @returns A new map with <<"self">> values replaced +replace_self_values(Config, Opts) -> + maps:map( + fun(Key, Value) -> + case Value of + <<"self">> -> + hb_opts:get(Key, not_found, Opts); + _ -> + Value + end + end, + Config + ). + +%% @doc Returns `true' if the request is signed by a trusted node. +%% +%% This function verifies whether an incoming request is signed by a node +%% that is part of the trusted nodes list in the green zone. It extracts +%% all signers from the request and checks if any of them match the trusted +%% nodes configured for this green zone. +%% +%% @param _M1 Ignored parameter +%% @param Req The request message to verify +%% @param Opts Configuration options containing trusted_nodes map +%% @returns {ok, Binary} with "true" or "false" indicating trust status +is_trusted(_M1, Req, Opts) -> + Signers = hb_message:signers(Req, Opts), + {ok, + hb_util:bin( + lists:any( + fun(Signer) -> + lists:member( + Signer, + maps:keys(hb_opts:get(trusted_nodes, #{}, Opts)) + ) + end, + Signers + ) + ) + }. + + %% @doc Encrypts an AES key with a node's RSA public key. %% %% This function securely encrypts the shared key for transmission: @@ -688,7 +987,6 @@ add_trusted_node(NodeAddr, Report, RequesterPubKey, Opts) -> %% @param AESKey The shared AES key (256-bit binary) %% @param RequesterPubKey The node's public RSA key %% @returns The encrypted AES key --spec encrypt_payload(AESKey :: binary(), RequesterPubKey :: term()) -> binary(). encrypt_payload(AESKey, RequesterPubKey) -> ?event(green_zone, {encrypt_payload, start}), %% Expect RequesterPubKey in the form: { {rsa, E}, Pub } @@ -712,8 +1010,6 @@ encrypt_payload(AESKey, RequesterPubKey) -> %% @param EncZoneKey The encrypted zone AES key (Base64 encoded or binary) %% @param Opts A map of configuration options %% @returns {ok, DecryptedKey} on success with the decrypted AES key --spec decrypt_zone_key(EncZoneKey :: binary(), Opts :: map()) -> - {ok, binary()} | {error, binary()}. decrypt_zone_key(EncZoneKey, Opts) -> % Decode if necessary RawEncKey = case is_binary(EncZoneKey) of @@ -739,8 +1035,9 @@ decrypt_zone_key(EncZoneKey, Opts) -> %% delegating to the dev_volume module, which provides a unified interface %% for volume management. %% -%% The encryption key used for the volume is the same AES key used for green zone -%% communication, ensuring that only nodes in the green zone can access the data. +%% The encryption key used for the volume is the same AES key used for green +%% zone communication, ensuring that only nodes in the green zone can access +%% the data. %% %% @param Key The password for the encrypted volume. %% @param Opts A map of configuration options. @@ -762,6 +1059,99 @@ try_mount_encrypted_volume(Key, Opts) -> ok % Still return ok as this is an optional operation end. +%%% =================================================================== +%%% Encryption Helper Functions +%%% =================================================================== + +%% @doc Encrypt data using AES-256-GCM with the green zone shared key. +%% +%% This function provides a standardized way to encrypt data using the +%% green zone AES key from the node's configuration. It generates a random IV +%% and returns the encrypted data with authentication tag, ready for base64 +%% encoding and transmission. +%% +%% @param Data The data to encrypt (will be converted to binary via +%% term_to_binary) +%% @param Opts Server configuration options containing priv_green_zone_aes +%% @returns {ok, {EncryptedData, IV}} where EncryptedData includes the auth tag, +%% or {error, Reason} if no AES key or encryption fails +encrypt_data(Data, Opts) -> + case hb_opts:get(priv_green_zone_aes, undefined, Opts) of + undefined -> + {error, no_green_zone_aes_key}; + AESKey -> + try + % Generate random IV + IV = crypto:strong_rand_bytes(16), + % Convert data to binary if needed + DataBin = case is_binary(Data) of + true -> Data; + false -> term_to_binary(Data) + end, + % Encrypt using AES-256-GCM + {EncryptedData, Tag} = crypto:crypto_one_time_aead( + aes_256_gcm, + AESKey, + IV, + DataBin, + <<>>, + true + ), + % Combine encrypted data and tag + Combined = <>, + {ok, {Combined, IV}} + catch + Error:Reason -> + {error, {encryption_failed, Error, Reason}} + end + end. + +%% @doc Decrypt data using AES-256-GCM with the green zone shared key. +%% +%% This function provides a standardized way to decrypt data that was +%% encrypted with encrypt_data/2. It expects the encrypted data to include +%% the 16-byte authentication tag at the end. +%% +%% @param Combined The encrypted data with authentication tag appended +%% @param IV The initialization vector used during encryption +%% @param Opts Server configuration options containing priv_green_zone_aes +%% @returns {ok, DecryptedData} or {error, Reason} +decrypt_data(Combined, IV, Opts) -> + case hb_opts:get(priv_green_zone_aes, undefined, Opts) of + undefined -> + {error, no_green_zone_aes_key}; + AESKey -> + try + % Separate ciphertext and authentication tag + CipherLen = byte_size(Combined) - 16, + case CipherLen >= 0 of + false -> + {error, invalid_encrypted_data_length}; + true -> + <> = + Combined, + % Decrypt using AES-256-GCM + DecryptedBin = crypto:crypto_one_time_aead( + aes_256_gcm, + AESKey, + IV, + Ciphertext, + <<>>, + Tag, + false + ), + {ok, DecryptedBin} + end + catch + Error:Reason -> + {error, {decryption_failed, Error, Reason}} + end + end. + +%%% =================================================================== +%%% Test Functions +%%% =================================================================== + %% @doc Test RSA operations with the existing wallet structure. %% %% This test function verifies that encryption and decryption using the RSA keys diff --git a/src/dev_ssl_cert.erl b/src/dev_ssl_cert.erl new file mode 100644 index 000000000..f20cee93a --- /dev/null +++ b/src/dev_ssl_cert.erl @@ -0,0 +1,1064 @@ +%%% @doc SSL Certificate device for automated Let's Encrypt certificate +%%% management using DNS-01 challenges. +%%% +%%% This device provides HTTP endpoints for requesting, managing, and renewing +%%% SSL certificates through Let's Encrypt's ACME v2 protocol. It supports +%%% both staging and production environments and handles the complete +%%% certificate lifecycle including DNS challenge generation and validation. +%%% +%%% The device generates DNS TXT records that users must manually add to their +%%% DNS providers, making it suitable for environments where automated DNS +%%% API access is not available. +%%% +%%% This module serves as the main device interface, orchestrating calls to +%%% specialized modules for validation, state management, challenge handling, +%%% and certificate operations. +-module(dev_ssl_cert). + +-include("include/hb.hrl"). +-include_lib("ssl_cert/include/ssl_cert.hrl"). + +%% Device API exports +-export([info/1, info/3, request/3, finalize/3]). +-export([renew/3, delete/3]). +-export([get_cert/3, request_cert/3]). + +-define(CERT_DIR, filename:join([element(2, file:get_cwd()), "certs"])). +-define(CERT_PEM_FILE, + filename:join( + [?CERT_DIR, <<"hyperbeam_cert.pem">>] + ) +). +-define(KEY_PEM_FILE, + filename:join( + [?CERT_DIR, <<"hyperbeam_key.pem">>] + ) +). +-define(DEFAULT_HTTPS_PORT, 443). + +%% @doc Controls which functions are exposed via the device API. +%% +%% This function defines the security boundary for the SSL certificate device +%% by explicitly listing which functions are available through HTTP endpoints. +%% +%% @param _ Ignored parameter +%% @returns A map with the `exports' key containing a list of allowed functions +info(_) -> + #{ + exports => [ + <<"info">>, + <<"request">>, + <<"finalize">>, + <<"renew">>, + <<"delete">>, + <<"get_cert">>, + <<"request_cert">> + ] + }. + +%% @doc Provides information about the SSL certificate device and its API. +%% +%% This function returns detailed documentation about the device, including: +%% 1. A high-level description of the device's purpose +%% 2. Version information +%% 3. Available API endpoints with their parameters and descriptions +%% 4. Configuration requirements and examples +%% +%% @param _Msg1 Ignored parameter +%% @param _Msg2 Ignored parameter +%% @param _Opts A map of configuration options +%% @returns {ok, Map} containing the device information and documentation +info(_Msg1, _Msg2, _Opts) -> + InfoBody = #{ + <<"description">> => + << + "SSL Certificate management with", + "Let's Encrypt DNS-01 challenges" + >>, + <<"version">> => <<"1.0">>, + <<"api">> => #{ + <<"info">> => #{ + <<"description">> => + <<"Get device info and API documentation">> + }, + <<"request">> => #{ + <<"description">> => <<"Request a new SSL certificate">>, + <<"configuration_required">> => #{ + <<"ssl_opts">> => #{ + <<"domains">> => + <<"List of domain names for certificate">>, + <<"email">> => + <<"Contact email for Let's Encrypt account">>, + <<"environment">> => + <<"'staging' or 'production'">>, + <<"auto_https">> => + << + "Automatically start HTTPS server and", + "redirect HTTP traffic (default: true)" + >>, + <<"https_port">> => <<"HTTPS port (default: 443)">> + } + }, + <<"example_config">> => #{ + <<"ssl_opts">> => #{ + <<"domains">> => + [<<"example.com">>, <<"www.example.com">>], + <<"email">> => <<"admin@example.com">>, + <<"environment">> => <<"staging">>, + <<"auto_https">> => <<"true">>, + <<"https_port">> => <<"443">> + } + }, + <<"usage">> => + << + "POST /ssl-cert@1.0/request", + " (returns challenges; state saved internally)" + >> + }, + <<"finalize">> => #{ + <<"description">> => + << + "Finalize certificate issuance", + "after DNS TXT records are set" + >>, + <<"usage">> => + << + "POST /ssl-cert@1.0/finalize", + " (validates and returns certificate)" + >>, + <<"auto_https">> => + << + "Automatically starts HTTPS server and redirects", + "HTTP traffic (default: true)" + >>, + <<"https_port">> => + << + "Configurable HTTPS port (default: 8443 for", + "development, set to 443 for production)" + >> + }, + <<"renew">> => #{ + <<"description">> => <<"Renew an existing certificate">>, + <<"required_params">> => #{ + <<"domains">> => <<"List of domain names to renew">> + } + }, + <<"delete">> => #{ + <<"description">> => <<"Delete a stored certificate">>, + <<"required_params">> => #{ + <<"domains">> => <<"List of domain names to delete">> + } + }, + <<"get_cert">> => #{ + <<"description">> => + <<"Get encrypted certificate and private key for sharing">>, + <<"usage">> => <<"POST /ssl-cert@1.0/get_cert">>, + <<"note">> => + << + "Returns encrypted certificate data that can be used by", + "another node with the same green zone AES key" + >> + }, + <<"request_cert">> => #{ + <<"description">> => + <<"Request and use certificate from another node">>, + <<"required_params">> => #{ + <<"green_zone_peer_location">> => <<"URL of the peer node">>, + <<"green_zone_peer_id">> => <<"ID of the peer node">> + }, + <<"usage">> => <<"POST /ssl-cert@1.0/request_cert">>, + <<"note">> => + << + "Automatically starts HTTPS server with the retrieved", + "certificate" + >> + } + } + }, + ssl_utils:build_success_response(200, InfoBody). + +%% @doc Requests a new SSL certificate for the specified domains. +%% +%% This function initiates the certificate request process: +%% 1. Validates the input parameters (domains, email, environment) +%% 2. Creates or retrieves an ACME account with Let's Encrypt +%% 3. Submits a certificate order for the specified domains +%% 4. Generates DNS-01 challenges for domain validation +%% 5. Stores the request state for subsequent operations +%% 6. Returns a request ID and initial status +%% +%% Required parameters in ssl_opts configuration: +%% - domains: List of domain names for the certificate +%% - email: Contact email for Let's Encrypt account registration +%% - environment: 'staging' or 'production' (use staging for testing) +%% +%% @param _M1 Ignored parameter +%% @param _M2 Request message containing certificate parameters +%% @param Opts A map of configuration options +%% @returns {ok, Map} with request ID and status, or {error, Reason} +request(_M1, _M2, Opts) -> + ?event({ssl_cert_request_started}), + maybe + {ok, ValidatedParams} ?= + extract_and_validate_ssl_params(Opts), + {ok, {RequestState, ChallengeData}} ?= + process_certificate_request_workflow(ValidatedParams, Opts), + build_request_response(RequestState, ChallengeData) + else + {error, <<"ssl_opts configuration required">>} -> + ssl_utils:build_error_response( + 400, + <<"ssl_opts configuration required">> + ); + {error, ReasonBin} when is_binary(ReasonBin) -> + ssl_utils:format_validation_error(ReasonBin); + {error, Reason} -> + ?event({ssl_cert_request_error_maybe, Reason}), + FormattedError = ssl_utils:format_error_details(Reason), + ssl_utils:build_error_response(500, FormattedError); + Error -> + ?event({ssl_cert_request_unexpected_error, Error}), + ssl_utils:build_error_response(500, <<"Internal server error">>) + end. + +%% @doc Finalizes a certificate request: validates challenges and downloads +%% the certificate. +%% +%% This function: +%% 1. Retrieves the stored request state +%% 2. Validates DNS challenges with Let's Encrypt +%% 3. Finalizes the order if challenges are valid +%% 4. Downloads the certificate if available +%% 5. Automatically starts HTTPS server on port 443 (if auto_https is enabled) +%% 6. Configures HTTP server to redirect to HTTPS +%% 7. Returns the certificate and HTTPS server status +%% +%% The auto_https feature (enabled by default) will: +%% - Start a new HTTPS listener on port 443 using the issued certificate +%% - Reconfigure the existing HTTP server to send 301 redirects to HTTPS +%% - Preserve all existing server configuration and functionality +%% +%% @param _M1 Ignored +%% @param _M2 Message containing request_state +%% @param Opts Options (supports auto_https: true/false) +%% @returns {ok, Map} result of validation and optionally certificate +finalize(_M1, _M2, Opts) -> + ?event({ssl_cert_finalize_started}), + maybe + {ok, {RequestState, PrivKeyRecord}} ?= + load_certificate_state(Opts), + {ok, {OrderStatus, Results, RequestState1}} ?= + validate_challenges(RequestState, PrivKeyRecord), + case OrderStatus of + ?ACME_STATUS_VALID -> + handle_valid_certificate( + RequestState1, + PrivKeyRecord, + Results, + Opts + ); + _ -> + build_pending_response(OrderStatus, Results, RequestState1) + end + else + {error, request_state_not_found} -> + ssl_utils:build_error_response( + 404, + <<"request state not found">> + ); + {error, invalid_request_state} -> + ssl_utils:build_error_response( + 400, + <<"request_state must be a map">> + ); + {error, Reason} -> + FormattedError = ssl_utils:format_error_details(Reason), + ssl_utils:build_error_response(500, FormattedError) + end. + + +%% @doc Renews an existing SSL certificate. +%% +%% This function initiates renewal for an existing certificate: +%% 1. Validates the domains parameter +%% 2. Retrieves the existing certificate configuration +%% 3. Initiates a new certificate request with the same parameters +%% 4. Returns a new request ID for the renewal process +%% +%% Required parameters in ssl_opts configuration: +%% - domains: List of domain names to renew +%% - email: Contact email for Let's Encrypt account +%% - environment: ACME environment setting +%% +%% @param _M1 Ignored parameter +%% @param M2 Request message containing domains to renew +%% @param Opts A map of configuration options +%% @returns {ok, Map} with renewal request ID, or {error, Reason} +renew(_M1, _M2, Opts) -> + ?event({ssl_cert_renewal_started}), + try + % Extract SSL options and validate + case extract_ssl_opts(Opts) of + {error, ErrorReason} -> + ssl_utils:build_error_response(400, ErrorReason); + {ok, SslOpts} -> + Domains = maps:get(<<"domains">>, SslOpts, not_found), + case Domains of + not_found -> + ?event({ssl_cert_renewal_domains_missing}), + ssl_utils:build_error_response( + 400, + <<"domains required in ssl_opts configuration">> + ); + _ -> + DomainList = ssl_utils:normalize_domains(Domains), + ssl_cert_ops:renew_certificate(DomainList, Opts) + end + end + catch + Error:CatchReason:Stacktrace -> + ?event({ssl_cert_renewal_error, Error, CatchReason, Stacktrace}), + ssl_utils:build_error_response(500, <<"Internal server error">>) + end. + +%% @doc Deletes a stored SSL certificate. +%% +%% This function removes a certificate from storage: +%% 1. Validates the domains parameter +%% 2. Locates the certificate in storage +%% 3. Removes the certificate files and metadata +%% 4. Returns confirmation of deletion +%% +%% Required parameters in ssl_opts configuration: +%% - domains: List of domain names to delete +%% +%% @param _M1 Ignored parameter +%% @param M2 Request message containing domains to delete +%% @param Opts A map of configuration options +%% @returns {ok, Map} with deletion confirmation, or {error, Reason} +delete(_M1, _M2, Opts) -> + ?event({ssl_cert_deletion_started}), + try + % Extract SSL options and validate + case extract_ssl_opts(Opts) of + {error, ErrorReason} -> + ssl_utils:build_error_response(400, ErrorReason); + {ok, SslOpts} -> + Domains = maps:get(<<"domains">>, SslOpts, not_found), + case Domains of + not_found -> + ?event({ssl_cert_deletion_domains_missing}), + ssl_utils:build_error_response( + 400, + <<"domains required in ssl_opts configuration">> + ); + _ -> + DomainList = ssl_utils:normalize_domains(Domains), + ssl_cert_ops:delete_certificate(DomainList, Opts) + end + end + catch + Error:CatchReason:Stacktrace -> + ?event({ssl_cert_deletion_error, Error, CatchReason, Stacktrace}), + ssl_utils:build_error_response(500, <<"Internal server error">>) + end. + +%% @doc Get encrypted certificate and private key for sharing with other nodes. +%% +%% This function encrypts the current certificate and private key using the +%% shared green zone AES key, similar to how the green zone shares wallet keys. +%% The encrypted data can be requested by another node that has the same +%% green zone AES key. +%% +%% @param _M1 Ignored parameter +%% @param _M2 Ignored parameter +%% @param Opts Server configuration options +%% @returns {ok, Map} with encrypted certificate data, or {error, Reason} +get_cert(_M1, _M2, Opts) -> + ?event(ssl_cert, {get_cert, start}), + maybe + {ok, CertPem} ?= file:read_file(?CERT_PEM_FILE), + {ok, KeyPem} ?= file:read_file(?KEY_PEM_FILE), + % Create combined certificate data + CertData = #{ + cert_pem => CertPem, + key_pem => KeyPem, + timestamp => erlang:system_time(second) + }, + % Encrypt using green zone helper function + {ok, {EncryptedData, IV}} ?= + dev_green_zone:encrypt_data(CertData, Opts), + ?event(ssl_cert, {get_cert, encrypt, complete}), + ssl_utils:build_success_response(200, #{ + <<"encrypted_cert">> => base64:encode(EncryptedData), + <<"iv">> => base64:encode(IV), + <<"message">> => + <<"Certificate encrypted and ready for sharing">> + }) + else + {error, enoent} -> + ?event(ssl_cert, {get_cert, file_not_found}), + ssl_utils:build_error_response( + 404, + <<"Certificate or key file not found">> + ); + {error, no_green_zone_aes_key} -> + ?event(ssl_cert, {get_cert, error, <<"no aes key">>}), + ssl_utils:build_error_response( + 400, + <<"Node not part of a green zone - no shared AES key">> + ); + {error, EncryptError} -> + ?event(ssl_cert, {get_cert, encrypt_error, EncryptError}), + ssl_utils:build_error_response(500, <<"Encryption failed">>); + Error -> + ?event(ssl_cert, {get_cert, unexpected_error, Error}), + ssl_utils:build_error_response(500, <<"Internal server error">>) + end. + +%% @doc Request certificate from another node and start HTTPS server. +%% +%% This function requests encrypted certificate data from another node, +%% decrypts it using the shared green zone AES key, and automatically +%% starts an HTTPS server with the retrieved certificate. +%% +%% Required parameters: +%% - peer_location: URL of the peer node +%% - peer_id: ID of the peer node for verification +%% +%% @param _M1 Ignored parameter +%% @param _M2 Request message containing peer information +%% @param Opts Server configuration options +%% @returns {ok, Map} with certificate status and HTTPS server info, or +%% {error, Reason} +request_cert(_M1, _M2, Opts) -> + ?event(ssl_cert, {request_cert, start}), + % Extract peer information + PeerLocation = hb_opts:get(<<"green_zone_peer_location">>, undefined, Opts), + PeerID = hb_opts:get(<<"green_zone_peer_id">>, undefined, Opts), + case {PeerLocation, PeerID} of + {undefined, _} -> + ssl_utils:build_error_response( + 400, + <<"green_zone_peer_location required">> + ); + {_, undefined} -> + ssl_utils:build_error_response( + 400, + <<"green_zone_peer_id required">> + ); + {_, _} -> + try_request_cert_from_peer(PeerLocation, PeerID, Opts) + end. + +%%% =================================================================== +%%% Internal Helper Functions +%%% =================================================================== + +%% @doc Try to request certificate from peer node. +%% +%% This function makes an HTTP request to the peer node's get_cert endpoint, +%% verifies the response signature, decrypts the certificate data, and +%% starts an HTTPS server with the retrieved certificate. +%% +%% @param PeerLocation URL of the peer node +%% @param PeerID Expected signer ID for verification +%% @param Opts Server configuration options +%% @returns {ok, Map} with certificate status, or {error, Reason} +try_request_cert_from_peer(PeerLocation, PeerID, Opts) -> + maybe + ?event(ssl_cert, {request_cert, getting_cert, PeerLocation, PeerID}), + % Request encrypted certificate from peer + {ok, CertResp} ?= hb_http:get(PeerLocation, + <<"/~ssl-cert@1.0/get_cert">>, Opts), + % Verify response signature + Signers = hb_message:signers(CertResp, Opts), + true ?= (hb_message:verify(CertResp, Signers, Opts) and + lists:member(PeerID, Signers)), + finalize_cert_request(CertResp, Opts) + else + false -> + ?event(ssl_cert, {request_cert, invalid_signature}), + ssl_utils:build_error_response( + 400, + <<"Invalid response signature from peer">> + ); + Error -> + ?event(ssl_cert, {request_cert, error, Error}), + ssl_utils:build_error_response( + 500, + <<"Failed to request certificate from peer">> + ) + end. + +%% @doc Finalize certificate request by decrypting and using the certificate. +%% +%% This function decrypts the certificate data received from the peer, +%% writes it to local files, and starts an HTTPS server. +%% +%% @param CertResp Response from peer containing encrypted certificate +%% @param Opts Server configuration options +%% @returns {ok, Map} with HTTPS server status +finalize_cert_request(CertResp, Opts) -> + maybe + % Extract encrypted data from response + Body = hb_ao:get(<<"body">>, CertResp, Opts), + Combined = + base64:decode(hb_ao:get(<<"encrypted_cert">>, Body, Opts)), + IV = base64:decode(hb_ao:get(<<"iv">>, Body, Opts)), + % Decrypt using green zone helper function + {ok, DecryptedBin} ?= dev_green_zone:decrypt_data(Combined, IV, Opts), + % Extract certificate components + #{cert_pem := CertPem, key_pem := KeyPem, timestamp := Timestamp} = + binary_to_term(DecryptedBin), + ?event( + ssl_cert, + {request_cert, decrypted_cert, {timestamp, Timestamp}} + ), + % Write certificate files + {ok, {CertFile, KeyFile}} ?= write_certificate_files(CertPem, KeyPem), + ?event(ssl_cert, {request_cert, files_written, {CertFile, KeyFile}}), + % Start HTTPS server with the certificate + HttpsPort = hb_opts:get(<<"https_port">>, ?DEFAULT_HTTPS_PORT, Opts), + RedirectTo = get_redirect_server_id(Opts), + HttpsResult = try hb_http_server:start_https_node( + CertFile, + KeyFile, + Opts, + RedirectTo, + HttpsPort + ) of + ServerUrl when is_binary(ServerUrl) -> + ?event(ssl_cert, {request_cert, https_started, ServerUrl}), + {started, ServerUrl} + catch + StartError:StartReason:StartStacktrace -> + ?event(ssl_cert, + { + request_cert, https_failed, + {error, StartError}, + {reason, StartReason}, + {stacktrace, StartStacktrace} + } + ), + {failed, {StartError, StartReason}} + end, + % Build response + ssl_utils:build_success_response(200, #{ + <<"message">> => + <<"Certificate retrieved and HTTPS server started">>, + <<"https_server">> => format_https_server_status(HttpsResult), + <<"certificate_timestamp">> => Timestamp + }) + else + {error, no_green_zone_aes_key} -> + ?event(ssl_cert, {request_cert, error, <<"no aes key">>}), + ssl_utils:build_error_response( + 400, + <<"Node not part of a green zone - no shared AES key">> + ); + {error, DecryptError} -> + ?event(ssl_cert, {request_cert, decrypt_error, DecryptError}), + ssl_utils:build_error_response( + 400, + <<"Failed to decrypt certificate data">> + ); + Error -> + ?event(ssl_cert, {request_cert, general_error, Error}), + ssl_utils:build_error_response( + 500, + <<"Internal server error">> + ) + end. + +%% @doc Extracts SSL options from configuration with validation. +%% +%% This function extracts and validates the ssl_opts configuration from +%% the provided options map, ensuring all required fields are present. +%% +%% @param Opts Configuration options map +%% @returns {ok, SslOpts} or {error, Reason} +extract_ssl_opts(Opts) when is_map(Opts) -> + case hb_opts:get(<<"ssl_opts">>, not_found, Opts) of + not_found -> + {error, <<"ssl_opts configuration required">>}; + SslOpts when is_map(SslOpts) -> + {ok, SslOpts}; + _ -> + {error, <<"ssl_opts must be a map">>} + end. + +%% @doc Load and validate certificate state from options. +%% +%% This function retrieves the stored certificate request state and private key +%% from the server options, validating that the request state exists and is +%% properly formatted as a map. +%% +%% @param Opts Server configuration options containing ssl_cert_request +%% and ssl_cert_rsa_key +%% @returns {ok, {RequestState, PrivKeyRecord}} or {error, Reason} +load_certificate_state(Opts) -> + RequestState = hb_opts:get(<<"priv_ssl_cert_request">>, not_found, Opts), + case RequestState of + not_found -> + {error, request_state_not_found}; + _ when is_map(RequestState) -> + PrivKeyRecord = + hb_opts:get(<<"priv_ssl_cert_rsa_key">>, not_found, Opts), + {ok, {RequestState, PrivKeyRecord}}; + _ -> + {error, invalid_request_state} + end. + +%% @doc Validate DNS challenges and return order status. +%% +%% This function validates the DNS-01 challenges with Let's Encrypt's +%% ACME server +%% to verify domain ownership. It extracts the order status, validation +%% results, +%% and updated request state from the validation response. +%% +%% @param RequestState Current certificate request state +%% @param PrivKeyRecord Private key record for challenge validation +%% @returns {ok, {OrderStatus, Results, RequestState1}} or {error, Reason} +validate_challenges(RequestState, PrivKeyRecord) -> + case ssl_cert_challenge:validate_dns_challenges_state( + RequestState, + PrivKeyRecord + ) of + {ok, ValResp} -> + ValBody = maps:get(<<"body">>, ValResp, #{}), + OrderStatus = maps:get(<<"order_status">>, ValBody, <<"unknown">>), + Results = maps:get(<<"results">>, ValBody, []), + RequestState1 = + maps:get(<<"request_state">>, ValBody, RequestState), + {ok, {OrderStatus, Results, RequestState1}}; + Error -> + Error + end. + +%% @doc Handle valid certificate: download and optionally start HTTPS server. +%% +%% This function processes a validated certificate order by downloading the +%% certificate from Let's Encrypt, extracting the certificate data, and +%% optionally starting an HTTPS server with the new certificate. +%% +%% @param RequestState Validated certificate request state +%% @param PrivKeyRecord Private key record for the certificate +%% @param Results Validation results from challenge verification +%% @param Opts Server configuration options +%% @returns {ok, Response} with certificate and optional HTTPS server +%% status +handle_valid_certificate(RequestState, PrivKeyRecord, Results, Opts) -> + case ssl_cert_ops:download_certificate_state(RequestState, Opts) of + {ok, DownResp} -> + ?event(ssl_cert, {ssl_cert_certificate_downloaded, DownResp}), + maybe + {ok, {CertPem, DomainsOut, PrivKeyPem}} ?= + extract_certificate_data(DownResp, PrivKeyRecord), + ?event( + ssl_cert, + { + ssl_cert_certificate_and_key_ready_for_nginx, + {domains, DomainsOut} + } + ), + HttpsResult = + maybe_start_https_server( + CertPem, + PrivKeyPem, + DomainsOut, + Opts + ), + build_success_response( + DomainsOut, + Results, + HttpsResult + ) + end; + {error, _} -> + build_processing_response(Results) + end. + +%% @doc Extract certificate data from download response. +%% +%% This function extracts the certificate PEM, domain list, and serialized +%% private key from the certificate download response. It handles the case +%% where no private key record is available. +%% +%% @param DownResp Certificate download response from Let's Encrypt +%% @param PrivKeyRecord Private key record (may be not_found) +%% @returns {ok, {CertPem, DomainsOut, PrivKeyPem}} +extract_certificate_data(DownResp, PrivKeyRecord) -> + DownBody = maps:get(<<"body">>, DownResp, #{}), + CertPem = maps:get(<<"certificate_pem">>, DownBody, <<>>), + DomainsOut = maps:get(<<"domains">>, DownBody, []), + PrivKeyPem = + case PrivKeyRecord of + not_found -> <<"">>; + Key -> ssl_cert_state:serialize_private_key(Key) + end, + {ok, {CertPem, DomainsOut, PrivKeyPem}}. + +%% @doc Optionally start HTTPS server with certificate. +%% +%% This function checks the auto_https configuration setting and conditionally +%% starts an HTTPS server with the provided certificate. If auto_https is +%% disabled, it skips the server startup. +%% +%% @param CertPem PEM-encoded certificate chain +%% @param PrivKeyPem PEM-encoded private key +%% @param DomainsOut List of domains for the certificate +%% @param Opts Server configuration options (checks auto_https setting) +%% @returns {started, ServerUrl} | {skipped, Reason} | {failed, Error} +maybe_start_https_server(CertPem, PrivKeyPem, DomainsOut, Opts) -> + {ok, SSLOpts} = extract_and_validate_ssl_params(Opts), + ?event(ssl_cert, {sslopts, {explicit, SSLOpts}}), + case hb_opts:get(<<"auto_https">>, true, SSLOpts) of + true -> + ?event( + ssl_cert, + { + starting_https_server_with_certificate, + {domains, DomainsOut} + } + ), + HttpsPort = hb_opts:get(<<"https_port">>, ?DEFAULT_HTTPS_PORT, SSLOpts), + start_https_server_with_certificate( + CertPem, + PrivKeyPem, + DomainsOut, + Opts, + HttpsPort + ); + false -> + ?event(ssl_cert, {auto_https_disabled, {domains, DomainsOut}}), + {skipped, auto_https_disabled} + end. + +%% @doc Start HTTPS server with certificate files. +%% +%% This function writes the certificate and key to temporary files, determines +%% the HTTP server to redirect from, and starts a new HTTPS server. It handles +%% all aspects of HTTPS server startup including redirect configuration. +%% +%% @param CertPem PEM-encoded certificate chain +%% @param PrivKeyPem PEM-encoded private key +%% @param DomainsOut List of domains for logging and tracking +%% @param Opts Server configuration options +%% @param HttpsPort HTTPS port number for the server +%% @returns {started, ServerUrl} or {failed, {Error, Reason}} +start_https_server_with_certificate( + CertPem,PrivKeyPem, DomainsOut, Opts, HttpsPort +) -> + maybe + {ok, {CertFile, KeyFile}} ?= + write_certificate_files(CertPem, PrivKeyPem), + RedirectTo = get_redirect_server_id(Opts), + ?event( + ssl_cert, + { + https_server_config, + {cert_file, CertFile}, + {key_file, KeyFile}, + {redirect_to, RedirectTo}, + {https_port, HttpsPort} + } + ), + try hb_http_server:start_https_node( + CertFile, + KeyFile, + Opts, + RedirectTo, + HttpsPort + ) of + ServerUrl when is_binary(ServerUrl) -> + ?event( + ssl_cert, + { + https_server_started_successfully, + {server_url, ServerUrl}, + {domains, DomainsOut} + } + ), + {started, ServerUrl} + catch + Error:Reason:Stacktrace -> + ?event(ssl_cert, + { + https_server_start_failed, + {error, Error}, + {reason, Reason}, + {stacktrace, Stacktrace}, + {domains, DomainsOut} + } + ), + {failed, {Error, Reason}} + end + end. + +%% @doc Write certificate and key to files. +%% +%% This function writes the PEM-encoded certificate and private key to +%% files that can be used by Cowboy for TLS configuration. It ensures +%% the target directory exists before writing files. +%% Both files must be written successfully for the operation to succeed. +%% +%% @param CertPem PEM-encoded certificate chain +%% @param PrivKeyPem PEM-encoded private key +%% @returns {ok, {CertFile, KeyFile}} or {error, Reason} +write_certificate_files(CertPem, PrivKeyPem) -> + CertFile = ?CERT_PEM_FILE, + KeyFile = ?KEY_PEM_FILE, + % Ensure the directory exists + case filelib:ensure_dir(filename:join(?CERT_DIR, "dummy")) of + ok -> + case { + file:write_file(CertFile, CertPem), + file:write_file(KeyFile, ssl_utils:bin(PrivKeyPem)) + } of + {ok, ok} -> {ok, {CertFile, KeyFile}}; + {Error, ok} -> Error; + {ok, Error} -> Error; + {Error1, _Error2} -> Error1 % Return first error if both fail + end; + {error, Reason} -> + {error, {failed_to_create_cert_directory, Reason}} + end. + +%% @doc Get the server ID for HTTP redirect setup. +%% +%% This function determines which HTTP server should be configured to +%% redirect +%% traffic to HTTPS. It first checks for an explicit http_server setting, +%% then falls back to using the current server's wallet address. +%% +%% @param Opts Server configuration options +%% @returns ServerID binary for the HTTP server to configure +get_redirect_server_id(Opts) -> + case hb_opts:get(http_server, no_server, Opts) of + no_server -> + % Fallback to current server wallet + hb_util:human_id( + ar_wallet:to_address( + hb_opts:get(priv_wallet, hb:wallet(), Opts) + ) + ); + ServerId -> + ServerId + end. + +%% @doc Build success response with certificate and HTTPS server info. +%% +%% This function constructs the final success response containing the +%% issued +%% certificate, private key, validation results, and HTTPS server status. +%% The response format is standardized for API consumers. +%% +%% @param DomainsOut List of domains the certificate covers +%% @param Results Validation results from challenge verification +%% @param HttpsResult HTTPS server startup result +%% @returns {ok, #{status => 200, body => ResponseMap}} +build_success_response(DomainsOut, Results, HttpsResult) -> + ResponseBody = #{ + <<"message">> => <<"Certificate issued successfully">>, + <<"domains">> => DomainsOut, + <<"results">> => Results, + <<"https_server">> => format_https_server_status(HttpsResult) + }, + ssl_utils:build_success_response(200, ResponseBody). + +%% @doc Format HTTPS server status for response. +%% +%% This function formats the HTTPS server startup result into a +%% standardized +%% response structure with status, URL, and descriptive message. It handles +%% success, failure, and skipped cases. +%% +%% @param HttpsResult Server startup result: {started, Url} | {failed, Error} +%% | {skipped, Reason} +%% @returns Map with status, server_url/error/reason, and message fields +format_https_server_status({started, ServerUrl}) -> + #{ + <<"status">> => <<"started">>, + <<"server_url">> => ServerUrl, + <<"message">> => iolist_to_binary([ + <<"HTTPS server started at ">>, + ServerUrl, + <<", HTTP traffic will be redirected">> + ]) + }; +format_https_server_status({failed, {Error, Reason}}) -> + #{ + <<"status">> => <<"failed">>, + <<"error">> => ssl_utils:bin(hb_format:term({Error, Reason})), + <<"message">> => + <<"Certificate issued but HTTPS server failed to start">> + }; +format_https_server_status({skipped, Reason}) -> + #{ + <<"status">> => <<"skipped">>, + <<"reason">> => ssl_utils:bin(Reason), + <<"message">> => + <<"Certificate issued, HTTPS server not started ", + "(auto_https disabled)">> + }. + +%% @doc Build response for pending certificate orders. +%% +%% This function creates a response for certificate orders that are not yet +%% valid, indicating that DNS challenge validation is still in progress or +%% incomplete. +%% +%% @param OrderStatus Current ACME order status (e.g., pending, +%% processing) +%% @param Results Validation results from challenge attempts +%% @param RequestState1 Updated request state for potential retry +%% @returns {ok, #{status => 200, body => ResponseMap}} +build_pending_response(OrderStatus, Results, RequestState1) -> + ResponseBody = #{ + <<"message">> => <<"Validation not complete">>, + <<"order_status">> => OrderStatus, + <<"results">> => Results, + <<"request_state">> => RequestState1 + }, + ssl_utils:build_success_response(200, ResponseBody). + +%% @doc Build response when certificate is still processing. +%% +%% This function creates a response for orders that have been finalized +%% but +%% where the certificate is not yet ready for download from Let's +%% Encrypt. +%% This typically happens when there's a delay in certificate issuance. +%% +%% @param Results Validation results from challenge verification +%% @returns {ok, #{status => 200, body => ResponseMap}} +build_processing_response(Results) -> + ResponseBody = #{ + <<"message">> => + <<"Order finalized; certificate not ready for download yet">>, + <<"order_status">> => ?ACME_STATUS_PROCESSING, + <<"results">> => Results + }, + ssl_utils:build_success_response(200, ResponseBody). + +%% @doc Extract and validate SSL parameters from options. +%% +%% This function loads server options, extracts SSL configuration, and +%% validates all required parameters using the ssl_cert_validation +%% module. +%% It leverages the library's comprehensive validation functions. +%% +%% @param Opts Server configuration options +%% @returns {ok, ValidatedParams} or {error, Reason} +extract_and_validate_ssl_params(Opts) -> + maybe + LoadedOpts = hb_cache:ensure_all_loaded(Opts, Opts), + StrippedOpts = + maps:without( + [<<"ssl_cert_rsa_key">>, <<"ssl_cert_opts">>], + LoadedOpts + ), + ?event({ssl_cert_request_started_with_opts, StrippedOpts}), + % Extract SSL options from configuration + {ok, SslOpts} ?= extract_ssl_opts(StrippedOpts), + % Extract parameters + Domains = maps:get(<<"domains">>, SslOpts, not_found), + Email = maps:get(<<"email">>, SslOpts, not_found), + Environment = maps:get(<<"environment">>, SslOpts, staging), + ?event({ + ssl_cert_request_params_from_config, + {domains, Domains}, + {email, Email}, + {environment, Environment} + }), + % Use library validation function - this does all the heavy lifting! + {ok, ValidatedParams} ?= + ssl_cert_validation:validate_request_params( + Domains, + Email, + Environment + ), + % Enhance with system defaults (library already includes key_size) + EnhancedParams = ValidatedParams#{ + storage_path => ?SSL_CERT_STORAGE_PATH + }, + {ok, EnhancedParams} + end. + +%% @doc Process the complete certificate request workflow. +%% +%% This function handles the ACME certificate request processing and +%% state persistence using the ssl_cert_ops module. It orchestrates +%% the request submission and state management. +%% +%% @param ValidatedParams Validated certificate request parameters +%% @param Opts Server configuration options +%% @returns {ok, {RequestState, ChallengeData}} or {error, Reason} +process_certificate_request_workflow(ValidatedParams, Opts) -> + maybe + % Process the certificate request using library function + Wallet = hb_opts:get(priv_wallet, hb:wallet(), Opts), + {ok, ProcResp} ?= + ssl_cert_ops:process_certificate_request(ValidatedParams, Wallet), + {ok, {RequestState, ChallengeData}} ?= + persist_request_state(ProcResp, Opts), + {ok, {RequestState, ChallengeData}} + end. + +%% @doc Build the certificate request response. +%% +%% This function constructs the response for a successful certificate +%% request +%% using the ssl_utils response building functions. It includes DNS challenges +%% and instructions for the next step. +%% +%% @param RequestState Certificate request state data (unused but kept +%% for consistency) +%% @param FormattedChallenges Formatted DNS challenges for the response +%% @returns {ok, #{status => 200, body => ResponseMap}} +build_request_response(_RequestState, FormattedChallenges) -> + ResponseBody = #{ + <<"message">> => + << + "Create DNS TXT records for the following", + " challenges, then call finalize" + >>, + <<"challenges">> => FormattedChallenges, + <<"next_step">> => <<"finalize">> + }, + ssl_utils:build_success_response(200, ResponseBody). + +%% @doc Persist certificate request state in server options. +%% +%% This function extracts the request state and certificate key from +%% the +%% processing response and persists them in the server options for later +%% retrieval during finalization. It uses ssl_cert_challenge library +%% functions for formatting challenges. +%% +%% @param ProcResp Processing response from certificate request +%% @param Opts Server configuration options +%% @returns {ok, {RequestState, ChallengeData}} or {error, Reason} +persist_request_state(ProcResp, Opts) -> + maybe + NewOpts = hb_http_server:get_opts(Opts), + ProcBody = maps:get(<<"body">>, ProcResp, #{}), + RequestState0 = maps:get(<<"request_state">>, ProcBody, #{}), + CertificateKey = maps:get(<<"certificate_key">>, ProcBody, not_found), + ?event({ssl_cert_orchestration_created_request}), + % Persist request state in node opts (overwrites previous) + ok = hb_http_server:set_opts( + NewOpts#{ + <<"priv_ssl_cert_request">> => RequestState0, + <<"priv_ssl_cert_rsa_key">> => CertificateKey + } + ), + % Format challenges using library function + Challenges = maps:get(<<"challenges">>, RequestState0, []), + FormattedChallenges = + ssl_cert_challenge:format_challenges_for_response(Challenges), + {ok, {RequestState0, FormattedChallenges}} + end. + diff --git a/src/hb_http_client.erl b/src/hb_http_client.erl index 4df7e3ce9..cce3b7478 100644 --- a/src/hb_http_client.erl +++ b/src/hb_http_client.erl @@ -22,7 +22,10 @@ start_link(Opts) -> req(Args, Opts) -> req(Args, false, Opts). req(Args, ReestablishedConnection, Opts) -> case hb_opts:get(http_client, gun, Opts) of - gun -> gun_req(Args, ReestablishedConnection, Opts); + gun -> + MaxRedirects = hb_maps:get(gun_max_redirects, Opts, 5), + GunArgs = Args#{redirects_left => MaxRedirects}, + gun_req(GunArgs, ReestablishedConnection, Opts); httpc -> httpc_req(Args, ReestablishedConnection, Opts) end. @@ -35,11 +38,13 @@ httpc_req(Args, _, Opts) -> body := Body } = Args, ?event({httpc_req, Args}), - {Host, Port} = parse_peer(Peer, Opts), - Scheme = case Port of - 443 -> "https"; - _ -> "http" + ParsedPeer = uri_string:parse(iolist_to_binary(Peer)), + #{ scheme := Scheme, host := Host } = ParsedPeer, + DefaultPort = case Scheme of + <<"https">> -> 443; + <<"http">> -> 80 end, + Port = maps:get(port, ParsedPeer, DefaultPort), ?event(http_client, {httpc_req, {explicit, Args}}), URL = binary_to_list(iolist_to_binary([Scheme, "://", Host, ":", integer_to_binary(Port), Path])), FilteredHeaders = hb_maps:without([<<"content-type">>, <<"cookie">>], Headers, Opts), @@ -78,9 +83,11 @@ httpc_req(Args, _, Opts) -> } end, ?event({http_client_outbound, Method, URL, Request}), + FollowRedirects = hb_maps:get(http_follow_redirects, Opts, true), + ReqOpts = [{autoredirect, FollowRedirects}], HTTPCOpts = [{full_result, true}, {body_format, binary}], StartTime = os:system_time(millisecond), - case httpc:request(Method, Request, [], HTTPCOpts) of + case httpc:request(Method, Request, ReqOpts, HTTPCOpts) of {ok, {{_, Status, _}, RawRespHeaders, RespBody}} -> EndTime = os:system_time(millisecond), RespHeaders = @@ -105,46 +112,57 @@ httpc_req(Args, _, Opts) -> end. gun_req(Args, ReestablishedConnection, Opts) -> - StartTime = os:system_time(millisecond), - #{ peer := Peer, path := Path, method := Method } = Args, - Response = + StartTime = os:system_time(millisecond), + #{ peer := Peer, path := Path, method := Method, redirects_left := RedirectsLeft } = Args, + Response = case catch gen_server:call(?MODULE, {get_connection, Args, Opts}, infinity) of {ok, PID} -> ar_rate_limiter:throttle(Peer, Path, Opts), case request(PID, Args, Opts) of - {error, Error} when Error == {shutdown, normal}; - Error == noproc -> + {error, Error} when Error == {shutdown, normal}; Error == noproc -> case ReestablishedConnection of true -> {error, client_error}; false -> req(Args, true, Opts) end; - Reply -> - Reply - end; + Reply = {_Ok, StatusCode, RedirectRes, _} -> + FollowRedirects = hb_maps:get(http_follow_redirects, Opts, true), + case lists:member(StatusCode, [301, 302, 307, 308]) of + true when FollowRedirects, RedirectsLeft > 0 -> + RedirectArgs = Args#{ redirects_left := RedirectsLeft - 1 }, + handle_redirect( + RedirectArgs, + ReestablishedConnection, + Opts, + RedirectRes, + Reply + ); + _ -> Reply + end + end; {'EXIT', _} -> {error, client_error}; Error -> Error - end, - EndTime = os:system_time(millisecond), - %% Only log the metric for the top-level call to req/2 - not the recursive call - %% that happens when the connection is reestablished. - case ReestablishedConnection of - true -> - ok; - false -> - record_duration(#{ - <<"request-method">> => method_to_bin(Method), - <<"request-path">> => hb_util:bin(Path), - <<"status-class">> => get_status_class(Response), - <<"duration">> => EndTime - StartTime - }, - Opts - ) - end, - Response. + end, + EndTime = os:system_time(millisecond), + %% Only log the metric for the top-level call to req/2 - not the recursive call + %% that happens when the connection is reestablished. + case ReestablishedConnection of + true -> + ok; + false -> + record_duration(#{ + <<"request-method">> => method_to_bin(Method), + <<"request-path">> => hb_util:bin(Path), + <<"status-class">> => get_status_class(Response), + <<"duration">> => EndTime - StartTime + }, + Opts + ) + end, + Response. %% @doc Record the duration of the request in an async process. We write the %% data to prometheus if the application is enabled, as well as invoking the @@ -421,6 +439,7 @@ handle_info({gun_down, PID, Protocol, Reason, _KilledStreams, _UnprocessedStream handle_info({'DOWN', _Ref, process, PID, Reason}, #state{ pid_by_peer = PIDByPeer, status_by_pid = StatusByPID } = State) -> + ?event(redirect, {down, {pid, PID}, {reason, Reason}}), case hb_maps:get(PID, StatusByPID, not_found) of not_found -> {noreply, State}; @@ -455,6 +474,37 @@ terminate(Reason, #state{ status_by_pid = StatusByPID }) -> %%% Private functions. %%% ================================================================== +handle_redirect(Args, ReestablishedConnection, Opts, Res, Reply) -> + case lists:keyfind(<<"location">>, 1, Res) of + false -> + % There's no Location header, so we can't follow the redirect. + Reply; + {_LocationHeaderName, Location} -> + case uri_string:parse(Location) of + {error, _Reason, _Detail} -> + % Server returned a Location header but the URI was malformed. + Reply; + Parsed -> + #{ scheme := NewScheme, host := NewHost, path := NewPath } = Parsed, + Port = maps:get(port, Parsed, undefined), + FormattedPort = case Port of + undefined -> ""; + _ -> lists:flatten(io_lib:format(":~i", [Port])) + end, + NewPeer = lists:flatten( + io_lib:format( + "~s://~s~s~s", + [NewScheme, NewHost, FormattedPort, NewPath] + ) + ), + NewArgs = Args#{ + peer := NewPeer, + path := NewPath + }, + gun_req(NewArgs, ReestablishedConnection, Opts) + end + end. + %% @doc Safe wrapper for prometheus_gauge:inc/2. inc_prometheus_gauge(Name) -> case application:get_application(prometheus) of @@ -481,7 +531,13 @@ inc_prometheus_counter(Name, Labels, Value) -> end. open_connection(#{ peer := Peer }, Opts) -> - {Host, Port} = parse_peer(Peer, Opts), + ParsedPeer = uri_string:parse(iolist_to_binary(Peer)), + #{ scheme := Scheme, host := Host } = ParsedPeer, + DefaultPort = case Scheme of + <<"https">> -> 443; + <<"http">> -> 80 + end, + Port = maps:get(port, ParsedPeer, DefaultPort), ?event(http_outbound, {parsed_peer, {peer, Peer}, {host, Host}, {port, Port}}), BaseGunOpts = #{ @@ -503,9 +559,9 @@ open_connection(#{ peer := Peer }, Opts) -> ) }, Transport = - case Port of - 443 -> tls; - _ -> tcp + case Scheme of + <<"https">> -> tls; + <<"http">> -> tcp end, DefaultProto = case hb_features:http3() of @@ -516,7 +572,13 @@ open_connection(#{ peer := Peer }, Opts) -> GunOpts = case Proto = hb_opts:get(protocol, DefaultProto, Opts) of http3 -> BaseGunOpts#{protocols => [http3], transport => quic}; - _ -> BaseGunOpts + _ -> BaseGunOpts#{ + transport => Transport, + tls_opts => [ + % {verify, verify_none}, % For development - disable peer verification + {cacerts, public_key:cacerts_get()} + ] + } end, ?event(http_outbound, {gun_open, @@ -526,22 +588,7 @@ open_connection(#{ peer := Peer }, Opts) -> {transport, Transport} } ), - gun:open(Host, Port, GunOpts). - -parse_peer(Peer, Opts) -> - Parsed = uri_string:parse(Peer), - case Parsed of - #{ host := Host, port := Port } -> - {hb_util:list(Host), Port}; - URI = #{ host := Host } -> - { - hb_util:list(Host), - case hb_maps:get(scheme, URI, undefined, Opts) of - <<"https">> -> 443; - _ -> hb_opts:get(port, 8734, Opts) - end - } - end. + gun:open(hb_util:list(Host), Port, GunOpts). reply_error([], _Reason) -> ok; @@ -755,4 +802,4 @@ get_status_class(Data) when is_binary(Data) -> get_status_class(Data) when is_atom(Data) -> atom_to_binary(Data); get_status_class(_) -> - <<"unknown">>. \ No newline at end of file + <<"unknown">>. diff --git a/src/hb_http_server.erl b/src/hb_http_server.erl index 9a6c0453d..688a75f28 100644 --- a/src/hb_http_server.erl +++ b/src/hb_http_server.erl @@ -1,29 +1,114 @@ -%%% @doc A router that attaches a HTTP server to the AO-Core resolver. -%%% Because AO-Core is built to speak in HTTP semantics, this module -%%% only has to marshal the HTTP request into a message, and then -%%% pass it to the AO-Core resolver. -%%% -%%% `hb_http:reply/4' is used to respond to the client, handling the -%%% process of converting a message back into an HTTP response. -%%% -%%% The router uses an `Opts' message as its Cowboy initial state, -%%% such that changing it on start of the router server allows for -%%% the execution parameters of all downstream requests to be controlled. +%%% @doc HyperBEAM HTTP/HTTPS server with SSL certificate integration. +%%% +%%% This module provides a complete HTTP and HTTPS server implementation +%%% for HyperBEAM nodes, with automatic SSL certificate management and +%%% HTTP to HTTPS redirect capabilities. +%%% +%%% Key features: +%%% - HTTP server with AO-Core integration for message processing +%%% - HTTPS server with automatic SSL certificate deployment +%%% - HTTP to HTTPS redirect with 301 Moved Permanently responses +%%% - SSL certificate integration via dev_ssl_cert device +%%% - Configurable ports for development and production +%%% - Prometheus metrics integration (optional) +%%% - Complete application lifecycle management +%%% +%%% The module marshals HTTP requests into HyperBEAM message format, +%%% processes them through the AO-Core resolver, and converts responses +%%% back to HTTP format using `hb_http:reply/4'. +%%% +%%% Configuration is managed through an `Opts' message that serves as +%%% Cowboy's initial state, allowing dynamic control of execution +%%% parameters for all downstream requests. -module(hb_http_server). --export([start/0, start/1, allowed_methods/2, init/2]). --export([set_opts/1, set_opts/2, get_opts/0, get_opts/1]). --export([set_default_opts/1, set_proc_server_id/1]). --export([start_node/0, start_node/1]). + +%% Public API exports +-export([ + start/0, start/1, + start_node/0, start_node/1, + start_https_node/5 +]). + +%% Request handling exports +-export([ + init/2, + allowed_methods/2 +]). + +%% HTTPS and redirect exports +-export([ + redirect_to_https/3 +]). + +%% Configuration and state management exports +-export([ + set_opts/1, set_opts/2, + get_opts/0, get_opts/1, + set_default_opts/1, + set_proc_server_id/1 +]). + +%% Type specifications +-type server_opts() :: map(). +-type server_id() :: binary(). +-type listener_ref() :: pid(). + +%% Function specifications +-spec start() -> {ok, listener_ref()}. +-spec start(server_opts()) -> {ok, listener_ref()}. +-spec start_node() -> binary(). +-spec start_node(server_opts()) -> binary(). +-spec start_https_node( + binary(), + binary(), + server_opts(), + server_id() | no_server, + integer() +) -> binary(). +-spec redirect_to_https(cowboy_req:req(), server_opts(), integer()) -> + {ok, cowboy_req:req(), server_opts()}. + -include_lib("eunit/include/eunit.hrl"). -include("include/hb.hrl"). -%% @doc Starts the HTTP server. Optionally accepts an `Opts' message, which -%% is used as the source for server configuration settings, as well as the -%% `Opts' argument to use for all AO-Core resolution requests downstream. +%% Default configuration constants +-define(DEFAULT_HTTP_PORT, 8734). +-define(DEFAULT_IDLE_TIMEOUT, 300000). +-define(DEFAULT_CONFIG_FILE, <<"config.flat">>). +-define(DEFAULT_PRIV_KEY_FILE, <<"hyperbeam-key.json">>). +-define(DEFAULT_DASHBOARD_PATH, <<"/~hyperbuddy@1.0/dashboard">>). +-define(RANDOM_PORT_MIN, 10000). +-define(RANDOM_PORT_RANGE, 50000). + +%% Test certificate paths +-define(TEST_CERT_FILE, "test/test-tls.pem"). +-define(TEST_KEY_FILE, "test/test-tls.key"). + +%% HTTP/3 timeouts +-define(HTTP3_STARTUP_TIMEOUT, 2000). + +%%% =================================================================== +%%% Public API & Main Entry Points +%%% =================================================================== + +%% @doc Starts the HTTP server with configuration loading and setup. +%% +%% This function performs the complete HTTP server initialization including: +%% 1. Loading configuration from files +%% 2. Setting up store and wallet configuration +%% 3. Displaying the startup greeter message +%% 4. Starting the HTTP server with merged configuration +%% +%% The function loads configuration from the configured location, merges it +%% with environment defaults, and starts all necessary services. +%% +%% @returns {ok, Listener} where Listener is the Cowboy listener PID start() -> ?event(http, {start_store, <<"cache-mainnet">>}), Loaded = - case hb_opts:load(Loc = hb_opts:get(hb_config_location, <<"config.flat">>)) of + case hb_opts:load( + Loc = hb_opts:get(hb_config_location, ?DEFAULT_CONFIG_FILE) + ) of {ok, Conf} -> ?event(boot, {loaded_config, Loc, Conf}), Conf; @@ -42,7 +127,8 @@ start() -> UpdatedStoreOpts = case StoreOpts of no_store -> no_store; - _ when is_list(StoreOpts) -> hb_store_opts:apply(StoreOpts, StoreDefaults); + _ when is_list(StoreOpts) -> + hb_store_opts:apply(StoreOpts, StoreDefaults); _ -> StoreOpts end, hb_store:start(UpdatedStoreOpts), @@ -50,170 +136,128 @@ start() -> hb:wallet( hb_opts:get( priv_key_location, - <<"hyperbeam-key.json">>, + ?DEFAULT_PRIV_KEY_FILE, Loaded ) ), - maybe_greeter(MergedConfig, PrivWallet), + print_greeter_if_not_test(MergedConfig, PrivWallet), start( Loaded#{ priv_wallet => PrivWallet, store => UpdatedStoreOpts, - port => hb_opts:get(port, 8734, Loaded), - cache_writers => [hb_util:human_id(ar_wallet:to_address(PrivWallet))] + port => hb_opts:get(port, ?DEFAULT_HTTP_PORT, Loaded), + cache_writers => + [hb_util:human_id(ar_wallet:to_address(PrivWallet))] } ). + +%% @doc Starts the HTTP server with provided options. +%% +%% This function starts the HTTP server using the provided configuration +%% options. It ensures all required applications are started, initializes +%% HyperBEAM, and creates the server with default option processing. +%% +%% @param Opts Configuration options map for the server +%% @returns {ok, Listener} where Listener is the Cowboy listener PID start(Opts) -> - application:ensure_all_started([ - kernel, - stdlib, - inets, - ssl, - ranch, - cowboy, - gun, - os_mon - ]), + start_required_applications(), hb:init(), BaseOpts = set_default_opts(Opts), {ok, Listener, _Port} = new_server(BaseOpts), {ok, Listener}. -%% @doc Print the greeter message to the console if we are not running tests. -maybe_greeter(MergedConfig, PrivWallet) -> - case hb_features:test() of - false -> - print_greeter(MergedConfig, PrivWallet); - true -> - ok - end. +%% @doc Start a test node with default configuration. +%% +%% This function starts a complete HyperBEAM node for testing purposes +%% using default configuration. It's a convenience wrapper around +%% start_node/1 with an empty options map. +%% +%% @returns Node URL binary for making HTTP requests +start_node() -> + start_node(#{}). -%% @doc Print the greeter message to the console. Includes the version, operator -%% address, URL to access the node, and the wider configuration (including the -%% keys inherited from the default configuration). -print_greeter(Config, PrivWallet) -> - FormattedConfig = hb_format:term(Config, Config, 2), - io:format("~n" - "===========================================================~n" - "== ██╗ ██╗██╗ ██╗██████╗ ███████╗██████╗ ==~n" - "== ██║ ██║╚██╗ ██╔╝██╔══██╗██╔════╝██╔══██╗ ==~n" - "== ███████║ ╚████╔╝ ██████╔╝█████╗ ██████╔╝ ==~n" - "== ██╔══██║ ╚██╔╝ ██╔═══╝ ██╔══╝ ██╔══██╗ ==~n" - "== ██║ ██║ ██║ ██║ ███████╗██║ ██║ ==~n" - "== ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ==~n" - "== ==~n" - "== ██████╗ ███████╗ █████╗ ███╗ ███╗ VERSION: ==~n" - "== ██╔══██╗██╔════╝██╔══██╗████╗ ████║ v~p. ==~n" - "== ██████╔╝█████╗ ███████║██╔████╔██║ ==~n" - "== ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║ EAT GLASS, ==~n" - "== ██████╔╝███████╗██║ ██║██║ ╚═╝ ██║ BUILD THE ==~n" - "== ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ FUTURE. ==~n" - "===========================================================~n" - "== Node activate at: ~s ==~n" - "== Operator: ~s ==~n" - "===========================================================~n" - "== Config: ==~n" - "===========================================================~n" - " ~s~n" - "===========================================================~n", - [ - ?HYPERBEAM_VERSION, - string:pad( - lists:flatten( - io_lib:format( - "http://~s:~p", - [ - hb_opts:get(host, <<"localhost">>, Config), - hb_opts:get(port, 8734, Config) - ] - ) - ), - 35, leading, $ - ), - hb_util:human_id(ar_wallet:to_address(PrivWallet)), - FormattedConfig - ] - ). +%% @doc Start a complete HyperBEAM node with custom configuration. +%% +%% This function performs complete node startup including: +%% 1. Starting all required Erlang applications +%% 2. Initializing HyperBEAM core systems +%% 3. Starting the supervisor tree +%% 4. Creating and starting the HTTP server +%% 5. Returning the node URL for client connections +%% +%% @param Opts Configuration options map for the node +%% @returns Node URL binary like <<"http://localhost:8734/">> +start_node(Opts) -> + start_required_applications(), + hb:init(), + hb_sup:start_link(Opts), + ServerOpts = set_default_opts(Opts), + {ok, _Listener, Port} = new_server(ServerOpts), + <<"http://localhost:", (integer_to_binary(Port))/binary, "/">>. -%% @doc Trigger the creation of a new HTTP server node. Accepts a `NodeMsg' -%% message, which is used to configure the server. This function executed the -%% `start' hook on the node, giving it the opportunity to modify the `NodeMsg' -%% before it is used to configure the server. The `start' hook expects gives and -%% expects the node message to be in the `body' key. +%% @doc Start an HTTPS node with the given certificate and key. +%% +%% This function follows the same pattern as start_node() but creates an HTTPS +%% server instead of HTTP. It does complete application startup, supervisor +%% initialization, and proper node configuration. +%% +%% @param CertFile Path to certificate PEM file +%% @param KeyFile Path to private key PEM file +%% @param Opts Server configuration options (supports https_port) +%% @param RedirectTo HTTP server ID to configure for redirect +%% @param HttpsPort HTTPS port number for the server +%% @returns HTTPS node URL binary like <<"https://localhost:8443/">> +start_https_node(CertFile, KeyFile, Opts, RedirectTo, HttpsPort) -> + ?event(https, {starting_https_node, {opts_keys, maps:keys(Opts)}}), + % Ensure all required applications are started + start_required_applications(), + % Initialize HyperBEAM + hb:init(), + % Start supervisor with HTTPS-specific options + StrippedOpts = maps:without([port], Opts), + HttpsOpts = StrippedOpts#{ + port => HttpsPort + }, + hb_sup:start_link(HttpsOpts), + % Set up server options for HTTPS + ServerOpts = set_default_opts(HttpsOpts), + % Create the HTTPS server using new_server with TLS transport + {ok, _Listener, Port} = + new_https_server(ServerOpts, CertFile, KeyFile, RedirectTo, HttpsPort), + % Return HTTPS URL + <<"https://localhost:", (integer_to_binary(Port))/binary, "/">>. + +%%% =================================================================== +%%% Core Server Creation +%%% =================================================================== + +%% @doc Create a new HTTP server with full configuration processing. +%% +%% This function handles the complete HTTP server creation workflow: +%% 1. Merging provided options with environment defaults +%% 2. Processing startup hooks for configuration modification +%% 3. Generating unique server identifiers +%% 4. Setting up Cowboy dispatchers and protocol options +%% 5. Configuring optional Prometheus metrics +%% 6. Starting the appropriate protocol listener (HTTP/2 or HTTP/3) +%% +%% @param RawNodeMsg Raw node message configuration +%% @returns {ok, Listener, Port} or {error, Reason} new_server(RawNodeMsg) -> + % Prepare node message with defaults RawNodeMsgWithDefaults = hb_maps:merge( hb_opts:default_message_with_env(), RawNodeMsg#{ only => local } ), - HookMsg = #{ <<"body">> => RawNodeMsgWithDefaults }, - NodeMsg = - case dev_hook:on(<<"start">>, HookMsg, RawNodeMsgWithDefaults) of - {ok, #{ <<"body">> := NodeMsgAfterHook }} -> NodeMsgAfterHook; - Unexpected -> - ?event(http, - {failed_to_start_server, - {unexpected_hook_result, Unexpected} - } - ), - throw( - {failed_to_start_server, - {unexpected_hook_result, Unexpected} - } - ) - end, - % Put server ID into node message so it's possible to update current server + % Process startup hooks using shared utility + {ok, NodeMsg} = process_server_hooks(RawNodeMsgWithDefaults), + % Initialize HTTP and create server ID hb_http:start(), - ServerID = - hb_util:human_id( - ar_wallet:to_address( - hb_opts:get( - priv_wallet, - no_wallet, - NodeMsg - ) - ) - ), - % Put server ID into node message so it's possible to update current server - % params - NodeMsgWithID = hb_maps:put(http_server, ServerID, NodeMsg), - Dispatcher = cowboy_router:compile([{'_', [{'_', ?MODULE, ServerID}]}]), - ProtoOpts = #{ - env => #{dispatch => Dispatcher, node_msg => NodeMsgWithID}, - stream_handlers => [cowboy_stream_h], - max_connections => infinity, - idle_timeout => hb_opts:get(idle_timeout, 300000, NodeMsg) - }, - PrometheusOpts = - case hb_opts:get(prometheus, not hb_features:test(), NodeMsg) of - true -> - ?event(prometheus, - {starting_prometheus, {test_mode, hb_features:test()}} - ), - % Attempt to start the prometheus application, if possible. - try - application:ensure_all_started([prometheus, prometheus_cowboy]), - ProtoOpts#{ - metrics_callback => - fun prometheus_cowboy2_instrumenter:observe/1, - stream_handlers => [cowboy_metrics_h, cowboy_stream_h] - } - catch - Type:Reason -> - % If the prometheus application is not started, we can - % still start the HTTP server, but we won't have any - % metrics. - ?event(prometheus, - {prometheus_not_started, {type, Type}, {reason, Reason}} - ), - ProtoOpts - end; - false -> - ?event(prometheus, - {prometheus_not_started, {test_mode, hb_features:test()}} - ), - ProtoOpts - end, + ServerID = generate_server_id(NodeMsg), + % Create protocol options with Prometheus support + ProtoOpts = create_base_protocol_opts(ServerID, NodeMsg), + PrometheusOpts = add_prometheus_if_enabled(ProtoOpts, NodeMsg), DefaultProto = case hb_features:http3() of true -> http3; @@ -239,19 +283,85 @@ new_server(RawNodeMsg) -> ), {ok, Listener, Port}. +%% @doc Create a new HTTPS server with TLS configuration. +%% +%% This function creates an HTTPS server using the provided SSL certificate +%% files. It handles the complete HTTPS server setup including: +%% 1. Processing server startup hooks +%% 2. Creating unique HTTPS server identifiers +%% 3. Setting up dispatchers and protocol options +%% 4. Configuring Prometheus metrics if enabled +%% 5. Starting the TLS listener with certificates +%% 6. Setting up HTTP to HTTPS redirect if requested +%% +%% @param Opts Server configuration options +%% @param CertFile Path to SSL certificate PEM file +%% @param KeyFile Path to SSL private key PEM file +%% @param RedirectTo HTTP server ID to configure for redirect (or no_server) +%% @param HttpsPort HTTPS port number for the server +%% @returns {ok, Listener, Port} or {error, Reason} +new_https_server(Opts, CertFile, KeyFile, RedirectTo, HttpsPort) -> + ?event(https, {creating_new_https_server, {opts_keys, maps:keys(Opts)}}), + try + {ok, NodeMsg} = process_server_hooks(Opts), + {_ServerID, HttpsServerID} = create_https_server_id(NodeMsg), + {_Dispatcher, ProtoOpts} = + create_https_dispatcher(HttpsServerID, NodeMsg), + FinalProtoOpts = add_prometheus_if_enabled(ProtoOpts, NodeMsg), + {ok, Listener} = + start_tls_listener( + HttpsServerID, + HttpsPort, + CertFile, + KeyFile, + FinalProtoOpts + ), + setup_redirect_if_needed(RedirectTo, NodeMsg, HttpsPort), + {ok, Listener, HttpsPort} + catch + Error:Reason:Stacktrace -> + ?event( + https, + { + https_server_creation_failed, + {error, Error}, + {reason, Reason}, + {stacktrace, Stacktrace} + } + ), + {error, {Error, Reason}} + end. + +%%% =================================================================== +%%% Protocol-Specific Server Functions +%%% =================================================================== + +%% @doc Start HTTP/3 server using QUIC transport. +%% +%% This function starts an HTTP/3 server using the QUIC protocol for +%% enhanced performance. It handles: +%% 1. Starting the QUICER application for QUIC support +%% 2. Creating a Cowboy QUIC listener with test certificates +%% 3. Configuring Ranch server options for QUIC transport +%% 4. Setting up connection supervision +%% +%% @param ServerID Unique server identifier +%% @param ProtoOpts Protocol options for Cowboy +%% @param NodeMsg Node configuration message +%% @returns {ok, Port, ServerPID} or {error, Reason} start_http3(ServerID, ProtoOpts, NodeMsg) -> ?event(http, {start_http3, ServerID}), Parent = self(), ServerPID = spawn(fun() -> application:ensure_all_started(quicer), - {ok, Listener} = cowboy:start_quic( + {ok, _Listener} = cowboy:start_quic( ServerID, TransOpts = #{ socket_opts => [ - {certfile, "test/test-tls.pem"}, - {keyfile, "test/test-tls.key"}, - {port, Port = hb_opts:get(port, 8734, NodeMsg)} + {certfile, ?TEST_CERT_FILE}, + {keyfile, ?TEST_KEY_FILE}, + {port, Port = hb_opts:get(port, ?DEFAULT_HTTP_PORT, NodeMsg)} ] }, ProtoOpts @@ -276,10 +386,17 @@ start_http3(ServerID, ProtoOpts, NodeMsg) -> receive stop -> stopped end end), receive {ok, Port} -> {ok, Port, ServerPID} - after 2000 -> + after ?HTTP3_STARTUP_TIMEOUT -> {error, {timeout, starting_http3_server, ServerID}} end. +%% @doc HTTP/3 connection supervisor loop. +%% +%% This function provides a minimal connection supervisor for HTTP/3 +%% servers. QUIC doesn't use traditional connection supervisors, so +%% this is a placeholder that ignores all messages. +%% +%% @returns never returns (infinite loop) http3_conn_sup_loop() -> receive _ -> @@ -287,18 +404,33 @@ http3_conn_sup_loop() -> http3_conn_sup_loop() end. +%% @doc Start HTTP/2 server using TCP transport. +%% +%% This function starts an HTTP/2 server with fallback to HTTP/1.1 +%% using TCP transport. It handles: +%% 1. Starting a Cowboy clear (non-TLS) listener +%% 2. Port configuration and binding +%% 3. Restart handling for already-started listeners +%% +%% @param ServerID Unique server identifier +%% @param ProtoOpts Protocol options for Cowboy +%% @param NodeMsg Node configuration message +%% @returns {ok, Port, Listener} or {error, Reason} start_http2(ServerID, ProtoOpts, NodeMsg) -> ?event(http, {start_http2, ServerID}), StartRes = cowboy:start_clear( ServerID, [ - {port, Port = hb_opts:get(port, 8734, NodeMsg)} + {port, Port = hb_opts:get(port, ?DEFAULT_HTTP_PORT, NodeMsg)} ], ProtoOpts ), case StartRes of {ok, Listener} -> - ?event(debug_router_info, {http2_started, {listener, Listener}, {port, Port}}), + ?event( + debug_router_info, + {http2_started, {listener, Listener}, {port, Port}} + ), {ok, Port, Listener}; {error, {already_started, Listener}} -> ?event(http, {http2_already_started, {listener, Listener}}), @@ -314,9 +446,28 @@ start_http2(ServerID, ProtoOpts, NodeMsg) -> start_http2(ServerID, ProtoOpts, NodeMsg) end. -%% @doc Entrypoint for all HTTP requests. Receives the Cowboy request option and -%% the server ID, which can be used to lookup the node message. + +%%% =================================================================== +%%% Request Handling +%%% =================================================================== + +%% @doc Entrypoint for all HTTP requests. +%% +%% This function serves as the main entry point for all incoming HTTP +%% requests. It handles two types of requests: +%% 1. Redirect requests - configured to redirect HTTP to HTTPS +%% 2. Normal requests - standard HyperBEAM request processing +%% +%% The function routes requests based on the handler state type. +%% +%% @param Req Cowboy request object +%% @param State Either {redirect_https, Opts, HttpsPort} or ServerID +%% @returns {ok, UpdatedReq, State} +init(Req, {redirect_https, Opts, HttpsPort}) -> + % Handle HTTPS redirect + redirect_to_https(Req, Opts, HttpsPort); init(Req, ServerID) -> + % Handle normal requests case cowboy_req:method(Req) of <<"OPTIONS">> -> cors_reply(Req, ServerID); _ -> @@ -324,29 +475,20 @@ init(Req, ServerID) -> handle_request(Req, Body, ServerID) end. -%% @doc Helper to grab the full body of a HTTP request, even if it's chunked. -read_body(Req) -> read_body(Req, <<>>). -read_body(Req0, Acc) -> - case cowboy_req:read_body(Req0) of - {ok, Data, _Req} -> {ok, << Acc/binary, Data/binary >>}; - {more, Data, Req} -> read_body(Req, << Acc/binary, Data/binary >>) - end. - -%% @doc Reply to CORS preflight requests. -cors_reply(Req, _ServerID) -> - Req2 = cowboy_req:reply(204, #{ - <<"access-control-allow-origin">> => <<"*">>, - <<"access-control-allow-headers">> => <<"*">>, - <<"access-control-allow-methods">> => - <<"GET, POST, PUT, DELETE, OPTIONS, PATCH">> - }, Req), - ?event(http_debug, {cors_reply, {req, Req}, {req2, Req2}}), - {ok, Req2, no_state}. - -%% @doc Handle all non-CORS preflight requests as AO-Core requests. Execution -%% starts by parsing the HTTP request into HyerBEAM's message format, then -%% passing the message directly to `meta@1.0' which handles calling AO-Core in -%% the appropriate way. +%% @doc Handle all non-CORS preflight requests as AO-Core requests. +%% +%% This function processes normal HTTP requests through the AO-Core system: +%% 1. Adding request timing information +%% 2. Retrieving server configuration options +%% 3. Handling root path redirects to default dashboard +%% 4. Parsing HTTP requests into HyperBEAM message format +%% 5. Invoking the meta@1.0 device for request processing +%% 6. Converting responses back to HTTP format +%% +%% @param RawReq Raw Cowboy request object +%% @param Body HTTP request body as binary +%% @param ServerID Server identifier for configuration lookup +%% @returns {ok, UpdatedReq, State} handle_request(RawReq, Body, ServerID) -> % Insert the start time into the request so that it can be used by the % `hb_http' module to calculate the duration of the request. @@ -356,15 +498,15 @@ handle_request(RawReq, Body, ServerID) -> put(server_id, ServerID), case {cowboy_req:path(RawReq), cowboy_req:qs(RawReq)} of {<<"/">>, <<>>} -> - % If the request is for the root path, serve a redirect to the default - % request of the node. + % If the request is for the root path, serve a + % redirect to the default request of the node. Req2 = cowboy_req:reply( 302, #{ <<"location">> => hb_opts:get( default_request, - <<"/~hyperbuddy@1.0/dashboard">>, + ?DEFAULT_DASHBOARD_PATH, NodeMsg ) }, @@ -394,7 +536,8 @@ handle_request(RawReq, Body, ServerID) -> _ -> ok end, - CommitmentCodec = hb_http:accept_to_codec(ReqSingleton, NodeMsg), + CommitmentCodec = + hb_http:accept_to_codec(ReqSingleton, NodeMsg), ?event(http, {parsed_singleton, {req_singleton, ReqSingleton}, @@ -425,7 +568,67 @@ handle_request(RawReq, Body, ServerID) -> end end. +%% @doc Read the complete body of an HTTP request. +%% +%% This function handles reading HTTP request bodies that may be sent +%% in chunks. It accumulates all chunks into a single binary for +%% processing by the request handler. +%% +%% @param Req Cowboy request object +%% @returns {ok, Body} where Body is the complete request body +read_body(Req) -> read_body(Req, <<>>). + +%% @doc Read HTTP request body with accumulator for chunked data. +%% +%% This is the internal implementation that handles chunked request +%% bodies by recursively reading chunks and accumulating them into +%% a single binary. +%% +%% @param Req0 Cowboy request object +%% @param Acc Accumulator binary for body chunks +%% @returns {ok, CompleteBody} +read_body(Req0, Acc) -> + case cowboy_req:read_body(Req0) of + {ok, Data, _Req} -> {ok, << Acc/binary, Data/binary >>}; + {more, Data, Req} -> read_body(Req, << Acc/binary, Data/binary >>) + end. + +%% @doc Reply to CORS preflight requests. +%% +%% This function handles HTTP OPTIONS requests for CORS (Cross-Origin +%% Resource Sharing) preflight checks. It returns appropriate CORS +%% headers allowing cross-origin requests from any domain with any +%% headers and standard HTTP methods. +%% +%% @param Req Cowboy request object +%% @param _ServerID Server identifier (unused) +%% @returns {ok, UpdatedReq, State} +cors_reply(Req, _ServerID) -> + Req2 = cowboy_req:reply(204, #{ + <<"access-control-allow-origin">> => <<"*">>, + <<"access-control-allow-headers">> => <<"*">>, + <<"access-control-allow-methods">> => + <<"GET, POST, PUT, DELETE, OPTIONS, PATCH">> + }, Req), + ?event(http_debug, {cors_reply, {req, Req}, {req2, Req2}}), + {ok, Req2, no_state}. + %% @doc Return a 500 error response to the client. +%% +%% This function handles internal server errors by: +%% 1. Formatting error details and stacktrace for logging +%% 2. Creating a structured error message +%% 3. Logging the error with appropriate formatting +%% 4. Removing noise from stacktrace and details +%% 5. Sending the error response to the client +%% +%% @param Req Cowboy request object +%% @param Singleton Request singleton for response formatting +%% @param Type Error type +%% @param Details Error details +%% @param Stacktrace Error stacktrace +%% @param NodeMsg Node configuration for formatting +%% @returns {ok, UpdatedReq, State} handle_error(Req, Singleton, Type, Details, Stacktrace, NodeMsg) -> DetailsStr = hb_util:bin(hb_format:message(Details, NodeMsg, 1)), StacktraceStr = hb_util:bin(hb_format:trace(Stacktrace)), @@ -451,23 +654,123 @@ handle_error(Req, Singleton, Type, Details, Stacktrace, NodeMsg) -> % Remove leading and trailing noise from the stacktrace and details. FormattedErrorMsg = ErrorMsg#{ - <<"stacktrace">> => hb_util:bin(hb_format:remove_noise(StacktraceStr)), - <<"details">> => hb_util:bin(hb_format:remove_noise(DetailsStr)) + <<"stacktrace">> => + hb_util:bin(hb_format:remove_noise(StacktraceStr)), + <<"details">> => + hb_util:bin(hb_format:remove_noise(DetailsStr)) }, hb_http:reply(Req, Singleton, FormattedErrorMsg, NodeMsg). -%% @doc Return the list of allowed methods for the HTTP server. +%% @doc Return the list of allowed HTTP methods for the server. +%% +%% This function specifies which HTTP methods are supported by the +%% HyperBEAM HTTP server. It's used by Cowboy for method validation +%% and CORS preflight responses. +%% +%% @param Req Cowboy request object +%% @param State Handler state +%% @returns {MethodList, Req, State} where MethodList contains allowed methods allowed_methods(Req, State) -> { - [<<"GET">>, <<"POST">>, <<"PUT">>, <<"DELETE">>, <<"OPTIONS">>, <<"PATCH">>], + [ + <<"GET">>, <<"POST">>, <<"PUT">>, + <<"DELETE">>, <<"OPTIONS">>, <<"PATCH">> + ], Req, State }. -%% @doc Merges the provided `Opts' with uncommitted values from `Request', -%% preserves the http_server value, and updates node_history by prepending -%% the `Request'. If a server reference exists, updates the Cowboy environment -%% variable 'node_msg' with the resulting options map. +%%% =================================================================== +%%% HTTPS & Redirect Functions +%%% =================================================================== + +%% @doc Set up HTTP to HTTPS redirect on the original server. +%% +%% This function modifies an existing HTTP server's dispatcher to redirect +%% all incoming traffic to the HTTPS equivalent. It: +%% 1. Creates a new Cowboy dispatcher with redirect handlers +%% 2. Updates the server's environment with the new dispatcher +%% 3. Logs the redirect configuration for debugging +%% +%% @param ServerID HTTP server identifier to configure for redirect +%% @param Opts Configuration options containing HTTPS port information +%% @param HttpsPort HTTPS port number for the server +%% @returns ok +setup_http_redirect(ServerID, Opts, HttpsPort) -> + ?event(https, {setting_up_http_redirect, {server_id, ServerID}}), + % Create a new dispatcher that redirects everything to HTTPS + % We use a special redirect handler that will be handled by init/2 + RedirectDispatcher = cowboy_router:compile([ + {'_', [ + {'_', ?MODULE, {redirect_https, Opts, HttpsPort}} + ]} + ]), + % Update the server's dispatcher + cowboy:set_env(ServerID, dispatch, RedirectDispatcher), + ?event(https, {http_redirect_configured, {server_id, ServerID}}). + +%% @doc HTTP to HTTPS redirect handler. +%% +%% This handler processes HTTP requests and sends 301 Moved Permanently +%% responses to redirect clients to HTTPS. It: +%% 1. Extracts host, path, and query string from the request +%% 2. Determines the appropriate HTTPS port from configuration +%% 3. Constructs the HTTPS URL preserving path and query parameters +%% 4. Sends a 301 redirect with CORS headers +%% +%% @param Req0 Cowboy request object +%% @param State Handler state containing server options +%% @param HttpsPort HTTPS port number for the server +%% @returns {ok, UpdatedReq, State} +redirect_to_https(Req0, State, HttpsPort) -> + Host = cowboy_req:host(Req0), + Path = cowboy_req:path(Req0), + Qs = cowboy_req:qs(Req0), + % Get HTTPS port from state, default to 443 + % Build the HTTPS URL with port if not standard HTTPS port + BaseUrl = case HttpsPort of + 443 -> <<"https://", Host/binary>>; + _ -> + PortBin = integer_to_binary(HttpsPort), + <<"https://", Host/binary, ":", PortBin/binary>> + end, + Location = case Qs of + <<>> -> + <>; + _ -> + <> + end, + ?event( + https, + { + redirecting_to_https, + {from, Path}, + {to, Location}, + {https_port, HttpsPort} + } + ), + % Send 301 redirect + Req = cowboy_req:reply(301, #{ + <<"location">> => Location, + <<"access-control-allow-origin">> => <<"*">>, + <<"access-control-allow-headers">> => <<"*">>, + <<"access-control-allow-methods">> => + <<"GET, POST, PUT, DELETE, OPTIONS, PATCH">> + }, Req0), + {ok, Req, State}. + +%%% =================================================================== +%%% Configuration & State Management +%%% =================================================================== + +%% @doc Set server options by updating Cowboy environment. +%% +%% This function updates the server's runtime configuration by setting +%% the 'node_msg' environment variable in the Cowboy listener. It's used +%% to dynamically update server behavior without restarting. +%% +%% @param Opts Options map containing http_server reference and new settings +%% @returns ok set_opts(Opts) -> case hb_opts:get(http_server, no_server_ref, Opts) of no_server_ref -> @@ -475,6 +778,19 @@ set_opts(Opts) -> ServerRef -> ok = cowboy:set_env(ServerRef, node_msg, Opts) end. + +%% @doc Merge request with server options and update node history. +%% +%% This function performs advanced options merging by: +%% 1. Preparing and normalizing both request and server options +%% 2. Merging uncommitted request values with server configuration +%% 3. Updating the node history with the new request +%% 4. Preserving the http_server reference for future updates +%% 5. Updating the live server configuration +%% +%% @param Request Request message with new configuration values +%% @param Opts Current server options +%% @returns {ok, MergedOpts} where MergedOpts contains the updated configuration set_opts(Request, Opts) -> PreparedOpts = hb_opts:mimic_default_types( @@ -496,25 +812,59 @@ set_opts(Request, Opts) -> ?event(set_opts, {merged_opts, {explicit, MergedOpts}}), History = hb_opts:get(node_history, [], Opts) - ++ [ hb_private:reset(maps:without([node_history], PreparedRequest)) ], + ++ [ + hb_private:reset( + maps:without([node_history], PreparedRequest) + ) + ], FinalOpts = MergedOpts#{ http_server => hb_opts:get(http_server, no_server, Opts), node_history => History }, {set_opts(FinalOpts), FinalOpts}. -%% @doc Get the node message for the current process. +%% @doc Get server options for the current process. +%% +%% This function retrieves the current server configuration for the +%% calling process by looking up the server ID from the process +%% dictionary and fetching the associated node message. +%% +%% @returns Server options map or no_node_msg if not found get_opts() -> get_opts(#{ http_server => get(server_id) }). +%% @doc Get server options for a specific server. +%% +%% This function retrieves the server configuration for a specific +%% server by extracting the server reference and fetching the +%% 'node_msg' environment variable from Cowboy. +%% +%% @param NodeMsg Node message containing server reference +%% @returns Server options map or no_node_msg if not found get_opts(NodeMsg) -> ServerRef = hb_opts:get(http_server, no_server_ref, NodeMsg), cowboy:get_env(ServerRef, node_msg, no_node_msg). %% @doc Initialize the server ID for the current process. +%% +%% This function stores the server identifier in the process dictionary +%% so that other functions can retrieve server-specific configuration +%% without explicitly passing the server ID. +%% +%% @param ServerID Server identifier to store +%% @returns ok set_proc_server_id(ServerID) -> put(server_id, ServerID). -%% @doc Apply the default node message to the given opts map. +%% @doc Apply default configuration to the provided options. +%% +%% This function enhances the provided options with system defaults: +%% 1. Generating a random port if none provided +%% 2. Creating a new wallet if none provided +%% 3. Setting up default store configuration +%% 4. Adding derived values like address and force_signed flag +%% +%% @param Opts Base options map to enhance with defaults +%% @returns Enhanced options map with all required defaults set_default_opts(Opts) -> % Create a temporary opts map that does not include the defaults. TempOpts = Opts#{ only => local }, @@ -524,7 +874,7 @@ set_default_opts(Opts) -> case hb_opts:get(port, no_port, TempOpts) of no_port -> rand:seed(exsplus, erlang:system_time(microsecond)), - 10000 + rand:uniform(50000); + ?RANDOM_PORT_MIN + rand:uniform(?RANDOM_PORT_RANGE); PassedPort -> PassedPort end, Wallet = @@ -553,10 +903,102 @@ set_default_opts(Opts) -> force_signed => true }. -%% @doc Test that we can start the server, send a message, and get a response. -start_node() -> - start_node(#{}). -start_node(Opts) -> +%%% =================================================================== +%%% UI & Display Functions +%%% =================================================================== + +%% @doc Conditionally print the startup greeter message. +%% +%% This function displays the HyperBEAM startup banner and configuration +%% information, but only when not running in test mode. It provides +%% visual feedback about successful server startup and configuration. +%% +%% @param MergedConfig Complete server configuration +%% @param PrivWallet Private wallet for operator address display +%% @returns ok +print_greeter_if_not_test(MergedConfig, PrivWallet) -> + case hb_features:test() of + false -> + print_greeter(MergedConfig, PrivWallet); + true -> + ok + end. + +%% @doc Print the HyperBEAM startup banner and configuration. +%% +%% This function displays a detailed startup message including: +%% 1. ASCII art HyperBEAM logo +%% 2. Version information +%% 3. Server URL for access +%% 4. Operator wallet address +%% 5. Complete configuration details +%% +%% The output provides comprehensive information about the running +%% server instance for debugging and verification. +%% +%% @param Config Server configuration map +%% @param PrivWallet Private wallet for operator identification +%% @returns ok +print_greeter(Config, PrivWallet) -> + FormattedConfig = hb_format:term(Config, Config, 2), + io:format("~n" + "===========================================================~n" + "== ██╗ ██╗██╗ ██╗██████╗ ███████╗██████╗ ==~n" + "== ██║ ██║╚██╗ ██╔╝██╔══██╗██╔════╝██╔══██╗ ==~n" + "== ███████║ ╚████╔╝ ██████╔╝█████╗ ██████╔╝ ==~n" + "== ██╔══██║ ╚██╔╝ ██╔═══╝ ██╔══╝ ██╔══██╗ ==~n" + "== ██║ ██║ ██║ ██║ ███████╗██║ ██║ ==~n" + "== ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ==~n" + "== ==~n" + "== ██████╗ ███████╗ █████╗ ███╗ ███╗ VERSION: ==~n" + "== ██╔══██╗██╔════╝██╔══██╗████╗ ████║ v~p. ==~n" + "== ██████╔╝█████╗ ███████║██╔████╔██║ ==~n" + "== ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║ EAT GLASS, ==~n" + "== ██████╔╝███████╗██║ ██║██║ ╚═╝ ██║ BUILD THE ==~n" + "== ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ FUTURE. ==~n" + "===========================================================~n" + "== Node activate at: ~s ==~n" + "== Operator: ~s ==~n" + "===========================================================~n" + "== Config: ==~n" + "===========================================================~n" + " ~s~n" + "===========================================================~n", + [ + ?HYPERBEAM_VERSION, + string:pad( + lists:flatten( + io_lib:format( + "http://~s:~p", + [ + hb_opts:get(host, <<"localhost">>, Config), + hb_opts:get(port, ?DEFAULT_HTTP_PORT, Config) + ] + ) + ), + 35, leading, $ + ), + hb_util:human_id(ar_wallet:to_address(PrivWallet)), + FormattedConfig + ] + ). + +%%% =================================================================== +%%% Shared Server Utilities +%%% =================================================================== + +%% @doc Start all required applications for HyperBEAM servers. +%% +%% This function ensures all necessary Erlang applications are started +%% for both HTTP and HTTPS servers. The applications include: +%% 1. Core Erlang applications (kernel, stdlib) +%% 2. Network applications (inets, ssl) +%% 3. HTTP server applications (ranch, cowboy) +%% 4. HTTP client applications (gun) +%% 5. System monitoring (os_mon) +%% +%% @returns ok or {error, Reason} +start_required_applications() -> application:ensure_all_started([ kernel, stdlib, @@ -566,22 +1008,264 @@ start_node(Opts) -> cowboy, gun, os_mon - ]), - hb:init(), - hb_sup:start_link(Opts), - ServerOpts = set_default_opts(Opts), - {ok, _Listener, Port} = new_server(ServerOpts), - <<"http://localhost:", (integer_to_binary(Port))/binary, "/">>. + ]). + +%% @doc Generate unique server ID from wallet address. +%% +%% This function creates a unique server identifier by: +%% 1. Extracting the private wallet from node configuration +%% 2. Converting the wallet to an Arweave address +%% 3. Creating a human-readable ID from the address +%% +%% The resulting ID is used for Cowboy listener registration and +%% server identification throughout the system. +%% +%% @param NodeMsg Node configuration containing wallet information +%% @returns ServerID binary for use as Cowboy listener name +generate_server_id(NodeMsg) -> + hb_util:human_id( + ar_wallet:to_address( + hb_opts:get(priv_wallet, no_wallet, NodeMsg) + ) + ). + +%% @doc Create base protocol options for Cowboy servers. +%% +%% This function creates the standard protocol options used by both +%% HTTP and HTTPS servers. It configures: +%% 1. Cowboy dispatcher with the server module and ID +%% 2. Environment variables including node message +%% 3. Stream handlers for request processing +%% 4. Connection limits and timeout settings +%% +%% @param ServerID Server identifier for the dispatcher +%% @param NodeMsg Node configuration message +%% @returns Protocol options map for Cowboy listener +create_base_protocol_opts(ServerID, NodeMsg) -> + NodeMsgWithID = hb_maps:put(http_server, ServerID, NodeMsg), + Dispatcher = cowboy_router:compile([{'_', [{'_', ?MODULE, ServerID}]}]), + #{ + env => #{dispatch => Dispatcher, node_msg => NodeMsgWithID}, + stream_handlers => [cowboy_stream_h], + max_connections => infinity, + idle_timeout => hb_opts:get(idle_timeout, ?DEFAULT_IDLE_TIMEOUT, NodeMsg) + }. + +%% @doc Add Prometheus metrics to protocol options if enabled. +%% +%% This function conditionally enhances protocol options with Prometheus +%% metrics collection. It: +%% 1. Checks if Prometheus is enabled in configuration +%% 2. Starts Prometheus applications if needed +%% 3. Adds metrics callback and enhanced stream handlers +%% 4. Handles graceful fallback if Prometheus is unavailable +%% +%% @param ProtoOpts Base protocol options to enhance +%% @param NodeMsg Node configuration message +%% @returns Enhanced protocol options with optional Prometheus support +add_prometheus_if_enabled(ProtoOpts, NodeMsg) -> + case hb_opts:get(prometheus, not hb_features:test(), NodeMsg) of + true -> + ?event(prometheus, + {starting_prometheus, {test_mode, hb_features:test()}} + ), + try + application:ensure_all_started([prometheus, prometheus_cowboy]), + ProtoOpts#{ + metrics_callback => + fun prometheus_cowboy2_instrumenter:observe/1, + stream_handlers => [cowboy_metrics_h, cowboy_stream_h] + } + catch + Type:Reason -> + ?event(prometheus, + {prometheus_not_started, {type, Type}, {reason, Reason}} + ), + ProtoOpts + end; + false -> + ?event(prometheus, + {prometheus_not_started, {test_mode, hb_features:test()}} + ), + ProtoOpts + end. +%% @doc Process server startup hooks for configuration modification. +%% +%% This function executes the startup hook system, allowing external +%% devices and modules to modify server configuration before startup. +%% It: +%% 1. Wraps options in the expected hook message format +%% 2. Calls the startup hook with the configuration +%% 3. Extracts the modified configuration from the hook response +%% 4. Handles hook execution errors with appropriate logging +%% +%% @param Opts Initial server options to process through hooks +%% @returns {ok, ModifiedNodeMsg} or throws {failed_to_start_server, Reason} +process_server_hooks(Opts) -> + HookMsg = #{ <<"body">> => Opts }, + case dev_hook:on(<<"start">>, HookMsg, Opts) of + {ok, #{ <<"body">> := NodeMsgAfterHook }} -> + {ok, NodeMsgAfterHook}; + Unexpected -> + ?event(server, + {failed_to_start_server, + {unexpected_hook_result, Unexpected} + } + ), + throw( + {failed_to_start_server, + {unexpected_hook_result, Unexpected} + } + ) + end. + +%%% =================================================================== +%%% HTTPS Server Helper Functions +%%% =================================================================== + +%% @doc Create HTTPS server IDs from node configuration. +%% +%% This function generates unique server identifiers for HTTPS servers: +%% 1. Initializes the HTTP module for request handling +%% 2. Generates the base server ID using the shared utility +%% 3. Creates the HTTPS-specific server ID by appending '_https' +%% +%% The HTTPS server ID is used for Cowboy listener registration and +%% must be unique from the HTTP server ID. +%% +%% @param NodeMsg Node configuration message containing wallet +%% @returns {ServerID, HttpsServerID} tuple for server identification +create_https_server_id(NodeMsg) -> + % Initialize HTTP module + hb_http:start(), + % Create server ID using shared utility + ServerID = generate_server_id(NodeMsg), + HttpsServerID = <>, + {ServerID, HttpsServerID}. + +%% @doc Create HTTPS dispatcher and protocol options. +%% +%% This function sets up the Cowboy dispatcher and protocol options +%% for HTTPS servers by leveraging the shared utility functions. +%% It: +%% 1. Creates base protocol options using the shared utility +%% 2. Extracts the dispatcher for return compatibility +%% 3. Ensures consistent configuration between HTTP and HTTPS +%% +%% @param HttpsServerID Unique HTTPS server identifier +%% @param NodeMsg Node configuration message +%% @returns {Dispatcher, ProtoOpts} tuple for Cowboy configuration +create_https_dispatcher(HttpsServerID, NodeMsg) -> + % Use shared utility for protocol options + ProtoOpts = create_base_protocol_opts(HttpsServerID, NodeMsg), + % Extract dispatcher for return (though not used in current flow) + #{env := #{dispatch := Dispatcher}} = ProtoOpts, + {Dispatcher, ProtoOpts}. + +%% @doc Start TLS listener for HTTPS server. +%% +%% This function starts the actual Cowboy TLS listener with the +%% provided certificate files and protocol options. It handles +%% the low-level server startup. +%% +%% @param HttpsServerID Unique HTTPS server identifier +%% @param HttpsPort Port number for HTTPS server +%% @param CertFile Path to certificate PEM file +%% @param KeyFile Path to private key PEM file +%% @param ProtoOpts Protocol options for Cowboy +%% @returns {ok, Listener} or {error, Reason} +start_tls_listener(HttpsServerID, HttpsPort, CertFile, KeyFile, ProtoOpts) -> + ?event( + https, + { + starting_tls_listener, + {server_id, HttpsServerID}, + {port, HttpsPort}, + {cert_file, CertFile}, + {key_file, KeyFile} + } + ), + case cowboy:start_tls( + HttpsServerID, + [ + {port, HttpsPort}, + {certfile, CertFile}, + {keyfile, KeyFile} + ], + ProtoOpts + ) of + {ok, Listener} -> + ?event( + https, + { + https_server_started, + {listener, Listener}, + {server_id, HttpsServerID}, + {port, HttpsPort} + } + ), + {ok, Listener}; + {error, Reason} -> + ?event(https, {tls_listener_start_failed, {reason, Reason}}), + {error, Reason} + end. + +%% @doc Set up HTTP to HTTPS redirect if needed. +%% +%% This function conditionally configures an existing HTTP server +%% to redirect all traffic to HTTPS. It: +%% 1. Validates the redirect target server ID +%% 2. Configures HTTP server redirect if target is valid +%% 3. Logs redirect setup or skipping with reasons +%% 4. Handles invalid server IDs gracefully +%% +%% The redirect setup allows seamless HTTP to HTTPS migration. +%% +%% @param RedirectTo HTTP server ID to configure (or no_server to skip) +%% @param NodeMsg Node configuration message with HTTPS port +%% @param HttpsPort HTTPS port number for redirect URL construction +%% @returns ok +setup_redirect_if_needed(RedirectTo, NodeMsg, HttpsPort) -> + ?event( + https, + { + checking_for_http_server_to_redirect, + {original_server_id, RedirectTo} + } + ), + case RedirectTo of + no_server -> + ?event(https, {no_original_server_to_redirect}), + ok; + _ when is_binary(RedirectTo) -> + ?event( + https, + { + setting_up_redirect_from_http_to_https, + {http_server, RedirectTo}, + {https_port, HttpsPort} + } + ), + setup_http_redirect(RedirectTo, NodeMsg, HttpsPort); + _ -> + ?event(https, {invalid_redirect_server_id, RedirectTo}), + ok + end. + +%%% =================================================================== %%% Tests -%%% The following only covering the HTTP server initialization process. For tests -%%% of HTTP server requests/responses, see `hb_http.erl'. - -%% @doc Ensure that the `start' hook can be used to modify the node options. We -%% do this by creating a message with a device that has a `start' key. This -%% key takes the message's body (the anticipated node options) and returns a -%% modified version of that body, which will be used to configure the node. We -%% then check that the node options were modified as we expected. +%%% =================================================================== + +%% @doc Test server startup hook functionality. +%% +%% This test verifies that the startup hook system works correctly by: +%% 1. Creating a test device with a startup hook +%% 2. Starting a node with the hook configuration +%% 3. Verifying that the hook modified the server options +%% 4. Confirming the modified options are accessible via the API +%% +%% @returns ok (test assertion) set_node_opts_test() -> Node = start_node(#{ @@ -603,8 +1287,16 @@ set_node_opts_test() -> {ok, LiveOpts} = hb_http:get(Node, <<"/~meta@1.0/info">>, #{}), ?assert(hb_ao:get(<<"test-success">>, LiveOpts, false, #{})). -%% @doc Test the set_opts/2 function that merges request with options, -%% manages node history, and updates server state. +%% @doc Test the set_opts/2 function for options merging and history. +%% +%% This test validates the options merging functionality by: +%% 1. Starting a test node with a known wallet +%% 2. Testing empty node history initialization +%% 3. Testing single request option merging +%% 4. Testing multiple request history accumulation +%% 5. Verifying node history growth and option persistence +%% +%% @returns ok (test assertions) set_opts_test() -> DefaultOpts = hb_opts:default_message_with_env(), start_node(DefaultOpts#{ @@ -638,15 +1330,27 @@ set_opts_test() -> ?assert(length(NodeHistory2) == 2), ?assert(Key2 == <<"world2">>), % Test case 3: Non-empty node_history case - {ok, UpdatedOpts3} = set_opts(#{}, UpdatedOpts2#{ <<"hello3">> => <<"world3">> }), + {ok, UpdatedOpts3} = + set_opts(#{}, UpdatedOpts2#{ <<"hello3">> => <<"world3">> }), NodeHistory3 = hb_opts:get(node_history, not_found, UpdatedOpts3), Key3 = hb_opts:get(<<"hello3">>, not_found, UpdatedOpts3), ?event(debug_node_history, {node_history_length, length(NodeHistory3)}), ?assert(length(NodeHistory3) == 3), ?assert(Key3 == <<"world3">>). +%% @doc Test server restart functionality. +%% +%% This test verifies that servers can be restarted with updated +%% configuration by: +%% 1. Starting a server with initial configuration +%% 2. Starting a second server with the same wallet but different config +%% 3. Verifying that the second server has the updated configuration +%% 4. Confirming that server restart preserves functionality +%% +%% @returns ok (test assertion) restart_server_test() -> - % We force HTTP2, overriding the HTTP3 feature, because HTTP3 restarts don't work yet. + % We force HTTP2, overriding the HTTP3 feature, + % because HTTP3 restarts don't work yet. Wallet = ar_wallet:new(), BaseOpts = #{ <<"test-key">> => <<"server-1">>, @@ -657,5 +1361,5 @@ restart_server_test() -> N2 = start_node(BaseOpts#{ <<"test-key">> => <<"server-2">> }), ?assertEqual( {ok, <<"server-2">>}, - hb_http:get(N2, <<"/~meta@1.0/info/test-key">>, #{protocol => http2}) + hb_http:get(N2, <<"/~meta@1.0/info/test-key">>, #{}) ). diff --git a/src/hb_opts.erl b/src/hb_opts.erl index 6d262593b..a0e0af2d8 100644 --- a/src/hb_opts.erl +++ b/src/hb_opts.erl @@ -107,6 +107,12 @@ default_message() -> %% What HTTP client should the node use? %% Options: gun, httpc http_client => gun, + %% Should the HTTP client automatically follow 3xx redirects? + http_follow_redirects => true, + %% For the gun HTTP client, to mitigate resource exhaustion attacks, what's + %% the maximum number of automatic 3xx redirects we'll allow when + %% http_follow_redirects = true? + gun_max_redirects => 5, %% Scheduling mode: Determines when the SU should inform the recipient %% that an assignment has been scheduled for a message. %% Options: aggressive(!), local_confirmation, remote_confirmation, @@ -177,6 +183,7 @@ default_message() -> #{<<"name">> => <<"test-device@1.0">>, <<"module">> => dev_test}, #{<<"name">> => <<"volume@1.0">>, <<"module">> => dev_volume}, #{<<"name">> => <<"secret@1.0">>, <<"module">> => dev_secret}, + #{<<"name">> => <<"ssl-cert@1.0">>, <<"module">> => dev_ssl_cert}, #{<<"name">> => <<"wasi@1.0">>, <<"module">> => dev_wasi}, #{<<"name">> => <<"wasm-64@1.0">>, <<"module">> => dev_wasm}, #{<<"name">> => <<"whois@1.0">>, <<"module">> => dev_whois} @@ -920,4 +927,4 @@ ensure_node_history_test() -> ] }, ?assertEqual({error, invalid_values}, ensure_node_history(InvalidItems, RequiredOpts)). --endif. \ No newline at end of file +-endif.