docs: iroh-socks5 evaluated — not the base, but OQ-SK-03 prior art

- new docs/research/iroh-socks5-eval.md: full source review of
  iroh-socks5 0.5.0 (github.com/mattgeddes/iroh-socks5, MIT OR
  Apache-2.0) — quality verified, structural sibling of the alksocks
  shape, but iroh-coupled (no generic-T seam) with an RFC subset, so
  fast-socks5 stays the protocol base (POC #1/#4 conclusions stand)
- phase-0: prior-art section for iroh-socks5; OQ-SK-03 updated with its
  UDP design as the POC #2 baseline (framed {addr, data} in-band
  datagrams, RFC header codec at front doors only, no producer-side
  flow table, control-EOF association lifetime, FRAG != 0 refused)
This commit is contained in:
2026-09-13 14:58:41 +00:00
parent 7c6bc65618
commit cf5432209a
2 changed files with 320 additions and 5 deletions
+261
View File
@@ -0,0 +1,261 @@
---
status: complete
last_updated: 2026-09-13
---
# alksocks research — iroh-socks5 as an alternative base
Evaluation of `iroh-socks5` 0.5.0 (crates.io, published 2026-08-31 by
Matthew Geddes) as a potential base for alksocks, in place of or in
addition to `fast-socks5`. Source reviewed in full:
`github.com/mattgeddes/iroh-socks5` (the crates.io entry has no
repository link, but the repo exists and matches the published source;
vcs sha in the `.crate` file: `af31f7fc`). ~1.3k LoC, 9 src files, MIT OR
Apache-2.0.
## Verdict
**Not a base for the protocol layer; a useful design reference for
OQ-SK-03 (UDP) and a confirmation of POC #1's conclusions.** The crate is
well-written and the closest structural sibling to alksocks' shape
("SOCKS5 sessions riding a single bidirectional stream over an
ALPN-multiplexed QUIC connection"), but it is iroh-coupled where alksocks
must be substrate-agnostic, implements its own simplified RFC 1928
subset, and lacks the typestate/interception-point structure that makes
fast-socks5 composable. What it *does* validate: the "one SOCKS5 session
= one bidirectional stream, framed request/reply, then pass-through"
wire shape (POC #1's conclusion, independently arrived at), and a
channels-native UDP ASSOCIATE design that answers several OQ-SK-03
sub-questions.
## What it is
An iroh `ProtocolHandler` (ALPN `iroh-socks5/1`) with two halves:
- **`Server`** (the remote side): accepts the ALPN, checks the connecting
`EndpointId` against an allow-list (empty = accept all), reads one
framed `Request`, dials the target (`TcpStream` / `TcpListener` /
`UdpSocket` on the exit host), writes one framed `Reply`, then relays
raw bytes (CONNECT/BIND) or framed `Datagram`s (UDP ASSOCIATE) until
the stream ends.
- **`Client`** (the local side): binds a real TCP listener, speaks the
*RFC 1928 wire format* to any vanilla SOCKS5 client that connects
(curl/browser/tun2proxy front door), and maps each session onto one
iroh `open_bi()` stream carrying the framed `Request`/`Reply` protocol.
Module map:
| file | contents | iroh-coupled? |
|---|---|---|
| `protocol.rs` | frame codec (u32-LE length + postcard), `Request`/`Reply`/`Datagram`/`TargetAddr` | no (pure async I/O) |
| `socks5.rs` | RFC 1928 wire codec: greeting (no-auth only), request parse, reply write, UDP header codec | no (pure async I/O) |
| `server.rs` | iroh `ProtocolHandler` impl, dial/accept logic | yes |
| `client.rs` | local SOCKS5 proxy (TCP listener + RFC codec → iroh stream) | yes |
| `relay.rs` | two-pump relays: TCP↔stream (with half-close), UDP select loops | mostly no (`SendStream`/`RecvStream` in signatures) |
| `dns.rs` | system / DNS-over-HTTPS resolver choice for the exit side | no |
| `key.rs` | secret-key file load/store | iroh types |
| `lib.rs` | endpoint builder presets | iroh |
| `test_util.rs` | local relay infra + local DoH server for tests | yes |
## Quality assessment (verified against the source)
Better than the README's self-deprecation suggests ("LLM-assisted... All
bugs are mine"). The wire codecs are clean, bounded, and unit-tested;
the integration suite (`tests/integration.rs`, 12 tests under
`test-utils`) exercises real iroh connections — direct and via-relay
dials, CONNECT (IP + domain), refused-target error mapping, full UDP
ASSOCIATE round-trip, BIND, allow-list accept/deny, greeting edge cases.
Doc comments are genuinely explanatory. Notable specifics:
- **Framing**: `read_frame` handles clean EOF vs truncated-frame
correctly (tested), bounds frame size at 128 KiB, and uses postcard +
serde (not hand-rolled enums) for the inner types.
- **UDP header codec** (`socks5.rs` `parse_udp_packet`/
`format_udp_packet`): correct ATYP handling, uses `get(..)?` bounds
checks, propagates the FRAG byte, domain targets supported — same
functional slot as fast-socks5's `new_udp_header`/`parse_udp_request`.
- **Half-close propagation** in `relay_tcp`: each direction signals
`finish()`/`shutdown()` on completion — a hand-rolled two-pump that
*does* shut down the opposite sink (the ADR-050 contract), then joins
both halves (`tokio::join!` + propagate both errors).
- **UDP association lifecycle**: the client side ends the association
when the SOCKS5 control connection closes (`wait_until_closed`
drains-and-detects EOF on the control TCP), not merely when the UDP
socket goes quiet — the RFC's "TCP control connection closes →
association ends" semantic, which many proxies get wrong.
- **Reply-code mapping** is faithful where it matters (refused vs
unreachable distinguishes `ErrorKind::ConnectionRefused`).
Weaknesses (acceptable for its scope, relevant if borrowed):
- **RFC 1929 username/password is absent** — no-auth only
(`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth
is the iroh `EndpointId` allow-list at the connection level.
- **BIND's second-incoming-connection shape** accepts exactly one
inbound connection and has no timeout or multi-accept handling (RFC
1928 BIND is notoriously underspecified; this is a reasonable minimal
read, but it binds a real `TcpListener` — the exact thing alksocks'
no-bind principle forbids in the protocol layer).
- **UDP relay is one socket + flow-table-free on the server side**: all
datagrams from the association share one `UdpSocket`, replies are
attributed by source address implicitly (`udp.recv_from` → echoed
back as `TargetAddr::Ip(from)`). Fine for the common case; no
per-endpoint flow table like alknet's `Socks5UdpSocket` model.
- **`allowlist_denies_unknown_peer`** closes the QUIC connection without
an RFC reply — a vanilla client sees a hard close, not a SOCKS5 error
code. (In alksocks this maps to the typed `channel:open_failed`
establisher refusal, which POC #1 already handles better.)
- Client-side UDP binds a real socket per association (necessarily —
it's the front door for vanilla clients); server-side UDP likewise
binds.
## Fit against alksocks' requirements
### What matches alksocks' shape (validating evidence)
1. **"ALPN as a service" isomorphism.** iroh's `ProtocolHandler::accept`
+ `Router::accept(ALPN, handler)` is the same posture as alkcall's
`register_openable` (ALPN-scoped produced resource, handler per
connection). iroh 1.0's `ProtocolHandler` is even *single-method*
(`accept(Connection)`) — structurally the open-handler shape POC #1
used. The crate's ALPN string convention (`iroh-socks5/1`) matches
the alk/ ALPN convention too.
2. **One stream per session, no sub-demux.** The `Request`/`Reply`
framing phase followed by raw pass-through (or datagram framing) is
exactly the phase model phase-0 §OQ-SK-03 hunches at, and exactly
what POC #1 validated for CONNECT. Two independent implementations
converging on this shape is prior-art confirmation.
3. **Producer/consumer split.** `Server` = producer half (accepts,
dials), `Client` = consumer half with the optional local TCP front
door (`bind_listener`/`serve` is the ssh `-D` shape POC #1's
`local_front_door` example validated). The *only* consumer path is
the local bind — there is no in-band (channels-native) client session
type, which is where alksocks' wrapper differs.
4. **Target resolution on the producing side.** Domains are carried
unresolved (`TargetAddr::Domain`) and resolved at the exit node —
the ssh `-D` semantic phase-0 §OQ-SK-05 notes, with a pluggable
resolver (`DnsResolver`) and a privacy rationale (DoH so the exit
host's network can't observe names) that mirrors the iroh-privacy
motivation in OQ-SK-06.
### Where it cannot serve as the base
1. **iroh types are load-bearing in the data plane.** `server.rs` and
`client.rs` are written against `iroh::endpoint::{Connection,
SendStream, RecvStream}` concretely; `relay.rs` signatures take
`&mut SendStream`. Extracting a substrate-agnostic core would mean
keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs) — and
those are the parts fast-socks5 already provides in stricter, more
complete form (typestate, auth traits, reply-code enums, generic `T`).
2. **Not generic over the stream.** The whole reason POC #1 could drop a
channels `BiStream` into fast-socks5's `Socks5ServerProtocol<T>` with
zero adaptation is fast-socks5's `T: AsyncRead + AsyncWrite + Unpin`
genericity (AGENTS.md convention 7). iroh-socks5 has no such seam —
the equivalent refactor would be a rewrite, not a fork.
3. **RFC 1928 subset.** No username/password auth (OQ-SK-02's RFC-facing
path), no auth-method negotiation machinery (fast-socks5's
`AuthMethod` trait), simplified BIND, no `ReplyError` enum fidelity
(reply codes are bare `u8` consts). fast-socks5 is a conformant
implementation with a test suite covering the RFC edge cases;
iroh-socks5 is a pragmatic subset.
4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) — heavy
dependency for the wasm-clean default-crate invariant (OQ-SK-04);
the vendored-subset posture would reduce it to the two codec files
anyway, at which point fast-socks5's already-POC'd fork (POC #4:
~120 cfg lines, functional wasm-clean subset) dominates.
5. **No in-band client session type.** The only consumer path is the
local TCP front door — there is no client half that speaks the
framed protocol from inside a process without a kernel listener
(fast-socks5's `Socks5Stream::use_stream` slot). alksocks'
consumer half needs exactly that; iroh-socks5 doesn't offer it.
### The valuable part: OQ-SK-03 evidence
iroh-socks5 implements channels-native UDP ASSOCIATE without binding a
relay-visible port on the producer side beyond the real egress socket —
the exact question OQ-SK-03 poses. Its answers:
- **Datagram stage rides the same stream** (its "second channel" option
is not used; the association's stream carries framed `Datagram`s
post-reply). Matches the phase-model hunch, including "control stream
goes silent after the reply" — nothing else is ever sent on the
control stream after the ASSOCIATE reply; the client-side relay
monitors the control connection's EOF (`wait_until_closed`) as the
association's end-of-life, and the RFC's "post-reply control data is
garbage" semantic holds.
- **The datagram framing is a length prefix, not the RFC UDP header** —
the RFC UDP header (`RSV/FRAG/ATYP/addr/port`) is applied only at the
*front door* (the local UDP socket a vanilla client sends to), where
it belongs: `relay_udp_client` parses/`format_udp_packet`s at the
socket boundary and shuttles clean `(addr, data)` frames inboard.
This is a cleaner cut than phase-0's hunch (which put the length
prefix inside the same stream as RFC headers): the alkcall-channel
wire carries `{addr, data}` postcard frames (boundary-preserving by
construction — an empty datagram is a frame with empty `data`, never
EOF; alktunnels F-2's requirement is met structurally), and RFC header
encode/decode exists only where a vanilla-client socket boundary
exists (the optional local backends on either side).
- **Server-side `BND.ADDR` semantics**: the ASSOCIATE reply reports the
*real* bound UDP socket address (`udp.local_addr()`) — because in
this design the server-side relay socket is real. On alksocks'
channels path (no socket), phase-0's sentinel question remains open;
iroh-socks5 sidesteps it by having the vanilla-facing side be the
*client* half. A producer-side vanilla UDP front door (OQ-SK-03's
local-backend-bridge option) would need the same real-socket
treatment; the wrapper-aware in-band path can carry a sentinel or
(better, per this evidence) no address at all — the framed `Datagram`
stream needs no `BND.ADDR` to function; the address is pure
RFC-compatibility surface.
- **No per-endpoint flow table on the producer side** — the relay
socket is one `send_to`/`recv_from` loop with `TargetAddr` carried
per datagram. This is *simpler* than alknet's flow-table model and
sufficient: per-datagram addressing in the frame does the
demultiplexing. Notable simplification for POC #2's design.
- **UDP fragmentation dropped with a warning** (FRAG ≠ 0 rejected).
Reasonable v1 posture; tun2proxy's udpgw does the same.
- **Client-side per-session UDP socket bound at the front door with a
fallback** (requested addr → 0.0.0.0:port on failure).
## Comparison table
| dimension | fast-socks5 (current base) | iroh-socks5 |
|---|---|---|
| RFC 1928 fidelity | full (typestate, all commands, reply-code enum, RFC 1929) | subset (no-auth only, bare-code BIND) |
| Stream genericity | `T: AsyncRead + AsyncWrite + Unpin` throughout | concrete iroh `SendStream`/`RecvStream` |
| Server structure | explicit typestate + interception points | single `accept()` method, monolithic |
| Auth extension seam | `AuthMethod` trait | none (allow-list only) |
| UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`) — equivalent |
| UDP ASSOCIATE over the transport | default handler binds sockets; custom seam narrow (phase-0 caveat) | clean framed-datagram design, no producer-side virtualization needed |
| Producer dial seam | interception points (`run_tcp_proxy`, command-read typestate) | none (dial hardwired in `handle_connection`) |
| Client session genericity | `Socks5Stream::use_stream` (any `T`) | none (local TCP front door only) |
| wasm posture | fork verified minimal (POC #4) | moot — iroh + tokio-full are native-only |
| Tests | unit + examples; router/custom-auth examples | strong integration suite over real iroh infra |
| License | MIT (we own upstream) | MIT OR Apache-2.0 |
| LOC | ~4k | ~1.3k |
## Verdict
**Keep fast-socks5 as the protocol base** (the POC #1/#4 conclusions
stand); **adopt iroh-socks5's UDP design as prior art for OQ-SK-03**:
1. The framed `{addr, data}` datagram stream (no RFC header in-band, RFC
header only at vanilla front doors) is the cleaner expression of the
phase model — the RFC UDP header becomes a front-door codec concern,
not a channels-wire concern.
2. Per-datagram addressing with no producer-side flow table suffices
(revisit alknet's flow-table posture in light of this).
3. Control-stream EOF ends the association (`wait_until_closed` shape —
on channels, the channel's EOF is the same signal; no extra control
vocabulary needed).
4. FRAG ≠ 0 refused is an acceptable v1 posture.
Attribution if code is reused (the two codec files are the candidates —
though fast-socks5 already covers them): MIT OR Apache-2.0 dual license,
copyright Matthew Geddes; keep LICENSE-MIT/LICENSE-APACHE entries and a
provenance note in any vendored file header.
Also worth filing upstream (courtesy, we own fast-socks5, but
iroh-socks5 is upstream's own crate): nothing blocking. Optionally a
repository link on the crates.io entry would help discoverability — the
repo exists at `github.com/mattgeddes/iroh-socks5` but `repository` is
null on crates.io.