Files
alksocks/docs/research/iroh-socks5-eval.md
T
glm-5.3-flash cf5432209a 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)
2026-09-13 14:58:41 +00:00

15 KiB

status, last_updated
status last_updated
complete 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 Datagrams (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 Datagrams 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_packets 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.