Changelog

View Source

All 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 with masque:start_listener_h1/2. The racer stages a tertiary h1 attempt after h1_prefer_timeout_ms (default 500 ms) behind the existing h2 head-start, giving Apple-style transport racing over classic HTTPS paths.
  • proxy_authorization client opt for classic CONNECT-TCP over h1 (Proxy-Authorization header passthrough).
  • masque_uri:build_authority/2 and masque_uri:parse_authority_form/1 helpers. IPv6 literals get bracketed on outbound authorities and unwrapped on CONNECT request-targets.
  • masque:start_chain_listener_h2/2 and start_chain_listener_h1/2 complete 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 with protocol => ip forward IP packets both ways, forward the egress's initial ROUTE_ADVERTISEMENT, and forward unprompted ADDRESS_ASSIGN entries (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-initiated ADDRESS_REQUEST has 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 => true in connect_opts() (or in upstream_opts on masque_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 fingerprint verify / cacerts / ssl_opts / alpn so 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_headers option on masque:connect/3. Prepends caller-supplied headers to the CONNECT (or GET+Upgrade on h1) request, so auth schemes that ride on the handshake (Privacy Pass Authorization: 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 from accept/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/2 now also sets the tcp_handler and ip_handler to masque_chain_handler so 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 call masque:start_listener/2 directly 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 catch operator was migrated to try ... catch ... end across the session modules. Dependencies bumped: h1 moved to the hex erlang_h1 0.6.2 package, h2 to 0.9.0, instrument to v1.1.3 (OTP 29 support), hackney to 4.3.0, and proper to 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 for ADDRESS_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_uri re-seated on it with unchanged public API; new masque_uri_ip for 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 single transport field, mirroring the TCP session architecture rather than cloning per transport.
  • Default masque_ip_proxy_handler: round-robin address-pool allocator, listener-owned DNS resolution before accept/1 (hostnames resolved, addresses stitched into req(), SSRF/BCP-38 policy runs on the resolved list), initial ROUTE_ADVERTISEMENT from config + resolution, BCP-38 source-address filter on the inbound data plane, pluggable forward_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/3 validates target shape vs. protocol and forces capsule-protocol: ?1 on 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.md usage guide with the §-mapped compliance table; examples/ip_echo.erl runnable sample.

Changed

  • listener_opts() public type corrected: cert / key instead of the stale certfile / keyfile (the real listeners have read the former for several releases).
  • masque_handler:req() extended with protocol => ip, ip_target, ip_ipproto, resolved_addresses keys.
  • masque_h2_session_sup grew 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 :protocol pseudo-header selects the handler. Client picks protocol => tcp | udp in 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 via gen_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 by send/recv/{masque_data,...} everywhere. Breaking change.
  • Server dispatches by :protocol to udp_handler or tcp_handler.

[0.3.0] - 2026-04-17

Added

  • Server-side proxy chaining: masque_chain_handler relays each tunnel through an upstream MASQUE proxy, enabling two-hop topologies (Private Relay pattern). Convenience wrapper masque:start_chain_listener/2.
  • 2 new CT cases: chain_round_trip (full Client-Ingress-Egress-UDP path) and chain_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/3 accepts transports => [h3, h2] (default) and gives h3 a 250 ms head start before launching h2 in parallel. First 2xx wins; the loser is cancelled. Tunable via prefer_timeout_ms.
  • masque:start_listener_h2/2 and masque:h2_handlers/1 for dedicated h2 listeners and integration into user-owned h2 servers.
  • masque_h2_session_sup (simple_one_for_one under masque_sup) for proper OTP supervision of h2 server sessions.
  • set_owner/2 gen_statem call on both session modules so the transport racer can transfer ownership after a winning handshake.
  • erlang_h2 0.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's quic_h3 stack.
  • 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/2 for a dedicated listener, plus h3_handlers/1 returning handler and connection_handler funs for embedding MASQUE inside a user-owned quic_h3:start_server/3 call. Optional fallback fun routes non-MASQUE requests to the caller.
  • masque_handler behaviour: accept/1 gate, init/2, handle_packet/2, handle_capsule/3, handle_info/2, terminate/2; actions include send_packet, send_capsule, close_session.
  • Built-in masque_udp_proxy_handler that 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 existing quic_h3 server, 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/2 runs 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_handler calls gen_udp:connect/3 on 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-length or content-type, or that drop the requested capsule-protocol: ?1 header.
  • UDP udp_error / udp_closed events, 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 owner slot, 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.