Changelog
View SourceAll notable changes to masque are recorded here.
The format follows Keep a Changelog
and the project uses Semantic Versioning.
[Unreleased]
[0.7.0] - 2026-06-13
Added
- HTTP/1.1 fallback for all three tunnel protocols. CONNECT-UDP
(RFC 9298) and CONNECT-IP (RFC 9484) use HTTP Upgrade +
RFC 9297 capsules; CONNECT-TCP uses classic HTTP CONNECT
(RFC 9110 §9.3.6). Opt in per client call with
masque:connect(URL, Target, #{transports => [h3, h2, h1], ...})and on the server withmasque:start_listener_h1/2. The racer stages a tertiary h1 attempt afterh1_prefer_timeout_ms(default 500 ms) behind the existing h2 head-start, giving Apple-style transport racing over classic HTTPS paths. proxy_authorizationclient opt for classic CONNECT-TCP over h1 (Proxy-Authorizationheader passthrough).masque_uri:build_authority/2andmasque_uri:parse_authority_form/1helpers. IPv6 literals get bracketed on outbound authorities and unwrapped on CONNECT request-targets.masque:start_chain_listener_h2/2andstart_chain_listener_h1/2complete the chain-listener trio (h3 was already there). A Private-Relay-shaped ingress now takes the same one-liner shape on every transport.- CONNECT-IP support in
masque_chain_handler. Ingress tunnels withprotocol => ipforward IP packets both ways, forward the egress's initialROUTE_ADVERTISEMENT, and forward unpromptedADDRESS_ASSIGNentries (request_id = 0). Prompted ADDRESS_ASSIGN forwarding requires request-id remapping and stays out of this change; a client that expects the chain to round-trip a client-initiatedADDRESS_REQUESThas to wait for that follow-up. examples/two_hop_relay.erl: a standalone runnable two-hop relay (ingress + egress on loopback, self-signed certs, all three transports, UDP + TCP round-trip helpers). Demonstrates the Apple-Private-Relay shape as a 300-line reference.- Opt-in upstream connection pooling for h2 / h3 MASQUE tunnels.
Pass
upstream_pool => trueinconnect_opts()(or inupstream_optsonmasque_chain_handler) to share one pooled transport connection across many tunnels; each tunnel rides a fresh stream. h3 conns are always opened datagram-capable so CONNECT-UDP / -TCP / -IP can coexist on a single QUIC owner. Pool keys fingerprintverify/cacerts/ssl_opts/alpnso callers with different trust or ALPN stay isolated. h1 bypasses the pool (1-tunnel-per-socket). Default behaviour is unchanged when the flag is absent. - Client-side
request_headersoption onmasque:connect/3. Prepends caller-supplied headers to the CONNECT (or GET+Upgrade on h1) request, so auth schemes that ride on the handshake (Privacy PassAuthorization: PrivateToken ..., proxy metadata) have a library-native hook. Reserved pseudo-headers are dropped and CR/LF in h1 values is refused to prevent header injection. - Handler-side
{reject, Error, ExtraHeaders}return form fromaccept/1. Lets an ingress attach challenge headers to rejected handshakes (WWW-Authenticate: PrivateToken ...,Retry-After, etc.) without leaving the library contract. Caller-supplied headers override the library's defaults on key collision. Works on all three transports.
Changed
masque:start_chain_listener/2now also sets thetcp_handlerandip_handlertomasque_chain_handlerso every protocol the client might pick is chained upstream. Previously only the UDP path was chained and TCP / IP fell through to the direct proxy handlers; callers that want the old split behaviour can still callmasque:start_listener/2directly and set each handler. Same change applies to the new_h2' and_h1' wrappers.- Build and test suite now run on OTP 29. The deprecated prefix
catchoperator was migrated totry ... catch ... endacross the session modules. Dependencies bumped:h1moved to the hexerlang_h10.6.2 package,h2to 0.9.0,instrumentto v1.1.3 (OTP 29 support),hackneyto 4.3.0, andproperto 1.5.0 for tests.
Fixed
- Chained CONNECT-IP no longer drops the egress's initial
ROUTE_ADVERTISEMENT. The ingress IP server session could forward the advertisement before it sent its own 200 and claimed the downstream stream, so the capsule went to a not-yet-open stream and was lost. Handler actions produced before finalize are now buffered and flushed in order once the stream is open.
[0.5.0] - 2026-04-19
Added
- CONNECT-IP (RFC 9484) over HTTP/3 and HTTP/2. One listener can
now serve CONNECT-UDP, CONNECT-TCP, and CONNECT-IP simultaneously;
each has its own handler/template option pair (
ip_handler+ip_uri_template). - Bidirectional control plane per RFC 9484 §5 (both endpoints can
send every capsule; site-to-site pattern from §8.2 works without
workarounds):
masque:send_ip_packet/2,masque:request_addresses/2,masque:assign_addresses/2,masque:advertise_routes/2,masque:ip_info/1. masque_ip_capsule: codec forADDRESS_ASSIGN(0x01),ADDRESS_REQUEST(0x02),ROUTE_ADVERTISEMENT(0x03) with full §4.7.3 validation (ordering, disjointness, protocol-0 overlap).- Generic URI-template engine
masque_uri_template(Level-1 path placeholders, Level-3{?var1,var2}query form, absolute-URI awareness);masque_urire-seated on it with unchanged public API; newmasque_uri_ipfor CONNECT-IP (client-absolute, server-side path+query match pattern). - Transport-generic IP sessions
(
masque_ip_client_session,masque_ip_server_session) that dispatch H2 / H3 on a singletransportfield, mirroring the TCP session architecture rather than cloning per transport. - Default
masque_ip_proxy_handler: round-robin address-pool allocator, listener-owned DNS resolution beforeaccept/1(hostnames resolved, addresses stitched intoreq(), SSRF/BCP-38 policy runs on the resolved list), initialROUTE_ADVERTISEMENTfrom config + resolution, BCP-38 source-address filter on the inbound data plane, pluggableforward_fun. masque_icmp: RFC 792 + RFC 4443 error builders with correct invoking-packet truncation (548 B ICMPv4, 1232 B ICMPv6) and IPv6 pseudo-header checksum. Session{icmp_error, ...}action emits the resulting IP packet as a context-0 datagram.- RFC 9484 §8 MTU check on the H3 client handshake — aborts with
{mtu_too_low, Got, 1280}if the negotiated QUIC datagram size can't carry a 1280-byte IPv6 packet. - Client
connect/3validates target shape vs. protocol and forcescapsule-protocol: ?1on CONNECT-IP (no way to accidentally dial without it). - 9 ICMP eunit tests, 29 URI eunit tests, 24 capsule/datagram eunit tests, 3 CT cases for H3 CONNECT-IP, 3 CT cases for H2 CONNECT-IP, 7 CT cases for RFC 9484 normative compliance.
docs/connect_ip.mdusage guide with the §-mapped compliance table;examples/ip_echo.erlrunnable sample.
Changed
listener_opts()public type corrected:cert/keyinstead of the stalecertfile/keyfile(the real listeners have read the former for several releases).masque_handler:req()extended withprotocol => ip,ip_target,ip_ipproto,resolved_addresseskeys.masque_h2_session_supgrew an IP branch so H2 CONNECT-IP tunnels land on the IP session module (previously defaulted to the UDP session, which silently dropped IP capsules).
[0.4.0] - 2026-04-17
Added
- CONNECT-TCP (draft-ietf-httpbis-connect-tcp) alongside CONNECT-UDP.
One listener serves both protocols; the
:protocolpseudo-header selects the handler. Client picksprotocol => tcp | udpin opts. - Unified API:
masque:send/2,masque:recv/2,{masque_data, Sess, Data}for both protocols. No backward-compat aliases. masque_tcp_proxy_handler: bridges CONNECT-TCP tunnels to real TCP connections viagen_tcp.masque_tcp_client_session: client TCP session over h3 or h2.masque_tcp_server_session: per-tunnel TCP server session.- 4 new CT cases:
tcp_echo_round_trip,tcp_large_transfer,tcp_target_closes,tcp_and_udp_same_listener.
Changed
send_packet/recv_packet/{masque_packet,...}replaced bysend/recv/{masque_data,...}everywhere. Breaking change.- Server dispatches by
:protocoltoudp_handlerortcp_handler.
[0.3.0] - 2026-04-17
Added
- Server-side proxy chaining:
masque_chain_handlerrelays each tunnel through an upstream MASQUE proxy, enabling two-hop topologies (Private Relay pattern). Convenience wrappermasque:start_chain_listener/2. - 2 new CT cases:
chain_round_trip(full Client-Ingress-Egress-UDP path) andchain_upstream_failure_returns_502.
[0.2.0] - 2026-04-17
Added
- HTTP/2 transport (
masque_h2_client_session,masque_h2_server,masque_h2_server_session) using Extended CONNECT (RFC 8441) and DATAGRAM capsules (RFC 9297 S3.2) on the request-body stream. - Apple-style transport racing:
masque:connect/3acceptstransports => [h3, h2](default) and gives h3 a 250 ms head start before launching h2 in parallel. First 2xx wins; the loser is cancelled. Tunable viaprefer_timeout_ms. masque:start_listener_h2/2andmasque:h2_handlers/1for dedicated h2 listeners and integration into user-owned h2 servers.masque_h2_session_sup(simple_one_for_one undermasque_sup) for proper OTP supervision of h2 server sessions.set_owner/2gen_statem call on both session modules so the transport racer can transfer ownership after a winning handshake.erlang_h20.4.0 as a required dependency (tag-pinned).
[0.1.0] - 2026-04-16
First release. RFC 9298 CONNECT-UDP over HTTP/3, client + server.
Added
- RFC 9298 Extended CONNECT handshake server and client, built on
erlang_quic'squic_h3stack. - Client API on
masque:connect/2,3,send_packet/2,3,recv_packet/2,send_capsule/3,set_active/2,close/1,info/1. Dual delivery modes: message ({masque_packet, Sess, Data}) and blocking queue. - Server API:
start_listener/2for a dedicated listener, plush3_handlers/1returninghandlerandconnection_handlerfuns for embedding MASQUE inside a user-ownedquic_h3:start_server/3call. Optionalfallbackfun routes non-MASQUE requests to the caller. masque_handlerbehaviour:accept/1gate,init/2,handle_packet/2,handle_capsule/3,handle_info/2,terminate/2; actions includesend_packet,send_capsule,close_session.- Built-in
masque_udp_proxy_handlerthat bridges tunnels to real UDP flows. Knobs:allow,resolver,family,port,socket_opts,max_capsule_size. - URI template module accepting absolute
http(s)://…or path-shaped templates; host validated as IPv4, IPv6, or LDH registered name (IPv6 zone IDs rejected). - Per-HTTP/3-connection router (
masque_server_connection) demuxing HTTP Datagrams and stream bytes to per-tunnel session processes keyed by stream-id; many tunnels per connection. - Capsule protocol (RFC 9297) on both sides, with bounded incoming buffer (default 1 MiB).
Proxy-Status(RFC 9209) header on handshake rejections (dns_error,connection_timeout,destination_ip_prohibited,http_protocol_error, …).- Documentation:
README.md,docs/usage.md(client modes, multiple tunnels, integration with an existingquic_h3server, handler lifecycle, error-code table),docs/features.md(RFC coverage matrix and security posture). - Examples:
examples/udp_echo_proxy.erl,examples/udp_dig_client.erl. - Tests: 23 common_test cases (handshake, echo, real UDP round-trip,
capsules, concurrency, load, boundary, integration, spoofing,
oversize), 33 eunit cases, 3 PropEr properties on the codecs,
skippable external-peer interop suite driven by
MASQUE_GO_BIN.
Security
- Handler
init/2runs before the 2xx handshake response, so a tunnel only commits once DNS and socket setup have succeeded. Failures surface to the client as 502 instead of a silent broken tunnel. masque_udp_proxy_handlercallsgen_udp:connect/3on the target so the kernel drops any inbound packet whose source does not match. A defensive application-level source check is in place as well.- UDP payloads are clamped to the RFC 9298 §5 ceiling of 65527 bytes in both directions.
- Malformed or truncated capsules abort the HTTP/3 stream with
H3_MESSAGE_ERROR(RFC 9297 §3.3). - Client rejects 2xx handshake responses that carry
content-lengthorcontent-type, or that drop the requestedcapsule-protocol: ?1header. - UDP
udp_error/udp_closedevents, and terminal send failures (closed,einval,enotconn), close the tunnel promptly.
Known limitations
- HTTP/3 only. HTTP/1.1 Upgrade and HTTP/2 transports are out of scope for v0.1.
- One QUIC connection per client session; multiplexing many tunnels through a single client-side QUIC handshake is a v0.2 item.
- MASQUE takes the H3 connection's
ownerslot, so it cannot share a single listener with another extension that also needs the owner (e.g. WebTransport). Use separate listeners on separate ports. - Proxy chaining and per-tunnel authorization hooks deferred to v0.2.
- RFC 9484 (Proxying IP) will live in a separate library on top of
masque, not here.