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

+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`