From cf5432209a70b80bddc06f944cefbfd199f50a3e Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 13 Sep 2026 14:58:41 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20iroh-socks5=20evaluated=20=E2=80=94=20n?= =?UTF-8?q?ot=20the=20base,=20but=20OQ-SK-03=20prior=20art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/research/iroh-socks5-eval.md | 261 ++++++++++++++++++++++++++++++ docs/research/phase-0.md | 64 +++++++- 2 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 docs/research/iroh-socks5-eval.md diff --git a/docs/research/iroh-socks5-eval.md b/docs/research/iroh-socks5-eval.md new file mode 100644 index 0000000..420fa2b --- /dev/null +++ b/docs/research/iroh-socks5-eval.md @@ -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` 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. \ No newline at end of file diff --git a/docs/research/phase-0.md b/docs/research/phase-0.md index a705873..173cf20 100644 --- a/docs/research/phase-0.md +++ b/docs/research/phase-0.md @@ -244,6 +244,36 @@ before designing against it. Key surface points, verified: channels open handler (inspect params → dial or refuse) and of the alknet ADR-090 client (hand the established stream to quinn). +### iroh-socks5 — the structural sibling (evaluated, not the base) + +`crates.io/crates/iroh-socks5` (v0.5.0, MIT OR Apache-2.0, repo: +`github.com/mattgeddes/iroh-socks5` — crates.io lacks the repository +link). A ~1.3k-LoC iroh `ProtocolHandler` tunneling SOCKS5 over iroh +QUIC: framed `Request`/`Reply` protocol postcard-encoded over one +bidirectional stream, then raw pass-through (CONNECT/BIND) or framed +`Datagram`s (UDP ASSOCIATE); producer-side dial + allow-list +(`EndpointId`s), consumer-side local TCP front door for vanilla clients. +Full evaluation: `docs/research/iroh-socks5-eval.md`. Verified: + +- **Independent confirmation of POC #1's shape** — one stream per SOCKS5 + session, framed control phase then pass-through, RFC codec only at + vanilla-client boundaries, resolution producer-side. Convergent + evolution from a codebase with no alk* lineage. +- **The load-bearing OQ-SK-03 prior art** — its UDP ASSOCIATE design + carries framed `{addr, data}` datagrams in-stream (no RFC UDP header + on the tunnel wire; the RFC header codec applies only at the local + front-door socket), needs no producer-side flow table (per-datagram + addressing in the frame does the demux), ends the association on + control-stream EOF, and refuses FRAG ≠ 0. This is the phase-model + hunch's cleaner expression; adopt as the POC #2 design baseline. +- **Why it is not the base:** iroh types are load-bearing in the data + plane (`SendStream`/`RecvStream` in relay signatures; no generic `T` + seam), the RFC surface is a subset (no-auth only, no auth-method + machinery, no reply-code enum fidelity), and the client side has no + in-band session type (local front door only). Extracting a + substrate-agnostic core yields ~550 LoC of codecs fast-socks5 already + provides in stricter form — the POC #1/#4 base decision stands. + ### alknet's SOCKS5 client + the quinn-proxy POC — the client-side ancestor `/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs` (ADR-090) @@ -380,6 +410,12 @@ the datagram stage must ride a channel. Sub-questions: clients will literally `sendto()` that address, so this shape only works for *wrapper-aware* client halves (the crate's own consumer session, or an assembly layer bridging a real UDP socket). + **iroh-socks5's refinement (2026-09-13 eval):** the in-band datagram + carries `{addr, data}` (boundary-preserving, no RFC header on the + tunnel wire) and the RFC UDP-header codec lives only at the + vanilla-client front doors — cleaner than putting the length + framing *inside* the RFC header stream as first hunched; see + `docs/research/iroh-socks5-eval.md`. - **Second channel for the datagram stage** — the producer establishes the association on channel 1, then the client opens a second channel that becomes the relay. Keeps CONNECT-shaped @@ -397,10 +433,12 @@ the datagram stage must ride a channel. Sub-questions: are ours to define. - **Per-datagram addressing** — the SOCKS5 UDP header carries the destination per datagram (ATYP + addr + port), so one association - multiplexes many endpoints: per-endpoint flow table producer-side - (the alknet `Socks5UdpSocket` model, and the udpgw prior art - alktunnels documented). Boundary preservation is mandatory (empty - datagram ≠ EOF — alktunnels F-2's lesson transfers directly). + multiplexes many endpoints. alknet's model used a producer-side + per-endpoint flow table; iroh-socks5 demonstrates the simpler cut — + per-datagram addressing *in the frame* does the demultiplexing, no + flow table. Boundary preservation is mandatory (empty + datagram ≠ EOF — alktunnels F-2's lesson transfers directly; the + `{addr, data}` frame shape meets it structurally). - **Do vanilla (wrapper-unaware) clients need channels-native UDP at all?** If the only UDP ASSOCIATE consumers are wrapper-aware, the sentinel-reply shape is fine and no virtualized relay address is @@ -612,6 +650,13 @@ promising approaches"): flow table, and empty-datagram handling (alktunnels F-2 transfers). (Reuses POC #1's producer skeleton; note `run_udp_proxy_custom` cannot be used as-is — see the corrected §Prior art caveat.) + **Design baseline updated 2026-09-13:** the iroh-socks5 eval + (`docs/research/iroh-socks5-eval.md`) suggests the in-band datagram + carries `{addr, data}` directly (no RFC UDP header on the tunnel + wire; RFC codec at front doors only) and drops the producer-side + flow table (per-datagram addressing in the frame); the sentinel + question may dissolve for the in-band path (no `BND.ADDR` needed + when the datagram stage needs no relay address). 3. **noq `AsyncUdpSocket` impl POC** — the client-side story against noq 1.2: associate through a fast-socks5 server, wrap as `noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06 @@ -644,6 +689,14 @@ Candidate reading for the research specialist (to be expanded): (`new_udp_header`/`parse_udp_request`, `ReplyError`), `examples/router.rs` (conditional interception), `examples/ custom_auth_server.rs`. +- iroh-socks5 — `crates.io/crates/iroh-socks5` v0.5.0, repo + `github.com/mattgeddes/iroh-socks5` (crates.io has no repository + link; the published source matches). Evaluated in + `docs/research/iroh-socks5-eval.md`: not the protocol base (iroh + coupling, RFC subset), but the OQ-SK-03 UDP prior art (framed + `{addr, data}` in-band datagrams, no flow table, control-EOF + association lifetime) and an independent confirmation of POC #1's + one-stream-per-session shape. - alktunnels — `docs/research/phase-0-findings.md` (the `-D` composition conclusion, hub-owns-the-connection, the UDP codec decision + F-2), `docs/architecture/` (params/ALPN/ACL ADR template), @@ -685,7 +738,8 @@ Candidate reading for the research specialist (to be expanded): including the three-component split) - [ ] Prior-art pass complete: fast-socks5 surface verified (§Prior art), alktunnels/alknet/noq lineage mapped, anti-prior-art list - written + written; iroh-socks5 evaluated (structural sibling, not the base; + OQ-SK-03 prior art — `docs/research/iroh-socks5-eval.md`) - [x] OQ-SK-01 (dial policy) — researched, half-answered (injected dialer hunch); **POC #1 resolved the shape empirically** (dial callback at the command-read interception point, boxed halves, no