%%% @doc Public API for the `masque' library.
%%%
%%% `masque' implements RFC 9298 (Proxying UDP in HTTP) on top of
%%% `erlang_quic's HTTP/3 stack. The functions in this module are the
%%% stable surface used by applications; all other modules are internal
%%% and may change between versions.
%%%
%%% The surface is filled in incrementally across the implementation
%%% plan. Step 1 only exposes the module so the application compiles
%%% and is loadable; the behavioural functions are added in later steps.
-module(masque).
-export([version/0]).
-export([connect/3, connect/2, close/1, info/1]).
-export([send/2, send/3, recv/2, set_mode/2]).
-export([send_capsule/3, shutdown_write/1]).
-export([start_listener/2, stop_listener/1]).
-export([start_listener_h2/2, stop_listener_h2/1]).
-export([start_listener_h1/2, stop_listener_h1/1]).
-export([drain_listener/1, undrain_listener/1, is_draining/1]).
-export([start_chain_listener/2,
start_chain_listener_h2/2,
start_chain_listener_h1/2]).
-export([h3_handlers/1, h2_handlers/1]).
%% CONNECT-IP (RFC 9484) client API.
-export([send_ip_packet/2,
request_addresses/2,
assign_addresses/2,
advertise_routes/2,
ip_info/1]).
%% Connect-UDP-Bind (draft-ietf-masque-connect-udp-listen-11) client API.
-export([bind_connect/3,
send_to/3,
assign_compression/2,
open_uncompressed_context/1,
close_compression/2,
proxy_public_address/1]).
-include("masque.hrl").
-include("masque_ip.hrl").
-export_type([
session/0,
proxy_uri/0,
target/0,
transport/0,
connect_opts/0,
listener_opts/0,
%% CONNECT-IP types.
ip_version/0, ip_prefix/0, ip_prefix_request/0,
ip_assignment/0, ip_route/0, ip_ipproto/0, ip_target/0,
request_id/0, nz_request_id/0
]).
%%====================================================================
%% Types
%%====================================================================
-type session() :: pid().
-type proxy_uri() :: binary() | string().
%% A UDP/TCP target — a resolved IP or a host name to resolve, and a
%% port. CONNECT-IP targets are `{ip_target(), ip_ipproto()}'.
-type udp_target() :: {binary() | inet:hostname() | inet:ip_address(),
inet:port_number()}.
-type target() :: udp_target() | {ip_target(), ip_ipproto()}.
-type transport() :: h3 | h2 | h1.
%%--------------------------------------------------------------------
%% CONNECT-IP types (RFC 9484)
%%--------------------------------------------------------------------
-type ip_version() :: 4 | 6.
-type ip_prefix() :: {4, inet:ip4_address(), 0..32}
| {6, inet:ip6_address(), 0..128}.
%% RFC 9484 §4.7.2 — ADDRESS_REQUEST Request IDs MUST be nonzero.
-type nz_request_id() :: pos_integer().
%% RFC 9484 §4.7.1 — ADDRESS_ASSIGN uses Request ID 0 for
%% unprompted (server-initiated) assignments.
-type request_id() :: non_neg_integer().
%% ADDRESS_REQUEST / ADDRESS_ASSIGN / ROUTE_ADVERTISEMENT entries are
%% exposed as tagged records (defined in `include/masque_ip.hrl').
%% Clients that want to build them without the include can use the
%% `masque_ip' helper module.
-type ip_prefix_request() :: #ip_prefix_request{}.
-type ip_assignment() :: #ip_assignment{}.
-type ip_route() :: #ip_route{}.
-type ip_ipproto() :: '*' | 0..255.
-type ip_target() ::
'*'
| inet:ip4_address() | inet:ip6_address()
| ip_prefix()
| binary(). %% hostname
-type connect_opts() ::
#{
%% Tunnel protocol: `udp' (default), `tcp', or `ip'.
protocol => udp | tcp | ip,
%% Transport preference. `[h3, h2]' (default) races the two.
%% `h1' may appear as a tertiary fallback for `udp' and `ip';
%% `tcp' support on h1 lands with the CONNECT-TCP step.
transports => [transport()],
prefer_timeout_ms => non_neg_integer(),
%% Head-start (ms) before the h1 attempt is spawned, measured
%% from when the h2 attempt starts. Default 500.
h1_prefer_timeout_ms => non_neg_integer(),
%% CONNECT-TCP over h1: value for the `Proxy-Authorization'
%% header (e.g. `<<"Basic dXNlcjpwYXNz">>'). Ignored on all
%% other transport/protocol combinations.
proxy_authorization => binary(),
uri_template => binary(),
verify => verify_peer | verify_none,
cacerts => [public_key:der_encoded()],
timeout => pos_integer() | infinity,
capsule_protocol => boolean(),
owner => pid(),
ssl_opts => [ssl:tls_client_option()],
%% CONNECT-IP: local send-side MTU (1280..65535, default 1500).
mtu => 1280..65535,
%% Opt-in connection pooling. When `true', h2 / h3 attempts
%% share a pooled upstream transport connection keyed by
%% host/port/transport + a hash of connect-affecting opts
%% (verify, cacerts, ssl_opts, alpn). h1 always bypasses the
%% pool. Default `false'.
upstream_pool => boolean(),
%% Tuning forwarded to the pooled owner on cold dials
%% (`idle_timeout_ms', `max_streams').
upstream_pool_opts => map(),
%% Extra request headers prepended to the CONNECT (or GET +
%% Upgrade on h1). Useful for auth schemes that ride on the
%% handshake request (`Authorization: PrivateToken token=...',
%% proxy-specific metadata). Library-controlled pseudo-headers
%% (`:method', `:scheme', `:authority', `:path', `:protocol',
%% `capsule-protocol') are not overridable; a duplicate here
%% is silently dropped.
request_headers => [{binary(), binary()}],
%% Internal - set by racer, not by callers.
transport => transport(),
proxy => {binary(), inet:port_number()},
alpn => [binary()],
mode => message | queue
}.
-type listener_opts() ::
#{
port := inet:port_number(),
%% DER binaries for H3, PEM paths for H2 — both listeners
%% read these same keys.
cert => term(),
key => term(),
uri_template => binary(), %% CONNECT-UDP
tcp_uri_template => binary(), %% CONNECT-TCP
ip_uri_template => binary(), %% CONNECT-IP (RFC 9484)
handler => module(), %% CONNECT-UDP handler
tcp_handler => module(), %% CONNECT-TCP handler
ip_handler => module(), %% CONNECT-IP handler
handler_opts => term(),
address_pool => ip_prefix() | [ip_prefix()],
routes => [ip_route()],
mtu => 1280..65535,
resolver => fun((binary()) ->
{ok, [inet:ip_address()]} | {error, term()}),
allow => fun((target()) -> boolean()),
%% CONNECT-TCP policy - forwarded to the tcp_handler through
%% handler_opts. `family' picks the outbound DNS-resolved
%% address class; `allow_private' gates non-global targets;
%% `connect_timeout' bounds the outbound dial; `socket_opts'
%% extend the gen_tcp options of the target socket.
family => auto | inet | inet6,
allow_private => boolean(),
connect_timeout => pos_integer(),
socket_opts => [gen_tcp:option()]
}.
%%====================================================================
%% API
%%====================================================================
%% @doc Returns the library version as declared in the application resource file.
-spec version() -> binary().
version() ->
{ok, Vsn} = application:get_key(masque, vsn),
list_to_binary(Vsn).
%% @doc Dial a MASQUE proxy and open a CONNECT-UDP tunnel to `Target'.
%%
%% `ProxyURI' is an `https://host:port' URL identifying the proxy;
%% `Target' is a `{Host, Port}' pair naming the UDP endpoint to reach.
%% Returns `{ok, Session}' on 2xx, `{error, Reason}' otherwise.
-spec connect(proxy_uri(), target(), connect_opts()) ->
{ok, session()} | {error, term()}.
connect(ProxyURI, Target, Opts) when is_map(Opts) ->
case validate_connect_opts(Target, Opts) of
{ok, Opts0} ->
case parse_proxy_uri(ProxyURI) of
{ok, Host, Port} ->
Owner = maps:get(owner, Opts0, self()),
Opts1 = Opts0#{proxy => {Host, Port}},
Transports = normalize_transports(
maps:get(transports, Opts1, [h3, h2])),
connect_via(Transports, Target, Opts1, Owner);
{error, _} = Err ->
Err
end;
{error, _} = Err ->
Err
end.
%% Validate and normalize `connect_opts()`. Enforces RFC 9484
%% invariants for CONNECT-IP (capsule protocol required, target
%% shape matches protocol) and widens/normalizes shared keys.
validate_connect_opts(Target, Opts) ->
Protocol = maps:get(protocol, Opts, udp),
case check_target_shape(Protocol, Target) of
ok ->
case check_capsule_protocol(Protocol, Opts) of
{ok, Opts1} ->
check_proxy_authorization(Opts1);
{error, _} = Err -> Err
end;
{error, _} = Err -> Err
end.
%% `proxy_authorization' is embedded verbatim on the CONNECT-TCP h1
%% wire; an embedded CR or LF would inject arbitrary headers. Reject
%% the opt before any socket is opened.
check_proxy_authorization(Opts) ->
case maps:find(proxy_authorization, Opts) of
error ->
{ok, Opts};
{ok, V} when is_binary(V) ->
case has_crlf(V) of
true ->
{error, {invalid_opts, proxy_authorization_contains_crlf}};
false ->
{ok, Opts}
end;
{ok, _} ->
{error, {invalid_opts, proxy_authorization_must_be_binary}}
end.
has_crlf(B) when is_binary(B) ->
binary:match(B, [<<"\r">>, <<"\n">>]) =/= nomatch.
check_target_shape(ip, {Target, IPProto}) ->
case masque_uri_ip:validate_target(Target) andalso
masque_uri_ip:validate_ipproto(IPProto) of
true -> ok;
false -> {error, {bad_target_for_protocol, ip}}
end;
check_target_shape(ip, _) ->
{error, {bad_target_for_protocol, ip}};
check_target_shape(_, {_, P}) when is_integer(P), P >= 0, P =< 65535 ->
ok;
check_target_shape(Proto, _) ->
{error, {bad_target_for_protocol, Proto}}.
check_capsule_protocol(ip, Opts) ->
case maps:get(capsule_protocol, Opts, true) of
false ->
{error, {invalid_opts, capsule_protocol_required_for_ip}};
_ ->
{ok, Opts#{capsule_protocol => true}}
end;
check_capsule_protocol(_, Opts) ->
{ok, Opts}.
connect_via([h3], Target, Opts, Owner) ->
dial_single_or_pool(session_mod(Opts, h3), h3, Target, Opts, Owner);
connect_via([h2], Target, Opts, Owner) ->
dial_single_or_pool(session_mod(Opts, h2), h2, Target, Opts, Owner);
connect_via([h1], Target, Opts, Owner) ->
dial_single(session_mod(Opts, h1), Target, Opts#{transport => h1}, Owner);
connect_via(Transports, Target, Opts, Owner)
when length(Transports) >= 2 ->
masque_racer:race(Transports, Target, Opts, Owner).
%% Single-transport dial that honours `upstream_pool => true' the
%% same way the racer does; h1 is pool-bypassed so it keeps the
%% plain dial_single path.
dial_single_or_pool(Mod, Transport, Target,
#{upstream_pool := true} = Opts, Owner)
when Transport =:= h2; Transport =:= h3 ->
case masque_racer:checkout_pool(Transport, Opts) of
{ok, Opts1} ->
dial_single(Mod, Target, Opts1#{transport => Transport}, Owner);
{error, _} = Err ->
Err
end;
dial_single_or_pool(Mod, Transport, Target, Opts, Owner) ->
dial_single(Mod, Target, Opts#{transport => Transport}, Owner).
session_mod(Opts, h3) ->
case maps:get(protocol, Opts, udp) of
tcp -> masque_tcp_client_session;
ip -> masque_ip_client_session;
udp_bind -> masque_udp_bind_client_session;
_ -> masque_client_session
end;
session_mod(Opts, h2) ->
case maps:get(protocol, Opts, udp) of
tcp -> masque_tcp_client_session;
ip -> masque_ip_client_session;
udp_bind -> masque_udp_bind_client_session;
_ -> masque_h2_client_session
end;
session_mod(Opts, h1) ->
case maps:get(protocol, Opts, udp) of
udp -> masque_h1_client_session;
ip -> masque_ip_h1_client_session;
tcp -> masque_tcp_h1_client_session;
udp_bind -> masque_udp_bind_h1_client_session
end.
%% Direct (non-racing) dial via a single transport module.
%% Uses start (not start_link) + monitor so a fast session failure
%% returns {error, _} instead of crashing the caller with an EXIT.
dial_single(Mod, Target, Opts, Owner) ->
case Mod:start(Target, Opts, Owner) of
{ok, Pid} ->
MRef = erlang:monitor(process, Pid),
Timeout = maps:get(timeout, Opts, 5000),
Result = try gen_statem:call(Pid, handshake_await,
Timeout + 1000)
catch
exit:{noproc, _} -> {error, session_died};
exit:{normal, _} -> {error, session_died};
exit:{{shutdown,_}, _} -> {error, session_died}
end,
erlang:demonitor(MRef, [flush]),
case Result of
ok ->
{ok, Pid};
{error, Reason} ->
try exit(Pid, kill) catch _:_ -> ok end,
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
normalize_transports([]) -> [h3, h2];
normalize_transports(L) when is_list(L) ->
[T || T <- L, T =:= h3 orelse T =:= h2 orelse T =:= h1].
%% @equiv connect(ProxyURI, Target, #{})
-spec connect(proxy_uri(), target()) -> {ok, session()} | {error, term()}.
connect(ProxyURI, Target) ->
connect(ProxyURI, Target, #{}).
%% @doc Close a MASQUE session.
-spec close(session()) -> ok.
close(Sess) when is_pid(Sess) ->
%% All session modules export stop/1.
_ = (try gen_statem:call(Sess, stop, 5000) catch _:_ -> ok end),
ok.
%% @doc Return a map describing the session's current state and peers.
-spec info(session()) -> map().
info(Sess) when is_pid(Sess) ->
gen_statem:call(Sess, info, 1000).
%% @doc Send data through the tunnel.
%%
%% For UDP tunnels: sends a UDP packet (context-id 0). For TCP tunnels:
%% sends raw bytes on the stream.
-spec send(session(), iodata()) -> ok | {error, term()}.
send(Sess, Data) ->
gen_statem:call(Sess, {send, Data}).
%% @doc Send data under an explicit context-id (UDP extension use).
-spec send(session(), non_neg_integer(), iodata()) ->
ok | {error, term()}.
send(Sess, ContextId, Data) ->
gen_statem:call(Sess, {send, ContextId, Data}).
%% @doc Block until data is received or `Timeout' ms elapses.
%%
%% Requires the session to be in `queue' delivery mode (see
%% {@link set_mode/2}).
-spec recv(session(), pos_integer()) ->
{ok, binary()} | {error, timeout | term()}.
recv(Sess, Timeout) ->
gen_statem:call(Sess, {recv, Timeout}, Timeout + 500).
%% @doc Send a capsule on the tunnel's request stream (RFC 9297 §3.2).
-spec send_capsule(session(), non_neg_integer(), iodata()) ->
ok | {error, term()}.
send_capsule(Sess, Type, Value) ->
gen_statem:call(Sess, {send_capsule, Type, Value}).
%% @doc Switch the session between `message' and `queue' delivery modes.
%%
%% `message' (default) delivers every incoming packet to the owner as
%% `{masque_data, Sess, Data}'. `queue' buffers packets and requires
%% the caller to pull them via {@link recv/2}.
-spec set_mode(session(), message | queue) -> ok.
set_mode(Sess, Mode) ->
gen_statem:call(Sess, {set_mode, Mode}).
%% @doc Half-close the write side of a TCP tunnel.
%%
%% Sends END_STREAM and prevents further writes. The session stays
%% open for receiving data. Returns `{error, not_supported}' on UDP
%% sessions. Returns `{error, not_ready}' if still connecting.
-spec shutdown_write(session()) -> ok | {error, term()}.
shutdown_write(Sess) when is_pid(Sess) ->
gen_statem:call(Sess, shutdown_write).
%%====================================================================
%% CONNECT-IP client API (RFC 9484)
%%====================================================================
%% @doc Send a full IP packet (starting at the IP header) through a
%% CONNECT-IP tunnel. Rejects packets larger than the session's MTU.
-spec send_ip_packet(session(), binary()) -> ok | {error, term()}.
send_ip_packet(Sess, Packet) when is_pid(Sess), is_binary(Packet) ->
masque_ip_client_session:send_ip_packet(Sess, Packet).
%% @doc Send an ADDRESS_REQUEST capsule asking the peer to assign
%% one or more addresses. Returns the allocated Request IDs.
-spec request_addresses(session(),
[{ip_version(), inet:ip_address(), non_neg_integer()}]) ->
{ok, [nz_request_id()]} | {error, term()}.
request_addresses(Sess, Prefixes) when is_pid(Sess) ->
masque_ip_client_session:request_addresses(Sess, Prefixes).
%% @doc Send an ADDRESS_ASSIGN capsule. Non-zero Request IDs must
%% match an outstanding peer ADDRESS_REQUEST; ID 0 is always
%% accepted (unprompted, RFC 9484 §4.7.1).
-spec assign_addresses(session(), [ip_assignment()]) ->
ok | {error, term()}.
assign_addresses(Sess, Entries) when is_pid(Sess) ->
masque_ip_client_session:assign_addresses(Sess, Entries).
%% @doc Send a ROUTE_ADVERTISEMENT capsule.
-spec advertise_routes(session(), [ip_route()]) -> ok | {error, term()}.
advertise_routes(Sess, Routes) when is_pid(Sess) ->
masque_ip_client_session:advertise_routes(Sess, Routes).
%% @doc Inspect the CONNECT-IP session state.
-spec ip_info(session()) -> #{
assigned := [ip_assignment()],
routes := [ip_route()],
mtu := 1280..65535,
transport := transport()
}.
ip_info(Sess) when is_pid(Sess) ->
masque_ip_client_session:ip_info(Sess).
%%====================================================================
%% Connect-UDP-Bind client API
%% (draft-ietf-masque-connect-udp-listen-11)
%%====================================================================
%% @doc Open a Connect-UDP-Bind tunnel to `ProxyURI'. `Target' is
%% either `unscoped' (the bind socket on the proxy can talk to any
%% peer the proxy's policy allows) or `{Host, Port}' for a scoped
%% bind. The session emits `{masque_bind_packet, _, Peer, Bytes}'
%% messages to the owner; use `send_to/3' to send.
-spec bind_connect(proxy_uri(),
unscoped | {binary() | inet:hostname(), 1..65535},
connect_opts()) -> {ok, session()} | {error, term()}.
bind_connect(ProxyURI, Target, Opts) when is_map(Opts) ->
Opts1 = Opts#{protocol => udp_bind},
case parse_proxy_uri(ProxyURI) of
{ok, Host, Port} ->
Owner = maps:get(owner, Opts1, self()),
Opts2 = Opts1#{proxy => {Host, Port}},
Transports = normalize_transports(
maps:get(transports, Opts2, [h3, h2])),
connect_via(Transports, Target, Opts2, Owner);
{error, _} = Err ->
Err
end.
%% @doc Send a UDP payload to `Peer' via the bind tunnel. The session
%% picks a context-id from the compression table; if none exists it
%% falls back to the uncompressed-context channel if open, otherwise
%% returns `{error, no_compression_context}'.
-spec send_to(session(),
{inet:ip_address(), inet:port_number()},
binary()) -> ok | {error, term()}.
send_to(Sess, Peer, Bytes)
when is_pid(Sess), is_binary(Bytes) ->
Mod = bind_session_module(Sess),
Mod:send_to(Sess, Peer, Bytes).
%% @doc Open an outbound compressed context for `Peer'. Returns the
%% allocated Context ID; the mapping is safe to use on send once the
%% matching `{masque_compression_acked, _, ContextId}' message
%% arrives.
-spec assign_compression(session(),
{inet:ip_address(), inet:port_number()}) ->
{ok, pos_integer()} | {error, term()}.
assign_compression(Sess, Peer) when is_pid(Sess) ->
Mod = bind_session_module(Sess),
Mod:assign_compression(Sess, Peer).
%% @doc Open the singleton uncompressed (IP Version 0) context.
%% Client-only per draft-11.
-spec open_uncompressed_context(session()) ->
{ok, pos_integer()} | {error, term()}.
open_uncompressed_context(Sess) when is_pid(Sess) ->
Mod = bind_session_module(Sess),
Mod:open_uncompressed_context(Sess).
%% @doc Retire a compression context.
-spec close_compression(session(), pos_integer()) ->
ok | {error, term()}.
close_compression(Sess, Id)
when is_pid(Sess), is_integer(Id), Id > 0 ->
Mod = bind_session_module(Sess),
Mod:close_compression(Sess, Id).
%% @doc Read the parsed `Proxy-Public-Address' list the proxy
%% advertised on the bind 2xx response.
-spec proxy_public_address(session()) ->
{ok, [{inet:ip_address(), inet:port_number()}]} | {error, term()}.
proxy_public_address(Sess) when is_pid(Sess) ->
Mod = bind_session_module(Sess),
Mod:proxy_public_address(Sess).
%% Pick the right bind client module based on the session's
%% transport. We can't do this from the pid alone without asking it,
%% so we fall through to the h2/h3 module which handles both. The h1
%% module accepts the same calls so dispatch via either is fine, but
%% we route by querying `info/1' to be precise.
bind_session_module(Sess) ->
try gen_statem:call(Sess, info, 1000) of
#{transport := h1} -> masque_udp_bind_h1_client_session;
_ -> masque_udp_bind_client_session
catch _:_ -> masque_udp_bind_client_session
end.
%%====================================================================
%% Server facade
%%====================================================================
-spec start_listener(atom(), listener_opts()) -> {ok, pid()} | {error, term()}.
start_listener(Name, Opts) ->
masque_server:start_listener(Name, Opts).
-spec stop_listener(atom()) -> ok | {error, term()}.
stop_listener(Name) ->
masque_server:stop_listener(Name).
-spec start_listener_h2(atom(), map()) ->
{ok, h2:server_ref()} | {error, term()}.
start_listener_h2(Name, Opts) ->
masque_h2_server:start_listener(Name, Opts).
-spec stop_listener_h2(h2:server_ref() | atom()) -> ok | {error, term()}.
stop_listener_h2(Ref) ->
masque_h2_server:stop_listener(Ref).
-spec start_listener_h1(atom(), map()) ->
{ok, h1:server_ref()} | {error, term()}.
start_listener_h1(Name, Opts) ->
masque_h1_server:start_listener(Name, Opts).
-spec stop_listener_h1(h1:server_ref() | atom()) -> ok | {error, term()}.
stop_listener_h1(Ref) ->
masque_h1_server:stop_listener(Ref).
%% @doc Stop accepting new tunnels but let existing ones finish.
-spec drain_listener(atom()) -> ok.
drain_listener(Name) ->
persistent_term:put({masque_drain, Name}, true),
ok.
%% @doc Re-enable new tunnels after draining.
-spec undrain_listener(atom()) -> ok.
undrain_listener(Name) ->
persistent_term:erase({masque_drain, Name}),
ok.
%% @doc Check if a listener is draining.
-spec is_draining(atom() | undefined) -> boolean().
is_draining(undefined) -> false;
is_draining(Name) ->
persistent_term:get({masque_drain, Name}, false).
%% @doc Start a chaining (two-hop) listener on HTTP/3.
%%
%% Convenience wrapper: starts an h3 listener with
%% `masque_chain_handler' wired up for all three tunnel protocols
%% (UDP, TCP, IP). Every accepted tunnel is relayed to the upstream
%% proxy specified in `handler_opts.upstream_proxy'.
%%
%% Callers that want only a subset of protocols to chain can still
%% call {@link start_listener/2} directly and set `handler',
%% `tcp_handler', `ip_handler' individually.
%%
%% See {@link start_chain_listener_h2/2} and
%% {@link start_chain_listener_h1/2} for the HTTP/2 and HTTP/1.1
%% siblings. A full Apple-Private-Relay-shaped ingress runs all three
%% so the client can race them.
-spec start_chain_listener(atom(), map()) -> {ok, pid()} | {error, term()}.
start_chain_listener(Name, Opts) ->
start_listener(Name, chain_all(Opts)).
%% @doc Start a chaining (two-hop) listener on HTTP/2.
%%
%% Same shape as {@link start_chain_listener/2}; only the outer
%% transport differs. All three tunnel protocols chain; upstream
%% proxy URI goes in `handler_opts.upstream_proxy'.
-spec start_chain_listener_h2(atom(), map()) ->
{ok, h2:server_ref()} | {error, term()}.
start_chain_listener_h2(Name, Opts) ->
start_listener_h2(Name, chain_all(Opts)).
%% @doc Start a chaining (two-hop) listener on HTTP/1.1.
%%
%% Same shape as {@link start_chain_listener/2}; only the outer
%% transport differs. All three tunnel protocols chain; upstream
%% proxy URI goes in `handler_opts.upstream_proxy'.
-spec start_chain_listener_h1(atom(), map()) ->
{ok, h1:server_ref()} | {error, term()}.
start_chain_listener_h1(Name, Opts) ->
start_listener_h1(Name, chain_all(Opts)).
chain_all(Opts) ->
Opts#{handler => masque_chain_handler,
tcp_handler => masque_chain_handler,
ip_handler => masque_chain_handler}.
-spec h2_handlers(map()) ->
#{handler := fun((pid(), non_neg_integer(), binary(), binary(),
list()) -> any())}.
h2_handlers(Opts) ->
masque_h2_server:h2_handlers(Opts).
%% @doc Return the `handler' and `connection_handler' funs needed to
%% run MASQUE inside a user-owned `quic_h3:start_server/3' call.
%%
%% See {@link masque_server:h3_handlers/1} for the accepted option
%% keys (including the `fallback' hook that routes non-MASQUE requests
%% to the caller's own handler).
-spec h3_handlers(map()) ->
#{handler := masque_server:h3_handler_fun(),
connection_handler := masque_server:connection_handler_fun()}.
h3_handlers(Opts) ->
masque_server:h3_handlers(Opts).
%%====================================================================
%% Internal
%%====================================================================
parse_proxy_uri(URI) when is_binary(URI) ->
parse_proxy_uri(binary_to_list(URI));
parse_proxy_uri(URI) when is_list(URI) ->
case uri_string:parse(URI) of
#{scheme := "https", host := Host, port := Port}
when is_integer(Port) ->
{ok, list_to_binary(Host), Port};
#{scheme := "https", host := Host} ->
{ok, list_to_binary(Host), 443};
_ ->
{error, {invalid_proxy_uri, URI}}
end.