Skip to main content

src/masque_uri_ip.erl

%%% @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.