CONNECT-IP (RFC 9484)

View Source

masque implements RFC 9484 ("Proxying IP in HTTP") on top of the same H3 + H2 + H1 transports used for CONNECT-UDP. The client and server APIs follow the shape of the existing UDP/TCP code: one masque:connect/3 to dial, one listener per port, owner-message delivery on the client, handler callbacks on the server.

The remainder of this doc is the usage surface. For the on-the-wire protocol details see RFC 9484 directly.

HTTP/1.1 fallback

On networks that block QUIC and also refuse HTTP/2 (or strip ALPN), masque can fall back to HTTP/1.1 over classic HTTPS. The on-the-wire handshake is an HTTP Upgrade:

GET /.well-known/masque/ip/{target}/{ipproto}/ HTTP/1.1
Host: proxy.example:4443
Connection: Upgrade
Upgrade: connect-ip
Capsule-Protocol: ?1

On 101 Switching Protocols the raw TLS socket becomes the tunnel; RFC 9297 capsules (DATAGRAM for IP packets, ADDRESS_ASSIGN / ADDRESS_REQUEST / ROUTE_ADVERTISEMENT for the control plane) are framed directly on that socket. Wire format is identical to the h2 and h3 paths.

Enable via transports => [h3, h2, h1] (client) and masque:start_listener_h1/2 (server). The racer stages the h1 attempt after h1_prefer_timeout_ms (default 500 ms) behind the h2 head-start, so h1 only opens sockets when the preferred transports have failed or are slow.

Client

{ok, Sess} =
    masque:connect(<<"https://proxy.example:4443">>,
                   {'*', '*'},               %% {ip_target(), ip_ipproto()}
                   #{protocol   => ip,
                     transports => [h3, h2]}).

Optional uri_template in connect_opts() overrides the default template. When omitted the client synthesises https://<proxy-authority>/.well-known/masque/ip/{target}/{ipproto}/. An explicit template must be an absolute URI; non-absolute inputs return {error, {bad_template, absolute_uri_required}}. Disabling the capsule-protocol header for an IP session returns {error, {invalid_opts, capsule_protocol_required_for_ip}} — RFC 9484 §4 makes the header mandatory.

Inbound events arrive as messages to the owner (default self()):

{masque_ip_packet,           Sess, Packet :: binary()}
{masque_address_assign,      Sess, [ip_assignment()]}
{masque_address_request,     Sess, [ip_prefix_request()]}
{masque_route_advertisement, Sess, [ip_route()]}
{masque_ip_error,            Sess, Reason}
{masque_closed,              Sess, Reason}

Outbound control plane is fully typed; both endpoints may send all three capsules (RFC 9484 §5, site-to-site example §8.2):

ok                      = masque:send_ip_packet(Sess, Packet).
{ok, [Id1, Id2]}        = masque:request_addresses(Sess,
                              [{4, {0,0,0,0}, 0},
                               {6, {0,0,0,0,0,0,0,0}, 0}]).
ok                      = masque:assign_addresses(Sess, Assignments).
ok                      = masque:advertise_routes(Sess, Routes).
#{assigned := _, routes := _, mtu := 1500, transport := h3}
                        = masque:ip_info(Sess).
ok                      = masque:close(Sess).

Nonzero Request IDs passed to assign_addresses/2 must match an outstanding peer ADDRESS_REQUEST; otherwise the call returns {error, {no_such_pending_request, Id}}.

Server

{ok, _} = masque:start_listener(ip_proxy, #{
    port => 4443,
    cert => CertDerOrPemPath,
    key  => KeyDerOrPemPath,

    %% CONNECT-IP specific:
    ip_uri_template => <<"/.well-known/masque/ip/{target}/{ipproto}/">>,
    ip_handler      => masque_ip_proxy_handler,   %% default
    address_pool    => {4, {10,200,0,0}, 16},     %% allocate /32s
    routes          => [{4,{0,0,0,0},{255,255,255,255},0}],
    resolver        => fun inet_res_resolver/1,   %% optional, default
                                                  %% wraps inet_res
    mtu             => 1500
}).

One listener serves UDP, TCP, and IP simultaneously — each has distinct handler / uri_template option pairs (handler + uri_template for UDP, tcp_handler + tcp_uri_template for TCP, ip_handler + ip_uri_template for IP). Dispatch is by :protocol on the incoming request.

The default masque_ip_proxy_handler implements the phase-1 handler: round-robin allocation over address_pool, an initial ROUTE_ADVERTISEMENT built from routes plus the resolver's output, and BCP-38 source-address filtering. To forward packets, pass handler_opts => #{forward_fun => Fun}. Fun(Packet, State) may return {reply, Packet, State}, {drop, State}, {forward, State}, or ok.

Custom handlers

Implement masque_handler. Optional callbacks added by CONNECT-IP:

handle_ip_packet(Packet :: binary(), State) ->
    {ok, State} | {ok, State, [action()]} | {stop, Reason, State}.

handle_address_request([ip_prefix_request()], State) -> ...
handle_address_assign([ip_assignment()], State)      -> ...
handle_route_advertisement([ip_route()], State)      -> ...

