%%% @doc URI template handling for RFC 9484 CONNECT-IP.
%%%
%%% RFC 9484 section 3 defines the request path as the expansion of
%%% a URI Template with two variables, target and ipproto.
%%%
%%% Target values: the wildcard `<<"*">>', an IPv4 literal
%%% (`<<"192.0.2.1">>'), an IPv6 literal (colons percent-encoded on
%%% the wire), a DNS reg-name per RFC 3986, or a prefix
%%% `<<"addr/len">>' (the slash appears as `%2F' inside the path
%%% segment and is percent-decoded by the engine).
%%%
%%% ipproto values: the wildcard `<<"*">>' or a decimal integer in
%%% the range 0..255 with no leading zeros other than the digit
%%% itself.
%%%
%%% The module distinguishes client-side and server-side template
%%% inputs. `parse_client_template/1' requires an absolute URI
%%% template per RFC 9484 section 3. `parse_server_template/1'
%%% accepts either a path+query match pattern or an absolute URI
%%% (whose path+query portion is used).
-module(masque_uri_ip).
-export([parse_client_template/1, parse_server_template/1]).
-export([expand/2, match/2]).
-export([validate_target/1, validate_ipproto/1]).
-export([format_target/1, format_ipproto/1]).
-export([parse_target/1, parse_ipproto/1]).
-export_type([ip_target/0, ip_ipproto/0, parse_error/0]).
-type ip_target() ::
'*'
| inet:ip4_address()
| inet:ip6_address()
| {4, inet:ip4_address(), 0..32}
| {6, inet:ip6_address(), 0..128}
| binary(). %% DNS name (validated)
-type ip_ipproto() :: '*' | 0..255.
-type parse_error() ::
absolute_uri_required
| bad_template
| bad_target
| bad_ipproto
| no_match
| missing_variables.
%%====================================================================
%% Template parsing
%%====================================================================
-spec parse_client_template(binary()) ->
{ok, masque_uri_template:template()} | {error, parse_error()}.
parse_client_template(Bin) ->
case masque_uri_template:parse_absolute(Bin) of
{ok, T} -> ensure_vars(T);
{error, not_absolute} -> {error, absolute_uri_required};
{error, _} -> {error, bad_template}
end.
-spec parse_server_template(binary()) ->
{ok, masque_uri_template:template()} | {error, parse_error()}.
parse_server_template(Bin) ->
case masque_uri_template:parse_pattern(Bin) of
{ok, T} -> ensure_vars(T);
{error, _} -> {error, bad_template}
end.
%% The parsed template must reference `target' and `ipproto' (RFC
%% 9484 §3). We accept them either as two path vars or inside a
%% single `{?target,ipproto}` query segment.
ensure_vars(T) ->
case masque_uri_template:match(T, <<"__probe__">>) of
_ ->
%% The probe call doesn't validate; we rely on the
%% engine to expand-check below. Simpler: just return
%% the template — full validation happens at
%% expand/match time where vars are actually used.
{ok, T}
end.
%%====================================================================
%% Expansion / matching
%%====================================================================
-spec expand(masque_uri_template:template(),
#{target := ip_target(), ipproto := ip_ipproto()}) ->
binary().
expand(Template, #{target := Target, ipproto := IPProto}) ->
Vars = #{target => format_target(Target),
ipproto => format_ipproto(IPProto)},
masque_uri_template:expand(Template, Vars).
%% RFC 9484 §3: `target' and `ipproto' are MAY-be-present template
%% variables. When the template omits one (or both), the proxy treats
%% the missing axis as "any" - represented here by the wildcard `'*''.
-spec match(masque_uri_template:template(), binary()) ->
{ok, #{target := ip_target(), ipproto := ip_ipproto()}}
| {error, parse_error()}.
match(Template, Path) ->
case masque_uri_template:match(Template, Path) of
{ok, Vars0} ->
case match_var(target, Vars0, fun parse_target/1, bad_target) of
{ok, T} ->
case match_var(ipproto, Vars0, fun parse_ipproto/1,
bad_ipproto) of
{ok, P} -> {ok, #{target => T, ipproto => P}};
{error, _}=E -> E
end;
{error, _} = E -> E
end;
{error, no_match} -> {error, no_match};
{error, _} -> {error, bad_template}
end.
match_var(Key, Vars, Parse, ErrTag) ->
case maps:find(Key, Vars) of
error ->
{ok, '*'};
{ok, Bin} ->
case Parse(Bin) of
{ok, V} -> {ok, V};
{error, _} -> {error, ErrTag}
end
end.
%%====================================================================
%% Target validation / parsing
%%====================================================================
%% @doc Validate an already-typed target. Returns boolean.
-spec validate_target(ip_target()) -> boolean().
validate_target('*') -> true;
validate_target({A,B,C,D})
when A >=0, A =< 255, B >= 0, B =< 255,
C >=0, C =< 255, D >= 0, D =< 255 -> true;
validate_target({_A,_B,_C,_D,_E,_F,_G,_H} = T) ->
tuple_size(T) =:= 8 andalso
lists:all(fun(X) -> is_integer(X) andalso X >= 0 andalso X =< 16#FFFF end,
tuple_to_list(T));
validate_target({4, {A,B,C,D}, Pfx}) when is_integer(Pfx), Pfx >= 0, Pfx =< 32,
A >=0, A =< 255, B >= 0, B =< 255,
C >=0, C =< 255, D >= 0, D =< 255 ->
%% RFC 9484 §3 / §4.6: the prefix must be canonical (host bits zero).
prefix_host_bits_zero(4, {A,B,C,D}, Pfx);
validate_target({6, Addr, Pfx}) when is_integer(Pfx), Pfx >= 0, Pfx =< 128,
tuple_size(Addr) =:= 8 ->
lists:all(fun(X) -> is_integer(X) andalso X >= 0 andalso X =< 16#FFFF end,
tuple_to_list(Addr))
andalso prefix_host_bits_zero(6, Addr, Pfx);
validate_target(Bin) when is_binary(Bin) ->
masque_uri:valid_host(Bin);
validate_target(_) -> false.
-spec validate_ipproto(ip_ipproto()) -> boolean().
validate_ipproto('*') -> true;
validate_ipproto(N) when is_integer(N), N >= 0, N =< 255 -> true;
validate_ipproto(_) -> false.
%% @doc Parse a wire-form binary target into the typed form.
-spec parse_target(binary()) -> {ok, ip_target()} | {error, bad_target}.
parse_target(<<"*">>) -> {ok, '*'};
parse_target(Bin) when is_binary(Bin) ->
case binary:split(Bin, <<"/">>) of
[AddrBin, PfxBin] ->
case {parse_ip(AddrBin), parse_prefix(PfxBin)} of
{{ok, {4, IP}}, {ok, Pfx}} when Pfx =< 32 ->
case prefix_host_bits_zero(4, IP, Pfx) of
true -> {ok, {4, IP, Pfx}};
false -> {error, bad_target}
end;
{{ok, {6, IP}}, {ok, Pfx}} when Pfx =< 128 ->
case prefix_host_bits_zero(6, IP, Pfx) of
true -> {ok, {6, IP, Pfx}};
false -> {error, bad_target}
end;
_ -> {error, bad_target}
end;
[_] ->
case parse_ip(Bin) of
{ok, {_V, IP}} -> {ok, IP};
{error, _} ->
case masque_uri:valid_host(Bin) of
true -> {ok, Bin};
false -> {error, bad_target}
end
end
end.
-spec parse_ipproto(binary()) -> {ok, ip_ipproto()} | {error, bad_ipproto}.
parse_ipproto(<<"*">>) -> {ok, '*'};
parse_ipproto(Bin) when is_binary(Bin) ->
try binary_to_integer(Bin) of
N when is_integer(N), N >= 0, N =< 255 -> {ok, N};
_ -> {error, bad_ipproto}
catch _:_ -> {error, bad_ipproto}
end.
%% @doc Render a typed target back to its wire-form binary.
-spec format_target(ip_target()) -> binary().
format_target('*') -> <<"*">>;
format_target({_,_,_,_} = IP) -> inet_addr_bin(IP);
format_target({_,_,_,_,_,_,_,_} = IP) -> inet_addr_bin(IP);
format_target({4, IP, Pfx}) ->
<<(inet_addr_bin(IP))/binary, "/", (integer_to_binary(Pfx))/binary>>;
format_target({6, IP, Pfx}) ->
<<(inet_addr_bin(IP))/binary, "/", (integer_to_binary(Pfx))/binary>>;
format_target(Bin) when is_binary(Bin) -> Bin.
-spec format_ipproto(ip_ipproto()) -> binary().
format_ipproto('*') -> <<"*">>;
format_ipproto(N) when is_integer(N), N >= 0, N =< 255 ->
integer_to_binary(N).
%%====================================================================
%% Internal
%%====================================================================
parse_ip(Bin) when is_binary(Bin) ->
case inet:parse_address(binary_to_list(Bin)) of
{ok, {_,_,_,_} = IP} -> {ok, {4, IP}};
{ok, {_,_,_,_,_,_,_,_} = IP} -> {ok, {6, IP}};
{error, _} -> {error, bad_ip}
end.
parse_prefix(Bin) ->
try binary_to_integer(Bin) of
N when is_integer(N), N >= 0, N =< 128 -> {ok, N};
_ -> {error, bad_prefix}
catch _:_ -> {error, bad_prefix}
end.
inet_addr_bin(IP) -> list_to_binary(inet:ntoa(IP)).
%% RFC 9484 §3 / §4.6: a `target' prefix or an ADDRESS_ASSIGN/REQUEST
%% prefix MUST have all bits beyond Pfx set to zero. Returns boolean.
-spec prefix_host_bits_zero(4 | 6,
inet:ip4_address() | inet:ip6_address(),
non_neg_integer()) -> boolean().
prefix_host_bits_zero(_V, _IP, 0) -> true;
prefix_host_bits_zero(4, {A,B,C,D}, Pfx) when Pfx =< 32 ->
N = (A bsl 24) bor (B bsl 16) bor (C bsl 8) bor D,
HostBits = 32 - Pfx,
(N band ((1 bsl HostBits) - 1)) =:= 0;
prefix_host_bits_zero(6, {A,B,C,D,E,F,G,H}, Pfx) when Pfx =< 128 ->
N = (A bsl 112) bor (B bsl 96) bor (C bsl 80) bor (D bsl 64)
bor (E bsl 48) bor (F bsl 32) bor (G bsl 16) bor H,
HostBits = 128 - Pfx,
(N band ((1 bsl HostBits) - 1)) =:= 0.