diff --git a/test/OVMF-1.55.fd b/OVMF-1.55.fd similarity index 100% rename from test/OVMF-1.55.fd rename to OVMF-1.55.fd diff --git a/erlang_ls.config b/erlang_ls.config index f5621bee0..16dda9163 100644 --- a/erlang_ls.config +++ b/erlang_ls.config @@ -6,11 +6,11 @@ diagnostics: apps_dirs: - "src" - "src/*" -include_dirs: - - "src/include" include_dirs: - "src" - "src/include" + - "_build/default/lib" + - "_build/default/lib/*/include" lenses: enabled: - ct-run-test diff --git a/rebar.config b/rebar.config index 56927139d..efb6b3f59 100644 --- a/rebar.config +++ b/rebar.config @@ -64,7 +64,6 @@ ]}. {cargo_opts, [ - {src_dir, "native/dev_snp_nif"}, {src_dir, "deps/elmdb/native/elmdb_nif"} ]}. @@ -81,9 +80,15 @@ {port_env, [ {"(linux|darwin|solaris)", "CFLAGS", - "$CFLAGS -I${REBAR_ROOT_DIR}/_build/wamr/core/iwasm/include -I/usr/local/lib/erlang/usr/include/"}, - {"(linux|darwin|solaris)", "LDFLAGS", "$LDFLAGS -L${REBAR_ROOT_DIR}/_build/wamr/lib -lvmlib -lei"}, - {"(linux|darwin|solaris)", "LDLIBS", "-lei"} + "$CFLAGS " + "-Wno-error=incompatible-pointer-types " + "-Wno-error=pointer-sign " + "-I${REBAR_ROOT_DIR}/_build/wamr/core/iwasm/include " + "-I/usr/local/lib/erlang/usr/include/"}, + {"(linux|darwin|solaris)", "LDFLAGS", "$LDFLAGS -L${REBAR_ROOT_DIR}/_build/wamr/lib -lvmlib -lei"}, + {"(linux|darwin|solaris)", "LDLIBS", "-lei"}, + {"linux", "CFLAGS", "$CFLAGS -I/usr/include/openssl"}, + {"linux", "LDFLAGS", "$LDFLAGS -lssl -lcrypto"} ]}. {post_hooks, [ @@ -91,19 +96,19 @@ {"(linux|darwin|solaris)", compile, "echo 'Post-compile hooks executed'"}, { compile, "rm -f native/hb_beamr/*.o native/hb_beamr/*.d"}, { compile, "rm -f native/hb_keccak/*.o native/hb_keccak/*.d"}, + { compile, "rm -f native/dev_snp_nif/*.o native/dev_snp_nif/*.d"}, + { compile, "rm -f native/snp_nif/*.o native/snp_nif/*.d"}, { compile, "mkdir -p priv/html"}, { compile, "cp -R src/html/* priv/html"}, + { compile, "mkdir -p priv/ovmf"}, + { compile, "cp OVMF-1.55.fd priv/ovmf/OVMF-1.55.fd"}, { compile, "cp _build/default/lib/elmdb/priv/crates/elmdb_nif/elmdb_nif.so _build/default/lib/elmdb/priv/elmdb_nif.so 2>/dev/null || true" } ]}. {provider_hooks, [ - {pre, [ - {compile, {cargo, build}} - ]}, {post, [ {compile, {pc, compile}}, - {clean, {pc, clean}}, - {clean, {cargo, clean}} + {clean, {pc, clean}} ]} ]}. @@ -118,6 +123,9 @@ {"./priv/hb_keccak.so", [ "./native/hb_keccak/hb_keccak.c", "./native/hb_keccak/hb_keccak_nif.c" + ]}, + {"./priv/snp_nif.so", [ + "./native/snp_nif/snp_nif.c" ]} ]}. @@ -132,7 +140,8 @@ {prometheus_httpd, "2.1.15"}, {prometheus, "6.0.3"}, {graphql, "0.17.1", {pkg, graphql_erl}}, - {luerl, "1.3.0"} + {luerl, "1.3.0"}, + {ssl_cert, "1.0.1"} ]}. {shell, [ @@ -144,7 +153,7 @@ ]}. {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, [ @@ -155,7 +164,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 6da23354c..785548277 100644 --- a/rebar.lock +++ b/rebar.lock @@ -17,7 +17,8 @@ {<<"prometheus">>,{pkg,<<"prometheus">>,<<"6.0.3">>},0}, {<<"prometheus_cowboy">>,{pkg,<<"prometheus_cowboy">>,<<"0.2.0">>},0}, {<<"prometheus_httpd">>,{pkg,<<"prometheus_httpd">>,<<"2.1.15">>},0}, - {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},0}]}. + {<<"ranch">>,{pkg,<<"ranch">>,<<"2.2.0">>},0}, + {<<"ssl_cert">>,{pkg,<<"ssl_cert">>,<<"1.0.1">>},0}]}. [ {pkg_hash,[ {<<"accept">>, <<"CD6E34A2D7E28CA38B2D3CB233734CA0C221EFBC1F171F91FEC5F162CC2D18DA">>}, @@ -30,7 +31,8 @@ {<<"prometheus">>, <<"95302236124C0F919163A7762BF7D2B171B919B6FF6148D26EB38A5D2DEF7B81">>}, {<<"prometheus_cowboy">>, <<"526F75D9850A9125496F78BCEECCA0F237BC7B403C976D44508543AE5967DAD9">>}, {<<"prometheus_httpd">>, <<"8F767D819A5D36275EAB9264AFF40D87279151646776069BF69FBDBBD562BD75">>}, - {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}]}, + {<<"ranch">>, <<"25528F82BC8D7C6152C57666CA99EC716510FE0925CB188172F41CE93117B1B0">>}, + {<<"ssl_cert">>, <<"5E4133E7D524141836C045838C98E69964E188707DF12032CE5DA902BB40C9A3">>}]}, {pkg_hash_ext,[ {<<"accept">>, <<"CA69388943F5DAD2E7232A5478F16086E3C872F48E32B88B378E1885A59F5649">>}, {<<"cowboy">>, <<"EA99769574550FE8A83225C752E8A62780A586770EF408816B82B6FE6D46476B">>}, @@ -42,5 +44,6 @@ {<<"prometheus">>, <<"53554ECADAC0354066801D514D1A244DD026175E4EE3A9A30192B71D530C8268">>}, {<<"prometheus_cowboy">>, <<"2C7EB12F4B970D91E3B47BAAD0F138F6ADC34E53EEB0AE18068FF0AFAB441B24">>}, {<<"prometheus_httpd">>, <<"67736D000745184D5013C58A63E947821AB90CB9320BC2E6AE5D3061C6FFE039">>}, - {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}]} + {<<"ranch">>, <<"FA0B99A1780C80218A4197A59EA8D3BDAE32FBFF7E88527D7D8A4787EFF4F8E7">>}, + {<<"ssl_cert">>, <<"2E37259313514B854EE0BC5B0696250883568CD1A5FC9EC338D78E27C521E65D">>}]} ]. diff --git a/src/dev_green_zone.erl b/src/dev_green_zone.erl index b46b173a7..0586eb497 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(binary(), 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. @@ -37,14 +107,17 @@ info(_) -> %% 2. Version information %% 3. Available API endpoints with their parameters and descriptions %% -%% @param _Base Ignored parameter -%% @param _Req Ignored parameter +%% @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(_Base, _Req, _Opts) -> +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(_Base, _Req, _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">> + <<"required-node-opts">> => #{ + <<"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">> + <<"required-node-opts">> => #{ + <<"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 (encode term so encrypt is binary-only) + {ok, {EncryptedData, IV}} ?= encrypt_data(term_to_binary({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,173 +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. - % hb_message:commit expects Opts map (which contains priv_wallet), not wallet tuple - Req = hb_cache:ensure_all_loaded( - hb_message:commit(MergedReq, InitOpts), + 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. + +%% @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, InitOpts), + InitOpts + ), + ?event({join_req, {explicit, Req}}), + ?event({verify_res, hb_message:verify(Req)}), + {ok, Req} end. -%%%-------------------------------------------------------------------- -%%% Internal Functions -%%%-------------------------------------------------------------------- +%% @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: @@ -613,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. @@ -627,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)), @@ -635,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 @@ -662,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), @@ -679,6 +750,234 @@ 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) -> + case hb_opts:as(<<"green-zone">>, Opts) of + {ok, IdentityOpts} -> hb_opts:get(priv_wallet, undefined, IdentityOpts); + {error, not_found} -> 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), + 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. +%% The result is passed through hb_cache:ensure_all_loaded/2 so any lazy links +%% in the config or in the fetched Opts values are resolved. +%% +%% @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 and lazy links resolved +replace_self_values(Config, Opts) -> + Replaced = maps:map( + fun(Key, Value) -> + case Value of + <<"self">> -> + hb_opts:get(Key, not_found, Opts); + _ -> + Value + end + end, + Config + ), + hb_cache:ensure_all_loaded(Replaced, Opts). + +%% @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: @@ -689,7 +988,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 } @@ -713,8 +1011,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 @@ -740,8 +1036,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. @@ -763,6 +1060,94 @@ 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. +%% +%% Accepts only binary payloads. Encrypt and decrypt are reciprocal for +%% binaries: decrypt_data(Enc, IV, Opts) returns the same binary passed to +%% encrypt_data. Encoding/decoding (e.g. term_to_binary/binary_to_term) is +%% the caller's responsibility. +%% +%% @param Data Binary to encrypt (non-binary returns {error, not_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, non-binary data, or encryption fails +encrypt_data(Data, Opts) when is_binary(Data) -> + 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), + % Encrypt using AES-256-GCM + {EncryptedData, Tag} = crypto:crypto_one_time_aead( + aes_256_gcm, + AESKey, + IV, + Data, + <<>>, + true + ), + % Combine encrypted data and tag + Combined = <>, + {ok, {Combined, IV}} + catch + Error:Reason -> + {error, {encryption_failed, Error, Reason}} + end + end; +encrypt_data(_Data, _Opts) -> + {error, not_binary}. + +%% @doc Decrypt data using AES-256-GCM with the green zone shared key. +%% +%% Returns the same binary that was passed to encrypt_data/2. Decoding +%% (e.g. binary_to_term) is the caller's responsibility. +%% +%% @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/hb_http_client.erl b/src/hb_http_client.erl index 3611cc088..eb6defeba 100644 --- a/src/hb_http_client.erl +++ b/src/hb_http_client.erl @@ -34,7 +34,10 @@ request(Args, RemainingRetries, Opts) -> do_request(Args, Opts) -> case hb_opts:get(http_client, gun, Opts) of - gun -> gun_req(Args, Opts); + gun -> + MaxRedirects = hb_maps:get(gun_max_redirects, Opts, 5), + GunArgs = Args#{redirects_left => MaxRedirects}, + gun_req(GunArgs, false, Opts); httpc -> httpc_req(Args, Opts) end. @@ -68,11 +71,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), @@ -111,9 +116,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 = @@ -137,11 +144,9 @@ httpc_req(Args, Opts) -> {error, Reason} end. -gun_req(Args, Opts) -> - gun_req(Args, false, Opts). gun_req(Args, ReestablishedConnection, Opts) -> StartTime = os:system_time(millisecond), - #{ peer := Peer, path := Path, method := Method } = Args, + #{ 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} -> @@ -153,6 +158,20 @@ gun_req(Args, ReestablishedConnection, Opts) -> true -> {error, client_error}; false -> gun_req(Args, true, Opts) 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; Reply -> Reply end; @@ -179,6 +198,35 @@ gun_req(Args, ReestablishedConnection, Opts) -> end, Response. +handle_redirect(Args, ReestablishedConnection, Opts, Res, Reply) -> + case lists:keyfind(<<"location">>, 1, Res) of + false -> + Reply; + {_LocationHeaderName, Location} -> + case uri_string:parse(Location) of + {error, _Reason, _Detail} -> + 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 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 %% `http_monitor' if appropriate. @@ -198,6 +246,7 @@ record_duration(Details, Opts) -> GetFormat, [ <<"request-method">>, + <<"request-path">>, <<"status-class">> ] ), @@ -282,7 +331,7 @@ init_prometheus(Opts) -> application:ensure_all_started([prometheus, prometheus_cowboy]), prometheus_counter:new([ {name, gun_requests_total}, - {labels, [http_method, status_class]}, + {labels, [http_method, route, status_class]}, { help, "The total number of GUN requests." @@ -293,7 +342,7 @@ init_prometheus(Opts) -> prometheus_histogram:new([ {name, http_request_duration_seconds}, {buckets, [0.01, 0.1, 0.5, 1, 5, 10, 30, 60]}, - {labels, [http_method, status_class]}, + {labels, [http_method, route, status_class]}, { help, "The total duration of an hb_http_client:req call. This includes more than" @@ -312,11 +361,13 @@ init_prometheus(Opts) -> ]), prometheus_counter:new([ {name, http_client_downloaded_bytes_total}, - {help, "The total amount of bytes requested via HTTP, per remote endpoint"} + {help, "The total amount of bytes requested via HTTP, per remote endpoint"}, + {labels, [route]} ]), prometheus_counter:new([ {name, http_client_uploaded_bytes_total}, - {help, "The total amount of bytes posted via HTTP, per remote endpoint"} + {help, "The total amount of bytes posted via HTTP, per remote endpoint"}, + {labels, [route]} ]), ?event(started), {ok, #state{ opts = Opts }}. @@ -451,6 +502,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}; @@ -511,7 +563,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 = #{ @@ -533,9 +591,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 @@ -546,7 +604,12 @@ 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 => [ + {cacerts, public_key:cacerts_get()} + ] + } end, ?event(http_outbound, {gun_open, @@ -556,7 +619,7 @@ open_connection(#{ peer := Peer }, Opts) -> {transport, Transport} } ), - gun:open(Host, Port, GunOpts). + gun:open(hb_util:list(Host), Port, GunOpts). parse_peer(Peer, Opts) -> Parsed = uri_string:parse(Peer), @@ -579,14 +642,16 @@ reply_error([PendingRequest | PendingRequests], Reason) -> ReplyTo = element(1, PendingRequest), Args = element(2, PendingRequest), Method = hb_maps:get(method, Args), - record_response_status(Method, {error, Reason}), + Path = hb_maps:get(path, Args), + record_response_status(Method, Path, {error, Reason}), gen_server:reply(ReplyTo, {error, Reason}), reply_error(PendingRequests, Reason). -record_response_status(Method, Response) -> +record_response_status(Method, Path, Response) -> inc_prometheus_counter(gun_requests_total, [ hb_util:list(method_to_bin(Method)), + Path, hb_util:list(get_status_class(Response)) ], 1 @@ -655,7 +720,7 @@ do_gun_request(PID, Args, Opts) -> is_peer_request => hb_maps:get(is_peer_request, Args, true, Opts) }, Response = await_response(hb_maps:merge(Args, ResponseArgs, Opts), Opts), - record_response_status(Method, Response), + record_response_status(Method, Path, Response), inet:stop_timer(Timer), Response. @@ -692,7 +757,7 @@ await_response(Args, Opts) -> end; {data, fin, Data} -> FinData = iolist_to_binary([Acc | Data]), - download_metric(FinData), + download_metric(FinData, Args), upload_metric(Args), {ok, hb_maps:get(status, Args, undefined, Opts), @@ -700,16 +765,16 @@ await_response(Args, Opts) -> FinData }; {error, timeout} = Response -> - record_response_status(Method, Response), + record_response_status(Method, Path, Response), gun:cancel(PID, Ref), log(warn, gun_await_process_down, Args, Response, Opts), Response; {error, Reason} = Response when is_tuple(Reason) -> - record_response_status(Method, Response), + record_response_status(Method, Path, Response), log(warn, gun_await_process_down, Args, Reason, Opts), Response; Response -> - record_response_status(Method, Response), + record_response_status(Method, Path, Response), log(warn, gun_await_unknown, Args, Response, Opts), Response end. @@ -729,17 +794,17 @@ log(Type, Event, #{method := Method, peer := Peer, path := Path}, Reason, Opts) ), ok. -download_metric(Data) -> +download_metric(Data, #{path := Path}) -> inc_prometheus_counter( http_client_downloaded_bytes_total, - [], + [Path], byte_size(Data) ). -upload_metric(#{method := post, body := Body}) -> +upload_metric(#{method := post, path := Path, body := Body}) -> inc_prometheus_counter( http_client_uploaded_bytes_total, - [], + [Path], byte_size(Body) ); upload_metric(_) -> diff --git a/src/hb_http_server.erl b/src/hb_http_server.erl index 456eda3f0..a60ae2828 100644 --- a/src/hb_http_server.erl +++ b/src/hb_http_server.erl @@ -1,31 +1,117 @@ -%%% @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, {path, Loc}, {config, Conf}}), + ?event(boot, {loaded_config, Loc, Conf}), Conf; {error, Reason} -> ?event(boot, {failed_to_load_config, Loc, Reason}), @@ -42,7 +128,7 @@ start() -> UpdatedStoreOpts = case StoreOpts of no_store -> no_store; - _ when is_list(StoreOpts) -> + _ when is_list(StoreOpts) -> hb_store_opts:apply(StoreOpts, StoreDefaults); _ -> StoreOpts end, @@ -51,173 +137,128 @@ start() -> hb:wallet( hb_opts:get( priv_key_location, - <<"hyperbeam-key.json">>, + ?DEFAULT_PRIV_KEY_FILE, Loaded ) ), - maybe_greeter(Loaded, 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 live at: ~s ==~n" - "== Operator: ~s ==~n" - "===========================================================~n" - "== Config: ==~n" - "===========================================================~n" - " ~s~n~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) - ] - ) - ), - 39, - leading, - $ % Note: Space after `$` is functional, not garbage. - ), - 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:", (hb_util:bin(Port))/binary, "/">>. + +%% @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 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 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]), - prometheus_registry:register_collector(hb_metrics_collector), - 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; @@ -247,19 +288,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} = + {ok, _Listener} = cowboy:start_quic( ServerID, TransOpts = #{ socket_opts => [ - {certfile, "test/test-tls.pem"}, - {keyfile, "test/test-tls.key"}, + {certfile, ?TEST_CERT_FILE}, + {keyfile, ?TEST_KEY_FILE}, {port, hb_opts:get(port, 0, NodeMsg)} ] }, @@ -286,10 +393,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 _ -> @@ -297,6 +411,18 @@ 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 = @@ -331,9 +457,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); _ -> @@ -341,29 +486,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. @@ -373,15 +509,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/index">>, + ?DEFAULT_DASHBOARD_PATH, NodeMsg ) }, @@ -397,51 +533,113 @@ handle_request(RawReq, Body, ServerID) -> {cowboy_req, {explicit, Req}, {body, {string, Body}}} } ), + % TracePID = hb_tracer:start_trace(), % Parse the HTTP request into HyerBEAM's message format. - try hb_http:req_to_tabm_singleton(Req, Body, NodeMsg) of - ReqSingleton -> - try - CommitmentCodec = - hb_http:accept_to_codec(ReqSingleton, NodeMsg), - ?event(http, - {parsed_singleton, - {req_singleton, ReqSingleton}, - {accept_codec, CommitmentCodec}}, - #{} - ), - % Invoke the meta@1.0 device to handle the request. - {ok, Res} = - dev_meta:handle( - NodeMsg#{ - commitment_device => CommitmentCodec - }, - ReqSingleton - ), - hb_http:reply(Req, ReqSingleton, Res, NodeMsg) - catch - Type:Details:Stacktrace -> - handle_error( - Req, - ReqSingleton, - Type, - Details, - Stacktrace, - NodeMsg - ) - end - catch ParseError:ParseDetails:ParseStacktrace -> - handle_error( - Req, - #{}, - ParseError, - ParseDetails, - ParseStacktrace, - NodeMsg - ) + ReqSingleton = + try hb_http:req_to_tabm_singleton(Req, Body, NodeMsg) + catch ParseError:ParseDetails:ParseStacktrace -> + {parse_error, ParseError, ParseDetails, ParseStacktrace} + end, + try + case ReqSingleton of + {parse_error, PType, PDetails, PStacktrace} -> + erlang:raise(PType, PDetails, PStacktrace); + _ -> + ok + end, + CommitmentCodec = + hb_http:accept_to_codec(ReqSingleton, NodeMsg), + ?event(http, + {parsed_singleton, + {req_singleton, ReqSingleton}, + {accept_codec, CommitmentCodec}} + % #{trace => TracePID} + ), + % hb_tracer:record_step(TracePID, request_parsing), + % Invoke the meta@1.0 device to handle the request. + {ok, Res} = + dev_meta:handle( + NodeMsg#{ + commitment_device => CommitmentCodec + % trace => TracePID + }, + ReqSingleton + ), + hb_http:reply(Req, ReqSingleton, Res, NodeMsg) + catch + Type:Details:Stacktrace -> + handle_error( + Req, + ReqSingleton, + Type, + Details, + Stacktrace, + NodeMsg + ) 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)), @@ -462,29 +660,128 @@ handle_error(Req, Singleton, Type, Details, Stacktrace, NodeMsg) -> 1 ) } - }, - 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 -> @@ -492,6 +789,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( @@ -513,30 +823,64 @@ 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 }, % Get the port to use for the server. If no port is provided, we use port 0 - % will the operating system assign a free port. + % and let the operating system assign a free port. Port = hb_opts:get(port, 0, TempOpts), Wallet = case hb_opts:get(priv_wallet, no_viable_wallet, TempOpts) of @@ -564,10 +908,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, @@ -577,22 +1013,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:", (hb_util:bin(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(#{ @@ -614,8 +1292,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#{ @@ -649,15 +1335,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">>, @@ -668,5 +1366,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 1000d8361..e5a998fa5 100644 --- a/src/hb_opts.erl +++ b/src/hb_opts.erl @@ -115,6 +115,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,