--- 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 for the *local egress* variant (revisit alknet's flow-table posture in light of this). Caveat added 2026-09-13: under *composed* egress (per-target alktunnels udp-tunnels, the OQ-SK-03 handle-swap discussion) the flow table returns as a destination→tunnel-channel table — a dial-callback policy, not a protocol-layer concern. 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.