Packages
macula
0.10.2
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/macula_nat_system/macula_nat_detector.erl
%%%-------------------------------------------------------------------
%%% @doc
%%% NAT Type Detector using NATCracker Methodology.
%%%
%%% Detects the local peer's NAT characteristics by probing external
%%% observers (gateways/peers with public IPs) and analyzing reflexive
%%% addresses. Classification follows NATCracker's 27 NAT type model.
%%%
%%% NAT Policy Detection:
%%% 1. Mapping Policy (m): How NAT maps internal to external addresses
%%% - EI (Endpoint-Independent): Same external addr for all destinations
%%% - HD (Host-Dependent): Different external addr per destination host
%%% - PD (Port-Dependent): Different external addr per destination host:port
%%%
%%% 2. Filtering Policy (f): What incoming packets NAT accepts
%%% - EI: Accepts from any source
%%% - HD: Accepts from hosts we've contacted
%%% - PD: Accepts from host:port we've contacted
%%%
%%% 3. Allocation Policy (a): How NAT chooses external ports
%%% - PP (Port-Preservation): external_port = local_port
%%% - PC (Port-Contiguity): external_port = last_port + delta
%%% - RD (Random): No predictable pattern
%%%
%%% Detection Algorithm (Fast - 200-400ms):
%%% 1. Send NAT_PROBE to primary observer (100ms RTT)
%%% 2. Send NAT_PROBE to secondary observer (parallel, 100ms RTT)
%%% 3. Compare reflexive addresses to classify NAT type
%%%
%%% Most Common NAT Types (per NATCracker):
%%% - (EI, PD, PP): 37% of consumer NATs
%%% - (EI, EI, PP): 15% (Full Cone)
%%% - (PD, PD, RD): 12% (Symmetric)
%%% @end
%%%-------------------------------------------------------------------
-module(macula_nat_detector).
-behaviour(gen_server).
-include_lib("kernel/include/logger.hrl").
%% API
-export([
start_link/1,
detect/0,
detect/1,
get_local_profile/0,
add_observation/2,
refresh/0
]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-define(SERVER, ?MODULE).
-define(DEFAULT_DETECTION_TIMEOUT_MS, 2000).
-define(REFRESH_INTERVAL_MS, 300000). % 5 minutes
%%%===================================================================
%%% Types
%%%===================================================================
-type observation() :: #{
observer_id := binary(),
reflexive_address := {inet:ip_address(), inet:port_number()},
local_address := {inet:ip_address(), inet:port_number()},
observed_at := integer()
}.
-record(state, {
local_profile :: macula_nat_cache:nat_profile() | undefined,
observations :: [observation()],
detection_timeout_ms :: pos_integer(),
last_detection :: integer() | undefined
}).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Start the NAT detector server.
-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Opts) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Opts, []).
%% @doc Detect local NAT type (async, returns immediately).
%% Results are cached and available via get_local_profile/0.
-spec detect() -> ok.
detect() ->
gen_server:cast(?SERVER, detect).
%% @doc Detect NAT type using specific observer endpoint.
-spec detect(binary()) -> ok.
detect(ObserverEndpoint) when is_binary(ObserverEndpoint) ->
gen_server:cast(?SERVER, {detect, ObserverEndpoint}).
%% @doc Get the cached local NAT profile.
-spec get_local_profile() -> {ok, macula_nat_cache:nat_profile()} | not_detected.
get_local_profile() ->
gen_server:call(?SERVER, get_local_profile).
%% @doc Add an observation from an external observer.
%% Called when we receive a NAT_PROBE_REPLY with our reflexive address.
-spec add_observation(binary(), {inet:ip_address(), inet:port_number()}) -> ok.
add_observation(ObserverId, ReflexiveAddress) ->
gen_server:cast(?SERVER, {add_observation, ObserverId, ReflexiveAddress}).
%% @doc Trigger NAT type refresh (re-detection).
-spec refresh() -> ok.
refresh() ->
gen_server:cast(?SERVER, refresh).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
init(Opts) ->
Timeout = maps:get(detection_timeout_ms, Opts, ?DEFAULT_DETECTION_TIMEOUT_MS),
?LOG_INFO("NAT detector started (timeout=~p ms)", [Timeout]),
%% Schedule periodic refresh
schedule_refresh(),
{ok, #state{
local_profile = undefined,
observations = [],
detection_timeout_ms = Timeout,
last_detection = undefined
}}.
handle_call(get_local_profile, _From, #state{local_profile = undefined} = State) ->
{reply, not_detected, State};
handle_call(get_local_profile, _From, #state{local_profile = Profile} = State) ->
{reply, {ok, Profile}, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast(detect, State) ->
%% Trigger detection using any available observers
NewState = trigger_detection(State),
{noreply, NewState};
handle_cast({detect, ObserverEndpoint}, State) ->
%% Trigger detection using specific observer
NewState = trigger_detection_with_observer(ObserverEndpoint, State),
{noreply, NewState};
handle_cast({add_observation, ObserverId, ReflexiveAddress}, State) ->
%% Add observation and potentially update NAT classification
NewState = process_observation(ObserverId, ReflexiveAddress, State),
{noreply, NewState};
handle_cast(refresh, State) ->
?LOG_DEBUG("NAT type refresh triggered"),
NewState = State#state{observations = []},
trigger_detection(NewState),
{noreply, NewState};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(refresh, State) ->
?LOG_DEBUG("Periodic NAT refresh"),
schedule_refresh(),
NewState = maybe_refresh_detection(State),
{noreply, NewState};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
%%%===================================================================
%%% Internal functions
%%%===================================================================
%% @private
%% @doc Trigger NAT detection by sending probes to known observers.
-spec trigger_detection(#state{}) -> #state{}.
trigger_detection(State) ->
%% In a real implementation, this would:
%% 1. Look up bootstrap gateways from DHT
%% 2. Send NAT_PROBE messages to multiple observers
%% 3. Wait for NAT_PROBE_REPLY with reflexive addresses
%% 4. Call add_observation for each reply
%%
%% For now, we log and wait for observations from external sources
?LOG_DEBUG("NAT detection triggered, waiting for observations"),
State.
%% @private
%% @doc Trigger detection using a specific observer.
-spec trigger_detection_with_observer(binary(), #state{}) -> #state{}.
trigger_detection_with_observer(ObserverEndpoint, State) ->
?LOG_DEBUG("NAT detection triggered with observer ~s", [ObserverEndpoint]),
%% TODO: Send NAT_PROBE to specific observer
State.
%% @private
%% @doc Process an observation and update NAT classification if enough data.
-spec process_observation(binary(), {inet:ip_address(), inet:port_number()}, #state{}) -> #state{}.
process_observation(ObserverId, ReflexiveAddress, State) ->
#state{observations = Observations} = State,
%% Get local address (for port delta calculation)
LocalAddress = get_local_address(),
Observation = #{
observer_id => ObserverId,
reflexive_address => ReflexiveAddress,
local_address => LocalAddress,
observed_at => erlang:system_time(second)
},
NewObservations = [Observation | Observations],
?LOG_DEBUG("Added NAT observation from ~s: ~p", [ObserverId, ReflexiveAddress]),
%% Try to classify NAT type with available observations
NewState = State#state{observations = NewObservations},
maybe_classify_nat(NewState).
%% @private
%% @doc Attempt to classify NAT type if we have enough observations.
-spec maybe_classify_nat(#state{}) -> #state{}.
maybe_classify_nat(#state{observations = Observations} = State) when length(Observations) < 1 ->
%% Need at least 1 observation for basic classification
State;
maybe_classify_nat(#state{observations = Observations} = State) ->
%% Classify based on available observations
Profile = classify_nat_type(Observations),
%% Cache the profile
NodeId = get_local_node_id(),
macula_nat_cache:put(NodeId, Profile),
?LOG_INFO("NAT type detected: mapping=~p, filtering=~p, allocation=~p",
[maps:get(mapping_policy, Profile),
maps:get(filtering_policy, Profile),
maps:get(allocation_policy, Profile)]),
State#state{
local_profile = Profile,
last_detection = erlang:system_time(second)
}.
%% @private
%% @doc Classify NAT type based on observations.
%% With 1 observation: Basic classification (conservative)
%% With 2+ observations: Full classification (accurate)
-spec classify_nat_type([observation()]) -> macula_nat_cache:nat_profile().
classify_nat_type([]) ->
%% No observations - assume worst case (symmetric NAT)
create_profile(pd, pd, rd);
classify_nat_type([Obs]) ->
%% Single observation - can determine port preservation
#{reflexive_address := {_IP, ExtPort}, local_address := {_, LocalPort}} = Obs,
AllocationPolicy = port_preservation_policy(ExtPort, LocalPort),
%% With single observation, assume conservative (port-dependent filtering)
%% as we can't determine mapping/filtering policy without multiple observers
create_profile(ei, pd, AllocationPolicy);
classify_nat_type(Observations) when length(Observations) >= 2 ->
%% Multiple observations - can classify mapping and allocation policies
[Obs1 | Rest] = Observations,
Obs2 = hd(Rest),
#{reflexive_address := {IP1, Port1}, local_address := {_, LocalPort1}} = Obs1,
#{reflexive_address := {IP2, Port2}, local_address := {_, LocalPort2}} = Obs2,
%% Determine mapping policy based on reflexive addresses
MappingPolicy = classify_mapping_policy(IP1, Port1, IP2, Port2),
%% Determine allocation policy based on port patterns
AllocationPolicy = classify_allocation_policy(LocalPort1, Port1, LocalPort2, Port2),
%% Filtering policy is harder to determine without active probing
%% Default to port-dependent (most common conservative case)
FilteringPolicy = infer_filtering_policy(MappingPolicy),
create_profile(MappingPolicy, FilteringPolicy, AllocationPolicy).
%% @private
%% @doc Classify mapping policy based on observed reflexive addresses.
-spec classify_mapping_policy(inet:ip_address(), inet:port_number(),
inet:ip_address(), inet:port_number()) ->
macula_nat_cache:mapping_policy().
classify_mapping_policy(IP, Port, IP, Port) ->
ei; % Same IP and port - Endpoint Independent
classify_mapping_policy(IP, _Port1, IP, _Port2) ->
hd; % Same IP, different port - Host Dependent
classify_mapping_policy(_IP1, _Port1, _IP2, _Port2) ->
pd. % Different IP - Port Dependent
%% @private
%% @doc Classify allocation policy based on port patterns.
-spec classify_allocation_policy(inet:port_number(), inet:port_number(),
inet:port_number(), inet:port_number()) ->
macula_nat_cache:allocation_policy().
classify_allocation_policy(Port, Port, Port, Port) ->
pp; % Both preserved - Port Preservation
classify_allocation_policy(_LocalPort1, ExtPort1, _LocalPort2, ExtPort2) ->
classify_port_contiguity(abs(ExtPort2 - ExtPort1)).
%% @private
%% @doc Check for port contiguity based on delta between external ports.
-spec classify_port_contiguity(non_neg_integer()) -> macula_nat_cache:allocation_policy().
classify_port_contiguity(Delta) when Delta < 10 ->
pc; % Sequential ports - Port Contiguity
classify_port_contiguity(_Delta) ->
rd. % No pattern - Random
%% @private
%% @doc Infer filtering policy from mapping policy.
%% Conservative: assume filtering is at least as restrictive as mapping.
-spec infer_filtering_policy(macula_nat_cache:mapping_policy()) ->
macula_nat_cache:filtering_policy().
infer_filtering_policy(ei) -> pd; % EI mapping often has PD filtering
infer_filtering_policy(hd) -> pd; % HD mapping typically has PD filtering
infer_filtering_policy(pd) -> pd. % PD mapping has PD filtering
%% @private
%% @doc Determine port preservation policy from external and local ports.
-spec port_preservation_policy(inet:port_number(), inet:port_number()) ->
macula_nat_cache:allocation_policy().
port_preservation_policy(Port, Port) ->
pp; % Port Preservation
port_preservation_policy(_ExtPort, _LocalPort) ->
rd. % Can't determine without more data, assume random
%% @private
%% @doc Create a NAT profile with given policies.
-spec create_profile(macula_nat_cache:mapping_policy(),
macula_nat_cache:filtering_policy(),
macula_nat_cache:allocation_policy()) ->
macula_nat_cache:nat_profile().
create_profile(MappingPolicy, FilteringPolicy, AllocationPolicy) ->
#{
node_id => get_local_node_id(),
mapping_policy => MappingPolicy,
filtering_policy => FilteringPolicy,
allocation_policy => AllocationPolicy,
can_receive_unsolicited => (MappingPolicy =:= ei andalso FilteringPolicy =:= ei),
requires_relay => (MappingPolicy =:= pd andalso AllocationPolicy =:= rd),
relay_capable => is_relay_capable(),
detected_at => erlang:system_time(second),
ttl_seconds => 300
}.
%% @private
%% @doc Get the local node ID.
-spec get_local_node_id() -> binary().
get_local_node_id() ->
%% Try to get from macula_names, fallback to generated
case catch macula_names:local_node_id() of
NodeId when is_binary(NodeId) -> NodeId;
_ -> crypto:strong_rand_bytes(32)
end.
%% @private
%% @doc Get local address (best guess).
-spec get_local_address() -> {inet:ip_address(), inet:port_number()}.
get_local_address() ->
%% Try to determine local address from bound sockets
%% For now, return placeholder
{{0, 0, 0, 0}, 0}.
%% @private
%% @doc Check if local peer can act as relay (has public IP).
-spec is_relay_capable() -> boolean().
is_relay_capable() ->
%% TODO: Detect if we have a public IP
false.
%% @private
%% @doc Schedule periodic refresh.
-spec schedule_refresh() -> reference().
schedule_refresh() ->
erlang:send_after(?REFRESH_INTERVAL_MS, self(), refresh).
%% @private
%% @doc Conditionally refresh NAT detection if profile exists.
-spec maybe_refresh_detection(#state{}) -> #state{}.
maybe_refresh_detection(#state{local_profile = undefined} = State) ->
State;
maybe_refresh_detection(State) ->
NewState = State#state{observations = []},
trigger_detection(NewState).