docs(arch): client-dial SOCKS5 proxy seam — ADR-090, OQ-67

AlknetClient 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 — 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 (socks5 feature + fast-socks5 dep are opt-in).

SOCKS5 is the sole proxy protocol (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, a service
one side offers the other) — compose at the SOCKS5 protocol level
without alknet-type-level coupling.

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 (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).

- ADR-090: Client-Dial SOCKS5 Proxy Seam
- OQ-67: iroh Proxy Support (Direct-Connection Peer Exposure)
- client README: proxy section, struct/builder, Proxy error variant,
  socks5 feature gate, deps, assembly example, decisions/open-questions
- README/open-questions: index entries, Current State, OQ count 67/20
This commit is contained in:
glm-5.2 committed 2026-07-16 08:42:33 +00:00
1 parent ed40f95d96
commit b7d67e5a5f
5 files changed
+775 -11

No files matched your search

+30 -3
View File
@@ -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
+147 -7
View File
@@ -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<tokio_rustls::TlsConnector>,
#[cfg(feature = "iroh")]
iroh: Option<iroh::Endpoint>,
// 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<Socks5ProxyConfig>,
}
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<Socks5Credentials>,
}
```
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`
@@ -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<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.
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<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.)
```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<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), 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<Socks5ProxyConfig>` 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<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` 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).
+25 -1
View File
@@ -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
@@ -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).