MASQUE

Crate hopf-masque: RFC 9298 (Proxying UDP in HTTP, "CONNECT-UDP") and RFC 9484 (Proxying IP in HTTP, "CONNECT-IP") building blocks layered on hopf-http's ProtocolUpgradeHandler and Capsule Protocol machinery — the same upgrade pattern hopf-websocket uses for its own framing. This is not a standalone MASQUE proxy program: it is a pair of server-side request handlers plus a pair of client dial functions that an application assembles into one, with the actual UDP relaying (CONNECT-UDP) or IP packet forwarding (CONNECT-IP) either fully handled or left to application-supplied hooks — see What "building blocks" means.

What "building blocks" means

hopf-masque ships no proxy binary and no example crate. It gives an application, on the server side, two ServerHandlerFactory implementations that plug into any of hopf-http's H1/H2/H3 servers, and, on the client side (feature h3), two functions that dial a proxy the same way any other hopf-http request would. What each side does past accepting/dialling the tunnel differs by protocol:

In short: CONNECT-UDP is proxy-grade out of the box (bring a policy); CONNECT-IP is protocol plumbing only (bring a policy and a forwarder).

Standards

Spec Notes
RFC 9298 (Proxying UDP in HTTP) CONNECT-UDP: server relay (ConnectUdpFactory) and client (connect_udp, feature h3), both over H1/H2/H3
RFC 9484 (Proxying IP in HTTP) CONNECT-IP: server accept + application-driven forwarding (ConnectIpFactory) and client (connect_ip, feature h3)
RFC 9297 (HTTP Datagrams and the Capsule Protocol) Provided by hopf-http (capsule/context_id modules); every accept response and every client request in this crate unconditionally sends Capsule-Protocol: ?1 and rejects a peer that doesn't — raw byte-stream fallback is never used
RFC 9220 / RFC 8441 Extended CONNECT H2/H3 request shape: :method: CONNECT, :protocol: connect-udp or connect-ip
HTTP/1.1 Upgrade (RFC 9110 §7.8) H1 request shape: GET + Upgrade: connect-udp/connect-ip + Connection: Upgrade — H1 has no :protocol pseudo-header, so this is the only shape available there

CONNECT-UDP server (ConnectUdpFactory)

ConnectUdpFactory::new(dns, runtime, policy) builds a ServerHandlerFactory to hand to any hopf-http server (H1, H2, or H3 via listen_h3). Per accepted request it: matches the Extended-CONNECT or Upgrade shape for connect-udp, requires Capsule-Protocol: ?1, parses the target path, resolves it via the supplied DnsResolver, asks the policy, opens an outbound UDP socket on one of the supplied Runtime's workers, and installs a ConnectUdpRelay (ProtocolUpgradeHandler) that pumps datagrams both ways until closed or idle.

Type / fn Shape Notes
ConnectUdpFactory::new (Arc<DnsResolver>, Arc<Runtime>, Arc<dyn ConnectUdpPolicy>) -> Self No permissive default policy exists anywhere in the crate — an open UDP relay is never the default
ConnectUdpFactory::with_idle_timeout (self, Duration) -> Self Overrides DEFAULT_IDLE_TIMEOUT (5 minutes) — RFC 9298 sets no lifetime bound itself; this is purely this crate's own relay-teardown policy
ConnectUdpPolicy trait: is_target_allowed(&self, addr: IpAddr, port: u16) -> bool Checked against the resolved address, not the original hostname — implement DNS-rebinding protection here if a public-facing relay needs it (e.g. rejecting private/loopback ranges)
parse_connect_udp_target (path: &str) -> Option<ConnectUdpTarget { host, port }> Parses the RFC 9298 §2 URI template below; strict — a truncated percent-escape, non-hex escape, invalid UTF-8, non-numeric or out-of-range port all reject rather than best-effort decode

