Packages
macula
4.2.4
7.1.0
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/route_packet/macula_route_packet.erl
%%%-------------------------------------------------------------------
%%% @doc Egress routing for macula-net packets.
%%%
%%% Per the macula-net spec (PLAN_MACULA_NET.md §5.2), this slice owns
%%% the egress flow:
%%%
%%% IPv6 packet from TUN → look up dst → encapsulate → send to station
%%%
%%% Two modes:
%%%
%%% <dl>
%%% <dt>`static'</dt>
%%% <dd>Phase 1 default. Caller pre-populates an ETS table of
%%% `{Address, StationId, SendFun}'. Used by tests and the
%%% smoke / netns demos. Selected when `configure/1' receives
%%% `stations'.</dd>
%%% <dt>`dht'</dt>
%%% <dd>Phase 2. On miss in {@link macula_cache_route}, calls
%%% {@link macula_resolve_address:resolve/3} for the destination,
%%% caches the answer (TTL = record's `expires_at'), and dials
%%% the resolved host via the configured `connect_fn'. Selected
%%% when `configure/1' receives `resolver'.</dd>
%%% </dl>
%%%
%%% CBOR encoding uses {@link macula_cbor_nif:pack/1}.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_route_packet).
-export([
configure/1,
mode/0,
routes/0,
lookup/1,
dispatch/1,
dispatch_envelope/2,
encapsulate/3
]).
-export_type([
station_entry/0,
send_fun/0,
own_address/0,
resolver/0
]).
-type station_entry() :: #{
address := <<_:128>>,
station := binary(),
send := send_fun()
}.
-type send_fun() :: fun((StationId :: binary(), CborEnvelope :: binary()) ->
ok | {error, term()}).
-type connect_fun() :: fun((StationId :: binary(),
Host :: binary() | string(),
Port :: 1..65535) -> ok | {error, term()}).
-type resolver() :: #{
realm_pubkey := <<_:256>>,
find_fn := macula_resolve_address:find_fn(),
connect_fn := connect_fun(),
send_fn := send_fun()
}.
-type own_address() :: <<_:128>>.
-define(TABLE, macula_route_packet_table).
-define(OWN_ADDR_KEY, own_address).
-define(MODE_KEY, mode).
-define(RESOLVER_KEY, resolver).
-define(DEFAULT_TTL, 64).
%% =============================================================================
%% Public API
%% =============================================================================
%% @doc Install routing. Two shapes:
%%
%% Static: `#{own_address, stations}' — Phase 1 / tests.
%% DHT: `#{own_address, resolver}' — Phase 2.
%%
%% Idempotent. Calling again replaces the previous configuration.
-spec configure(#{own_address := own_address(),
stations => [station_entry()],
resolver => resolver()}) -> ok.
configure(#{own_address := OwnAddr, stations := Stations}) ->
ensure_table(),
true = ets:delete_all_objects(?TABLE),
true = ets:insert(?TABLE, {?OWN_ADDR_KEY, OwnAddr}),
true = ets:insert(?TABLE, {?MODE_KEY, static}),
lists:foreach(
fun(#{address := A, station := S, send := F}) ->
true = ets:insert(?TABLE, {{addr, A}, S, F})
end, Stations),
ok;
configure(#{own_address := OwnAddr,
resolver := #{realm_pubkey := _,
find_fn := _,
connect_fn := _,
send_fn := _} = Resolver}) ->
ensure_table(),
true = ets:delete_all_objects(?TABLE),
true = ets:insert(?TABLE, {?OWN_ADDR_KEY, OwnAddr}),
true = ets:insert(?TABLE, {?MODE_KEY, dht}),
true = ets:insert(?TABLE, {?RESOLVER_KEY, Resolver}),
ok.
-spec mode() -> static | dht | undefined.
mode() ->
case ets:info(?TABLE) of
undefined -> undefined;
_ ->
case ets:lookup(?TABLE, ?MODE_KEY) of
[{_, M}] -> M;
[] -> undefined
end
end.
%% @doc Return the configured static stations (diagnostics; empty in dht mode).
-spec routes() -> [#{address := <<_:128>>, station := binary()}].
routes() ->
case ets:info(?TABLE) of
undefined -> [];
_ ->
ets:foldl(
fun({{addr, A}, S, _F}, Acc) ->
[#{address => A, station => S} | Acc];
(_, Acc) -> Acc
end, [], ?TABLE)
end.
%% @doc Look up the route for `Address'.
%%
%% In static mode this is the synchronous lookup of Phase 1.
%% In dht mode the lookup is cache-only — a cold miss returns
%% `not_found' rather than triggering a DHT call (use {@link
%% dispatch/1} for the full resolve+connect+send path).
-spec lookup(<<_:128>>) ->
{ok, #{station := binary(), send := send_fun()}} | not_found.
lookup(Address) ->
lookup_in_mode(mode(), Address).
lookup_in_mode(undefined, _Address) -> not_found;
lookup_in_mode(static, Address) ->
case ets:lookup(?TABLE, {addr, Address}) of
[{_, Station, Send}] -> {ok, #{station => Station, send => Send}};
[] -> not_found
end;
lookup_in_mode(dht, Address) ->
case macula_cache_route:lookup(Address) of
{ok, #{station_pubkey := Pk}} ->
#{send_fn := Send} = resolver(),
{ok, #{station => Pk, send => Send}};
_ ->
not_found
end.
%% @doc Egress dispatch. Takes a raw IPv6 packet, looks up its
%% destination, wraps it in a macula-net envelope, sends to the
%% hosting station. In dht mode performs a DHT resolve on cache miss
%% and dials the resolved host via the configured `connect_fn'.
-spec dispatch(Packet :: binary()) ->
{ok, StationId :: binary()} | {error, no_route | malformed_packet | term()}.
dispatch(Packet) when is_binary(Packet), byte_size(Packet) >= 40 ->
T0 = erlang:monotonic_time(microsecond),
Src = macula_route_packet_ipv6:src(Packet),
Dst = macula_route_packet_ipv6:dst(Packet),
Result = dispatch_in_mode(mode(), Packet, Src, Dst),
emit_dispatch_telemetry(Result, T0),
Result;
dispatch(_) ->
telemetry:execute([macula, net, egress, dropped],
#{count => 1},
#{reason => <<"malformed_packet">>}),
{error, malformed_packet}.
emit_dispatch_telemetry({ok, _Station}, T0) ->
Latency = erlang:monotonic_time(microsecond) - T0,
telemetry:execute([macula, net, egress, dispatched],
#{latency_us => Latency},
#{kind => <<"data">>});
emit_dispatch_telemetry({error, Reason}, _T0) ->
telemetry:execute([macula, net, egress, dropped],
#{count => 1},
#{reason => reason_bin(Reason)}).
reason_bin(R) when is_atom(R) -> atom_to_binary(R, utf8);
reason_bin(R) when is_binary(R) -> R;
reason_bin(_) -> <<"unknown">>.
%% Static mode (Phase 1).
dispatch_in_mode(static, Packet, Src, Dst) ->
dispatch_to(lookup_in_mode(static, Dst), Packet, Src, Dst);
%% DHT mode (Phase 2). Cache-first; on miss/expired, resolve + connect
%% + cache + send.
dispatch_in_mode(dht, Packet, Src, Dst) ->
Envelope = encapsulate(Packet, Src, Dst),
Resolver = resolver(),
Send = maps:get(send_fn, Resolver),
deliver_dht(macula_cache_route:lookup(Dst), Dst, Envelope, Send, Resolver);
dispatch_in_mode(undefined, _Packet, _Src, _Dst) ->
{error, not_configured}.
deliver_dht({ok, #{station_pubkey := Pk}}, _Dst, Envelope, Send, _Resolver) ->
send_via(Send(Pk, Envelope), Pk);
deliver_dht(_MissOrExpired, Dst, Envelope, Send, Resolver) ->
Realm = maps:get(realm_pubkey, Resolver),
FindFn = maps:get(find_fn, Resolver),
ConnFn = maps:get(connect_fn, Resolver),
case macula_resolve_address:resolve(Dst, Realm, FindFn) of
{ok, Endpoint} ->
install_and_send(Endpoint, Dst, Envelope, Send, ConnFn);
{error, _} = Err ->
map_resolve_error(Err)
end.
install_and_send(#{station_pubkey := Pk,
quic_port := Port,
host_advertised := Hosts,
expires_at := X} = _Endpoint,
Dst, Envelope, Send, ConnFn) ->
case pick_host(Hosts) of
{error, _} = Err -> Err;
{ok, Host} ->
case ConnFn(Pk, Host, Port) of
ok ->
ok = macula_cache_route:insert(Dst, #{
station_pubkey => Pk,
host => Host,
port => Port,
expires_at => X
}),
send_via(Send(Pk, Envelope), Pk);
{error, _} = Err ->
Err
end
end.
pick_host([]) -> {error, no_route};
pick_host([H | _]) when is_binary(H) -> {ok, H};
pick_host([H | _]) when is_list(H) -> {ok, H};
pick_host(_) -> {error, no_route}.
map_resolve_error({error, not_found}) -> {error, no_route};
map_resolve_error({error, bad_address_binding}) -> {error, bad_address_binding};
map_resolve_error({error, bad_signature}) -> {error, bad_signature};
map_resolve_error({error, _} = E) -> E.
%% Pattern-matched dispatch helper (static mode).
dispatch_to(not_found, _Packet, _Src, _Dst) ->
{error, no_route};
dispatch_to({ok, #{station := Station, send := Send}}, Packet, Src, Dst) ->
Envelope = encapsulate(Packet, Src, Dst),
send_via(Send(Station, Envelope), Station).
send_via(ok, Station) -> {ok, Station};
send_via({error, _} = Err, _Station) -> Err.
%% @doc Forward a pre-built macula-net envelope toward `Dst'.
%%
%% Same lookup + resolve + connect + send pipeline as {@link
%% dispatch/1}, but skips the IPv6-to-CBOR encapsulation step. Used
%% by {@link macula_host_attach_controller} when a hosted daemon
%% emits a data envelope whose `dst' is neither hosted on the same
%% station nor the station's own address — the host station forwards
%% the same bytes onward, preserving the envelope's `src' so the
%% routing is transparent at L3.
-spec dispatch_envelope(CborEnvelope :: binary(), Dst :: <<_:128>>) ->
{ok, StationId :: binary()} | {error, term()}.
dispatch_envelope(Cbor, Dst) when is_binary(Cbor), is_binary(Dst), byte_size(Dst) =:= 16 ->
Result = dispatch_envelope_in_mode(mode(), Cbor, Dst),
emit_relay_telemetry(Result),
Result.
emit_relay_telemetry({ok, _Station}) ->
telemetry:execute([macula, net, relay, dispatched],
#{count => 1}, #{kind => <<"data">>});
emit_relay_telemetry({error, Reason}) ->
telemetry:execute([macula, net, egress, dropped],
#{count => 1}, #{reason => reason_bin(Reason)}).
dispatch_envelope_in_mode(undefined, _Cbor, _Dst) ->
{error, not_configured};
dispatch_envelope_in_mode(static, Cbor, Dst) ->
forward_static(lookup_in_mode(static, Dst), Cbor);
dispatch_envelope_in_mode(dht, Cbor, Dst) ->
Resolver = resolver(),
Send = maps:get(send_fn, Resolver),
deliver_dht(macula_cache_route:lookup(Dst), Dst, Cbor, Send, Resolver).
forward_static(not_found, _Cbor) ->
{error, no_route};
forward_static({ok, #{station := Station, send := Send}}, Cbor) ->
send_via(Send(Station, Cbor), Station).
%% @doc Build a macula-net envelope around an IPv6 packet. Public so
%% other layers (e.g. daemon attachment) can craft envelopes directly.
-spec encapsulate(Payload :: binary(),
Src :: <<_:128>>,
Dst :: <<_:128>>) -> binary().
encapsulate(Payload, Src, Dst) ->
macula_cbor_nif:pack(#{
<<"v">> => 1,
<<"type">> => <<"data">>,
<<"src">> => Src,
<<"dst">> => Dst,
<<"ttl">> => ?DEFAULT_TTL,
<<"payload">> => Payload
}).
%% =============================================================================
%% Internals
%% =============================================================================
resolver() ->
[{_, R}] = ets:lookup(?TABLE, ?RESOLVER_KEY),
R.
ensure_table() ->
case ets:info(?TABLE) of
undefined ->
_ = ets:new(?TABLE, [named_table, public, set,
{read_concurrency, true}]),
ok;
_ ->
ok
end.