- 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)
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 connectingEndpointIdagainst an allow-list (empty = accept all), reads one framedRequest, dials the target (TcpStream/TcpListener/UdpSocketon the exit host), writes one framedReply, then relays raw bytes (CONNECT/BIND) or framedDatagrams (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 irohopen_bi()stream carrying the framedRequest/Replyprotocol.
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_framehandles 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.rsparse_udp_packet/format_udp_packet): correct ATYP handling, usesget(..)?bounds checks, propagates the FRAG byte, domain targets supported — same functional slot as fast-socks5'snew_udp_header/parse_udp_request. - Half-close propagation in
relay_tcp: each direction signalsfinish()/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_closeddrains-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
(
negotiatewrites0xFFif the client doesn't offer no-auth). Auth is the irohEndpointIdallow-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 asTargetAddr::Ip(from)). Fine for the common case; no per-endpoint flow table like alknet'sSocks5UdpSocketmodel. allowlist_denies_unknown_peercloses 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 typedchannel:open_failedestablisher 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)
- "ALPN as a service" isomorphism. iroh's
ProtocolHandler::acceptRouter::accept(ALPN, handler)is the same posture as alkcall'sregister_openable(ALPN-scoped produced resource, handler per connection). iroh 1.0'sProtocolHandleris 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.
- One stream per session, no sub-demux. The
Request/Replyframing 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. - Producer/consumer split.
Server= producer half (accepts, dials),Client= consumer half with the optional local TCP front door (bind_listener/serveis the ssh-Dshape POC #1'slocal_front_doorexample 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. - Target resolution on the producing side. Domains are carried
unresolved (
TargetAddr::Domain) and resolved at the exit node — the ssh-Dsemantic 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
- iroh types are load-bearing in the data plane.
server.rsandclient.rsare written againstiroh::endpoint::{Connection, SendStream, RecvStream}concretely;relay.rssignatures take&mut SendStream. Extracting a substrate-agnostic core would mean keeping onlyprotocol.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, genericT). - Not generic over the stream. The whole reason POC #1 could drop a
channels
BiStreaminto fast-socks5'sSocks5ServerProtocol<T>with zero adaptation is fast-socks5'sT: AsyncRead + AsyncWrite + Unpingenericity (AGENTS.md convention 7). iroh-socks5 has no such seam — the equivalent refactor would be a rewrite, not a fork. - RFC 1928 subset. No username/password auth (OQ-SK-02's RFC-facing
path), no auth-method negotiation machinery (fast-socks5's
AuthMethodtrait), simplified BIND, noReplyErrorenum fidelity (reply codes are bareu8consts). fast-socks5 is a conformant implementation with a test suite covering the RFC edge cases; iroh-socks5 is a pragmatic subset. - 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. - 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_streamslot). 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_clientparses/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 emptydata, 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.ADDRsemantics: 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 framedDatagramstream needs noBND.ADDRto 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_fromloop withTargetAddrcarried 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:
- 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. - Per-datagram addressing with no producer-side flow table suffices (revisit alknet's flow-table posture in light of this).
- Control-stream EOF ends the association (
wait_until_closedshape — on channels, the channel's EOF is the same signal; no extra control vocabulary needed). - 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.