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:
1 parent
ed40f95d96
commit
b7d67e5a5f
5 files changed
+775
-11
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user