Packages
macula
5.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/host_identity/macula_host_identity.erl
%%%-------------------------------------------------------------------
%%% @doc Hosted-identity table for a macula-net station.
%%%
%%% PLAN_MACULA_NET_PHASE3.md §6.1. Tracks daemons that have attached
%%% to *this* station. On `attach/4', verifies the delegation,
%%% records the daemon, builds a signed `hosted_address_map' record
%%% and pushes it through the configured `put_fn'. On `detach/1',
%%% drops the entry; the record expires by TTL (Phase 4 may publish
%%% an explicit tombstone).
%%%
%%% The slice is a `gen_server' so it can refresh the hosted records
%%% on a timer at the same cadence {@link macula_advertise_station}
%%% uses for the station's own records.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_host_identity).
-behaviour(gen_server).
-export([
start_link/1,
stop/0,
attach/4,
detach/1,
hosted/1,
lookup/1,
hosted_addresses/0,
hosted_records/0,
refresh_now/0
]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-export_type([config/0, attach_conn/0]).
-define(SERVER, ?MODULE).
-define(TABLE, macula_host_identity_table).
-define(DEFAULT_REFRESH_MS, 60_000).
-type attach_conn() :: term().
-type config() :: #{
realm_pubkey := <<_:256>>,
host_pubkey := <<_:256>>,
host_privkey := macula_identity:key_pair() | macula_identity:privkey(),
put_fn := macula_advertise_station:put_fn(),
refresh_ms => pos_integer()
}.
-record(entry, {
daemon_pubkey :: <<_:256>>,
delegation :: macula_record:host_delegation(),
attach_conn :: attach_conn()
}).
-record(state, {
config :: config(),
refresh_ms :: pos_integer(),
timer :: reference() | undefined
}).
%% =============================================================================
%% Public API
%% =============================================================================
-spec start_link(config()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Config, []).
-spec stop() -> ok.
stop() ->
case whereis(?SERVER) of
undefined -> ok;
_ -> gen_server:stop(?SERVER)
end.
%% @doc Register a daemon as hosted by this station.
%%
%% `Delegation' must already be signed by the daemon (i.e.
%% {@link macula_record:sign_host_delegation/2}). The station
%% verifies the delegation and that its fields agree with the
%% station's identity + realm; any mismatch yields an error and the
%% daemon is NOT registered.
-spec attach(DaemonAddr :: <<_:128>>,
DaemonPubkey:: <<_:256>>,
Delegation :: macula_record:host_delegation(),
AttachConn :: attach_conn()) ->
ok | {error, term()}.
attach(DaemonAddr, DaemonPubkey, Delegation, AttachConn)
when is_binary(DaemonAddr), byte_size(DaemonAddr) =:= 16,
is_binary(DaemonPubkey), byte_size(DaemonPubkey) =:= 32 ->
gen_server:call(?SERVER,
{attach, DaemonAddr, DaemonPubkey, Delegation, AttachConn}).
-spec detach(<<_:128>>) -> ok.
detach(DaemonAddr) when is_binary(DaemonAddr), byte_size(DaemonAddr) =:= 16 ->
gen_server:call(?SERVER, {detach, DaemonAddr}).
-spec hosted(<<_:128>>) -> boolean().
hosted(DaemonAddr) ->
case ets:info(?TABLE) of
undefined -> false;
_ -> ets:member(?TABLE, DaemonAddr)
end.
-spec lookup(<<_:128>>) -> {ok, attach_conn()} | not_found.
lookup(DaemonAddr) ->
case ets:info(?TABLE) of
undefined -> not_found;
_ -> lookup_entry(ets:lookup(?TABLE, DaemonAddr))
end.
lookup_entry([{_, #entry{attach_conn = C}}]) -> {ok, C};
lookup_entry([]) -> not_found.
-spec hosted_addresses() -> [<<_:128>>].
hosted_addresses() ->
case ets:info(?TABLE) of
undefined -> [];
_ -> ets:foldl(fun collect_address/2, [], ?TABLE)
end.
collect_address({A, _}, Acc) -> [A | Acc].
%% @doc Snapshot the current set of hosted_address_map records (signed).
-spec hosted_records() -> [macula_record:record()].
hosted_records() ->
gen_server:call(?SERVER, hosted_records).
-spec refresh_now() -> ok.
refresh_now() ->
gen_server:call(?SERVER, refresh_now).
%% =============================================================================
%% gen_server callbacks
%% =============================================================================
init(#{realm_pubkey := _,
host_pubkey := _,
host_privkey := _,
put_fn := PutFn} = Config) when is_function(PutFn, 1) ->
process_flag(trap_exit, true),
ensure_table(),
RefreshMs = maps:get(refresh_ms, Config, ?DEFAULT_REFRESH_MS),
Timer = erlang:send_after(RefreshMs, self(), refresh_tick),
{ok, #state{config = Config, refresh_ms = RefreshMs, timer = Timer}}.
handle_call({attach, Addr, DaemonPk, Delegation, Conn}, _From,
#state{config = Config} = State) ->
Reply = handle_attach(Addr, DaemonPk, Delegation, Conn, Config),
{reply, Reply, State};
handle_call({detach, Addr}, _From, State) ->
_ = ets:delete(?TABLE, Addr),
{reply, ok, State};
handle_call(hosted_records, _From, #state{config = Config} = State) ->
{reply, build_all_records(Config), State};
handle_call(refresh_now, _From, State) ->
publish_all(State),
{reply, ok, State};
handle_call(_Other, _From, State) ->
{reply, {error, unknown_call}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(refresh_tick, #state{refresh_ms = Ms} = State) ->
publish_all(State),
NewTimer = erlang:send_after(Ms, self(), refresh_tick),
{noreply, State#state{timer = NewTimer}};
handle_info(_Other, State) ->
{noreply, State}.
terminate(_Reason, #state{timer = Timer}) ->
cancel(Timer),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%% =============================================================================
%% Internals — attach validation
%% =============================================================================
handle_attach(Addr, DaemonPk, Delegation, Conn, Config) ->
case validate_attach(Addr, DaemonPk, Delegation, Config) of
ok ->
true = ets:insert(?TABLE,
{Addr, #entry{daemon_pubkey = DaemonPk,
delegation = Delegation,
attach_conn = Conn}}),
publish_one(Addr, DaemonPk, Delegation, Config),
ok;
{error, _} = Err ->
Err
end.
validate_attach(Addr, DaemonPk, Delegation,
#{realm_pubkey := Realm, host_pubkey := HostPk}) ->
%% derive_address(realm, daemon_pk) must equal Addr.
case macula_address:derive(Realm, DaemonPk) of
Addr -> validate_delegation_fields(Delegation, DaemonPk, HostPk, Realm);
_ -> {error, address_does_not_match_daemon_pubkey}
end.
validate_delegation_fields(#{daemon_pubkey := DPk,
host_pubkey := HPk,
realm_pubkey := RPk,
not_after_ms := NA} = Delegation,
DaemonPk, HostPk, Realm) ->
Now = erlang:system_time(millisecond),
case {DPk =:= DaemonPk, HPk =:= HostPk, RPk =:= Realm, Now < NA} of
{true, true, true, true} ->
verify_delegation(Delegation);
{false, _, _, _} -> {error, delegation_daemon_mismatch};
{_, false, _, _} -> {error, delegation_host_mismatch};
{_, _, false, _} -> {error, delegation_realm_mismatch};
{_, _, _, false} -> {error, delegation_expired}
end;
validate_delegation_fields(_Other, _DaemonPk, _HostPk, _Realm) ->
{error, malformed_delegation}.
verify_delegation(Delegation) ->
case macula_record:verify_host_delegation(Delegation) of
{ok, _} -> ok;
{error, R} -> {error, {bad_delegation, R}}
end.
%% =============================================================================
%% Record building + publication
%% =============================================================================
publish_one(Addr, DaemonPk, Delegation,
#{host_pubkey := HostPk, host_privkey := Privkey,
put_fn := PutFn}) ->
Record = build_record(HostPk, Privkey, Addr, DaemonPk, Delegation),
log_put(PutFn(Record), Record).
publish_all(#state{config = Config}) ->
lists:foreach(fun(R) ->
log_put((maps:get(put_fn, Config))(R), R)
end, build_all_records(Config)).
build_all_records(#{host_pubkey := HostPk, host_privkey := Privkey}) ->
ets:foldl(fun({Addr, #entry{daemon_pubkey = DPk, delegation = Del}},
Acc) ->
[build_record(HostPk, Privkey, Addr, DPk, Del) | Acc]
end, [], ?TABLE).
build_record(HostPk, Privkey, Addr, DaemonPk, Delegation) ->
%% Re-attach the daemon_pubkey explicitly even though it's also in
%% the delegation — the record's payload exposes it directly so
%% resolvers can extract it without re-parsing the inner CBOR
%% before the signature is checked.
_ = DaemonPk,
macula_record:sign(
macula_record:hosted_address_map(HostPk, Addr, Delegation),
Privkey).
log_put(ok, _Record) ->
ok;
log_put({error, Reason}, Record) ->
error_logger:warning_msg(
"[host_identity] put_fn failed for hosted ~p: ~p",
[binary:part(macula_record:key(Record), 0, 4), Reason]).
%% =============================================================================
%% ETS housekeeping
%% =============================================================================
ensure_table() ->
case ets:info(?TABLE) of
undefined ->
_ = ets:new(?TABLE, [named_table, public, set,
{read_concurrency, true},
{keypos, 1}]),
ok;
_ -> ok
end.
cancel(undefined) -> ok;
cancel(T) when is_reference(T) ->
_ = erlang:cancel_timer(T),
ok.