- 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)
261 lines
15 KiB
Markdown
261 lines
15 KiB
Markdown
---
|
|
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. |