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