Packages

macula

0.43.3
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
macula src macula_nat_system macula_nat_classifier.erl
Raw

src/macula_nat_system/macula_nat_classifier.erl

%%%-------------------------------------------------------------------
%%% @doc
%%% Pure functional helpers for NAT type classification.
%%%
%%% Extracted from macula_nat_detector to separate pure logic from
%%% gen_server state management. All functions here are stateless
%%% and side-effect free (except environment variable reads for
%%% geo-config and node ID).
%%%
%%% Responsibilities:
%%% - NAT policy classification (mapping, filtering, allocation)
%%% - NAT profile creation with geo-location
%%% - IP address utilities (public/private/loopback detection)
%%% - Reflexive address parsing
%%% - Message field extraction (atom/binary key support)
%%% - Environment variable parsing helpers
%%% @end
%%%-------------------------------------------------------------------
-module(macula_nat_classifier).
%% NAT classification
-export([
classify_nat_type/1,
classify_mapping_policy/4,
classify_allocation_policy/4,
classify_port_contiguity/1,
infer_filtering_policy/1,
port_preservation_policy/2,
create_profile/3
]).
%% IP address utilities
-export([
is_public_ip/1,
is_private_ip/3,
is_loopback/1,
select_best_ip/1,
extract_ipv4_addresses/1
]).
%% Message parsing helpers
-export([
parse_reflexive_address/2,
get_msg_field/3,
ensure_observer_id/2
]).
%% Environment variable helpers
-export([
parse_float_env/1,
get_binary_env/1
]).
%%%===================================================================
%%% NAT Classification
%%%===================================================================
%% @doc Classify NAT type based on observations.
%% With 0 observations: assume worst case (symmetric NAT).
%% With 1 observation: basic classification (conservative).
%% With 2+ observations: full classification (accurate).
-spec classify_nat_type([map()]) -> macula_nat_cache:nat_profile().
classify_nat_type([]) ->
create_profile(pd, pd, rd);
classify_nat_type([Obs]) ->
#{reflexive_address := {_IP, ExtPort}, local_address := {_, LocalPort}} = Obs,
AllocationPolicy = port_preservation_policy(ExtPort, LocalPort),
create_profile(ei, pd, AllocationPolicy);
classify_nat_type(Observations) when length(Observations) >= 2 ->
[Obs1 | Rest] = Observations,
Obs2 = hd(Rest),
#{reflexive_address := {IP1, Port1}, local_address := {_, LocalPort1}} = Obs1,
#{reflexive_address := {IP2, Port2}, local_address := {_, LocalPort2}} = Obs2,
MappingPolicy = classify_mapping_policy(IP1, Port1, IP2, Port2),
AllocationPolicy = classify_allocation_policy(LocalPort1, Port1, LocalPort2, Port2),
FilteringPolicy = infer_filtering_policy(MappingPolicy),
create_profile(MappingPolicy, FilteringPolicy, AllocationPolicy).
%% @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
%% @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)).
%% @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
%% @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
%% @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
%% @doc Create a NAT profile with given policies.
%% Includes optional geo-location fields from environment variables:
%% - MACULA_LATITUDE: Float, -90.0 to 90.0
%% - MACULA_LONGITUDE: Float, -180.0 to 180.0
%% - MACULA_LOCATION: String, human-readable label e.g. "Amsterdam, NL"
-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) ->
BaseProfile = #{
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 => macula_nat_detector:is_relay_capable(),
detected_at => erlang:system_time(second),
ttl_seconds => 300
},
add_geo_config(BaseProfile).
%%%===================================================================
%%% IP Address Utilities
%%%===================================================================
%% @doc Check if IP is public (not private, not loopback, not link-local).
-spec is_public_ip(inet:ip_address()) -> boolean().
is_public_ip({0, 0, 0, 0}) -> false;
is_public_ip({127, _, _, _}) -> false; % Loopback
is_public_ip({10, _, _, _}) -> false; % Private Class A
is_public_ip({172, B, _, _}) when B >= 16, B =< 31 -> false; % Private Class B
is_public_ip({192, 168, _, _}) -> false; % Private Class C
is_public_ip({169, 254, _, _}) -> false; % Link-local
is_public_ip({_, _, _, _}) -> true; % Everything else is public
is_public_ip(_) -> false. % IPv6 or invalid
%% @doc Check if IP is private (RFC 1918).
-spec is_private_ip(integer(), integer(), integer()) -> boolean().
is_private_ip(10, _, _) -> true;
is_private_ip(172, B, _) when B >= 16, B =< 31 -> true;
is_private_ip(192, 168, _) -> true;
is_private_ip(_, _, _) -> false.
%% @doc Check if IP is loopback.
-spec is_loopback(integer()) -> boolean().
is_loopback(127) -> true;
is_loopback(_) -> false.
%% @doc Select best IP: prefer non-loopback, then private.
-spec select_best_ip([{inet:ip_address(), boolean(), boolean()}]) -> inet:ip_address().
select_best_ip([]) ->
{0, 0, 0, 0};
select_best_ip(IPs) ->
Sorted = lists:sort(fun({_, Private1, Loopback1}, {_, Private2, Loopback2}) ->
case {Loopback1, Loopback2} of
{true, false} -> false; % Non-loopback wins
{false, true} -> true;
_ ->
%% Both same loopback status, prefer private
Private1 >= Private2
end
end, IPs),
{IP, _, _} = hd(Sorted),
IP.
%% @doc Extract all IPv4 addresses from interfaces.
-spec extract_ipv4_addresses([{string(), proplists:proplist()}]) ->
[{inet:ip_address(), boolean(), boolean()}].
extract_ipv4_addresses(Interfaces) ->
lists:foldl(fun({_Name, Props}, Acc) ->
case proplists:get_value(addr, Props) of
{A, B, C, _D} = IP when is_integer(A) ->
[{IP, is_private_ip(A, B, C), is_loopback(A)} | Acc];
_ ->
Acc
end
end, [], Interfaces).
%%%===================================================================
%%% Message Parsing Helpers
%%%===================================================================
%% @doc Parse reflexive IP from binary or tuple format.
-spec parse_reflexive_address(binary() | tuple(), inet:port_number()) ->
{inet:ip_address(), inet:port_number()}.
parse_reflexive_address(IP, Port) when is_tuple(IP) ->
{IP, Port};
parse_reflexive_address(IP, Port) when is_binary(IP) ->
case inet:parse_address(binary_to_list(IP)) of
{ok, ParsedIP} -> {ParsedIP, Port};
{error, _} -> {{0, 0, 0, 0}, Port}
end.
%% @doc Helper to get field with atom or binary key.
-spec get_msg_field(map(), atom(), binary()) -> term() | undefined.
get_msg_field(Map, AtomKey, BinaryKey) ->
case maps:find(AtomKey, Map) of
{ok, Value} -> Value;
error ->
case maps:find(BinaryKey, Map) of
{ok, Value} -> Value;
error -> undefined
end
end.
%% @doc Ensure observer ID is valid binary.
-spec ensure_observer_id(term(), binary()) -> binary().
ensure_observer_id(undefined, Endpoint) -> Endpoint;
ensure_observer_id(Id, _Endpoint) when is_binary(Id) -> Id;
ensure_observer_id(_, Endpoint) -> Endpoint.
%%%===================================================================
%%% Environment Variable Helpers
%%%===================================================================
%% @doc Parse a float from environment variable.
%% Returns undefined if not set or invalid.
-spec parse_float_env(string()) -> float() | undefined.
parse_float_env(EnvVar) ->
case os:getenv(EnvVar) of
false -> undefined;
"" -> undefined;
Value ->
parse_float_safe(Value)
end.
%% @doc Get a binary value from environment variable.
%% Returns undefined if not set.
-spec get_binary_env(string()) -> binary() | undefined.
get_binary_env(EnvVar) ->
case os:getenv(EnvVar) of
false -> undefined;
"" -> undefined;
Value -> list_to_binary(Value)
end.
%%%===================================================================
%%% Internal Functions
%%%===================================================================
%% @private Get the local node ID.
-spec get_local_node_id() -> binary().
get_local_node_id() ->
case catch macula_names:local_node_id() of
NodeId when is_binary(NodeId) -> NodeId;
_ -> crypto:strong_rand_bytes(32)
end.
%% @private Read geo-location configuration from environment variables.
-spec add_geo_config(macula_nat_cache:nat_profile()) -> macula_nat_cache:nat_profile().
add_geo_config(Profile) ->
GeoConfig = get_geo_config(),
maps:merge(Profile, GeoConfig).
%% @private Get geo configuration from environment variables.
-spec get_geo_config() -> map().
get_geo_config() ->
maps:filter(fun(_K, V) -> V =/= undefined end, #{
latitude => parse_float_env("MACULA_LATITUDE"),
longitude => parse_float_env("MACULA_LONGITUDE"),
location_label => get_binary_env("MACULA_LOCATION")
}).
%% @private
parse_float_safe(Value) ->
handle_float_parse(catch string:to_float(Value), Value).
%% @private Handle float parsing result
handle_float_parse({'EXIT', _}, _Value) ->
undefined;
handle_float_parse({Float, _Rest}, _Value) when is_float(Float) ->
Float;
handle_float_parse({error, no_float}, Value) ->
try_parse_as_integer(Value);
handle_float_parse(_, _Value) ->
undefined.
%% @private Try parsing as integer and convert to float
try_parse_as_integer(Value) ->
handle_int_parse(catch string:to_integer(Value)).
%% @private Handle integer parsing result
handle_int_parse({'EXIT', _}) ->
undefined;
handle_int_parse({Int, _}) when is_integer(Int) ->
float(Int);
handle_int_parse(_) ->
undefined.