URI template (RFC 9298 §2): /.well-known/masque/udp/{target_host}/{target_port}/ — both segments required, non-empty, percent-encoded (needed for an IPv6 literal's colons).

use hopf_masque::{ConnectUdpFactory, ConnectUdpPolicy};
use std::net::IpAddr;
use std::sync::Arc;

struct OnlyPort443;
impl ConnectUdpPolicy for OnlyPort443 {
    fn is_target_allowed(&self, _addr: IpAddr, port: u16) -> bool {
        port == 443
    }
}

let factory = ConnectUdpFactory::new(dns_resolver, Arc::clone(&runtime), Arc::new(OnlyPort443))
    .with_idle_timeout(std::time::Duration::from_secs(120));
// `factory` is a `ServerHandlerFactory` — wrap it the same way as any
// other hopf-http server handler (CleartextHttpEndpoint, TLS, or listen_h3).

The relay's idle timer is self-rearming: any traffic in either direction resets it, and a full idle_timeout with none tears the tunnel down. Opening the outbound socket happens off the reactor thread entirely (a plain, dedicated thread) — ReactorHandle::register_udp's blocking round trip would deadlock if run from the same worker it targets, the same constraint hopf-mdns's own one-time registration documents.

CONNECT-IP server (ConnectIpFactory)

ConnectIpFactory::new(app, policy) builds a ServerHandlerFactory. Per accepted request it matches the Extended-CONNECT/Upgrade shape for connect-ip, requires Capsule-Protocol: ?1, parses the target/ipproto scope, asks the policy, then — with no DNS lookup or socket of its own to open — synchronously builds an app-supplied ConnectIpHandler and installs a ConnectIpRelay.

Type / fn Shape Notes
ConnectIpFactory::new (Arc<dyn ConnectIpHandlerFactory>, Arc<dyn ConnectIpPolicy>) -> Self No permissive default policy, same reasoning as CONNECT-UDP
ConnectIpPolicy trait: is_target_allowed(&self, target: &IpTarget, ipproto: &IpProto) -> bool Separate trait from ConnectUdpPolicy on purpose — CONNECT-IP's scope is never resolved and each half may be a wildcard, so one shared trait would leave one side or the other ignoring fields it doesn't need
ConnectIpHandlerFactory trait: create_handler(&self) -> Box<dyn ConnectIpHandler> Builds one handler per accepted tunnel — the application's forwarding logic, entirely outside this crate
ConnectIpHandler trait: opened(session), packet_received(&[u8]), address_requested(request_id, addr, prefix_len) (default: ignore), closed() (default: ignore) packet_received gets the Context-ID-0 payload: a full IP packet, IP Version field through the last payload byte (RFC 9484 §5)
ConnectIpSession trait: send_packet(&[u8]), assign_address(request_id, addr, prefix_len), advertise_route(start, end, ip_protocol), close() Safe to call from any thread, not just the one opened ran on — every method pokes the connection's reactor to flush promptly rather than waiting for the next inbound activity
parse_connect_ip_target (path: &str) -> Option<ConnectIpTarget { target: IpTarget, ipproto: IpProto }> IpTarget::Wildcard/IpTarget::Named(String), IpProto::Wildcard/IpProto::Number(u8) — either literal *, per RFC 9484 §3

URI template (RFC 9484 §3): /.well-known/masque/ip/{target}/{ipproto}/ — both segments required (either may be the literal *); target may be a hostname, an IPv4/IPv6 address, or an address+prefix (the slash before the prefix length is itself percent-encoded, e.g. 192.0.2.0%2F24, RFC 9484 §3's own example).

use hopf_masque::{
    ConnectIpFactory, ConnectIpHandler, ConnectIpHandlerFactory, ConnectIpPolicy,
    ConnectIpSession, IpProto, IpTarget,
};
use std::net::IpAddr;
use std::sync::Arc;

struct AllowWildcardOnly;
impl ConnectIpPolicy for AllowWildcardOnly {
    fn is_target_allowed(&self, target: &IpTarget, _ipproto: &IpProto) -> bool {
        matches!(target, IpTarget::Wildcard)
    }
}

struct EchoHandler { session: Option<Arc<dyn ConnectIpSession>> }
impl ConnectIpHandler for EchoHandler {
    fn opened(&mut self, session: Arc<dyn ConnectIpSession>) { self.session = Some(session); }
    fn packet_received(&mut self, packet: &[u8]) {
        if let Some(s) = &self.session { s.send_packet(packet); } // trivial echo
    }
}
struct EchoHandlerFactory;
impl ConnectIpHandlerFactory for EchoHandlerFactory {
    fn create_handler(&self) -> Box<dyn ConnectIpHandler> {
        Box::new(EchoHandler { session: None })
    }
}

let factory = ConnectIpFactory::new(Arc::new(EchoHandlerFactory), Arc::new(AllowWildcardOnly));

RFC 9484 capsules

crate::ip_capsule (private) codes the three RFC 9484 capsule types CONNECT-IP layers on top of hopf_http::capsule's generic Capsule Protocol framing (RFC 9297 §3); DATAGRAM itself needs no codec of its own here since it carries an opaque, context_id-prefixed payload both CONNECT-UDP and CONNECT-IP already share.

Capsule Type Direction Entry shape
DATAGRAM (RFC 9297) 0x00 either context_id::encode(REGISTERED_CONTEXT_ID, payload) — Context ID 0 is the tunnel's own IP packet; a nonzero, unrecognized Context ID is ignored per RFC 9484 §5, not treated as an error
ADDRESS_ASSIGN 0x01 server→client One or more {request_id, address, prefix_length} entries (RFC 9484 §4.2)
ADDRESS_REQUEST 0x02 client→server Identically shaped to ADDRESS_ASSIGN; the Request ID is client-chosen and echoed back on the matching assign
ROUTE_ADVERTISEMENT 0x03 server→client only One or more {start, end, ip_protocol} entries (RFC 9484 §4.3); ip_protocol: 0 means all protocols. RouteEntry::new rejects a mismatched address family or start > end rather than encoding an invalid range

HTTP Datagrams and the Capsule Protocol

Both CONNECT-UDP and CONNECT-IP are unconditional Capsule Protocol users: crate::accept::accept_headers always sets Capsule-Protocol: ?1 on the 200/101 accept response, and both server request handlers reject any request missing Capsule-Protocol: ?1 of its own (via hopf_http::capsule::capsule_protocol_enabled) with a 400 before ever looking at the target. Neither this crate's relays nor its clients implement the RFC 9297 §6 fallback of raw, non-capsule HTTP Datagrams for H3 — every payload here, on every transport (H1/H2/H3 alike), goes through the generic hopf_http::capsule DATAGRAM capsule and the hopf_http::context_id varint prefix, keeping the wire format identical across transports rather than branching on whether native QUIC DATAGRAM frames are available.

ProtocolUpgradeHandler::wants_datagrams always returns true for every upgrade handler in this crate; datagram_received/capsule_received decode the Context ID or capsule type and dispatch to the relay/client event handler. See QUIC / HTTP/3 for how hopf-http itself demultiplexes HTTP Datagrams and Capsule Protocol capsules onto a request stream.

CONNECT-UDP client (connect_udp)

Feature h3 only (needs hopf-http/h3 and hopf-quic for the transport-negotiating dial path, the same reason hopf-http itself gates h3). Dials proxy_host:proxy_port via hopf_http::connect_auto — an h3 attempt first when a QuicClientConfig is supplied, falling back per HttpFallback, exactly as any other outbound hopf-http request negotiates transport — and relays to target_host:target_port once the proxy accepts.

Item Shape
connect_udp (rt: &Arc<Runtime>, proxy_host: &str, proxy_port: u16, target_host: impl Into<String>, target_port: u16, fallback: HttpFallback, event_handler: Box<dyn ConnectUdpEventHandler>, quic_client_config: Option<Arc<QuicClientConfig>>, alt_svc_cache: Arc<AltSvcCache>, timeouts: HttpClientTimeouts, resolver: Option<Arc<DnsResolver>>, unix_path: Option<PathBuf>) -> io::Result<()>
ConnectUdpEventHandler trait: opened(session), datagram_received(&[u8]) (default: ignore), closed() (default: ignore), error(&io::Error) (default: ignore) — called before the tunnel ever opens if the proxy rejects the request or the connection fails
ConnectUdpSession trait: send_datagram(&[u8]), close() — handed to opened; safe from any thread

unix_path, when set, dials the proxy over a UNIX domain socket instead of TCP/IP/QUIC (proxy_host/proxy_port/quic_client_config/resolver are then used only to fill the Host header, since QUIC/h3 has no UNIX-domain transport); HttpFallback::Tls is not yet supported for a UNIX-domain dial.

CONNECT-IP client (connect_ip)

Mirrors connect_udp closely — same transport negotiation, same unix_path caveat — scoped to a target/ipproto pair (either may be a wildcard) instead of a concrete resolved host/port, and with the extra RFC 9484 capsule types surfaced on the event handler.

Item Shape
connect_ip (rt: &Arc<Runtime>, proxy_host: &str, proxy_port: u16, target: IpTarget, ipproto: IpProto, fallback: HttpFallback, event_handler: Box<dyn ConnectIpEventHandler>, quic_client_config: Option<Arc<QuicClientConfig>>, alt_svc_cache: Arc<AltSvcCache>, timeouts: HttpClientTimeouts, resolver: Option<Arc<DnsResolver>>, unix_path: Option<PathBuf>) -> io::Result<()>
ConnectIpEventHandler trait: opened(session), packet_received(&[u8]) (default: ignore), address_assigned(request_id, addr, prefix_len) (default: ignore), route_advertised(start, end, ip_protocol) (default: ignore), closed(), error(&io::Error)
ConnectIpClientSession trait: send_packet(&[u8]), send_address_request(&[RequestedAddress]), close()
RequestedAddress { request_id: u64, address: IpAddr, prefix_length: u8 } — request_id is caller-chosen, must be nonzero, and must not be reused within a tunnel (RFC 9484 §4.2)

Each send_address_request call encodes its whole slice into one ADDRESS_REQUEST capsule together — call it again separately if a later, distinct request shouldn't share a capsule with one already in flight.

Cargo features

Feature (hopf-masque) Gates
default Empty — the server-side relays (ConnectUdpFactory, ConnectIpFactory) build with nothing enabled; they need only hopf-core, hopf-http, and hopf-dns, none of which require QUIC
h3 hopf-http/h3 + dep:hopf-quic — required for connect_udp/connect_ip and their supporting types (client/ip_client modules are #[cfg(feature = "h3")]): an h3 dial attempt is only possible with a QUIC client config, the same reason hopf-http itself gates h3
integration h3 — real TCP/UDP loopback round-trip tests (src/integration.rs), excluded from ordinary cargo test runs; run with cargo test -p hopf-masque --features integration

In the hopf umbrella crate: feature masque pulls in hopf-masque (plus its own dns/http features); feature masque-h3 additionally enables hopf-masque/h3 (and the umbrella's own h3 feature) for the client functions.

Example

No example binary ships yet (see Limitations). This is adapted from the crate's own loopback test suite (crates/hopf-masque/src/integration.rs), which is the most realistic end-to-end usage currently in the tree — a CONNECT-UDP server relay accepting a request, plus a client dialling it and round-tripping one datagram through a UDP echo target:

use hopf_core::{ProtocolHandler, Runtime, RuntimeConfig, TcpListenerConfig};
use hopf_dns::DnsResolver;
use hopf_http::{AltSvcCache, CleartextHttpEndpoint, HttpClientTimeouts, HttpFallback, HttpLimits};
use hopf_masque::{connect_udp, ConnectUdpEventHandler, ConnectUdpFactory, ConnectUdpPolicy, ConnectUdpSession};
use std::net::IpAddr;
use std::sync::Arc;

struct AllowAny; // never do this in a real deployment
impl ConnectUdpPolicy for AllowAny {
    fn is_target_allowed(&self, _addr: IpAddr, _port: u16) -> bool { true }
}

// --- server: accept CONNECT-UDP over plain HTTP/1.1 ---
let rt = Arc::new(Runtime::start(RuntimeConfig::default())?);
let dns = Arc::new(DnsResolver::for_runtime(&rt)?);
let factory = Arc::new(ConnectUdpFactory::new(dns, Arc::clone(&rt), Arc::new(AllowAny)));
let (server_addr, _) = rt.add_tcp_listener(TcpListenerConfig::new(
    "127.0.0.1:0".parse()?,
    move || Box::new(CleartextHttpEndpoint::new(
        Arc::clone(&factory) as _, HttpLimits::default(),
    )) as Box<dyn ProtocolHandler>,
))?;

// --- client: dial the relay, tunnel to a UDP target through it ---
struct PrintHandler;
impl ConnectUdpEventHandler for PrintHandler {
    fn opened(&mut self, session: Arc<dyn ConnectUdpSession>) {
        session.send_datagram(b"ping");
    }
    fn datagram_received(&mut self, data: &[u8]) {
        println!("target replied: {data:?}");
    }
}

connect_udp(
    &rt,
    &server_addr.ip().to_string(), server_addr.port(),
    "udp-target.example", 53,
    HttpFallback::PlaintextH1,
    Box::new(PrintHandler),
    None,                          // no QUIC client config -> h1/h2 only
    Arc::new(AltSvcCache::new()),
    HttpClientTimeouts::default(),
    None,                          // default resolver
    None,                          // no UNIX-domain dial
)?;

Limitations

Implementation status

Shipped: RFC 9298 CONNECT-UDP, both server relay and client, feature-complete against the RFC's URI template and Context ID framing, over H1, H2, and H3. RFC 9484 CONNECT-IP's protocol plumbing — target/ipproto parsing including wildcards, Extended CONNECT/Upgrade acceptance, all three RFC 9484 capsule types (ADDRESS_ASSIGN, ADDRESS_REQUEST, ROUTE_ADVERTISEMENT), and the client side — is complete and interop-tested end to end against this crate's own relay (loopback, not against an external MASQUE implementation). Both protocols share hopf-http's Capsule Protocol/HTTP Datagram machinery and reuse the same Extended-CONNECT/Upgrade acceptance helper (crate::accept).

Known gaps: CONNECT-IP ships no actual IP forwarding — that is an explicit non-goal of this crate, not an oversight, per What "building blocks" means. No example binaries exist for either protocol. No non-capsule HTTP Datagram fallback. See Limitations for the rest.

See also