Review of the three new crates (alknet-tls, alknet-endpoint, alknet-client) + revised core found compile-blocking inconsistencies, stale claims, and dep-graph contradictions. All resolved: Critical: - C1: CallClient::connect / ChannelClient::connect_quic REMOVED (not delegated) — keeping them as thin wrappers over AlknetClient::dial_quic would make protocol crates depend on alknet-client, contradicting the dep graph. Callers compose dial + take-over (2 lines). - C2: alknet-client feature gates now pull alknet-core/quinn + alknet-core/iroh (for Connection::from_quinn_with_alpn / from_iroh). - C3: rustls-native-certs + webpki-roots added to alknet-tls deps (always-present, not feature-gated — CA-verify path is transport-agnostic). Warning: - W1: CallCredentials/RemoteIdentity moved to alknet-core (from alknet-call) — the dial must not depend on the call protocol; not a two-way-door, it determines the dep graph. - W2: webpki-roots fallback implemented in spec (ADR-088 §5 added) — the code claimed a fallback that never existed; now the store is never empty, NoRootAnchors unreachable, containerized deployments work. - W3: EndpointError removed entirely (BindFailed + HandlerNotFound both vestigial after ADR-083); shutdown() is now infallible. - W4: FingerprintPinVerifier moved to alknet-tls (from alknet-call) — alknet-call sheds quinn/rustls/rustls-pemfile/rustls-native-certs entirely; CallClient becomes a pure protocol crate. Plus: ClientError removed (only produced by removed connect); S1 (CallCredentials → ClientVerifierContext mapping + auth_token stripped at TLS boundary documented); amendment notes on ADR-017, ADR-069, ADR-080, ADR-082, ADR-087, ADR-090; overview crate graph + README index updated. 29 files, consistency-reviewed.
30 KiB
ADR-090: Client-Dial SOCKS5 Proxy Seam
Status
Accepted (adds a capability to ADR-089's AlknetClient; §5 amended
2026-07-16 — OQ-67 resolved: iroh proxy support decided as force
relay-only + HTTP-to-SOCKS5 bridge, grounded in the iroh-proxy POC)
Context
The privacy gap
A native client (AlknetClient, ADR-089) that dials a hub directly exposes
its real IP address to the hub (and to any relay on the path). This is the
same privacy exposure iroh documents for its own direct-connection case:
without a proxy, the peer and the relay see one's real IP. A client that
wants to keep its real IP private from the hub — the primary use case —
needs to route the dial through a trusted proxy that terminates the
network path and presents its own IP to the hub.
This is a first-class client capability, not a niche feature. The
old (alknet-main) client had a ConnectOptions.proxy: Option<String>
field for exactly this, and the iroh transport builder already took a
proxy_url parameter. But the old client's proxy support was
incomplete: the TCP/TLS transports never consumed the proxy field
(TcpStream::connect went direct), and only iroh's proxy_url was
wired. ADR-089's AlknetClient is the greenfield rewrite, and this is
the time to make the proxy a real, uniform, tested capability rather
than an aspirational field.
Two distinct SOCKS5 uses — do not conflate
There are two unrelated SOCKS5 concepts in the alknet design space. This ADR is about one of them; naming both prevents a conflation that would distort the scope.
-
Client-dial proxy (this ADR). The client routes its outbound dial to the hub through a SOCKS5 proxy. The client owns the proxy config; the hub sees the proxy's IP. This is transport-layer privacy for the establishment side. Lives in
alknet-client(the dial seam, ADR-089). -
SOCKS5-over-channels handler (the planned
alknet-socks5crate, not yet specced — ADR-085 scope table). One side offers a SOCKS5 proxy service to the other over a channels data channel (ALPNalknet/socks5). The offering side runs a SOCKS5 server backed by channelsdirect-tcpip-equivalent opens; the consuming side tunnels it locally and uses it like any SOCKS5 server. This is a service, not a client-dial concern. Lives inalknet-socks5(foundational handler, rides inside channels). Out of scope here.
The two compose without coupling: a client that wants both privacy on
its dial and a SOCKS5 service from the hub sets its dial
Socks5ProxyConfig (this ADR) to its local SOCKS5 endpoint, and that
endpoint may itself be a local tunnel to the hub's alknet/socks5
channels service. The dial proxy doesn't know the SOCKS5 server is a
channels handler; the channels handler doesn't know its client is an
AlknetClient dial. The composition is at the SOCKS5 protocol level,
not the alknet type level. This ADR concerns only #1.
QUIC-over-SOCKS5 is validated
The central technical question — can a quinn QUIC dial go through a
SOCKS5 proxy? — is settled by a de-risking PoC. QUIC is UDP; SOCKS5 UDP
ASSOCIATE (RFC 1928 §6/§7) carries UDP datagrams; quinn exposes a public
socket abstraction (quinn::AsyncUdpSocket) and a public constructor
(quinn::Endpoint::new_with_abstract_socket) that accept any impl. A
SOCKS5 UDP ASSOCIATE tunnel implemented as an AsyncUdpSocket is the
exact extension point. The PoC (/workspace/quinn-proxy-poc,
docs/research/quinn-quic-proxy/findings.md) runs end-to-end: a quinn
client completes the QUIC handshake and exchanges stream data with all
UDP traffic tunneled through a fast-socks5 proxy (5/5 runs clean). The
identical pattern is in production in BotBrowser (QUIC/HTTP3 + STUN over
SOCKS5 UDP ASSOCIATE in Chromium's network stack).
The PoC surfaced two real, acceptable limitations:
- ECN is lost across the proxy. The SOCKS5 UDP header carries no ECN bits, so the proxied QUIC path falls back to non-ECN congestion control. A performance cost on congested links, not a correctness issue — QUIC degrades gracefully. For alknet's call/channels traffic (low-bandwidth, latency-sensitive, not throughput-critical), the cost is negligible. Anyone who chooses to route through a proxy already accepts added latency and the loss of path optimizations; this is an expected cost of the privacy choice, not a surprise.
- The proxy must support UDP ASSOCIATE. Not every SOCKS5 proxy does
(notably
ssh -Ddoes not). This is a deployment requirement, not a code problem. The dial surfaces a clear error when the proxy rejectsUDP ASSOCIATE; the operator chooses a UDP-capable proxy. The fallback policy (fall back todial_tcp_tls, or fail closed) is a caller concern — see §6.
The per-datagram framing overhead (6–22 bytes of SOCKS5 header per datagram, ~0.4–1.8% of a typical QUIC datagram) and the one extra TCP control connection keeping the UDP association alive are both negligible.
Why SOCKS5 only (no HTTP CONNECT)
SOCKS5 supports both TCP (CONNECT, RFC 1928 §3) and UDP (UDP ASSOCIATE,
§6) within one protocol. HTTP CONNECT is TCP-only with no UDP
equivalent. Supporting only SOCKS5 means one ProxyConfig shape covers
both dial_quic (UDP ASSOCIATE) and dial_tcp_tls (CONNECT); adding
HTTP CONNECT would add an enum variant that only one of the three dials
could use, for no privacy gain (SOCKS5 CONNECT does what HTTP CONNECT
does). The old code supported both, but the greenfield rewrite has no
backward-compatibility obligation, and the simplicity of a single proxy
protocol is worth more than the marginal compatibility benefit of HTTP
CONNECT. SOCKS5 is the sole proxy protocol.
Decision
1. The proxy config is a first-class dial parameter on AlknetClient
AlknetClient gains an optional Socks5ProxyConfig. Following the
no-env-vars invariant (ADR-014), the config comes from Capabilities /
the assembly layer, never from ALL_PROXY / HTTPS_PROXY env vars.
(The old alknet-main client had a proxy: Option<String> field on
ConnectOptions; this ADR makes the concept real, typed, and uniformly
honored across the dials, where the old code's TCP/TLS transports
silently ignored it.)
pub struct Socks5ProxyConfig {
/// The proxy's TCP address (where the SOCKS5 control connection
/// connects). For UDP ASSOCIATE (the QUIC dial), the proxy replies
/// with a UDP relay address that may differ; the dial uses that.
pub addr: SocketAddr,
/// Optional username/password auth (RFC 1929). None = no-auth.
pub credentials: Option<Socks5Credentials>,
}
pub struct Socks5Credentials {
pub username: String,
pub password: String,
}
The config's crate location is an implementation detail (it can live
in alknet-client, alknet-core, or alknet-tls alongside the other
client config types) — the shape is decided; the crate home is
two-way-door. (Note: CallCredentials's location is not a
two-way-door — it is decided as alknet-core per ADR-089 §5, because it
determines the dep graph; Socks5ProxyConfig's location does not have
that dep-graph consequence.)
2. AlknetClient holds the proxy; each dial applies it
AlknetClient holds an Option<Socks5ProxyConfig> set via a builder
method, mirroring the with_quinn / with_iroh / with_tcp_tls
builder pattern:
impl AlknetClient {
/// Set the SOCKS5 proxy for all subsequent dials. When set, every
/// dial routes its transport through this proxy: UDP ASSOCIATE for
/// `dial_quic`, CONNECT for `dial_tcp_tls`, and force-relay-only +
/// HTTP-to-SOCKS5 bridge for `dial_iroh` (§5).
pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self;
}
The proxy is set once on the client (all dials use it), not per-dial.
Rationale: the proxy is a client-level privacy posture, not a
per-connection choice — a client that wants to hide its IP wants to hide
it on every dial. The per-dial alternative (each dial_* takes an
Option<&Socks5ProxyConfig>) is more flexible but rarely what a caller
wants, and it complicates the QUIC path where the proxy must be built
into the quinn::Endpoint (transport handle) rather than applied per
connect. A future per-dial override is a two-way-door addition; the
client-level default is the one-way-door surface.
The builder-method placement mirrors AlknetEndpoint's builder
(ADR-083): the assembly layer builds the transport handles and hands
them to the client; the proxy is another pre-built handle. The symmetry
holds — AlknetEndpoint takes pre-built server transports;
AlknetClient takes pre-built client transports + a proxy.
3. dial_quic routes QUIC through SOCKS5 UDP ASSOCIATE
When a proxy is configured, dial_quic does not call
quinn::Endpoint::client. It performs the SOCKS5 UDP ASSOCIATE
handshake (TCP control connection to the proxy, optional auth, receive
the UDP relay address), wraps the result in a Socks5UdpSocket (the
AsyncUdpSocket impl from the PoC), and builds the quinn::Endpoint
via new_with_abstract_socket with that socket. The TLS config
(TlsClientConfig, ADR-087), the ALPN negotiation, the Connection
construction (Connection::from_quinn_with_alpn, ADR-065), and the
credential handshake are all unchanged — the proxy is below all of them.
The Socks5UdpSocket implementation (~250 lines, validated by the PoC)
lives in alknet-client behind a socks5 feature flag. It is the
establishment concern (how the client reaches the hub), not a protocol
concern (what runs on the Connection). The Connection/dispatch/
credentials stay proxy-unaware.
Without a proxy, dial_quic is unchanged from ADR-089 —
quinn::Endpoint::client with a raw kernel UDP socket. The proxy is a
strict addition; the no-proxy path is the zero-cost default.
impl AlknetClient {
#[cfg(feature = "quinn")]
pub async fn dial_quic(
&self,
addr: SocketAddr,
server_name: &str,
alpn: &[u8],
credentials: &CallCredentials,
) -> Result<Connection, ClientDialError> {
let tls_config = TlsClientConfig::new(/* ... */)?;
let client_config = quinn::ClientConfig::new(tls_config.for_quinn());
let endpoint = match &self.socks5 {
Some(proxy) => build_proxied_quinn_endpoint(proxy).await?,
None => quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?,
};
let conn = endpoint.connect_with(client_config, addr, server_name)?.await?;
Ok(Connection::from_quinn_with_alpn(conn, alpn.to_vec()))
}
}
#[cfg(feature = "socks5")]
async fn build_proxied_quinn_endpoint(
proxy: &Socks5ProxyConfig,
) -> Result<quinn::Endpoint, ClientDialError> {
// 1. TCP control connection to proxy.addr.
// 2. SOCKS5 UDP ASSOCIATE handshake (with optional auth).
// 3. Socks5UdpSocket::from_datagram on the result.
// 4. quinn::Endpoint::new_with_abstract_socket with the Socks5UdpSocket.
}
4. dial_tcp_tls routes TCP through SOCKS5 CONNECT
When a proxy is configured, dial_tcp_tls connects a TcpStream to the
proxy's address, performs the SOCKS5 CONNECT handshake (RFC 1928 §3) to
the target addr, then wraps the resulting stream in TlsConnector as
before. This is the straightforward TCP proxy case — SOCKS5 CONNECT is
TCP-native. The TlsClientConfig, the SNI, the ALPN, and the
Connection::from_bidi (ADR-065) are all unchanged.
Without a proxy, dial_tcp_tls is unchanged — TcpStream::connect(addr)
then TlsConnector::connect. Same zero-cost default as dial_quic.
5. dial_iroh — force relay-only via iroh's proxy_url (OQ-67 resolved)
Amendment 2026-07-16 — OQ-67 resolved by the iroh-proxy POC (
/workspace/iroh-proxy-poc,docs/research/iroh-proxy-poc/findings.md). The original §5 deferred the iroh direct-path to OQ-67. This amendment replaces that deferral with the decision: force relay-only when a proxy is configured, via three stable public iroh Builder knobs. It also corrects a factual error about what iroh'sproxy_urlcovers (relay WebSocket only, not pkarr/DoH).
When a proxy is configured, dial_iroh forces the iroh endpoint to
relay-only mode, eliminating the direct (hole-punched) path entirely
and tunneling the relay WebSocket through the proxy. The peer sees
the relay's IP; the relay sees the proxy's IP; the client's real IP is
hidden on both surfaces.
The iroh endpoint is built by the assembly layer (ADR-089 §3 — iroh
shares the key, not the config). When Socks5ProxyConfig is set, the
assembly applies three stable public iroh Builder knobs together:
clear_ip_transports()— no IP/direct transport is bound. There is no kernel UDP socket from which a direct path could start.addr_filter(AddrFilter::relay_only())— the endpoint's published addresses contain only relay URLs, no direct IPs, so the peer cannot attempt a direct connection.proxy_url(http://...)— the relay's WebSocket is tunneled through an HTTP CONNECT proxy. The relay sees the proxy's IP.
All three are unconditional (no unstable-* feature flags). The POC
(docs/research/iroh-proxy-poc/findings.md) validates the composition
end-to-end: selected_is_relay=true, selected_is_ip=false,
any_direct_ip_path=false, 45-byte echo through the proxied relay,
5/5 runs clean. See
docs/research/iroh-proxy-poc/findings.md
§5.
Why force relay-only, not wrap iroh's QUIC in SOCKS5 UDP ASSOCIATE.
iroh uses a quinn fork (noq) that has
Endpoint::new_with_abstract_socket (the hook the quinn POC uses), but
iroh calls it internally with its own Transport multiplexer and keeps
the accessor pub(crate). The IP/direct transport binds its own
netwatch::UdpSocket with no injection point. The only public
socket-injection surface (unstable-custom-transports,
CustomTransport) operates on a separate CustomAddr address space
that iroh's hole-punching does not route through — wrong shape. The
quinn POC's Socks5UdpSocket does not transfer to iroh. Making
SOCKS5-UDP-over-iroh-direct work would require forking iroh, for a use
case that is currently hypothetical (direct-path peer-IP privacy with
direct-path latency). Force relay-only uses only stable public APIs,
requires no fork, and fully closes the peer-IP-exposure gap (by
eliminating the direct path when a proxy is configured). The quinn
POC's Socks5UdpSocket remains the reference shape if iroh ever
exposes an IP-transport socket hook upstream. See
docs/research/iroh-proxy-poc/findings.md
§2–§3.
Correction: what proxy_url actually covers. The original §5
stated proxy_url "proxies iroh's HTTP(S) traffic — relay connections,
DNS-over-HTTPS, pkarr publishing." Tracing iroh 1.0.2, this is only
partially true: proxy_url flows only to the relay transport actor
and is used only for the relay's WebSocket connection (an HTTP CONNECT
handshake, iroh-relay-1.0.2/src/client/tls.rs:127-216). It does
not flow to the pkarr publisher (uses the pkarr crate directly,
not reqwest), nor to the DNS-over-HTTPS resolver (uses
hickory-resolver directly), nor to the reqwest client builder
(util.rs:80-91 has no .proxy() call). So proxy_url covers the
relay WebSocket exposure surface only. For alknet's privacy model
this is fine because the recommended configuration is force
relay-only: with clear_ip_transports(), QAD (QUIC address
discovery) is disabled, and the peer-IP exposure is fully handled by
the relay path. The pkarr/DoH surfaces are not proxied by proxy_url;
if a deployment needs those proxied too, that is a separate gap (a
small upstream contribution to wire reqwest's .proxy() into
util::reqwest_client_builder), not this ADR's scope. See
docs/research/iroh-proxy-poc/findings.md
§4.
The proxy-protocol mismatch: HTTP-to-SOCKS5 bridge. There is one
integration wrinkle. Socks5ProxyConfig (this ADR) is a SOCKS5 proxy.
iroh's proxy_url expects an HTTP CONNECT proxy
(iroh-relay-1.0.2/src/client/tls.rs:173 — Method::CONNECT, checks
scheme() == "http"). They are different protocols:
dial_quicuses SOCKS5 UDP ASSOCIATE (UDP/QUIC).dial_tcp_tlsuses SOCKS5 CONNECT (TCP).- iroh's relay path uses HTTP CONNECT (the relay WebSocket).
A single Socks5ProxyConfig cannot be fed verbatim into iroh's
proxy_url. The integration runs a tiny local HTTP-to-SOCKS5 bridge
in the alknet client process when Socks5ProxyConfig is set and the
iroh path is used: a local HTTP CONNECT server (~80 lines, the POC's
src/proxy.rs is the template) that forwards each CONNECT tunnel to
the SOCKS5 proxy's CONNECT command. iroh's proxy_url is then pointed
at http://127.0.0.1:<local-port>. This unifies on a single
Socks5ProxyConfig and is invisible to the operator — the operator
configures one SOCKS5 proxy, and the bridge adapts it to the HTTP
CONNECT protocol iroh's relay client expects. The bridge is local-only
(no external surface), behind the socks5 feature flag, and adds no
new deps beyond what the quinn PoC already uses (fast-socks5).
Without a proxy, dial_iroh is unchanged — the iroh endpoint is built
with direct + relay as before. The proxy is a strict addition; the
no-proxy path is the zero-cost default (same as dial_quic and
dial_tcp_tls).
What is given up. Force relay-only forgoes iroh's direct-connection
latency advantage (the relay adds one network hop). For the hub
deployment (ADR-087, where the hub runs its own relay), this is
negligible. Relay availability becomes a hard dependency: with
clear_ip_transports(), if the relay is down, the client cannot
connect at all — there is no direct fallback. This is the intended
privacy/availability tradeoff: privacy is absolute (no IP leak),
availability is relay-bounded. A caller that prefers availability over
privacy for the iroh path simply does not set the proxy.
This does not block the first hub deployment — the hub's outbound worker dials (the hub-as-client case, ADR-087 §5) use QUIC or TCP+TLS, both of which honor the proxy directly (no bridge needed). The iroh path is one of three dials, and the force-relay-only resolution is the conservative default that closes the peer-IP-exposure gap with no fork.
6. Fallback policy is a caller concern, not a dial decision
When a proxy is configured and the proxy rejects UDP ASSOCIATE (the QUIC
case) or CONNECT (the TCP case), dial_quic / dial_tcp_tls return an
error. The dial does not silently fall back to a direct connection —
that would defeat the privacy posture the caller configured the proxy to
enforce. A caller that wants "try proxy, fall back to direct" composes
it: catch the error, dial on an AlknetClient without the proxy set.
Because the proxy is set once on the client (§2), this requires two
AlknetClient instances — one built with with_socks5_proxy, one
without — and the caller dials the proxy-less one on fallback. A future
per-dial proxy override (§2, two-way-door) would remove the need for a
second client, but the two-instance pattern is the composition the
current API supports. The fallback policy is the caller's, not the
dial's, because the dial cannot know whether the caller's privacy
requirement permits a direct fallback. This mirrors ADR-089's
"no transport fallback" stance — the dial is one-shot; the fallback
policy is a caller concern.
The ClientDialError (ADR-089) gains one variant:
pub enum ClientDialError {
// ... existing variants ...
/// SOCKS5 proxy failure — handshake rejected, UDP ASSOCIATE
/// unsupported, auth failed, or the proxy closed the control
/// connection. The dial did not reach the remote; the caller
/// decides whether to fall back to a direct dial or surface the
/// error.
#[error("SOCKS5 proxy: {0}")]
Proxy(String),
}
Proxy(String) takes String (not the proxy crate's error type) for
the same reason Connect(String) and Handshake(String) do per ADR-089
— the proxy error source type (fast-socks5 or a vendored impl) is an
implementation detail; the category is in the variant. The string
content is an implementation detail.
7. Feature gates
[features]
default = []
quinn = ["dep:quinn", "alknet-tls/quinn"]
tcp = ["dep:tokio-rustls", "alknet-tls/tcp"]
iroh = ["dep:iroh"]
socks5 = ["dep:fast-socks5"] # enables the proxied dial paths
socks5 is independent of the transport features — it enables the
proxy code path that dial_quic (UDP ASSOCIATE) and dial_tcp_tls
(CONNECT) use when a proxy is configured. Enabling socks5 without
quinn or tcp is a no-op (no dial uses it). Enabling quinn + socks5
enables proxied QUIC; tcp + socks5 enables proxied TCP+TLS. The
fast-socks5 dep is behind socks5, so deployments that don't use a
proxy don't pay the dep.
8. The socks5 crate dependency
The PoC uses fast-socks5 (v1.0.0, maintained by anyip.io, a residential
proxy provider) for the SOCKS5 client handshake and the test server.
The client-side surface used is small (Socks5Datagram::bind /
bind_with_password, new_udp_header, the header parsing). Whether to
keep fast-socks5 as a dep or vendor the ~100 lines of SOCKS5 handshake
- header framing is a two-way-door implementation detail. This ADR
records that
fast-socks5is the starting choice (it's the validated choice from the PoC); vendoring is a future option if the dep becomes a concern. TheAsyncUdpSocketimpl (Socks5UdpSocket, ~250 lines) is alknet's own code regardless — it's the integration glue, not the SOCKS5 protocol implementation.
What this does NOT change
AlknetEndpoint(ADR-083) — the server side is unchanged. The server accepts connections arriving from a proxy's IP, which is just normal QUIC/TCP. No proxy-specific code on the server.TlsClientConfig(ADR-087) — the client-side TLS config is unchanged. The proxy is below the TLS layer; the TLS handshake happens over the proxied transport exactly as it happens over a direct one.CallClient::spawn_dispatch/ChannelClient::from_connection(ADR-017, ADR-080) — the take-over APIs are unchanged. They consume theConnectionthe dial produces; they don't know it came through a proxy.- The
Connectiontype (ADR-065, ADR-070) — unchanged. A proxied QUIC connection still yields aConnection::from_quinn_with_alpn; a proxied TCP+TLS connection still yields aConnection::from_bidi. TheConnectionis proxy-unaware. - The
CallCredentials/RemoteIdentity(ADR-089) — unchanged. The credential bundle the dial takes is unaffected by the proxy. - The hub's
supervise_worker(hub README §"Dial") — the closure seam is unchanged. A hub that wants its outbound worker dials to go through a proxy builds itsAlknetClientwithwith_socks5_proxy; thesupervise_workerclosure callsdial_quic/dial_tcp_tlsand the proxy is applied transparently. The hub does not need to know the proxy exists. alknet-socks5(the channels data-channel handler, ADR-085) — unchanged and out of scope. The two SOCKS5 concepts compose at the SOCKS5 protocol level, not the alknet type level (see §"Two distinct SOCKS5 uses" above).
Consequences
Positive:
- The privacy gap is closed for QUIC and TCP+TLS dials. A native
client that wants to hide its real IP from the hub configures a
SOCKS5 proxy;
dial_quicanddial_tcp_tlsroute through it. The hub sees the proxy's IP. This is the capability the oldConnectOptions.proxyfield aspired to but never delivered for TCP/TLS. - The QUIC proxy is real, not theoretical. The PoC (
/workspace/quinn-proxy-poc, 5/5 runs clean) and BotBrowser's production use validate theAsyncUdpSocket+ UDP ASSOCIATE approach. The ECN loss is an accepted, documented cost of the privacy choice, not a hidden limitation. - The proxy is uniform across the rustls dials. One
Socks5ProxyConfigcovers bothdial_quic(UDP ASSOCIATE) anddial_tcp_tls(CONNECT). SOCKS5's TCP+UDP coverage is the reason a single proxy protocol suffices — no HTTP CONNECT variant needed. - The proxy is invisible above the dial.
Connection, dispatch, credentials, TLS config, the hub'ssupervise_workerclosure — none know a proxy is in the path. The proxy is purely an establishment concern, localized toalknet-client. - The no-proxy path is the zero-cost default.
dial_quicanddial_tcp_tlswithout a configured proxy are byte-identical to ADR-089. Thesocks5feature and thefast-socks5dep are opt-in; deployments that don't use a proxy pay nothing. - The two SOCKS5 concepts compose. A client using the hub's
alknet/socks5channels service for privacy points its dialSocks5ProxyConfigat the local tunnel end. No special integration; the composition is at the SOCKS5 protocol level.
Negative:
- iroh direct connections forgo direct-path latency when a proxy is
configured.
dial_irohwith a proxy forces relay-only (§5), eliminating the direct path. The relay adds one network hop; for the hub deployment (hub runs its own relay), this is negligible. Relay availability becomes a hard dependency — withclear_ip_transports(), if the relay is down, the client cannot connect at all. This is the intended privacy/availability tradeoff; a caller that prefers availability over privacy for the iroh path simply does not set the proxy. Does not block the first hub deployment (hub outbound dials use QUIC/TCP+TLS). - pkarr/DoH exposure surfaces are not covered by
proxy_url. iroh'sproxy_urlproxies the relay WebSocket only, not pkarr publishing or DNS-over-HTTPS (§5 correction). For the recommended force-relay-only configuration this is acceptable (QAD is disabled, peer-IP exposure is fully handled by the relay path). If a deployment needs pkarr/DoH proxied too, that is a separate gap (a small upstream contribution to wire reqwest's.proxy()into iroh'sutil::reqwest_client_builder), not this ADR's scope. - The
socks5feature andfast-socks5dep are new. A new optional feature and a new optional dep. The cost is contained (off by default, only pulled when a deployment uses a proxy), but it is a new entry in the feature matrix. - ECN loss on proxied QUIC. The proxied QUIC path loses Explicit Congestion Notification. This is a performance cost on congested links, not a correctness issue. It is an inherent property of SOCKS5 UDP ASSOCIATE (the header has no ECN bits), not an alknet choice. Documented and accepted.
- Proxy must support UDP ASSOCIATE for the QUIC dial. A
deployment requirement.
ssh -Ddoes not work; a UDP-capable SOCKS5 daemon (dante, fast-socks5-based, etc.) is needed. The dial surfaces a clear error when the proxy lacks UDP support; the operator chooses a compatible proxy.
Door type
One-way (proxy as a dial capability + the Socks5ProxyConfig
shape). The proxy being a first-class dial parameter on
AlknetClient is structural — every outbound-dialing role (hub, worker,
hub-worker) that wants privacy depends on it. The Socks5ProxyConfig
shape and the with_socks5_proxy builder method are one-way — changing
them after consumers exist is a rewrite. The Proxy variant on
ClientDialError is one-way (it's a public error variant). The
socks5 feature name and the fast-socks5 dep choice are two-way
(the feature can be renamed pre-1.0; the dep can be vendored later).
The Socks5UdpSocket internal implementation is two-way. The iroh
force-relay-only decision (§5) is one-way (it's a deployment
tradeoff: relay-bounded availability, no direct fallback) — the
HTTP-to-SOCKS5 bridge implementation is two-way. The fallback policy
(caller's concern, not the dial's) is one-way — once the dial refuses
to silently fall back, callers rely on that for their privacy
posture.
References
- PoC:
/workspace/quinn-proxy-poc— runcargo run -r. Load-bearing file:src/socks5_udp_socket.rs(~250 lines). 5/5 runs clean. - Research findings:
docs/research/quinn-quic-proxy/findings.md— the full write-up: theAsyncUdpSocketextension point, the SOCKS5 UDP ASSOCIATE mechanism, the PoC topology, the limitations (ECN, UDP ASSOCIATE requirement), the alternatives rejected (fork quinn, QUIC-in-TCP, HTTP CONNECT, userspace QUIC-over-TCP, wait for upstream), and the integration sketch. - ADR-089 —
AlknetClient, the dial seam this ADR adds the proxy capability to. The three dials (dial_quic/dial_tcp_tls/dial_iroh) are the surfaces the proxy applies to. - ADR-087 —
TlsClientConfig, unchanged by the proxy (the TLS handshake happens over the proxied transport). - ADR-083 —
AlknetEndpoint, unchanged (the server accepts connections from a proxy's IP as normal). - ADR-065 —
Connection::from_quinn_with_alpn/from_bidi, unchanged (theConnectionis proxy-unaware). - ADR-014 — the
no-env-vars invariant; the proxy config comes from
Capabilities/ the assembly layer, not env vars. - ADR-085 — the scope
table naming
alknet-socks5(the channels data-channel handler, distinct from this ADR's client-dial proxy). - RFC 1928 (SOCKS5) §3 (CONNECT), §6/§7 (UDP ASSOCIATE + UDP request header).
- RFC 1929 (SOCKS5 username/password auth).
- BotBrowser UDP-over-SOCKS5
(
deepwiki.com/botswin/BotBrowser/6.4-udp-over-socks5) — production implementation of the identical pattern in Chromium's network stack. - iroh-proxy PoC:
/workspace/iroh-proxy-poc— runcargo run -r. Load-bearing files:src/main.rs(Option 2 end-to-end),src/proxy.rs(HTTP CONNECT proxy). 5/5 runs clean. - iroh-proxy research findings:
docs/research/iroh-proxy-poc/findings.md— the iroh socket-stack investigation (no public IP-transport injection hook;CustomTransportis the wrong address space), theproxy_urlcoverage correction (relay WebSocket only, not pkarr/DoH), the three force-relay-only knobs, and the HTTP-to-SOCKS5 bridge. - OQ-67 (resolved by this ADR's §5 amendment) — iroh proxy support.