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.
Contents
- What "building blocks" means
- Standards
- CONNECT-UDP server (
ConnectUdpFactory) - CONNECT-IP server (
ConnectIpFactory) - RFC 9484 capsules
- HTTP Datagrams and the Capsule Protocol
- CONNECT-UDP client (
connect_udp) - CONNECT-IP client (
connect_ip) - Cargo features
- Example
- Limitations
- Implementation status
- See also
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:
- CONNECT-UDP is a complete relay on its own.
ConnectUdpFactoryresolves the request's{host}/{port}target via a suppliedDnsResolver, opens a real outbound UDP socket on one of the suppliedRuntime's workers, and pumps datagrams between it and the HTTP Datagram / Capsule Protocol channel for the tunnel's lifetime (crates/hopf-masque/src/relay.rs). The only thing an application supplies is aConnectUdpPolicydeciding which resolved targets to allow. - CONNECT-IP has no relay of its own. RFC 9484's
target/ipprotosegments are optionally wildcarded and are never resolved by this crate — what a hostname, IP literal, IP prefix, or bare*even means is left to the deployment (RFC 9484 itself takes no position either).hopf-masquetherefore has zero packet-forwarding code: no TUN device, no userspace router, no kernel network stack dependency anywhere in the workspace.ConnectIpFactoryonly parses the target, asks aConnectIpPolicywhether to accept it, and — once accepted — hands the application aConnectIpHandlerthat receives raw decoded IP packets and address requests and aConnectIpSessionto send packets, assign addresses, and advertise routes back. What happens to a packet in between is entirely the application's job. - The client side (feature
h3) is a dial helper, not a user agent.connect_udp/connect_ipbuild the RFC 9298/9484 request, negotiate transport throughhopf_http::connect_auto(h3 first when a QUIC client config is supplied, falling back perHttpFallback), and hand the application a session handle plus an event-handler trait. There is no bundled SOCKS-to-MASQUE or TUN-to-MASQUE adapter — an application wanting to expose a MASQUE tunnel as, say, a local SOCKS proxy or a virtual network interface has to write that glue itself.
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
- No example binaries or CLI proxy tool ship for either protocol — the crate is consumed as a library, and there is nothing in
examples/exercising it. - CONNECT-IP does no target resolution, address allocation, or packet forwarding of its own — an application must supply both a
ConnectIpPolicyand a realConnectIpHandler/ConnectIpHandlerFactoryfor a tunnel to do anything beyond accept. - Neither protocol falls back to raw, non-capsule HTTP Datagrams over native QUIC DATAGRAM frames (RFC 9297 §6) — every payload goes through Capsule Protocol framing unconditionally, on every transport including H3. A peer that only speaks the non-capsule form is rejected (missing
Capsule-Protocol: ?1is a400). - CONNECT-UDP's relay resolves a target hostname once, using the first address
DnsResolver::resolvereturns — there is no Happy-Eyeballs-style multi-address racing and no re-resolution mid-tunnel. - The server-side
ConnectUdpPolicy/ConnectIpPolicytraits have no permissive default anywhere in the crate by design — omitting a real policy is a compile-time (constructor-argument) requirement, not a runtime opt-in an application could silently skip. connect_udp/connect_iprequire theh3feature even for an application that only ever wants h1/h2 fallback — the sharedconnect_autodial path pulls inhopf-quicregardless of whether aQuicClientConfigis actually supplied at the call site.- No standalone MASQUE proxy application, and no bundled adapter exposing an accepted tunnel as a SOCKS proxy or a virtual network interface — see What "building blocks" means.
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
- QUIC / HTTP/3 — the H3 transport CONNECT-UDP/CONNECT-IP dial through, and where the underlying HTTP Datagram/Capsule Protocol demux lives
- HTTP overview
- HTTP server
- HTTP client
- WebSocket — the other
ProtocolUpgradeHandler-based upgrade in the tree - DNS —
DnsResolver, used byConnectUdpFactoryto resolve CONNECT-UDP targets