Current section

Files

Jump to
webtransport src webtransport.erl
Raw

src/webtransport.erl

%% Copyright (c) 2026, Benoit Chesneau.
%% Licensed under the Apache License, Version 2.0.
%%
%% @doc WebTransport Public API
%%
%% This module provides the public API for WebTransport over HTTP/2 and HTTP/3.
%%
%% == Server Usage ==
%%
%% ```
%% %% Start a WebTransport listener
%% {ok, Listener} = webtransport:start_listener(my_listener, #{
%% transport => h3,
%% port => 8443,
%% certfile => "cert.pem",
%% keyfile => "key.pem",
%% handler => my_wt_handler
%% }).
%%
%% %% Stop the listener
%% ok = webtransport:stop_listener(my_listener).
%% '''
%%
%% == Client Usage ==
%%
%% ```
%% %% Connect to a WebTransport server
%% {ok, Session} = webtransport:connect("example.com", 443, "/wt", #{
%% transport => h3
%% }).
%%
%% %% Open a bidirectional stream
%% {ok, Stream} = webtransport:open_stream(Session, bidi).
%%
%% %% Send data
%% ok = webtransport:send(Session, Stream, <<"Hello">>).
%%
%% %% Send with FIN flag
%% ok = webtransport:send(Session, Stream, <<"World">>, fin).
%%
%% %% Send unreliable datagram
%% ok = webtransport:send_datagram(Session, <<"ping">>).
%%
%% %% Close stream
%% ok = webtransport:close_stream(Session, Stream).
%%
%% %% Close session
%% ok = webtransport:close_session(Session).
%% '''
%%
%% == Handler Behaviour ==
%%
%% See {@link webtransport_handler} for the callback behaviour.
%%
-module(webtransport).
%% Integration API (for embedding in a generic HTTP server)
-export([h3_settings/0, h3_settings/1]).
-export([h2_settings/0, h2_settings/1]).
-export([accept/4]).
%% Server API (convenience: standalone listener)
-export([start_listener/2, stop_listener/1]).
-export([listeners/0, listener_info/1, listener_sockname/1]).
-export([listener_loop/1]).
%% Client API
-export([connect/4, connect/5]).
%% Session API
-export([open_stream/2]).
-export([send/3, send/4]).
-export([send_datagram/2]).
-export([close_stream/2]).
-export([reset_stream/3, stop_sending/3]).
-export([drain_session/1]).
-export([close_session/1, close_session/2, close_session/3]).
-export([session_info/1]).
-export([early_data_accepted/1]).
-ifdef(TEST).
%% Exported for eunit: lets tests drive the h2 CONNECT-stream data loop with
%% fake connection/session pids to assert it self-terminates.
-export([h2_data_loop/3]).
-endif.
%% Types
-type session() :: pid().
-type stream() :: non_neg_integer().
-type listener_name() :: atom().
-type request() :: #{
path := binary(),
authority := binary(),
headers => [{binary(), binary()}]
}.
-type listener_opts() :: #{
transport := h2 | h3,
port := inet:port_number(),
certfile := file:filename(),
keyfile := file:filename(),
handler := module(),
handler_opts => term(),
max_data => non_neg_integer(),
max_streams_bidi => non_neg_integer(),
max_streams_uni => non_neg_integer(),
%% Bind address / family. `ip' binds the listener socket to a specific
%% address (IPv4 4-tuple or IPv6 8-tuple); `family' forces `inet' or
%% `inet6' when no `ip' is given (e.g. the IPv6 wildcard). `socket_opts'
%% are passed verbatim to the underlying UDP (h3) listener socket.
ip => inet:ip_address(),
family => inet | inet6,
socket_opts => list(),
%% Per-SNI certificate selection. Invoked per connection with the
%% ClientHello SNI (RFC 6066 §3); the returned cert/key override the
%% static `certfile'/`keyfile' for that handshake, letting one listener
%% present different certificates per hostname. `cert' is a DER binary,
%% `key' a decoded private-key term, `cert_chain' the DER intermediates.
%% For h3 it is forwarded to `quic' (>= 1.6.5); for h2 it is adapted to
%% an `ssl' `sni_fun'. When the callback returns `{error, _}' (or raises)
%% the static cert is used for h2; for h3 the handshake fails.
sni_callback => sni_callback(),
%% `compat_mode' selects which WebTransport HTTP/3 draft the listener
%% accepts. Default `auto' accepts draft-15 and draft-02 and picks the
%% matching code path per CONNECT. Pin to `latest' to refuse draft-02
%% clients or to `legacy_browser_compat' to refuse draft-15 clients.
compat_mode => latest | legacy_browser_compat | auto
}.
-type sni_callback() :: fun(
(ServerName :: binary() | undefined) ->
{ok, #{cert := binary(), key := term(), cert_chain => [binary()]}}
| {error, term()}
).
-type connect_opts() :: #{
transport => h2 | h3,
certfile => file:filename(),
keyfile => file:filename(),
cacertfile => file:filename(),
verify => verify_none | verify_peer,
headers => [{binary(), binary()}],
timeout => timeout(),
%% Address-family / dual-stack controls (h3 only), passed through to the
%% QUIC client. `family' restricts resolution; `happy_eyeballs' toggles
%% RFC 8305 v6/v4 racing; `connection_attempt_delay' is the stagger in ms.
family => inet | inet6 | any,
happy_eyeballs => boolean(),
connection_attempt_delay => non_neg_integer(),
%% 0-RTT: a session ticket captured from a prior connection
%% (`{webtransport, session_ticket, Ticket}'). Opaque term, passed back to
%% the QUIC layer as-is. h3 only.
session_ticket => term(),
%% Clients pick an explicit compat mode. Default `latest' sends
%% draft-15 SETTINGS and `:protocol = webtransport-h3'. Use
%% `legacy_browser_compat' only when talking to a known draft-02 peer
%% (Chrome / Firefox / quic-go v0.9).
compat_mode => latest | legacy_browser_compat
}.
-export_type([session/0, stream/0, request/0]).
-export_type([listener_name/0, listener_opts/0, connect_opts/0, sni_callback/0]).
-include("webtransport.hrl").
%% ============================================================================
%% Integration API
%% ============================================================================
%% @doc Return HTTP/3 settings to merge into a quic_h3 server configuration.
%%
%% Use this when embedding WebTransport into an existing HTTP/3 server
%% instead of using `start_listener/2'. The returned map contains:
%%
%% - `settings' -- H3 SETTINGS to advertise (wt_enabled, flow control, etc.)
%% - `stream_type_handler' -- claims WT extension streams (0x41 bidi, 0x54 uni)
%% - `h3_datagram_enabled' -- enables QUIC datagrams
%% - `quic_opts' -- QUIC transport params (max_datagram_frame_size, reset_stream_at)
%% - `connection_handler' -- per-connection setup (creates the WT stream router)
%%
%% Merge the result into your quic_h3 server opts:
%% ```
%% Opts = maps:merge(webtransport:h3_settings(), #{
%% cert => CertDer, key => PrivateKey,
%% handler => fun my_handler/5
%% }),
%% quic_h3:start_server(my_server, 443, Opts).
%% '''
-spec h3_settings() -> map().
h3_settings() ->
h3_settings(#{}).
-spec h3_settings(map()) -> map().
h3_settings(Opts) ->
CompatMode = maps:get(compat_mode, Opts, auto),
Settings = case CompatMode of
auto ->
maps:merge(wt_h3:default_settings(latest),
wt_h3:default_settings(legacy_browser_compat));
Mode ->
wt_h3:default_settings(Mode)
end,
Claim = wt_stream_type_handler(),
ConnectionHandler = fun(QuicConnPid) ->
{ok, Router} = webtransport_h3_router:start(undefined),
ensure_router_table(),
%% Key the router by the QUIC connection pid. accept/4 derives the
%% same key from its H3 connection via quic_h3:get_quic_conn/1, so
%% it resolves this exact router no matter which process calls
%% accept/4. quic_h3 runs request handlers in a spawned process and
%% this callback runs in the listener process, so there is no shared
%% process dictionary to rely on; the embedder may also dispatch
%% accept/4 to its own per-request worker.
ets:insert(webtransport_routers, {QuicConnPid, Router}),
spawn(fun() -> reap_router(QuicConnPid, Router) end),
#{
owner => Router,
stream_type_handler => Claim,
h3_datagram_enabled => true
}
end,
#{
settings => Settings,
stream_type_handler => Claim,
h3_datagram_enabled => true,
quic_opts => #{
max_datagram_frame_size => 65535,
reset_stream_at => true
},
connection_handler => ConnectionHandler
}.
%% @doc Return HTTP/2 settings to merge into an h2 server configuration.
%%
%% ```
%% Opts = maps:merge(webtransport:h2_settings(), #{
%% cert => "cert.pem", key => "key.pem",
%% handler => fun my_handler/5,
%% enable_connect_protocol => true
%% }),
%% h2:start_server(443, Opts).
%% '''
-spec h2_settings() -> map().
h2_settings() ->
h2_settings(#{}).
-spec h2_settings(map()) -> map().
h2_settings(Opts) ->
#{
enable_connect_protocol => true,
settings => #{
enable_connect_protocol => 1,
wt_initial_max_data =>
maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
wt_initial_max_streams_bidi =>
maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
wt_initial_max_streams_uni =>
maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
}
}.
%% @doc Accept a WebTransport session on an incoming CONNECT request.
%%
%% Call this from your HTTP request handler when you want a particular
%% CONNECT request to become a WebTransport session. The function validates
%% headers, starts the session gen_statem, registers it as the stream
%% handler (like `quic_h3:set_stream_handler/3'), sends 200, and returns
%% `{ok, Session}'.
%%
%% ```
%% my_handler(Conn, StreamId, <<"CONNECT">>, <<"/wt">>, Headers) ->
%% webtransport:accept(Conn, StreamId, Headers, #{
%% transport => h3,
%% handler => my_wt_handler,
%% handler_opts => #{owner => self()}
%% });
%% my_handler(Conn, StreamId, <<"GET">>, _Path, _Headers) ->
%% quic_h3:send_response(Conn, StreamId, 200, []),
%% quic_h3:send_data(Conn, StreamId, <<"hello">>, true).
%% '''
-spec accept(pid(), non_neg_integer(), [{binary(), binary()}], map()) ->
{ok, session()} | {error, term()}.
accept(Conn, StreamId, Headers, Opts) ->
Transport = maps:get(transport, Opts, h3),
Handler = maps:get(handler, Opts, undefined),
HandlerOpts = maps:get(handler_opts, Opts, #{}),
Method = proplists:get_value(<<":method">>, Headers, <<>>),
Path = proplists:get_value(<<":path">>, Headers, <<"/">>),
case {Handler, Method} of
{undefined, _} ->
{error, missing_handler};
{_, <<"CONNECT">>} ->
case run_origin_check(Handler, Headers, HandlerOpts) of
accept ->
do_accept(Transport, Conn, StreamId, Path,
Headers, Handler, HandlerOpts, Opts);
{reject, Status, Reason} ->
send_reject(Transport, Conn, StreamId, Status, Reason),
{error, {rejected, Status}}
end;
{_, _} ->
{error, not_connect_method}
end.
do_accept(h3, H3Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
CompatMode = maps:get(compat_mode, Opts, auto),
case classify_h3_connect(Headers, CompatMode) of
{ok, ClientMode} ->
Router = get_or_create_router(H3Conn),
Opts1 = Opts#{compat_mode => ClientMode},
do_accept_h3(H3Conn, StreamId, Path, Headers,
Handler, HandlerOpts, Opts1, Router);
{error, Reason} ->
send_reject(h3, H3Conn, StreamId, 400,
iolist_to_binary(io_lib:format("~p", [Reason]))),
{error, Reason}
end;
do_accept(h2, Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
case is_webtransport_request(Headers) of
true ->
do_accept_h2(Conn, StreamId, Path, Headers,
Handler, HandlerOpts, Opts);
false ->
send_reject(h2, Conn, StreamId, 400, <<"Bad Request">>),
{error, not_webtransport}
end.
do_accept_h3(H3Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts, Router) ->
TransportState = webtransport_h3:new(H3Conn, StreamId, Router),
Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
Request = #{path => Path, authority => Authority, headers => Headers},
SessionOpts = maps:merge(HandlerOpts, #{
request => Request,
is_server => true,
handler_opts => HandlerOpts,
max_data => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
max_streams_bidi => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
max_streams_uni => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
}),
case webtransport_session:start_link(h3, TransportState, Handler, SessionOpts) of
{ok, Session} ->
quic_h3:set_stream_handler(H3Conn, StreamId, Session),
webtransport_h3_router:register_session(Router, StreamId, Session),
quic_h3:send_response(H3Conn, StreamId, 200, []),
{ok, Session};
{error, Reason} ->
send_reject(h3, H3Conn, StreamId, 500,
iolist_to_binary(io_lib:format("~p", [Reason]))),
{error, Reason}
end.
do_accept_h2(Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
h2:send_response(Conn, StreamId, 200, []),
TransportState = webtransport_h2:new(Conn, StreamId),
Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
Request = #{path => Path, authority => Authority, headers => Headers},
ServerDefaults = #{
u => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI),
bl => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
br => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA)
},
Negotiated = case proplists:get_value(<<"webtransport-init">>, Headers) of
undefined -> ServerDefaults;
InitBin ->
case wt_h2_init:parse(InitBin) of
{ok, Init} -> wt_h2_init:apply_greater_of(Init, ServerDefaults);
{error, _} -> ServerDefaults
end
end,
SessionOpts = maps:merge(HandlerOpts, #{
request => Request,
is_server => true,
handler_opts => HandlerOpts,
max_data => maps:get(br, Negotiated, ?DEFAULT_MAX_DATA),
max_streams_bidi => maps:get(bl, Negotiated, ?DEFAULT_MAX_STREAMS_BIDI),
max_streams_uni => maps:get(u, Negotiated, ?DEFAULT_MAX_STREAMS_UNI)
}),
case webtransport_session:start_link(h2, TransportState, Handler, SessionOpts) of
{ok, Session} ->
LoopPid = spawn(fun() -> h2_data_loop(Conn, StreamId, Session) end),
_ = h2:set_stream_handler(Conn, StreamId, LoopPid),
{ok, Session};
{error, Reason} ->
h2:send_data(Conn, StreamId,
iolist_to_binary(io_lib:format("~p", [Reason])), true),
{error, Reason}
end.
send_reject(h3, H3Conn, StreamId, Status, Body) ->
quic_h3:send_response(H3Conn, StreamId, Status, []),
quic_h3:send_data(H3Conn, StreamId, Body, true);
send_reject(h2, Conn, StreamId, Status, Body) ->
h2:send_response(Conn, StreamId, Status, []),
h2:send_data(Conn, StreamId, Body, true).
%% Find the WT stream router for an H3 connection.
%%
%% The router demuxes extension streams (uni 0x54, bidi 0x41) to sessions.
%% It is keyed by the QUIC connection pid: the `h3_settings/1'
%% connection_handler registers it under the pid it is handed, and accept/4
%% derives the same pid from its H3 connection via `quic_h3:get_quic_conn/1'.
%% Both agree on one router per connection without a shared process
%% dictionary, so accept/4 works from any process (e.g. an embedder's
%% per-request worker).
get_or_create_router(H3Conn) ->
ensure_router_table(),
Key = router_key(H3Conn),
case ets:lookup(webtransport_routers, Key) of
[{_, Router}] ->
case is_process_alive(Router) of
true -> Router;
false ->
ets:delete(webtransport_routers, Key),
create_and_register_router(Key)
end;
[] ->
%% No connection_handler ran for this connection (the server was
%% wired without h3_settings/1). Create a router so the session
%% still starts; lacking the connection_handler's `owner' it
%% won't receive peer-opened streams, but that is a caller-side
%% misconfiguration.
create_and_register_router(Key)
end.
%% The registry key shared by the connection_handler and accept/4: the QUIC
%% connection pid behind the H3 connection. The connection_handler is handed
%% this same pid directly, so both sides resolve one router per connection.
router_key(H3Conn) ->
quic_h3:get_quic_conn(H3Conn).
create_and_register_router(Key) ->
{ok, Router} = webtransport_h3_router:start(undefined),
ets:insert(webtransport_routers, {Key, Router}),
Router.
%% Drop a per-connection router's registry row and stop the router when its
%% QUIC connection ends, so a long-lived embedder doesn't accumulate stale
%% rows or idle router processes. Runs in a throwaway process: the
%% connection_handler that spawns it runs inside the quic listener, which
%% must not block or monitor on its behalf. The {Key, Router} match means a
%% later connection that reuses the pid (and overwrote the row) is left
%% untouched.
reap_router(QuicConnPid, Router) ->
Ref = erlang:monitor(process, QuicConnPid),
receive
{'DOWN', Ref, process, _, _} ->
ets:match_delete(webtransport_routers, {QuicConnPid, Router}),
try gen_server:stop(Router, normal, 5000) catch _:_ -> ok end,
ok
end.
ensure_router_table() ->
case ets:whereis(webtransport_routers) of
undefined ->
try
ets:new(webtransport_routers, [named_table, public, set])
catch
error:badarg -> ok %% race: another process created it
end;
_ ->
ok
end.
%% ============================================================================
%% Server API
%% ============================================================================
%% @doc Start a WebTransport listener.
%%
%% Options:
%% - `transport' - Required. Either `h2' (HTTP/2) or `h3' (HTTP/3)
%% - `port' - Required. Port to listen on
%% - `certfile' - Required. Path to TLS certificate
%% - `keyfile' - Required. Path to TLS private key
%% - `handler' - Required. Module implementing webtransport_handler behaviour
%% - `handler_opts' - Optional. Initial options passed to handler:init/2
%% - `max_data' - Optional. Initial max session data (default 1MB)
%% - `max_streams_bidi' - Optional. Initial max bidi streams (default 100)
%% - `max_streams_uni' - Optional. Initial max uni streams (default 100)
%%
-spec start_listener(listener_name(), listener_opts()) ->
{ok, pid()} | {error, term()}.
start_listener(Name, #{transport := Transport} = Opts) when is_atom(Name) ->
case validate_listener_opts(Opts) of
ok ->
case Transport of
h2 -> start_h2_listener(Name, Opts);
h3 -> start_h3_listener(Name, Opts)
end;
{error, _} = Err ->
Err
end.
%% @doc Stop a WebTransport listener.
-spec stop_listener(listener_name()) -> ok | {error, term()}.
stop_listener(Name) when is_atom(Name) ->
case whereis(Name) of
undefined ->
{error, not_found};
Pid ->
case persistent_term:get({webtransport_listener, Name}, undefined) of
undefined ->
{error, not_found};
#{transport := h2, server_ref := ServerRef} ->
h2:stop_server(ServerRef),
persistent_term:erase({webtransport_listener, Name}),
exit(Pid, shutdown),
ok;
#{transport := h3, server_ref := ServerRef} ->
quic_h3:stop_server(ServerRef),
persistent_term:erase({webtransport_listener, Name}),
exit(Pid, shutdown),
ok
end
end.
%% @doc List all active listeners.
-spec listeners() -> [listener_name()].
listeners() ->
%% Get all persistent terms that match our pattern
[Name || {{webtransport_listener, Name}, _} <- persistent_term:get()].
%% @doc Get information about a listener.
%%
%% The `sockname' field is the bound `{Ip, Port}'. For h3 it is resolved live
%% from the QUIC socket (correct even when bound with port 0 or `inet6'). For
%% h2 it is best-effort (requested address + bound port), since the h2 library
%% does not expose the bound IP.
-spec listener_info(listener_name()) -> {ok, map()} | {error, not_found}.
listener_info(Name) ->
case persistent_term:get({webtransport_listener, Name}, undefined) of
undefined ->
{error, not_found};
Info ->
Base = maps:without([server_ref], Info),
WithSockname = case listener_sockname(Name) of
{ok, Sockname} -> Base#{sockname => Sockname};
{error, _} -> Base
end,
{ok, WithSockname}
end.
%% @doc Get the bound `{Ip, Port}' of a listener.
%%
%% For h3, resolved live from the QUIC listener socket. For h2 this is
%% best-effort: the requested bind address (defaulting to the family wildcard)
%% paired with the actual bound port, because the h2 library exposes only the
%% port, not the bound IP.
-spec listener_sockname(listener_name()) ->
{ok, {inet:ip_address(), inet:port_number()}} | {error, term()}.
listener_sockname(Name) ->
case persistent_term:get({webtransport_listener, Name}, undefined) of
undefined ->
{error, not_found};
#{transport := h3} ->
quic:get_server_sockname(Name);
#{transport := h2, server_ref := ServerRef} = Info ->
Ip = maps:get(bind_ip, Info, {0, 0, 0, 0}),
{ok, {Ip, h2:server_port(ServerRef)}}
end.
%% ============================================================================
%% Client API
%% ============================================================================
%% @doc Connect to a WebTransport server.
%%
%% Options:
%% - `transport' - Optional. Either `h2' or `h3' (default: h3)
%% - `cacertfile' - Optional. CA certificate file for verification
%% - `verify' - Optional. `verify_none' or `verify_peer' (default: verify_peer)
%% - `headers' - Optional. Extra headers to send in CONNECT request
%% - `timeout' - Optional. Connection timeout in ms (default: 30000)
%%
-spec connect(Host, Port, Path, Opts) -> {ok, session()} | {error, term()} when
Host :: string() | binary() | inet:ip_address(),
Port :: inet:port_number(),
Path :: binary(),
Opts :: connect_opts().
connect(Host, Port, Path, Opts) ->
connect(Host, Port, Path, Opts, undefined).
%% @doc Connect to a WebTransport server with a custom handler.
-spec connect(Host, Port, Path, Opts, Handler) -> {ok, session()} | {error, term()} when
Host :: string() | binary() | inet:ip_address(),
Port :: inet:port_number(),
Path :: binary(),
Opts :: connect_opts(),
Handler :: module() | undefined.
connect(Host, Port, Path, Opts, Handler) ->
Transport = maps:get(transport, Opts, h3),
case Transport of
h2 -> connect_h2(Host, Port, Path, Opts, Handler);
h3 -> connect_h3(Host, Port, Path, Opts, Handler)
end.
%% ============================================================================
%% Session API
%% ============================================================================
%% @doc Open a new stream on the session.
-spec open_stream(session(), bidi | uni) -> {ok, stream()} | {error, term()}.
open_stream(Session, Type) when Type =:= bidi; Type =:= uni ->
webtransport_session:open_stream(Session, Type).
%% @doc Send data on a stream.
-spec send(session(), stream(), iodata()) -> ok | {error, term()}.
send(Session, Stream, Data) ->
webtransport_session:send(Session, Stream, Data, false).
%% @doc Send data on a stream with optional FIN flag.
-spec send(session(), stream(), iodata(), fin | nofin) -> ok | {error, term()}.
send(Session, Stream, Data, fin) ->
webtransport_session:send(Session, Stream, Data, true);
send(Session, Stream, Data, nofin) ->
webtransport_session:send(Session, Stream, Data, false).
%% @doc Send an unreliable datagram.
-spec send_datagram(session(), iodata()) -> ok | {error, term()}.
send_datagram(Session, Data) ->
webtransport_session:send_datagram(Session, Data).
%% @doc Close a stream gracefully (send FIN).
-spec close_stream(session(), stream()) -> ok | {error, term()}.
close_stream(Session, Stream) ->
webtransport_session:close_stream(Session, Stream).
%% @doc Abruptly terminate a stream with an error code.
-spec reset_stream(session(), stream(), non_neg_integer()) -> ok | {error, term()}.
reset_stream(Session, Stream, ErrorCode) ->
webtransport_session:reset_stream(Session, Stream, ErrorCode).
%% @doc Request that the peer stop sending on a stream.
-spec stop_sending(session(), stream(), non_neg_integer()) -> ok | {error, term()}.
stop_sending(Session, Stream, ErrorCode) ->
webtransport_session:stop_sending(Session, Stream, ErrorCode).
%% @doc Signal that no new streams will be created.
-spec drain_session(session()) -> ok.
drain_session(Session) ->
webtransport_session:drain(Session).
%% @doc Close the session gracefully.
-spec close_session(session()) -> ok.
close_session(Session) ->
close_session(Session, 0, <<>>).
%% @doc Close the session with an error code.
-spec close_session(session(), non_neg_integer()) -> ok.
close_session(Session, ErrorCode) ->
close_session(Session, ErrorCode, <<>>).
%% @doc Close the session with an error code and reason.
%% draft-14 §4.6 / draft-15 §5: Reason must be at most 1024 UTF-8 bytes.
-spec close_session(session(), non_neg_integer(), binary()) -> ok | {error, reason_too_long}.
close_session(_Session, _ErrorCode, Reason) when byte_size(Reason) > 1024 ->
{error, reason_too_long};
close_session(Session, ErrorCode, Reason) ->
webtransport_session:close(Session, ErrorCode, Reason).
%% @doc Get session information.
-spec session_info(session()) -> {ok, map()} | {error, term()}.
session_info(Session) ->
webtransport_session:get_info(Session).
%% @doc Report whether the session's connection negotiated 0-RTT early data.
%%
%% Returns `true', `false', or `unknown' for h3 sessions (RFC 9001 §4.6), and
%% `not_supported' for h2. This is connection-level: it reflects whether the
%% QUIC connection used 0-RTT, regardless of whether the WebTransport CONNECT
%% itself rode as early data.
-spec early_data_accepted(session()) -> boolean() | unknown | not_supported.
early_data_accepted(Session) ->
webtransport_session:early_data_accepted(Session).
%% ============================================================================
%% Internal Functions - Listener Setup
%% ============================================================================
validate_listener_opts(Opts) ->
Required = [transport, port, certfile, keyfile, handler],
Missing = [K || K <- Required, not maps:is_key(K, Opts)],
case Missing of
[] -> ok;
_ -> {error, {missing_options, Missing}}
end.
%% Build the `extra_socket_opts' list passed to quic for the h3 listener.
%% `quic_listener' infers the socket family from this list (the `inet6' atom
%% or an IPv6 `ip' tuple), so we just assemble the right entries:
%% - `family => inet6' or an 8-tuple `ip' selects IPv6
%% - `ip' adds `{ip, Addr}'
%% - user `socket_opts' are appended verbatim
build_extra_socket_opts(Opts) ->
Ip = maps:get(ip, Opts, undefined),
Family = maps:get(family, Opts, inet),
UserOpts = maps:get(socket_opts, Opts, []),
Inet6 = (Family =:= inet6) orelse is_ipv6_addr(Ip),
FamilyOpts = case Inet6 of
true -> [inet6];
false -> []
end,
IpOpts = case Ip of
undefined -> [];
_ -> [{ip, Ip}]
end,
FamilyOpts ++ IpOpts ++ UserOpts.
is_ipv6_addr({_, _, _, _, _, _, _, _}) -> true;
is_ipv6_addr(_) -> false.
%% Map our `ip' / `family' listener opts onto the `ip' / `inet6' keys that
%% h2:start_server understands.
add_h2_bind_opts(ServerOpts, Opts) ->
Ip = maps:get(ip, Opts, undefined),
Inet6 = (maps:get(family, Opts, inet) =:= inet6) orelse is_ipv6_addr(Ip),
WithIp = case Ip of
undefined -> ServerOpts;
_ -> ServerOpts#{ip => Ip}
end,
case Inet6 of
true -> WithIp#{inet6 => true};
false -> WithIp
end.
%% Forward a `sni_callback' into the h3 `quic_opts' map so it reaches
%% `quic:start_server' (quic >= 1.6.5 selects the cert per ClientHello SNI).
maybe_put_sni(Opts, QuicOpts) ->
case maps:find(sni_callback, Opts) of
{ok, Fun} -> QuicOpts#{sni_callback => Fun};
error -> QuicOpts
end.
%% Adapt a quic-style `sni_callback' to an `ssl' `sni_fun' for the h2 path.
%% `ssl' invokes `sni_fun' only when the ClientHello carries an SNI, so a
%% client with no SNI keeps the static certfile/keyfile.
add_h2_sni_opts(ServerOpts, Opts) ->
case maps:find(sni_callback, Opts) of
{ok, Fun} ->
SniFun = sni_fun_from_callback(Fun),
UserSslOpts = maps:get(ssl_opts, ServerOpts, []),
ServerOpts#{ssl_opts => [{sni_fun, SniFun} | UserSslOpts]};
error ->
ServerOpts
end.
%% Wrap the quic-style callback as an `ssl' `sni_fun'. `ssl' passes the
%% ServerName as a charlist; we hand the user a binary. On `{ok, _}' we
%% return the leaf+chain and re-encode the decoded key to the `{Tag, DER}'
%% form `ssl' expects via a `certs_keys' entry. Any other result (or a
%% raised error) aborts the handshake, matching h3's `handshake_failure'.
sni_fun_from_callback(Fun) ->
fun(ServerName) ->
ServerNameBin = case ServerName of
undefined -> undefined;
_ when is_list(ServerName) -> list_to_binary(ServerName);
_ when is_binary(ServerName) -> ServerName
end,
case Fun(ServerNameBin) of
{ok, #{cert := Cert, key := Key} = CertMap} when is_binary(Cert) ->
Chain = maps:get(cert_chain, CertMap, []),
KeyDer = {element(1, Key), public_key:der_encode(element(1, Key), Key)},
[{certs_keys, [#{cert => [Cert | Chain], key => KeyDer}]}];
Other ->
error({sni_callback_rejected, ServerNameBin, Other})
end
end.
%% Resolved default bind address for the stored listener record, used by
%% `listener_sockname/1' / `listener_info/1' when no explicit `ip' was given.
default_bind_ip(Opts) ->
case maps:get(ip, Opts, undefined) of
undefined ->
case (maps:get(family, Opts, inet) =:= inet6) of
true -> {0, 0, 0, 0, 0, 0, 0, 0};
false -> {0, 0, 0, 0}
end;
Ip ->
Ip
end.
start_h2_listener(Name, Opts) ->
#{
port := Port,
certfile := CertFile,
keyfile := KeyFile,
handler := Handler
} = Opts,
HandlerOpts = maps:get(handler_opts, Opts, #{}),
%% Create wrapper handler for H2
H2Handler = make_h2_handler(Handler, HandlerOpts, Opts),
WtSettings = #{
enable_connect_protocol => 1,
wt_initial_max_data =>
maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
wt_initial_max_streams_bidi =>
maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
wt_initial_max_streams_uni =>
maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
},
ServerOpts0 = #{
cert => CertFile,
key => KeyFile,
handler => H2Handler,
enable_connect_protocol => true,
settings => WtSettings
},
%% h2:start_server reads `ip' / `inet6' from its server-opts map. Map our
%% `ip' / `family' listener opts onto those keys.
ServerOpts1 = add_h2_bind_opts(ServerOpts0, Opts),
%% Per-SNI cert selection: adapt the quic-style `sni_callback' to an `ssl'
%% `sni_fun' and pass it through `ssl_opts' (h2 merges these on top of its
%% defaults). When no callback is set the static certfile/keyfile is used.
ServerOpts = add_h2_sni_opts(ServerOpts1, Opts),
%% h2:start_server spawn_link's acceptor/manager processes to its caller.
%% Delegating to a persistent owner process keeps those links on a
%% long-lived pid so the listener survives after the caller returns.
case start_h2_server_owner(Name, Port, Handler, ServerOpts) of
{ok, OwnerPid, ServerRef} ->
persistent_term:put({webtransport_listener, Name}, #{
transport => h2,
port => Port,
bind_ip => default_bind_ip(Opts),
handler => Handler,
server_ref => ServerRef
}),
{ok, OwnerPid};
{error, Reason} ->
{error, Reason}
end.
start_h2_server_owner(Name, Port, _Handler, ServerOpts) ->
Parent = self(),
Ref = make_ref(),
OwnerPid = spawn(fun() -> h2_owner_init(Parent, Ref, Name, Port, ServerOpts) end),
receive
{Ref, {ok, ServerRef}} ->
{ok, OwnerPid, ServerRef};
{Ref, {error, Reason}} ->
{error, Reason}
after 5000 ->
exit(OwnerPid, kill),
{error, h2_listener_start_timeout}
end.
h2_owner_init(Parent, Ref, Name, Port, ServerOpts) ->
case h2:start_server(Port, ServerOpts) of
{ok, ServerRef} ->
try register(Name, self()) catch _:_ -> ok end,
Parent ! {Ref, {ok, ServerRef}},
h2_owner_loop(Name, ServerRef);
{error, Reason} ->
Parent ! {Ref, {error, Reason}}
end.
h2_owner_loop(Name, ServerRef) ->
receive
{stop, _From} ->
h2:stop_server(ServerRef),
persistent_term:erase({webtransport_listener, Name}),
ok;
_ ->
h2_owner_loop(Name, ServerRef)
end.
start_h3_listener(Name, Opts) ->
#{
port := Port,
certfile := CertFile,
keyfile := KeyFile,
handler := Handler
} = Opts,
HandlerOpts = maps:get(handler_opts, Opts, #{}),
%% Read and decode certificate and key
case read_cert_and_key(CertFile, KeyFile) of
{ok, CertDer, PrivateKey} ->
Claim = wt_stream_type_handler(),
ConnectionHandler = fun(_QuicConnPid) ->
{ok, Router} = webtransport_h3_router:start(undefined),
#{
owner => Router,
handler => make_h3_handler(Handler, HandlerOpts, Opts, Router),
stream_type_handler => Claim,
h3_datagram_enabled => true
}
end,
ServerCompatMode = maps:get(compat_mode, Opts, auto),
%% `auto' advertises both latest (draft-15) AND legacy
%% (draft-02) setting keys. H3 SETTINGS are connection-
%% scoped and sent before any CONNECT, so the server cannot
%% know the client's draft at SETTINGS time. Peers MUST
%% ignore unknown settings (RFC 9114 §7.2.4.1), so the
%% merged shape is wire-safe. Pinning to a specific mode
%% sends only that mode's settings.
AdvertisedSettings = case ServerCompatMode of
auto ->
maps:merge(wt_h3:default_settings(latest),
wt_h3:default_settings(legacy_browser_compat));
Other ->
wt_h3:default_settings(Other)
end,
ServerOpts = #{
cert => CertDer,
key => PrivateKey,
handler => make_h3_handler(Handler, HandlerOpts, Opts, undefined),
settings => AdvertisedSettings,
%% `sni_callback' rides inside `quic_opts' so it reaches
%% `quic:start_server'; `quic_h3' only lifts cert/key from
%% the top level. quic selects the cert per ClientHello SNI.
quic_opts => maybe_put_sni(Opts, #{
max_datagram_frame_size => 65535,
reset_stream_at => true,
extra_socket_opts => build_extra_socket_opts(Opts)
}),
stream_type_handler => Claim,
connection_handler => ConnectionHandler
},
case quic_h3:start_server(Name, Port, ServerOpts) of
{ok, ServerRef} ->
Pid = spawn(?MODULE, listener_loop, [Name]),
register(Name, Pid),
persistent_term:put({webtransport_listener, Name}, #{
transport => h3,
port => Port,
bind_ip => default_bind_ip(Opts),
handler => Handler,
server_ref => ServerRef
}),
{ok, Pid};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
listener_loop(Name) ->
receive
{stop, _From} ->
persistent_term:erase({webtransport_listener, Name}),
ok;
_ ->
listener_loop(Name)
end.
%% ============================================================================
%% Internal Functions - Request Handling
%% ============================================================================
handle_h2_request(Conn, StreamId, <<"CONNECT">>, Path, Headers, Handler, HandlerOpts, Opts) ->
case is_webtransport_request(Headers) of
true ->
case run_origin_check(Handler, Headers, HandlerOpts) of
accept ->
accept_h2_session(Conn, StreamId, Path, Headers,
Handler, HandlerOpts, Opts);
{reject, Status, Reason} ->
h2:send_response(Conn, StreamId, Status, []),
h2:send_data(Conn, StreamId, Reason, true)
end;
false ->
h2:send_response(Conn, StreamId, 400, []),
h2:send_data(Conn, StreamId, <<"Bad Request">>, true)
end;
handle_h2_request(Conn, StreamId, _Method, _Path, _Headers, _Handler, _HandlerOpts, _Opts) ->
h2:send_response(Conn, StreamId, 405, []),
h2:send_data(Conn, StreamId, <<"Method Not Allowed">>, true).
handle_h3_request(H3Conn, StreamId, <<"CONNECT">>, Path, Headers,
Handler, HandlerOpts, Opts, Router) ->
ListenerMode = maps:get(compat_mode, Opts, auto),
case classify_h3_connect(Headers, ListenerMode) of
{ok, ClientMode} ->
case run_origin_check(Handler, Headers, HandlerOpts) of
accept ->
Opts1 = Opts#{compat_mode => ClientMode},
accept_h3_session(H3Conn, StreamId, Path, Headers,
Handler, HandlerOpts, Opts1, Router);
{reject, Status, Reason} ->
quic_h3:send_response(H3Conn, StreamId, Status, []),
quic_h3:send_data(H3Conn, StreamId, Reason, true)
end;
{error, Reason} ->
Body = iolist_to_binary(io_lib:format("Bad Request: ~p", [Reason])),
quic_h3:send_response(H3Conn, StreamId, 400, []),
quic_h3:send_data(H3Conn, StreamId, Body, true)
end;
handle_h3_request(H3Conn, StreamId, _Method, _Path, _Headers,
_Handler, _HandlerOpts, _Opts, _Router) ->
quic_h3:send_response(H3Conn, StreamId, 405, []),
quic_h3:send_data(H3Conn, StreamId, <<"Method Not Allowed">>, true).
accept_h2_session(Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts) ->
h2:send_response(Conn, StreamId, 200, []),
TransportState = webtransport_h2:new(Conn, StreamId),
Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
Request = #{
path => Path,
authority => Authority,
headers => Headers
},
%% Parse WebTransport-Init from the client's CONNECT request and
%% apply the greater-of rule (draft-14 §4.3.2) against our defaults.
ServerDefaults = #{
u => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI),
bl => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
br => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA)
},
Negotiated = case proplists:get_value(<<"webtransport-init">>, Headers) of
undefined -> ServerDefaults;
InitBin ->
case wt_h2_init:parse(InitBin) of
{ok, Init} -> wt_h2_init:apply_greater_of(Init, ServerDefaults);
{error, _} -> ServerDefaults
end
end,
SessionOpts = maps:merge(HandlerOpts, #{
request => Request,
is_server => true,
handler_opts => HandlerOpts,
max_data => maps:get(br, Negotiated, ?DEFAULT_MAX_DATA),
max_streams_bidi => maps:get(bl, Negotiated, ?DEFAULT_MAX_STREAMS_BIDI),
max_streams_uni => maps:get(u, Negotiated, ?DEFAULT_MAX_STREAMS_UNI)
}),
case webtransport_session:start_link(h2, TransportState, Handler, SessionOpts) of
{ok, Session} ->
LoopPid = spawn(fun() -> h2_data_loop(Conn, StreamId, Session) end),
_ = h2:set_stream_handler(Conn, StreamId, LoopPid);
{error, Reason} ->
h2:send_response(Conn, StreamId, 500, []),
h2:send_data(Conn, StreamId,
iolist_to_binary(io_lib:format("~p", [Reason])), true)
end.
accept_h3_session(H3Conn, StreamId, Path, Headers, Handler, HandlerOpts, Opts, Router) ->
TransportState = webtransport_h3:new(H3Conn, StreamId, Router),
Authority = proplists:get_value(<<":authority">>, Headers, <<>>),
Request = #{
path => Path,
authority => Authority,
headers => Headers
},
SessionOpts = maps:merge(HandlerOpts, #{
request => Request,
is_server => true,
handler_opts => HandlerOpts,
max_data => maps:get(max_data, Opts, ?DEFAULT_MAX_DATA),
max_streams_bidi => maps:get(max_streams_bidi, Opts, ?DEFAULT_MAX_STREAMS_BIDI),
max_streams_uni => maps:get(max_streams_uni, Opts, ?DEFAULT_MAX_STREAMS_UNI)
}),
%% Register the session with the router BEFORE the 200 goes out.
%% Otherwise the client sees 200, opens extension streams, and the
%% stream_type_data arrives at the router while sessions is still
%% empty. The router then silently drops the data.
case webtransport_session:start_link(h3, TransportState, Handler, SessionOpts) of
{ok, Session} ->
quic_h3:set_stream_handler(H3Conn, StreamId, Session),
case Router of
undefined -> ok;
_ -> webtransport_h3_router:register_session(Router, StreamId, Session)
end,
quic_h3:send_response(H3Conn, StreamId, 200, []),
ok;
{error, Reason} ->
quic_h3:send_response(H3Conn, StreamId, 500, []),
quic_h3:send_data(H3Conn, StreamId,
iolist_to_binary(io_lib:format("~p", [Reason])), true)
end.
%% Defaults to accept when the handler does not export origin_check/2.
run_origin_check(Handler, Headers, Opts) ->
_ = code:ensure_loaded(Handler),
case erlang:function_exported(Handler, origin_check, 2) of
true ->
try Handler:origin_check(Headers, Opts) of
accept -> accept;
{reject, Status, Reason}
when is_integer(Status), Status >= 400, Status < 600,
is_binary(Reason) ->
{reject, Status, Reason};
Other ->
logger:warning("invalid origin_check/2 result: ~p", [Other]),
accept
catch
Kind:Err:Stk ->
logger:warning("origin_check/2 crashed ~p:~p ~p", [Kind, Err, Stk]),
{reject, 500, <<"origin check failed">>}
end;
false ->
%% No custom origin_check/2 callback. The drafts (h3 §3.2,
%% h2 §3.2) require: "the server MUST verify the Origin header
%% to ensure that the specified origin is allowed". If the
%% request carries an Origin header (browser client), reject
%% by default so servers cannot accidentally skip verification.
%% Non-browser requests (no Origin header) are accepted.
case proplists:get_value(<<"origin">>, Headers) of
undefined -> accept;
_ -> {reject, 403, <<"origin not allowed">>}
end
end.
is_webtransport_request(Headers) ->
case proplists:get_value(<<":protocol">>, Headers) of
<<"webtransport">> -> true;
_ -> false
end.
%% Classify an incoming h3 CONNECT request against the listener's
%% compat_mode. Returns `{ok, ClientMode}' when the request is well-formed
%% and allowed, or `{error, Reason}' when it is malformed or disallowed.
classify_h3_connect(Headers, ListenerMode) ->
case wt_h3:detect_compat_mode(Headers) of
{ok, ClientMode} ->
case allowed_by_listener(ClientMode, ListenerMode) of
true -> {ok, ClientMode};
false -> {error, {compat_mode_refused, ClientMode}}
end;
{error, _} = Err ->
Err
end.
allowed_by_listener(_, auto) -> true;
allowed_by_listener(Mode, Mode) -> true;
allowed_by_listener(_, _) -> false.
%% ============================================================================
%% Internal Functions - Data Loops
%% ============================================================================
h2_data_loop(Conn, StreamId, Session) ->
%% Monitor both ends so the loop can never orphan: if the h2 connection
%% dies without a `closed' message, or the session goes away, we exit
%% instead of blocking in `receive' forever.
ConnRef = erlang:monitor(process, Conn),
SessionRef = erlang:monitor(process, Session),
h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef).
h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef) ->
receive
{h2, Conn, {data, StreamId, Data, _IsFin}} ->
%% Decode capsules and dispatch
case webtransport_h2:decode_capsules(Data) of
{ok, Capsules, _Rest} ->
lists:foreach(fun(Capsule) ->
dispatch_h2_capsule(Session, Capsule)
end, Capsules);
{error, Reason} ->
%% Malformed capsule framing on the CONNECT stream is
%% a protocol violation. Close the session.
logger:warning("h2 capsule decode error: ~p", [Reason]),
webtransport_session:close(Session, 0, <<"malformed capsule">>)
end,
h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef);
{h2, Conn, {stream_reset, StreamId, _ErrorCode}} ->
cleanup_data_loop(ConnRef, SessionRef),
webtransport_session:close(Session, 0, <<"stream reset">>);
{h2, Conn, closed} ->
cleanup_data_loop(ConnRef, SessionRef),
webtransport_session:close(Session, 0, <<"connection closed">>);
{'DOWN', ConnRef, process, Conn, _Reason} ->
erlang:demonitor(SessionRef, [flush]),
webtransport_session:close(Session, 0, <<"connection closed">>);
{'DOWN', SessionRef, process, Session, _Reason} ->
%% Session is gone; nothing left to forward to. Exit so we don't
%% leak this process for the life of the h2 connection.
erlang:demonitor(ConnRef, [flush]),
ok;
_ ->
h2_data_loop(Conn, StreamId, Session, ConnRef, SessionRef)
end.
cleanup_data_loop(ConnRef, SessionRef) ->
erlang:demonitor(ConnRef, [flush]),
erlang:demonitor(SessionRef, [flush]).
dispatch_h2_capsule(Session, {wt_stream, WtStreamId, Data}) ->
%% h2 multiplexes WT streams over CONNECT; peer-opened streams don't get
%% a separate open signal, so we seed the session's stream map on first
%% data (handle_stream_opened is idempotent). Direction follows the QUIC
%% stream-id rule (bit 1 = 0 bidi, 1 = uni) per draft-14.
webtransport_session:handle_stream_opened(Session, WtStreamId,
webtransport_stream:stream_type(WtStreamId)),
webtransport_session:handle_stream_data(Session, WtStreamId, Data, false);
dispatch_h2_capsule(Session, {wt_stream_fin, WtStreamId, Data}) ->
webtransport_session:handle_stream_opened(Session, WtStreamId,
webtransport_stream:stream_type(WtStreamId)),
webtransport_session:handle_stream_data(Session, WtStreamId, Data, true);
dispatch_h2_capsule(Session, {datagram, Data}) ->
webtransport_session:handle_datagram_data(Session, Data);
dispatch_h2_capsule(Session, {reset_stream, WtStreamId, ErrorCode}) ->
webtransport_session:handle_stream_closed(Session, WtStreamId, {reset, ErrorCode});
dispatch_h2_capsule(Session, {stop_sending, WtStreamId, ErrorCode}) ->
webtransport_session:handle_stream_closed(Session, WtStreamId, {stop_sending, ErrorCode});
dispatch_h2_capsule(Session, Capsule) ->
webtransport_session:handle_capsule(Session, Capsule).
%% ============================================================================
%% Internal Functions - Client Connection
%% ============================================================================
connect_h2(Host, Port, Path, Opts, Handler) ->
Caller = self(),
UserHandlerOpts = maps:get(handler_opts, Opts, #{}),
HandlerOpts = maps:merge(#{owner => Caller}, UserHandlerOpts),
Opts1 = Opts#{ssl_opts => build_h2_ssl_opts(Opts)},
case webtransport_h2:connect(Host, Port, Path, Opts1) of
{ok, TransportState} ->
%% Default handler for client
ActualHandler = case Handler of
undefined -> webtransport_client_handler;
_ -> Handler
end,
Request = #{
path => Path,
authority => authority(Host, Port),
headers => maps:get(headers, Opts, [])
},
SessionOpts = #{
request => Request,
is_server => false,
handler_opts => HandlerOpts
},
case webtransport_session:start_link(h2, TransportState, ActualHandler, SessionOpts) of
{ok, Session} ->
H2Conn = webtransport_h2:h2_conn(TransportState),
StreamId = webtransport_h2:connect_stream_id(TransportState),
LoopPid = spawn(fun() -> h2_data_loop(H2Conn, StreamId, Session) end),
_ = h2:set_stream_handler(H2Conn, StreamId, LoopPid),
{ok, Session};
{error, _} = Err ->
Err
end;
{error, Reason} ->
{error, Reason}
end.
connect_h3(Host, Port, Path, Opts0, Handler) ->
Caller = self(),
UserHandlerOpts = maps:get(handler_opts, Opts0, #{}),
HandlerOpts = maps:merge(#{owner => Caller}, UserHandlerOpts),
Opts = Opts0#{handler_opts => HandlerOpts},
Authority = authority(Host, Port),
{ok, Router} = webtransport_h3_router:start_link(Caller),
H3ConnOpts = build_h3_connect_opts(Opts),
case webtransport_h3_router:client_connect(Router, Host, Port, H3ConnOpts) of
{ok, H3Conn} ->
h3_validate_and_request(H3Conn, Authority, Path, Opts, Handler, Router);
{error, Reason} ->
%% No H3 connection was established, so the router never started
%% monitoring one and would otherwise linger (it traps exits, so
%% the caller link won't reap it). Stop it explicitly.
_ = gen_server:stop(Router, normal, 5000),
{error, Reason}
end.
build_h2_ssl_opts(Opts) ->
Base = maps:get(ssl_opts, Opts, []),
WithVerify =
case maps:get(verify, Opts, verify_peer) of
verify_none -> [{verify, verify_none} | Base];
verify_peer -> [{verify, verify_peer} | Base]
end,
WithCACerts =
case {maps:find(cacertfile, Opts), maps:find(cacerts, Opts)} of
{{ok, CAFile}, _} ->
case read_cacerts_file(CAFile) of
{ok, CACerts} -> [{cacerts, CACerts} | WithVerify];
_ -> WithVerify
end;
{_, {ok, CACerts}} -> [{cacerts, CACerts} | WithVerify];
_ -> WithVerify
end,
WithCert =
case {maps:find(certfile, Opts), maps:find(cert, Opts)} of
{{ok, CertFile}, _} -> [{certfile, CertFile} | WithCACerts];
{_, {ok, CertDer}} -> [{cert, CertDer} | WithCACerts];
_ -> WithCACerts
end,
case {maps:find(keyfile, Opts), maps:find(key, Opts)} of
{{ok, KeyFile}, _} -> [{keyfile, KeyFile} | WithCert];
{_, {ok, KeyTerm}} -> [{key, KeyTerm} | WithCert];
_ -> WithCert
end.
build_h3_connect_opts(Opts) ->
%% Client picks a compat_mode explicitly; default `latest' (draft-15).
%% Clients must not auto-probe the server, so the default is a single
%% disjoint handshake shape.
CompatMode = case maps:get(compat_mode, Opts, latest) of
legacy_browser_compat -> legacy_browser_compat;
_ -> latest
end,
WTSettings = wt_h3:default_settings(CompatMode),
BaseOpts = #{
settings => WTSettings,
sync => true,
connect_timeout => maps:get(timeout, Opts, 30000),
verify => maps:get(verify, Opts, verify_peer),
quic_opts => build_client_quic_opts(Opts),
h3_datagram_enabled => true,
stream_type_handler => fun
(uni, _, 16#54) -> claim;
(bidi, _, 16#41) -> claim;
(_, _, _) -> ignore
end
},
%% Handle client certificate if provided
WithCert = case {maps:find(certfile, Opts), maps:find(cert, Opts)} of
{{ok, CertFile}, _} ->
case read_cert_file(CertFile) of
{ok, CertDer} -> BaseOpts#{cert => CertDer};
_ -> BaseOpts
end;
{_, {ok, CertDer}} ->
BaseOpts#{cert => CertDer};
_ ->
BaseOpts
end,
%% Handle client key if provided
WithKey = case {maps:find(keyfile, Opts), maps:find(key, Opts)} of
{{ok, KeyFile}, _} ->
case read_key_file(KeyFile) of
{ok, KeyTerm} -> WithCert#{key => KeyTerm};
_ -> WithCert
end;
{_, {ok, KeyTerm}} ->
WithCert#{key => KeyTerm};
_ ->
WithCert
end,
%% Handle CA certificates if provided
case {maps:find(cacertfile, Opts), maps:find(cacerts, Opts)} of
{{ok, CAFile}, _} ->
case read_cacerts_file(CAFile) of
{ok, CACerts} -> WithKey#{cacerts => CACerts};
_ -> WithKey
end;
{_, {ok, CACerts}} ->
WithKey#{cacerts => CACerts};
_ ->
WithKey
end.
%% Build the `quic_opts' map for a client h3 connect. Threads the optional
%% IPv6 / Happy Eyeballs controls and a stored 0-RTT `session_ticket' through
%% to quic:connect/4 alongside the WebTransport transport defaults.
build_client_quic_opts(Opts) ->
Base = #{
max_datagram_frame_size => 65535,
reset_stream_at => true
},
PassThrough = [family, happy_eyeballs, connection_attempt_delay, session_ticket],
lists:foldl(fun(Key, Acc) ->
case maps:find(Key, Opts) of
{ok, Value} -> Acc#{Key => Value};
error -> Acc
end
end, Base, PassThrough).
%% Format an HTTP `:authority' from a host (string/binary/IP tuple) and port.
%% IPv6 literals and 8-tuple addresses are bracketed per RFC 3986.
authority(Host, Port) when is_tuple(Host) ->
authority(inet:ntoa(Host), Port);
authority(Host, Port) when is_list(Host) ->
authority(list_to_binary(Host), Port);
authority(Host, Port) when is_binary(Host) ->
PortBin = integer_to_binary(Port),
case is_ipv6_literal(Host) of
true -> <<"[", Host/binary, "]:", PortBin/binary>>;
false -> <<Host/binary, ":", PortBin/binary>>
end.
%% An IPv6 literal contains a colon and no brackets (a bare host or IPv4
%% literal never does). Already-bracketed input is left as-is.
is_ipv6_literal(<<"[", _/binary>>) -> false;
is_ipv6_literal(Host) -> binary:match(Host, <<":">>) =/= nomatch.
h3_validate_and_request(H3Conn, Authority, Path, Opts, Handler, Router) ->
CompatMode = maps:get(compat_mode, Opts, latest),
QuicConn = quic_h3:get_quic_conn(H3Conn),
case wt_h3:validate_wt_support(H3Conn, QuicConn, CompatMode) of
ok ->
h3_send_connect(H3Conn, Authority, Path, Opts, Handler, Router, CompatMode);
{error, Reason} ->
quic_h3:close(H3Conn),
{error, Reason}
end.
h3_send_connect(H3Conn, Authority, Path, Opts, Handler, Router, CompatMode) ->
Headers = maps:get(headers, Opts, []),
case wt_h3:request_session(H3Conn, Authority, Path, Headers, CompatMode) of
{ok, SessionId} ->
h3_await_response(H3Conn, SessionId, Authority, Path, Opts, Handler, Router);
{error, Reason} ->
quic_h3:close(H3Conn),
{error, Reason}
end.
h3_await_response(H3Conn, SessionId, Authority, Path, Opts, Handler, Router) ->
Timeout = maps:get(timeout, Opts, 30000),
receive
{quic_h3, H3Conn, {response, SessionId, Status, Headers}} when Status >= 200, Status < 300 ->
h3_start_session(H3Conn, SessionId, Authority, Path, Headers, Opts, Handler, Router);
{quic_h3, H3Conn, {response, SessionId, Status, _Headers}} ->
quic_h3:close(H3Conn),
{error, {http_error, Status}};
{quic_h3, H3Conn, closed} ->
{error, connection_closed}
after Timeout ->
quic_h3:close(H3Conn),
{error, timeout}
end.
h3_start_session(H3Conn, SessionId, Authority, Path, Headers, Opts, Handler, Router) ->
TransportState = webtransport_h3:new(H3Conn, SessionId, Router),
ActualHandler = case Handler of
undefined -> webtransport_client_handler;
_ -> Handler
end,
Request = #{
path => Path,
authority => Authority,
headers => Headers
},
HandlerOpts = maps:get(handler_opts, Opts, #{}),
SessionOpts = #{
request => Request,
is_server => false,
handler_opts => HandlerOpts
},
case webtransport_session:start_link(h3, TransportState, ActualHandler, SessionOpts) of
{ok, Session} ->
webtransport_h3_router:register_session(Router, SessionId, Session),
{ok, Session};
{error, _} = Err ->
%% Session failed to start after a successful CONNECT. Closing the
%% H3 connection makes the router stop via its h3_conn monitor, so
%% neither the connection nor the router is left dangling.
quic_h3:close(H3Conn),
Err
end.
%% ============================================================================
%% Handler Factories
%% ============================================================================
make_h2_handler(Handler, HandlerOpts, Opts) ->
fun(Conn, StreamId, Method, Path, Headers) ->
handle_h2_request(Conn, StreamId, Method, Path, Headers, Handler, HandlerOpts, Opts)
end.
make_h3_handler(Handler, HandlerOpts, Opts, Router) ->
fun(H3Conn, StreamId, Method, Path, Headers) ->
handle_h3_request(H3Conn, StreamId, Method, Path, Headers,
Handler, HandlerOpts, Opts, Router)
end.
wt_stream_type_handler() ->
fun
(uni, _StreamId, 16#54) -> claim;
(bidi, _StreamId, 16#41) -> claim;
(_, _, _) -> ignore
end.
%% ============================================================================
%% Certificate/Key Helpers
%% ============================================================================
%% @private Read and decode certificate and private key files.
read_cert_and_key(CertFile, KeyFile) ->
case {file:read_file(CertFile), file:read_file(KeyFile)} of
{{ok, CertPem}, {ok, KeyPem}} ->
case public_key:pem_decode(CertPem) of
[{_, CertDer, _} | _] ->
case decode_private_key(KeyPem) of
{ok, PrivateKey} ->
{ok, CertDer, PrivateKey};
{error, Reason} ->
{error, {key_decode_failed, Reason}}
end;
[] ->
{error, invalid_certificate}
end;
{{error, CertErr}, _} ->
{error, {cert_read_failed, CertErr}};
{_, {error, KeyErr}} ->
{error, {key_read_failed, KeyErr}}
end.
%% @private Decode a PEM-encoded private key.
decode_private_key(PemData) ->
case public_key:pem_decode(PemData) of
[{Type, Der, not_encrypted}] ->
decode_key_entry(Type, Der);
[{Type, Der, _Cipher}] ->
decode_key_entry(Type, Der);
_ ->
{error, invalid_private_key}
end.
decode_key_entry('RSAPrivateKey', Der) ->
{ok, public_key:der_decode('RSAPrivateKey', Der)};
decode_key_entry('ECPrivateKey', Der) ->
{ok, public_key:der_decode('ECPrivateKey', Der)};
decode_key_entry('PrivateKeyInfo', Der) ->
{ok, public_key:der_decode('PrivateKeyInfo', Der)};
decode_key_entry(Type, _Der) ->
{error, {unsupported_key_type, Type}}.
%% @private Read a single certificate from a PEM file.
read_cert_file(CertFile) ->
case file:read_file(CertFile) of
{ok, PemData} ->
case public_key:pem_decode(PemData) of
[{_, CertDer, _} | _] -> {ok, CertDer};
[] -> {error, invalid_certificate}
end;
{error, Reason} ->
{error, {cert_read_failed, Reason}}
end.
%% @private Read a private key from a PEM file.
read_key_file(KeyFile) ->
case file:read_file(KeyFile) of
{ok, PemData} ->
decode_private_key(PemData);
{error, Reason} ->
{error, {key_read_failed, Reason}}
end.
%% @private Read CA certificates from a PEM file.
read_cacerts_file(CAFile) ->
case file:read_file(CAFile) of
{ok, PemData} ->
Certs = [Der || {_, Der, _} <- public_key:pem_decode(PemData)],
case Certs of
[] -> {error, no_certificates};
_ -> {ok, Certs}
end;
{error, Reason} ->
{error, {cacerts_read_failed, Reason}}
end.