Packages
macula
4.2.2
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/attach_identity/macula_attach_identity.erl
%%%-------------------------------------------------------------------
%%% @doc Daemon-side attachment to a host station.
%%%
%%% PLAN_MACULA_NET_PHASE3.md §6.2. The daemon dials its host, sends
%%% a signed attach-request frame on a bidi stream, and from then on
%%% sends/receives macula-net envelopes through that same stream.
%%%
%%% The handshake reuses the existing
%%% {@link macula_net_transport_quic} pipe — daemons share the
%%% transport's framing logic with stations even though they don't
%%% accept inbound connections themselves. The handshake frame and
%%% subsequent envelopes are length-prefixed CBOR maps.
%%%
%%% Phase 3 MVP: no heartbeat (QUIC keepalive holds the connection),
%%% no automatic reconnect (Phase 4). Detach is explicit.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_attach_identity).
-behaviour(gen_server).
-export([
attach/4,
detach/1,
send/2,
set_inbound_handler/2,
daemon_address/1
]).
%% gen_server
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-export_type([config/0, handle/0, host_endpoint/0, inbound_handler/0]).
-type inbound_handler() :: fun((Envelope :: binary()) -> any()).
-define(SEND_TIMEOUT, 5000).
-define(DELEGATION_TTL_MS, 5 * 60 * 1000).
-type host_endpoint() :: #{
station_pubkey := <<_:256>>,
host := binary() | string(),
port := 1..65535
}.
-type config() :: #{
host_endpoint := host_endpoint(),
realm_pubkey := <<_:256>>,
daemon_keypair := macula_identity:key_pair(),
inbound_handler => inbound_handler()
}.
-type handle() :: pid().
-record(state, {
config :: config(),
station_id :: binary(),
daemon_addr :: <<_:128>>,
inbound_handler :: inbound_handler() | undefined
}).
%% =============================================================================
%% Public API
%% =============================================================================
%% @doc Attach to a host station. Performs the handshake and returns
%% an opaque handle on success. The caller may then push envelopes
%% via {@link send/2} and register an inbound handler via
%% {@link set_inbound_handler/2}.
-spec attach(host_endpoint(),
RealmPubkey :: <<_:256>>,
macula_identity:key_pair(),
map()) -> {ok, handle()} | {error, term()}.
attach(HostEndpoint, RealmPubkey, KeyPair, Opts) ->
Config = #{
host_endpoint => HostEndpoint,
realm_pubkey => RealmPubkey,
daemon_keypair => KeyPair,
inbound_handler => maps:get(inbound_handler, Opts, undefined)
},
gen_server:start_link(?MODULE, Config, []).
-spec detach(handle()) -> ok.
detach(Handle) when is_pid(Handle) ->
catch gen_server:stop(Handle, normal, 5000),
ok.
%% @doc Send a macula-net envelope over the attach stream.
-spec send(handle(), Envelope :: binary()) -> ok | {error, term()}.
send(Handle, Envelope) when is_pid(Handle), is_binary(Envelope) ->
gen_server:call(Handle, {send, Envelope}, ?SEND_TIMEOUT).
-spec set_inbound_handler(handle(), inbound_handler()) -> ok.
set_inbound_handler(Handle, Fun) when is_pid(Handle), is_function(Fun, 1) ->
gen_server:call(Handle, {set_inbound_handler, Fun}).
-spec daemon_address(handle()) -> {ok, <<_:128>>} | {error, term()}.
daemon_address(Handle) when is_pid(Handle) ->
gen_server:call(Handle, daemon_address).
%% =============================================================================
%% gen_server callbacks
%% =============================================================================
init(#{host_endpoint := #{station_pubkey := StationId,
host := Host,
port := Port},
realm_pubkey := Realm,
daemon_keypair := KeyPair,
inbound_handler := Handler} = Config) ->
process_flag(trap_exit, true),
DaemonPk = macula_identity:public(KeyPair),
DaemonAddr = macula_address:derive(Realm, DaemonPk),
after_connect(ensure_connected(StationId, Host, Port),
StationId, Host, Port,
DaemonPk, DaemonAddr, Realm, KeyPair, Handler, Config).
%% Stage 1: outbound QUIC connection up?
after_connect({error, _} = E, _StationId, _Host, _Port,
_DaemonPk, _DaemonAddr, _Realm, _KeyPair, _Handler, _Config) ->
{stop, E};
after_connect(ok, StationId, _Host, _Port,
DaemonPk, DaemonAddr, Realm, KeyPair, Handler, Config) ->
after_handshake(send_handshake(StationId, DaemonPk, DaemonAddr,
Realm, KeyPair),
StationId, DaemonAddr, Handler, Config).
%% Stage 2: handshake CBOR sent?
after_handshake({error, _} = E, _StationId, _DaemonAddr, _Handler, _Config) ->
{stop, E};
after_handshake(ok, StationId, DaemonAddr, Handler, Config) ->
%% Wire the transport's inbound handler so we can deliver received
%% envelopes to the daemon's callback.
Self = self(),
ok = macula_net_transport_quic:set_handler(
fun(Cbor, _StreamRef) ->
gen_server:cast(Self, {inbound, Cbor})
end),
{ok, #state{config = Config,
station_id = StationId,
daemon_addr = DaemonAddr,
inbound_handler = Handler}}.
handle_call({send, Envelope}, _From, #state{station_id = SId} = State) ->
{reply, macula_net_transport_quic:send(SId, Envelope), State};
handle_call({set_inbound_handler, Fun}, _From, State) ->
{reply, ok, State#state{inbound_handler = Fun}};
handle_call(daemon_address, _From, #state{daemon_addr = Addr} = State) ->
{reply, {ok, Addr}, State};
handle_call(_Other, _From, State) ->
{reply, {error, unknown_call}, State}.
handle_cast({inbound, _Cbor}, #state{inbound_handler = undefined} = State) ->
{noreply, State};
handle_cast({inbound, Cbor}, #state{inbound_handler = Fun} = State)
when is_function(Fun, 1) ->
safe_invoke(Fun, Cbor),
{noreply, State};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Msg, State) ->
{noreply, State}.
terminate(_Reason, #state{station_id = SId}) ->
catch macula_net_transport_quic:disconnect(SId),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%% =============================================================================
%% Internals
%% =============================================================================
%% Connect to host (idempotent — already_connected counts as success).
ensure_connected(StationId, Host, Port) ->
case macula_net_transport_quic:connect(StationId, Host, Port) of
ok -> ok;
{error, already_connected} -> ok;
{error, _} = E -> E
end.
send_handshake(StationId, DaemonPk, DaemonAddr, Realm, KeyPair) ->
Now = erlang:system_time(millisecond),
Delegation = macula_record:sign_host_delegation(
macula_record:host_delegation(
DaemonPk, StationId, Realm,
Now, Now + ?DELEGATION_TTL_MS),
KeyPair),
Hello = macula_cbor_nif:pack(#{
<<"type">> => <<"macula_attach_v1">>,
<<"daemon_pubkey">> => DaemonPk,
<<"daemon_addr">> => DaemonAddr,
<<"delegation">> => delegation_payload(Delegation)
}),
macula_net_transport_quic:send(StationId, Hello).
%% Encode the delegation in the same wire shape host_identity expects
%% to parse (single-letter keys; matches macula_record's internal
%% representation).
delegation_payload(#{daemon_pubkey := DaemonPk,
host_pubkey := HostPk,
realm_pubkey := Realm,
not_before_ms := NB,
not_after_ms := NA,
daemon_sig := Sig}) ->
#{
<<"d">> => DaemonPk,
<<"h">> => HostPk,
<<"r">> => Realm,
<<"nb">> => NB,
<<"na">> => NA,
<<"s">> => Sig
}.
safe_invoke(Fun, Arg) ->
%% Boundary: a user-supplied callback crash must not take down
%% the gen_server. Same exception logic as macula_net_transport_quic.
try Fun(Arg)
catch _:_ -> ok
end.