docs(arch): resolve OQ-67 — iroh proxy force-relay-only + HTTP-to-SOCKS5 bridge (ADR-090 §5 amended)

The iroh-proxy POC (/workspace/iroh-proxy-poc, 5/5 runs clean) settled
OQ-67: iroh does NOT expose a socket-injection hook for the IP/direct
transport (noq_endpoint() is pub(crate), the IP transport binds its own
netwatch::UdpSocket, CustomTransport operates on a separate CustomAddr
address space iroh's hole-punching doesn't route through). The quinn
POC's Socks5UdpSocket does not transfer to iroh. The decision: force
relay-only when a proxy is configured, via three stable public iroh
Builder knobs — clear_ip_transports() + addr_filter(relay_only) +
proxy_url. The peer sees the relay's IP; the relay sees the proxy's IP;
the client's real IP is hidden on both surfaces. No iroh fork required.

Because iroh's proxy_url expects an HTTP CONNECT proxy (not SOCKS5),
the integration runs a tiny local HTTP-to-SOCKS5 bridge (~80 lines) so
a single Socks5ProxyConfig covers all three dials uniformly: UDP
ASSOCIATE for dial_quic, CONNECT for dial_tcp_tls, force-relay-only +
HTTP-to-SOCKS5 bridge for dial_iroh.

The POC also corrected a factual error: iroh's proxy_url proxies the
relay WebSocket only, not pkarr/DoH (those use pkarr/hickory-resolver
directly). Acceptable for the force-relay-only config (QAD disabled);
spec text corrected.

Force relay-only forgoes iroh's direct-path latency advantage (negligible
for the hub deployment, which runs its own relay) and makes relay
availability a hard dependency — the intended privacy/availability
tradeoff; a caller that prefers availability over privacy for the iroh
path simply does not set the proxy.

- ADR-090 §5 amended: iroh force-relay-only decision + proxy_url
  coverage correction + HTTP-to-SOCKS5 bridge
- OQ-67: resolved (force relay-only)
- client README: iroh proxy row, bridge, limitations, ADR/OQ entries
- README/open-questions: OQ-67 resolved, Current State amendment
This commit is contained in:
glm-5.2 committed 2026-07-16 09:18:56 +00:00
1 parent f46482253b
commit 34e3be2801
5 files changed
+351 -167

No files matched your search

+46 -18
View File
@@ -14,8 +14,9 @@ take-overs (`CallClient::spawn_dispatch`,
client; it does not run protocols, manage peer lifecycle, or supervise
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.
(`with_socks5_proxy`, ADR-090) — all three dials route through it
(UDP ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only +
HTTP-to-SOCKS5 bridge for iroh).
## What
@@ -61,9 +62,10 @@ 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`). 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.
(ADR-090), the dials route their transport through the proxy — UDP
ASSOCIATE for QUIC, CONNECT for TCP+TLS, force-relay-only +
HTTP-to-SOCKS5 bridge for iroh — to hide the client's real IP from
the hub.
### What `AlknetClient` is NOT
@@ -109,7 +111,8 @@ pub struct AlknetClient {
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`.
// forces relay-only via an HTTP-to-SOCKS5 bridge — see ADR-090 §5.
// Feature-gated on `socks5`.
#[cfg(feature = "socks5")]
socks5: Option<Socks5ProxyConfig>,
}
@@ -128,8 +131,9 @@ 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. Feature-gated on `socks5`.
/// `dial_quic`, CONNECT for `dial_tcp_tls`, and force-relay-only +
/// HTTP-to-SOCKS5 bridge for `dial_iroh` (ADR-090 §5). Feature-gated
/// on `socks5`.
#[cfg(feature = "socks5")]
pub fn with_socks5_proxy(mut self, proxy: Socks5ProxyConfig) -> Self;
}
@@ -237,7 +241,7 @@ full rationale, the PoC grounding, and the limitations.
|------|----------------|-----------|--------------|
| `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 |
| `dial_iroh` | force relay-only (HTTP CONNECT, bridged) | `clear_ip_transports()` + `addr_filter(relay_only)` + `proxy_url` (HTTP CONNECT via a local HTTP-to-SOCKS5 bridge) — see ADR-090 §5 | The peer (via relay) + the relay (via the bridge → SOCKS5 proxy) |
The proxy config:
@@ -280,6 +284,25 @@ that don't use a proxy pay nothing.
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.
- **`dial_iroh` forces relay-only, forgoing direct-path latency.**
iroh does not expose a socket-injection hook for the IP/direct
transport (the quinn POC's `Socks5UdpSocket` does not transfer —
see ADR-090 §5). With a proxy configured, the iroh endpoint is built
with `clear_ip_transports()` + `addr_filter(relay_only)` +
`proxy_url`, eliminating the direct path. The peer sees the relay's
IP; the relay sees the proxy's IP (via the local HTTP-to-SOCKS5
bridge). Relay availability becomes a hard dependency — if the relay
is down, the client cannot connect at all. This is the intended
privacy/availability tradeoff; a caller that prefers availability
over privacy for the iroh path simply does not set the proxy.
- **`proxy_url` covers the relay WebSocket only, not pkarr/DoH.** iroh's
`proxy_url` proxies the relay WebSocket (HTTP CONNECT), not pkarr
publishing or DNS-over-HTTPS (those use `pkarr`/`hickory-resolver`
directly, not reqwest with a proxy). For the recommended
force-relay-only configuration this is acceptable (QAD is disabled,
peer-IP exposure is fully handled by the relay path). If a deployment
needs pkarr/DoH proxied too, that is a separate gap (a small upstream
contribution), not this ADR's scope.
- **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
@@ -615,7 +638,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 |
| [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, `dial_iroh` forces relay-only via an HTTP-to-SOCKS5 bridge; OQ-67 resolved; grounded in the quinn-proxy + iroh-proxy PoCs |
## Open Questions
@@ -634,14 +657,16 @@ 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** (resolved by ADR-090 §5 amendment): iroh proxy support —
`dial_iroh` with a proxy configured forces relay-only via three
stable public iroh Builder knobs (`clear_ip_transports()` +
`addr_filter(relay_only)` + `proxy_url`), with a local
HTTP-to-SOCKS5 bridge adapting the SOCKS5 proxy to iroh's HTTP
CONNECT expectation. iroh does not expose a socket-injection hook
for the IP/direct transport (the quinn POC's `Socks5UdpSocket` does
not transfer), so force-relay-only is the conservative default — no
fork, fully closes the peer-IP-exposure gap by eliminating the
direct path. Grounded in the iroh-proxy POC. See
[OQ-67](../../questions/067-iroh-proxy-support.md).
## References
@@ -673,6 +698,9 @@ See [open-questions.md](../../open-questions.md) for full details.
— `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)
- [`docs/research/iroh-proxy-poc/findings.md`](../../research/iroh-proxy-poc/findings.md)
— the iroh-proxy PoC findings (grounds ADR-090 §5's iroh
force-relay-only decision + HTTP-to-SOCKS5 bridge)
- [`crates/endpoint/README.md`](../endpoint/README.md) — `AlknetEndpoint`
(the server-side complement)
- [`crates/tls/README.md`](../tls/README.md) — `TlsClientConfig`