diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c3142b4..00b9045 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,12 +1,38 @@ --- status: draft -last_updated: 2026-07-15 +last_updated: 2026-07-16 --- # Alknet Architecture ## Current State +**Client-dial SOCKS5 proxy seam added (ADR-090, 2026-07-16).** +`AlknetClient` (ADR-089) gains an optional SOCKS5 proxy +(`with_socks5_proxy`) so a native client can hide its real IP from the +hub. `dial_quic` routes QUIC through SOCKS5 UDP ASSOCIATE (validated by +the `/workspace/quinn-proxy-poc` PoC and +[`docs/research/quinn-quic-proxy/findings.md`](../research/quinn-quic-proxy/findings.md) +— quinn's `AsyncUdpSocket` + `new_with_abstract_socket` is the +extension point; 5/5 runs clean); `dial_tcp_tls` routes through SOCKS5 +CONNECT. The proxy is invisible above the dial (`Connection`, +dispatch, credentials, TLS config all proxy-unaware) and the no-proxy +path is the zero-cost default (the `socks5` feature and `fast-socks5` +dep are opt-in). SOCKS5 is the sole proxy protocol (it covers both TCP +and UDP, so no HTTP CONNECT variant needed). The two distinct SOCKS5 +concepts — the client-dial proxy (ADR-090, transport-layer privacy) +and the planned `alknet-socks5` channels data-channel handler (ADR-085 +scope table, a service one side offers the other) — compose without +coupling (a client using the hub's `alknet/socks5` service tunnels it +locally and points its `Socks5ProxyConfig` at the local tunnel end). +iroh is the exception: `dial_iroh` does not consume +`Socks5ProxyConfig` — iroh's `proxy_url` covers the relay-exposure +surface, but the direct-connection peer-exposure case is +[OQ-67](open-questions.md) (deferred(unclear) — the pieces exist but +the iroh socket-stack composition isn't clear; does not block the +first hub deployment, which uses QUIC/TCP+TLS). See +[ADR-090](decisions/090-client-dial-socks5-proxy-seam.md). + **Workspace scope corrected (ADR-085, 2026-07-15).** The overview's crate graph had been describing the wrong scope since ADR-003 — a flat ~12-crate workspace including DNS, messaging, and NAPI, while omitting @@ -187,7 +213,7 @@ adapter location map is now consistent: all HTTP-backed adapters | [crates/vault/protocol.md](crates/vault/protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior | | [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — composes a subset of three endpoint types (web/native/iroh — ADR-086), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery | | [crates/tls/README.md](crates/tls/README.md) | reviewed | alknet-tls crate — shared TLS config (`TlsServerConfig` + `TlsClientConfig`) shared across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; split ALPN lists per endpoint type (ADR-086, resolves OQ-62); fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) | -| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); produces `Connection` for `CallClient`/`ChannelClient` take-over; `alknet/register` named (wire protocol deferred, OQ-66) | +| [crates/client/README.md](crates/client/README.md) | draft | alknet-client crate — the native client dial seam (`AlknetClient`), client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); optional SOCKS5 proxy (ADR-090 — UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS; iroh deferred OQ-67); produces `Connection` for `CallClient`/`ChannelClient` take-over; `alknet/register` named (wire protocol deferred, OQ-66) | | [crates/endpoint/README.md](crates/endpoint/README.md) | draft | alknet-endpoint crate — the server-side accept-loop runner (`AlknetEndpoint`), extracted from `alknet-core` (ADR-083 Am. 2026-07-15); takes pre-built transports via `with_quinn`/`with_iroh`/`with_tcp_tls`; public `dispatch` for SSH/WT; handler crates no longer transitively link quinn/iroh | | [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream | | [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates | @@ -290,10 +316,11 @@ adapter location map is now consistent: all HTTP-backed adapters | [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted | | [088](decisions/088-tlserror-shape.md) | `TlsError` Shape — Single Enum, Owned by `alknet-tls` | Accepted | | [089](decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — Native Client Dial Seam | Accepted (resolves OQ-55) | +| [090](decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | Accepted (raises OQ-67 for iroh) | ## Open Questions -Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (65 OQs across 18 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention). +Open questions are tracked in [open-questions.md](open-questions.md) — an index of theme-grouped tables (67 OQs across 20 themes) with a cross-theme [Deferred / Blocked](open-questions.md#deferred--blocked) section surfacing the safe-exit deferrals. Each OQ lives in its own file under [`questions/`](questions/) (`NNN-slug.md`, mirroring the ADR convention). ## Document Lifecycle diff --git a/docs/architecture/crates/client/README.md b/docs/architecture/crates/client/README.md index ded0c4e..3cafaf4 100644 --- a/docs/architecture/crates/client/README.md +++ b/docs/architecture/crates/client/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-07-15 +last_updated: 2026-07-16 --- # alknet-client @@ -12,7 +12,10 @@ on a chosen ALPN, and produces a `Connection` for the protocol take-overs (`CallClient::spawn_dispatch`, `ChannelClient::from_connection`) to consume. It is the Rust native client; it does not run protocols, manage peer lifecycle, or supervise -reconnection. It dials and produces a `Connection`. +reconnection. It dials and produces a `Connection`. A client that wants +to hide its real IP from the hub configures a SOCKS5 proxy +(`with_socks5_proxy`, ADR-090) — the rustls dials route through it +transparently. ## What @@ -57,7 +60,10 @@ that produces `Connection`s for the protocol take-overs to consume. Narrowed to the **native case**: dialing native endpoint types (QUIC + TCP+TLS, both rustls-consuming via `TlsClientConfig`; iroh as the key-not-config exception) over the native ALPNs (`alknet/register`, -`alknet/call`, `alknet/channels`). +`alknet/call`, `alknet/channels`). With an optional SOCKS5 proxy +(ADR-090), the rustls dials route their transport through the proxy +(UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS) to hide the client's +real IP from the hub. ### What `AlknetClient` is NOT @@ -101,6 +107,11 @@ pub struct AlknetClient { tcp_connector: Option, #[cfg(feature = "iroh")] iroh: Option, + // When set, `dial_quic` and `dial_tcp_tls` route through this + // SOCKS5 proxy (UDP ASSOCIATE / CONNECT respectively). `dial_iroh` + // is the exception — see OQ-67. Feature-gated on `socks5`. + #[cfg(feature = "socks5")] + socks5: Option, } impl AlknetClient { @@ -114,6 +125,13 @@ impl AlknetClient { #[cfg(feature = "iroh")] pub fn with_iroh(mut self, endpoint: iroh::Endpoint) -> Self; + + /// 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`. `dial_iroh` is the + /// exception — see OQ-67. Feature-gated on `socks5`. + #[cfg(feature = "socks5")] + pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self; } ``` @@ -122,7 +140,9 @@ The builder mirrors `AlknetEndpoint`'s `with_quinn` / `with_iroh` / handles and hands them to the client via builder methods. A native client that needs QUIC-with-TCP+TLS-fallback holds both a quinn endpoint and a TCP+TLS connector; a minimal iroh-only client holds only -the iroh endpoint. +the iroh endpoint. The `with_socks5_proxy` builder (ADR-090) adds the +privacy posture: when set, the rustls dials route through the proxy, +hiding the client's real IP from the hub. ### The three dials @@ -203,6 +223,83 @@ exception as the server side (ADR-082, ADR-087 §3). caller concern (or a future `dial_with_fallback` helper — two-way-door). +### SOCKS5 proxy (ADR-090) + +A client that wants to hide its real IP from the hub (the primary +privacy use case) configures a SOCKS5 proxy via `with_socks5_proxy`. +When set, the rustls dials route their transport through the proxy — +the hub sees the proxy's IP, not the client's. The proxy is a +first-class dial capability, not a niche feature. See +[ADR-090](../../decisions/090-client-dial-socks5-proxy-seam.md) for the +full rationale, the PoC grounding, and the limitations. + +| Dial | SOCKS5 command | Mechanism | Hides IP from | +|------|----------------|-----------|--------------| +| `dial_quic` | UDP ASSOCIATE (RFC 1928 §6) | `Socks5UdpSocket` (the crate's `quinn::AsyncUdpSocket` impl, behind `socks5`) → `new_with_abstract_socket` — see ADR-090 §3 | The hub (peer) | +| `dial_tcp_tls` | CONNECT (RFC 1928 §3) | `TcpStream` to the proxy + SOCKS5 CONNECT handshake, then `TlsConnector` over the proxied stream | The hub (peer) | +| `dial_iroh` | (exception — OQ-67) | iroh's own `proxy_url` (set at the assembly layer) covers the relay-exposure surface; direct-connection peer exposure is deferred | The relay (via `proxy_url`); peer exposure on direct connections is OQ-67 | + +The proxy config: + +```rust +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, +} +``` + +The config comes from `Capabilities` / the assembly layer (ADR-014), +never from `ALL_PROXY` / `HTTPS_PROXY` env vars — the no-env-vars +invariant. The config's crate location (it can live in `alknet-client`, +`alknet-core`, or `alknet-tls`) is a two-way-door implementation +detail; the shape is decided (ADR-090 §1). + +**The proxy is invisible above the dial.** `Connection`, dispatch, +`CallCredentials`, `TlsClientConfig`, the hub's `supervise_worker` +closure — none know a proxy is in the path. The proxy is purely an +establishment concern, localized to `alknet-client`. A proxied QUIC +connection still yields a `Connection::from_quinn_with_alpn`; a +proxied TCP+TLS connection still yields a `Connection::from_bidi`. The +protocol take-overs are proxy-unaware. + +**The no-proxy path is the zero-cost default.** `dial_quic` and +`dial_tcp_tls` without a configured proxy are byte-identical to ADR-089. +The `socks5` feature and the `fast-socks5` dep are opt-in; deployments +that don't use a proxy pay nothing. + +**Limitations (accepted, documented in ADR-090):** +- **ECN is lost on proxied QUIC.** 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. An expected cost of the privacy choice. +- **The proxy must support UDP ASSOCIATE for `dial_quic`.** A + deployment requirement — `ssh -D` does 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. +- **No silent fallback.** When a proxy is configured and the proxy + rejects the command, the dial returns `ClientDialError::Proxy`. 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, this + means two `AlknetClient` instances (one with `with_socks5_proxy`, + one without) — see ADR-090 §6 for the rationale. The fallback + policy is the caller's, not the dial's — same stance as ADR-089's + "no transport fallback." + +**Two distinct SOCKS5 uses — do not conflate.** The client-dial proxy +(this section, ADR-090) is transport-layer privacy for the +establishment side. The planned `alknet-socks5` crate (ADR-085 scope +table) is a SOCKS5 *service* one side offers the other over a channels +data channel (`alknet/socks5` ALPN) — a foundational handler, not a +client-dial concern. The two compose at the SOCKS5 protocol level, not +the alknet type level — see ADR-090 §"Two distinct SOCKS5 uses". + ### Credentials `AlknetClient`'s dials take a `CallCredentials` bundle — the existing @@ -342,13 +439,25 @@ pub enum ClientDialError { /// `dial_quic` called but `with_quinn` was not set. #[error("no transport handle configured for {transport}")] NoTransport { transport: &'static str }, + + /// SOCKS5 proxy failure — handshake rejected, UDP ASSOCIATE + /// unsupported, auth failed, or the proxy closed the control + /// connection (ADR-090). The dial did not reach the remote; the + /// caller decides whether to fall back to a direct dial or + /// surface the error. The dial never silently falls back — that + /// would defeat the privacy posture. + #[cfg(feature = "socks5")] + #[error("SOCKS5 proxy: {0}")] + Proxy(String), } ``` `TlsConfig` wraps `alknet_tls::TlsError` (ADR-088) — the config construction errors. `Connect` and `Handshake` are transport-level failures (pre- and post-handshake). `NoTransport` is a wiring error -(calling a dial without the matching `with_*`). +(calling a dial without the matching `with_*`). `Proxy` (ADR-090) is +the SOCKS5 proxy failure category — the dial's transport never reached +the remote because the proxy rejected or dropped the association. **`Handshake` resolves an ADR-088 §6 deferral.** ADR-088 §6 explicitly scoped `TlsError` to *config-construction* errors and deferred the @@ -380,6 +489,7 @@ 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 (ADR-090) ``` A deployment that dials QUIC only enables `quinn`. A deployment that @@ -388,7 +498,13 @@ dials TCP+TLS enables `tcp`. A deployment that dials iroh enables all three. The `quinn` and `tcp` features pull the corresponding features on `alknet-tls` (for `TlsClientConfig::for_quinn` / `for_tcp_tls`). The `iroh` feature does not pull `alknet-tls` features -— iroh has its own TLS. +— iroh has its own TLS. The `socks5` feature (ADR-090) 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; 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. ### Dependencies @@ -401,6 +517,7 @@ alknet-client ├── tokio-rustls (optional — dial_tcp_tls) ├── tokio (TcpStream, spawn) ├── iroh (optional — dial_iroh) +├── fast-socks5 (optional — SOCKS5 client, `socks5` feature — ADR-090) └── thiserror (ClientDialError) ``` @@ -452,8 +569,14 @@ A downstream worker or hub uses `alknet-client` like this: let quinn_endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?; // 2. Build the AlknetClient with the transport handles it needs. +// Optionally set a SOCKS5 proxy (ADR-090) to hide the client's real +// IP from the hub — all rustls dials route through it. let client = AlknetClient::new() - .with_quinn(quinn_endpoint); + .with_quinn(quinn_endpoint) + .with_socks5_proxy(Socks5ProxyConfig { + addr: "127.0.0.1:1080".parse()?, + credentials: None, // no-auth; or Some(Socks5Credentials { ... }) + }); // .with_tcp_tls(tls_connector) — if TCP+TLS fallback is needed // .with_iroh(iroh_endpoint) — if iroh is needed @@ -465,6 +588,9 @@ let creds = CallCredentials::new() }); // 4. Dial the hub on alknet/channels, take over as channels. +// The proxy (if set) is applied transparently by dial_quic — +// UDP ASSOCIATE for QUIC. The Connection, the channels take-over, +// and the credentials are all proxy-unaware. let conn = client .dial_quic(hub_addr, "alknet", b"alknet/channels", &creds) .await?; @@ -489,6 +615,7 @@ All design decisions are documented as ADRs in | ADR | Decision | Summary | |-----|----------|---------| | [089](../../decisions/089-alknetclient-native-dial-seam.md) | AlknetClient — native client dial seam | New crate `alknet-client`; client-side analogue of `AlknetEndpoint`; three dials (QUIC + TCP+TLS via `TlsClientConfig`, iroh via key); resolves OQ-55; `alknet/register` named, wire protocol deferred | +| [090](../../decisions/090-client-dial-socks5-proxy-seam.md) | Client-Dial SOCKS5 Proxy Seam | `AlknetClient` gains `with_socks5_proxy`; `dial_quic` routes via UDP ASSOCIATE, `dial_tcp_tls` via CONNECT; iroh deferred (OQ-67); grounded in the quinn-proxy PoC | ## Open Questions @@ -507,11 +634,22 @@ See [open-questions.md](../../open-questions.md) for full details. crate's ACL and OQ-58 (the token model is the shared blocker) and needs a dedicated ADR. The HTTP registration endpoint (OQ-58) remains the first implementation. +- **OQ-67** (deferred(unclear)): iroh proxy support — `dial_iroh` does + not consume `Socks5ProxyConfig` (ADR-090). iroh's `proxy_url` (set at + the assembly layer) covers the relay-exposure surface; the open case + is iroh's *direct* (hole-punched) connection, where the peer sees the + client's real IP. Wrapping iroh's QUIC in SOCKS5 UDP ASSOCIATE is a + different integration than the quinn POC (iroh has its own socket + stack, not `quinn::AsyncUdpSocket`). Does not block the first hub + deployment (hub outbound dials use QUIC/TCP+TLS). See + [OQ-67](../../questions/067-iroh-proxy-support.md). ## References - [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) — the decision this spec implements +- [ADR-090](../../decisions/090-client-dial-socks5-proxy-seam.md) — + the SOCKS5 proxy seam (client-dial privacy) - [ADR-083](../../decisions/083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint` (the server-side shape this spec mirrors) - [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) — @@ -533,6 +671,8 @@ See [open-questions.md](../../open-questions.md) for full details. `ChannelClient::from_connection` (the take-over the dial feeds) - [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) — `CallClient::spawn_dispatch` (the take-over the dial feeds) +- [`docs/research/quinn-quic-proxy/findings.md`](../../research/quinn-quic-proxy/findings.md) + — the quinn-over-SOCKS5 PoC findings (grounds ADR-090's QUIC path) - [`crates/endpoint/README.md`](../endpoint/README.md) — `AlknetEndpoint` (the server-side complement) - [`crates/tls/README.md`](../tls/README.md) — `TlsClientConfig` diff --git a/docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md b/docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md new file mode 100644 index 0000000..f32f2df --- /dev/null +++ b/docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md @@ -0,0 +1,477 @@ +# ADR-090: Client-Dial SOCKS5 Proxy Seam + +## Status + +Accepted (adds a capability to ADR-089's `AlknetClient`; raises OQ-67 for iroh) + +## 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` +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. + +1. **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). + +2. **SOCKS5-over-channels handler** (the planned `alknet-socks5` crate, + not yet specced — ADR-085 scope table). One side *offers* a SOCKS5 + proxy service to the other over a channels data channel (ALPN + `alknet/socks5`). The offering side runs a SOCKS5 server backed by + channels `direct-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 in `alknet-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 -D` does not). This is a deployment requirement, not a + code problem. The dial surfaces a clear error when the proxy rejects + `UDP ASSOCIATE`; the operator chooses a UDP-capable proxy. The + fallback policy (fall back to `dial_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` 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.) + +```rust +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, +} + +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), same as the `CallCredentials` location is an +implementation detail per ADR-089 §5. The shape is decided; the crate +home is two-way-door. + +### 2. `AlknetClient` holds the proxy; each dial applies it + +`AlknetClient` holds an `Option` set via a builder +method, mirroring the `with_quinn` / `with_iroh` / `with_tcp_tls` +builder pattern: + +```rust +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`. `dial_iroh` is the + /// exception — see OQ-67. + 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. + +```rust +impl AlknetClient { + #[cfg(feature = "quinn")] + pub async fn dial_quic( + &self, + addr: SocketAddr, + server_name: &str, + alpn: &[u8], + credentials: &CallCredentials, + ) -> Result { + 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 { + // 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` is the exception — deferred (OQ-67) + +iroh's `proxy_url` (set at `iroh::Endpoint` construction) proxies iroh's +HTTP(S) traffic — relay connections, DNS-over-HTTPS, pkarr publishing — +through the proxy. This hides the client's IP from iroh's *relay*, which +is a distinct privacy goal from hiding it from the *peer*. iroh's relay +already hides the client's IP from the peer (the peer sees the relay's +IP, by design), so `proxy_url` covers both exposure surfaces for the +relay-mediated path without touching iroh's QUIC socket. + +The open case is iroh's *direct* (hole-punched) connection: if iroh +establishes a direct QUIC connection to the peer, the peer sees the +client's real IP. Wrapping iroh's QUIC in SOCKS5 UDP ASSOCIATE is a +*different* integration than the quinn POC — iroh has its own socket +stack, not `quinn::AsyncUdpSocket` — and whether to force relay-only +when a proxy is configured vs. wrap iroh's QUIC is an open question. See +[OQ-67](../questions/067-iroh-proxy-support.md). + +For now, `dial_iroh` does not consume `Socks5ProxyConfig`. The iroh +endpoint is built by the assembly layer (ADR-089 §3 — iroh shares the +key, not the config); if the assembly layer sets iroh's `proxy_url`, the +relay-exposure surface is covered. The peer-exposure surface for iroh +direct connections is deferred to OQ-67. 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. + +### 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: + +```rust +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 + +```toml +[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-socks5` is the starting choice (it's the validated +choice from the PoC); vendoring is a future option if the dep becomes a +concern. The `AsyncUdpSocket` impl (`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 + the `Connection` the dial produces; they don't know it came through a + proxy. +- **The `Connection` type (ADR-065, ADR-070)** — unchanged. A proxied + QUIC connection still yields a `Connection::from_quinn_with_alpn`; a + proxied TCP+TLS connection still yields a `Connection::from_bidi`. The + `Connection` is 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 its `AlknetClient` with `with_socks5_proxy`; + the `supervise_worker` closure calls `dial_quic` / `dial_tcp_tls` and + 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_quic` and `dial_tcp_tls` route through it. The + hub sees the proxy's IP. This is the capability the old + `ConnectOptions.proxy` field 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 the + `AsyncUdpSocket` + 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 + `Socks5ProxyConfig` covers both `dial_quic` (UDP ASSOCIATE) and + `dial_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's `supervise_worker` closure — none + know a proxy is in the path. The proxy is purely an establishment + concern, localized to `alknet-client`. +- **The no-proxy path is the zero-cost default.** `dial_quic` and + `dial_tcp_tls` without a configured proxy are byte-identical to + ADR-089. The `socks5` feature and the `fast-socks5` dep are opt-in; + deployments that don't use a proxy pay nothing. +- **The two SOCKS5 concepts compose.** A client using the hub's + `alknet/socks5` channels service for privacy points its dial + `Socks5ProxyConfig` at the local tunnel end. No special integration; + the composition is at the SOCKS5 protocol level. + +**Negative:** + +- **iroh direct connections are not proxied by this ADR.** `dial_iroh` + does not consume `Socks5ProxyConfig`; iroh's `proxy_url` (set at the + assembly layer) covers the relay-exposure surface, but a direct + iroh connection exposes the client's real IP to the peer. This is + OQ-67. It does not block the first hub deployment (hub outbound + dials use QUIC/TCP+TLS), but it is a gap for iroh-direct privacy. +- **The `socks5` feature and `fast-socks5` dep 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 -D` does 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 +proxy question (OQ-67) is two-way until decided. 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` — run `cargo 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`](../../research/quinn-quic-proxy/findings.md) + — the full write-up: the `AsyncUdpSocket` extension 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](089-alknetclient-native-dial-seam.md) — `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](087-tlsclientconfig-not-blocked-on-dial.md) — + `TlsClientConfig`, unchanged by the proxy (the TLS handshake happens + over the proxied transport). +- [ADR-083](083-endpoint-as-accept-loop-runner.md) — `AlknetEndpoint`, + unchanged (the server accepts connections from a proxy's IP as + normal). +- [ADR-065](065-connection-from-stream-generic-single-stream.md) — + `Connection::from_quinn_with_alpn` / `from_bidi`, unchanged (the + `Connection` is proxy-unaware). +- [ADR-014](014-secret-material-flow-and-capability-injection.md) — the + no-env-vars invariant; the proxy config comes from + `Capabilities` / the assembly layer, not env vars. +- [ADR-085](085-workspace-scope-core-vs-consumer-repos.md) — 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. +- OQ-67 (raised by this ADR) — iroh proxy support (direct-connection + peer exposure). \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index dd84761..7e87138 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-07-15 +last_updated: 2026-07-16 --- # Open Questions @@ -208,6 +208,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis | OQ | Title | Status | Door | Pri | |----|-------|--------|------|-----| | [OQ-66](questions/066-alknet-register-wire-protocol.md) | `alknet/register` Wire Protocol | deferred(scope) | one | med | +| [OQ-67](questions/067-iroh-proxy-support.md) | iroh Proxy Support (Direct-Connection Peer Exposure) | deferred(unclear) | two | med | ## Deferred / Blocked @@ -303,6 +304,29 @@ filtering the tables above. for worker provisioning per OQ-58). - **Full file**: [OQ-66](questions/066-alknet-register-wire-protocol.md) +### OQ-67: iroh Proxy Support (Direct-Connection Peer Exposure) + +- **Investigation**: Work through the iroh socket stack to determine + whether iroh exposes a socket abstraction analogous to quinn's + `AsyncUdpSocket` + `new_with_abstract_socket` (the hook the quinn + POC uses). If it does, a `Socks5UdpSocket`-equivalent for iroh is + the same shape as the quinn integration. If it does not, the + alternatives are: (a) force relay-only when a proxy is configured + (the conservative default — the peer always sees the relay's IP, + never the client's), or (b) accept the gap (document that iroh direct + connections expose the client's IP). The investigation needs a + concrete iroh-direct-with-proxy use case to drive the choice — + without one, "force relay-only" is the conservative default. +- **Priority**: medium +- **Impacts**: A client that dials over iroh direct (hole-punched) + connections and wants to hide its real IP from the peer. Does NOT + block the first hub deployment — hub outbound dials use QUIC/TCP+TLS + (both honor the proxy, ADR-090). Does NOT block the relay-mediated + iroh path — iroh's relay hides the client's IP from the peer, and + iroh's `proxy_url` hides it from the relay. The gap is the iroh + *direct* path only. +- **Full file**: [OQ-67](questions/067-iroh-proxy-support.md) + ### OQ-56: Full Channel-Level Flow-Control Windowing - **Blocked on**: a real deployment observes head-of-line blocking on a diff --git a/docs/architecture/questions/067-iroh-proxy-support.md b/docs/architecture/questions/067-iroh-proxy-support.md new file mode 100644 index 0000000..f7cfed4 --- /dev/null +++ b/docs/architecture/questions/067-iroh-proxy-support.md @@ -0,0 +1,96 @@ +# OQ-67: iroh Proxy Support (Direct-Connection Peer Exposure) + +- **Origin**: `docs/architecture/decisions/090-client-dial-socks5-proxy-seam.md` + §5; `docs/architecture/crates/client/README.md` §"SOCKS5 proxy + (ADR-090)". +- **Status**: deferred(unclear) +- **Door type**: two-way +- **Priority**: medium +- **Impacts**: A client that dials over iroh direct (hole-punched) + connections and wants to hide its real IP from the peer. 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 (ADR-090). Does NOT block the relay-mediated + iroh path — iroh's relay already hides the client's IP from the peer + (the peer sees the relay's IP), and iroh's `proxy_url` (set at the + assembly layer) hides the client's IP from the relay. The gap is the + iroh *direct* path: when iroh hole-punches a direct QUIC connection, + the peer sees the client's real IP, and `Socks5ProxyConfig` (ADR-090) + does not cover it. +- **Investigation**: Work through the iroh socket stack to determine + whether iroh exposes a socket abstraction analogous to quinn's + `AsyncUdpSocket` + `new_with_abstract_socket` (the hook the quinn + POC uses, validated in `docs/research/quinn-quic-proxy/findings.md`). + If it does, a `Socks5UdpSocket`-equivalent for iroh is the same shape + as the quinn integration (adapt the PoC). If it does not, the + alternatives are: (a) force relay-only when a proxy is configured + (disable iroh's direct-connection path — the peer always sees the + relay's IP, never the client's), or (b) accept the gap (document that + iroh direct connections expose the client's IP, and privacy-conscious + iroh deployments use `proxy_url` + relay-only). The investigation + needs a concrete iroh-direct-with-proxy use case to drive the choice + — without one, "force relay-only" is the conservative default. +- **What is decided (ADR-090 §5)**: `dial_iroh` does **not** consume + `Socks5ProxyConfig`. iroh's `proxy_url` (set at `iroh::Endpoint` + construction by the assembly layer — ADR-089 §3, "iroh shares the + key, not the config") covers the relay-exposure surface: it proxies + iroh's HTTP(S) traffic (relay connections, DNS-over-HTTPS, pkarr + publishing) through the configured proxy, hiding the client's IP + from iroh's relay. For the relay-mediated path, this covers both + exposure surfaces (peer sees relay's IP; relay sees proxy's IP). The + open case is the direct path. +- **What is open**: how to cover iroh's *direct* (hole-punched) + connection path when a SOCKS5 proxy is configured. Three candidate + approaches: + 1. **Wrap iroh's QUIC in SOCKS5 UDP ASSOCIATE.** Analogous to the + quinn POC — if iroh exposes a socket abstraction that accepts a + custom UDP impl, wrap it in a `Socks5UdpSocket`-equivalent. This + is a *different* integration than the quinn POC (iroh has its own + socket stack, not `quinn::AsyncUdpSocket`), so the PoC does not + directly transfer. Requires investigating iroh's API surface for a + socket-injection hook. + 2. **Force relay-only when a proxy is configured.** Disable iroh's + direct-connection path when `Socks5ProxyConfig` is set (or when + the assembly layer sets iroh's `proxy_url`); all iroh connections + go through the relay, which hides the client's IP from the peer + by design. This is the conservative default — no new socket + integration, no ECN loss (the relay path is TCP-based), but it + gives up iroh's direct-connection latency advantage. + 3. **Accept the gap.** Document that iroh direct connections expose + the client's IP to the peer, and that privacy-conscious iroh + deployments should use `proxy_url` (relay-exposure) + force + relay-only (peer-exposure). This is honest but leaves the + `Socks5ProxyConfig` partially effective for iroh. + The choice depends on whether a concrete iroh-direct-with-proxy use + case exists that needs the peer-IP privacy AND the direct-connection + latency. Without that use case, (2) is the conservative default; if + the use case exists, (1) is the target (and needs the iroh socket + investigation). +- **Why deferred(unclear), not deferred(scope)**: The pieces exist — + SOCKS5 UDP ASSOCIATE is validated for quinn (the PoC), iroh's + `proxy_url` exists and covers the relay path, iroh's direct vs relay + path is a known iroh property. What is unclear is the *composition* + for iroh specifically: does iroh expose a socket hook (making (1) + feasible), and is there a concrete use case that needs direct-path + privacy (making (1) worth building vs. (2) sufficient)? The + resolution is *investigation* (work through iroh's socket stack, find + the use case), not *waiting* (for a spec or use case to arrive). The + quinn POC settled the quinn case; the iroh case needs its own + investigation, not a copy of the quinn answer. +- **Resolution**: Not yet decidable. The iroh socket stack needs + investigation (does it expose a `new_with_abstract_socket`-equivalent?), + and a concrete iroh-direct-with-proxy use case needs to surface + (or a deliberate decision to force relay-only needs to be made). The + quinn POC's `Socks5UdpSocket` is the reference shape if iroh exposes + the hook; the "force relay-only" fallback is the conservative default + if it does not or if no use case demands direct-path privacy. This + does not block the first hub deployment or the QUIC/TCP+TLS proxy + capability (ADR-090) — it is a gap specific to iroh direct + connections. +- **Cross-references**: ADR-090 (§5 — defers iroh proxy support to + this OQ), ADR-089 (§3 — iroh shares the key, not the config; + `proxy_url` is set at the assembly layer), ADR-087 (§3 — the iroh + exception on the client side), + [`docs/research/quinn-quic-proxy/findings.md`](../../research/quinn-quic-proxy/findings.md) + (the quinn-over-SOCKS5 PoC — the reference shape for the iroh + investigation). \ No newline at end of file