Actions the session interprets:

ActionEffect
{send_ip_packet, Packet}Emit Packet as a context-0 datagram.
{request_addresses, [ip_prefix()]}Emit an ADDRESS_REQUEST (server may ask the client for addresses, RFC 9484 §5.2 site-to-site).
{assign, [ip_assignment()]}Emit an ADDRESS_ASSIGN. Nonzero Request IDs must be in the pending-request set.
{advertise, [ip_route()]}Emit a ROUTE_ADVERTISEMENT.
{icmp_error, {Kind, Spec, Invoking}}Synthesise an ICMP error (see masque_icmp).
{send_capsule, Type, Value}Emit an extension capsule.
{close, Reason}Stop the tunnel.

ICMP errors

masque_icmp builds full ICMPv4/ICMPv6 packets:

Pkt4 = masque_icmp:dest_unreachable(v4, 1, InvokingV4).
Pkt6 = masque_icmp:packet_too_big(1400, InvokingV6).
Pkt6b = masque_icmp:time_exceeded(v6, 0, InvokingV6).

The invoking packet is truncated to the IPv4 minimum MTU (548 B body) or the IPv6 minimum MTU (1232 B body). Checksums include the IPv6 pseudo-header for ICMPv6. Drop these straight into masque:send_ip_packet/2 or return them from a handler:

handle_ip_packet(Pkt, State) ->
    case should_drop(Pkt) of
        {yes, Reason} ->
            {ok, State,
             [{icmp_error, {dest_unreachable, {v4, 1}, Pkt}}]};
        no ->
            forward(Pkt, State)
    end.

Plumbing for external consumers

The default proxy handler ships with hooks designed for downstream applications - typically a TUN/router process - that want to drive CONNECT-IP without forking the library. There are five seams:

1. Address registry

masque_ip_session_registry is a gen_server child of masque_sup that owns a single ETS table mapping every assigned address or prefix to the session pid that serves it. The default proxy handler calls into it on each allocation and on terminate/2.

%% From a TUN read loop, given the destination address of a packet:
case masque_ip_session_registry:lookup({10,0,0,5}) of
    {ok, SessionPid, _ContextId} ->
        masque_ip:inject_packet(SessionPid, Packet);
    not_found ->
        drop_or_icmp(Packet)
end.

Lookup is direct ETS - the registry process is only on the write path. Storage is a {Version, StartIntAddr} ordered set whose intervals never overlap, so a host route inside an enclosing prefix resolves to the session that owns the wider prefix. Sessions that exit abruptly are gc-ed automatically via process monitors.

Public API:

CallPurpose
lookup(IP)Longest-prefix match. Returns {ok, Pid, CtxId} | not_found.
register(V, Addr, Pfx, Pid, CtxId)Reject on overlap. Default proxy handler calls this from allocate_one/2.
release(V, Addr, Pfx)Free a single range. Default handler calls this from terminate/2.
release_pid(Pid)Drop everything owned by Pid.
all/0Snapshot for diagnostics.

2. Out-of-band packet injection

masque_ip:inject_packet(SessionPid, Packet) is a non-blocking cast that pushes Packet into a server session for delivery to the connected client, regardless of which transport that session uses (h1, h2, or h3). It re-uses the same wire path as the {send_ip_packet, _} handler action, so capsule framing, MTU checks, and metrics fire identically.

3. Lifecycle callback

Set lifecycle_fun => fun((Event, Detail) -> ok) in handler_opts to receive structured events from the default proxy handler:

EventDetail
address_assigned#{version, address, prefix_len, entry}
address_released#{version, address, prefix_len}
route_advertised#{routes => [#ip_route{}]}
packet_dropped#{reason, packet_size, ...}

Typical use: program kernel routes on assign / release, log dropped packets with their reason. Errors thrown from the callback are swallowed so a misbehaving consumer cannot break the data plane.

4. Per-family prefix policy

min_assignable_prefix => #{4 => Pfx4, 6 => Pfx6} (default #{4 => 32, 6 => 128} - host routes only) caps the widest prefix the allocator will hand out. To delegate /64s to clients while keeping IPv4 host-routed:

#{address_pool => {6, {16#2001,16#DB8,0,0,0,0,0,0}, 48},
  min_assignable_prefix => #{4 => 32, 6 => 64}}

The allocator walks the pool in stride-aligned blocks of 2^(MaxPfx - Pfx) and rejects ranges that overlap any prior assignment, so prefix and host allocations from the same pool coexist.

5. Rich forward_fun actions

In addition to the historical {reply, _, _} | {drop, _} | {forward, _} | ok | {error, _} shapes, forward_fun can return:

{actions, [forward_action()], NewState}.

forward_action() :: {send_ip_packet, binary()}
                  | {icmp_error, {atom(), term(), binary()}}
                  | {drop, atom()}.       %% telemetry only

{drop, Reason} bumps masque_metrics:ip_drop_inc(Reason) and emits a packet_dropped lifecycle event without putting anything on the wire, so a TUN consumer can both reply with an ICMP error and account for the dropped original in a single call:

