%%%------------------------------------------------------------------- %%% @doc Ingress delivery for macula-net packets. %%% %%% Receives a CBOR-encoded macula-net envelope (typically from %%% macula_net_transport_quic), decodes it via {@link macula_cbor_nif}, %%% validates against spec, and writes the inner IPv6 packet to the %%% local TUN device when the destination is one of the locally-hosted %%% addresses. %%% %%% Phase 1 simplification (PLAN_MACULA_NET.md §5.2): single-hop only. %%% This station receives a packet whose `dst' is expected to be local; %%% if not, we drop and log. Multi-hop forwarding (decrement TTL, %%% re-route) lands in Phase 2 alongside the DHT. %%% %%% Decoupled from {@link macula_tun} via a TunWriter callback so this %%% slice has no compile-time dependency on the kernel-side NIF. %%% @end %%%------------------------------------------------------------------- -module(macula_deliver_packet). -export([ configure/1, handle_envelope/1 ]). -export_type([ tun_writer/0, config/0 ]). -type tun_writer() :: fun((IPv6Packet :: binary()) -> ok | {error, term()}). -type config() :: #{ local_addresses := [<<_:128>>], tun_writer := tun_writer() }. -define(TABLE, macula_deliver_packet_table). -define(SUPPORTED_VSN, 1). -define(LOCAL_ADDRS_KEY, local_addresses). -define(TUN_WRITER_KEY, tun_writer). %% ============================================================================= %% Public API %% ============================================================================= %% @doc Install the local-addresses set + TUN writer callback. %% Calling configure/1 again replaces the previous config. -spec configure(config()) -> ok. configure(#{local_addresses := Addrs, tun_writer := Writer}) when is_list(Addrs), is_function(Writer, 1) -> ensure_table(), true = ets:insert(?TABLE, {?LOCAL_ADDRS_KEY, sets:from_list(Addrs)}), true = ets:insert(?TABLE, {?TUN_WRITER_KEY, Writer}), ok. %% @doc Handle a single inbound CBOR-encoded macula-net envelope. %% %% Returns: %% -spec handle_envelope(binary()) -> ok | {error, version_unsupported | decode_failed | no_route | not_configured | tun_write_failed | term()}. handle_envelope(Cbor) when is_binary(Cbor) -> T0 = erlang:monotonic_time(microsecond), Result = handle_with_config(lookup_config(), Cbor), emit_ingress_telemetry(Result, T0), Result. emit_ingress_telemetry(ok, T0) -> Latency = erlang:monotonic_time(microsecond) - T0, telemetry:execute([macula, net, ingress, delivered], #{latency_us => Latency}, #{kind => <<"data">>}); emit_ingress_telemetry({error, Reason}, _T0) -> telemetry:execute([macula, net, ingress, 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">>. %% ============================================================================= %% Internals %% ============================================================================= handle_with_config({error, _} = Err, _Cbor) -> Err; handle_with_config({ok, LocalAddrs, Writer}, Cbor) -> handle_decoded(macula_cbor_nif:unpack(Cbor), LocalAddrs, Writer). handle_decoded({error, _Reason}, _LocalAddrs, _Writer) -> {error, decode_failed}; handle_decoded({ok, #{<<"v">> := V}}, _LocalAddrs, _Writer) when V =/= ?SUPPORTED_VSN -> {error, version_unsupported}; handle_decoded({ok, #{<<"type">> := <<"data">>, <<"dst">> := Dst, <<"payload">> := Payload}}, LocalAddrs, Writer) -> deliver_local(sets:is_element(Dst, LocalAddrs), Payload, Writer); handle_decoded({ok, #{<<"type">> := _Other}}, _LocalAddrs, _Writer) -> %% ctrl/gossip handlers land in Phase 1.5+. Treat as %% unhandled-but-not-error for now. ok; handle_decoded({ok, _Other}, _LocalAddrs, _Writer) -> {error, decode_failed}. deliver_local(false, _Payload, _Writer) -> {error, no_route}; deliver_local(true, Payload, Writer) -> deliver_via(Writer(Payload)). deliver_via(ok) -> ok; deliver_via({error, _}) -> {error, tun_write_failed}. lookup_config() -> case ets:info(?TABLE) of undefined -> {error, not_configured}; _ -> case {ets:lookup(?TABLE, ?LOCAL_ADDRS_KEY), ets:lookup(?TABLE, ?TUN_WRITER_KEY)} of {[{_, Addrs}], [{_, Writer}]} -> {ok, Addrs, Writer}; _ -> {error, not_configured} end end. ensure_table() -> case ets:info(?TABLE) of undefined -> _ = ets:new(?TABLE, [named_table, public, set, {read_concurrency, true}]), ok; _ -> ok end.