forward_fun(Pkt, S) ->
    case ttl(Pkt) of
        0 ->
            {actions,
             [{icmp_error, {time_exceeded, {v4, 0}, Pkt}},
              {drop, ttl_zero}],
             S};
        _ ->
            {forward, S}
    end.

Drop counters

masque_metrics exposes simple counters-backed read APIs for the drop axis:

masque_metrics:ip_drop_count(bcp38).
masque_metrics:ip_drop_count(scope_target).
masque_metrics:ip_drop_count(forward_drop).

Recognised reasons live in masque_metrics:ip_drop_reasons/0; unknown reasons land in the other bucket. Public helper masque_ip_proxy_handler:emit_drop(Reason, Detail) lets a TUN consumer's own data path bump the same counters and lifecycle hook.

Wire-format notes

Context ID 0 carries raw IP packets (see masque_datagram). Unknown context IDs are silently dropped on receipt (RFC 9484 §6). On H3 the datagram channel is QUIC DATAGRAM frames; on H2 the datagram channel is RFC 9297 DATAGRAM-type capsules on the request-stream body; on H1 the channel is the same DATAGRAM-type capsule riding the upgraded TLS connection. The library's sessions dispatch internally so handler code sees the same handle_ip_packet/2 callback on all transports.

Capsules ADDRESS_ASSIGN (0x01), ADDRESS_REQUEST (0x02), and ROUTE_ADVERTISEMENT (0x03) are encoded/decoded by masque_ip_capsule. Validation rejects malformed route ordering, non-disjoint ranges, and protocol-0 ranges that overlap nonzero-protocol ranges for the same IP version (RFC 9484 §4.7.3).

RFC 9484 compliance map

SectionWhat the RFC requiresStatus in masque
§3 URI templateAbsolute URI Template (Level-1 path or Level-3 query); client expands, server matches :pathClient-side absolute-URI enforcement (masque_uri_ip:parse_client_template/1); server-side path+query match (masque_uri_template); both template forms supported
§4 Request:method=CONNECT, :protocol=connect-ip, capsule-protocol: ?1 on the requestEmitted by the IP client session; connect/3 forces capsule_protocol => true when protocol => ip
§4 Response2xx carries capsule-protocol: ?1; no content-length / content-typeValidated by the validate_response check in masque_ip_client_session; non-compliant responses tear the session down
§4.7.1 ADDRESS_ASSIGNNonzero Request ID echoes a prior ADDRESS_REQUEST; Request ID 0 = unprompted assignmentmasque_ip_capsule + masque_ip_server_session track the pending set; assign_addresses/2 rejects with {no_such_pending_request, Id} when violated
§4.7.1 DNS resolutionHostname targets MUST be resolved server-side before 2xx; resolved addresses advertisedmasque_server / masque_h2_server run the configurable resolver before accept/1; result attached to req() under resolved_addresses
§4.7.2 ADDRESS_REQUEST≥1 entry, unique nonzero Request IDs per senderEnforced in masque_ip_capsule (encode and decode)
§4.7.3 ROUTE_ADVERTISEMENTOrdered by (Version, Protocol, Start); disjoint within (Version, Protocol); protocol-0 ranges MUST NOT overlap nonzero-protocol rangesAll three rules validated by the validate_routes_result check in masque_ip_capsule
§5 Control plane directionalityBoth endpoints may send every capsule (site-to-site, §8.2)Symmetric API: typed request_addresses/2, assign_addresses/2, advertise_routes/2 on the client; matching actions ({request_addresses,_}, {assign,_}, {advertise,_}) on the server

| §6 Datagram framing | Context ID (varint) | Payload; context 0 = IP packet; unknown contexts dropped | masque_datagram (reused from RFC 9298) — exact shape; unknown contexts silently dropped in both session modules | | §7 Error handling | Malformed capsule → stream reset per RFC 9297 §3.3; non-2xx carries Proxy-Status | Sessions issue H3_MESSAGE_ERROR (H3) / PROTOCOL_ERROR (H2); rejects emit RFC 9209 Proxy-Status via masque_errors | | §7 ICMP synthesis | SHOULD forward ICMP error back when an IP packet can't be delivered | masque_icmp builds ICMPv4 / ICMPv6 errors with correct invoking-packet truncation (548 B / 1232 B) and IPv6 pseudo-header checksum; triggered from handlers via {icmp_error, {Kind, Spec, Invoking}} actions | | §8 MTU | Negotiated H3 datagram size ≥ 1280 B for IPv6 support, else abort | The check_datagram_mtu check in masque_ip_client_session aborts the H3 handshake with {mtu_too_low, Got, 1280}; H2 uses reliable capsules and is exempt | | §8 BCP-38 | Reject spoofed source addresses | Default handler's src_filter_passes/2 drops inbound packets whose source is not a locally-assigned address |

Not in scope

  • TUN device integration - a follow-up adds masque_ip_tun_device and masque_ip_tun_proxy_handler on top of the current handler API (see plan in doc/features.md).
  • Per-connection tunnel limits, auth hooks beyond accept/1 - same as the UDP/TCP paths; follow-up releases.