Post-POC #2 findings review: a real BIND use case and mechanism surfaced, plus a channel-capacity analysis for proxy workloads. - OQ-SK-08: fast-socks5 parses but refuses TCPBind — the wrapper owns the whole shape (zero upstream ask). Phase-model extension: reply#1 (BND.ADDR) -> quiet wait -> one inbound accept -> reply#2 (peer) -> raw pass-through; iroh-socks5's channels-native BIND is convergent precedent. Accept variants mirror OQ-SK-03's egress variants: feature-gated local listener (faithful RFC) vs composed alktunnels listen-tunnel (needs a listen-addr-inspection ask) vs wrapper-aware only. Security note: BIND is inbound egress — SOCKS5_OPEN_SCOPE must distinguish it from CONNECT/ASSOCIATE. - OQ-SK-09: 256-channel default vs proxy fan-out; wire change rejected, assembly-layer config + documented proxy-workload recommendation is the mechanism; in-channel port multiplexing ([port:u16] per chunk) considered and rejected (re-opens OQ-SK-03's demux-vs-phase trade, breaks 0-B pass-through, AGENTS.md #10). - OQ-SK-05 addendum: virtual address space / identity-scoped subnets adoptable with no wire change — DST.ADDR/BND.ADDR translation via the dial callback; hub exposure via terminate-and-re-produce. - POC #5 (BIND, both accept variants, vanilla front-door test) added to OQ-SK-07; checklist + convergence updated; upstream-asks list pinned (fast-socks5: net gate + UDP bind seam; alktunnels: listen-addr inspection). Verification: docs-only change (phase-0.md).
69 KiB
status: draft last_updated: 2026-09-14 (post-POC #2 review: OQ-SK-08 BIND, OQ-SK-09 capacity, OQ-SK-05 virtual-subnet addendum, POC #5)
alksocks — Phase 0 (Exploration)
This document captures Phase 0 (Exploration) for the alksocks crate:
vision, guiding principles, prior art, open questions (OQ-SK-01..NN), and
POC candidates. Phase 0's objective per docs/sdd_process.md: capture
vision and guiding principles; research options; validate approaches;
converge on a recommended approach. It is the input to Phase 1
(Architecture), where the Architect will produce docs/architecture/
specs, ADRs, and the open-questions tracker.
Drafted 2026-09-12, emerging from the initial setup discussion. The crate
is the sibling of alktty (alk/tty) and alktunnels (alk/tunnel) on the
alkcall substrate; where alktunnels realizes the -L/-R forwarding
flavors, alksocks realizes the -D flavor (dynamic / SOCKS5 proxy) —
the one alktunnels explicitly deferred to composition.
Vision and guiding principles
One sentence: a SOCKS5 (RFC 1928) producer/consumer protocol crate on
alkcall channels — an arbitrary-egress proxy service that never binds a
port unless explicitly configured to, wrapping fast-socks5 so the RFC
conversation rides inside a channels data channel like any other produced
resource.
The three components (2026-09-12 clarification — they serve different purposes and were at risk of being conflated):
- Producer half — a side exposes the SOCKS5 service as a produced,
ACL-scoped resource (registers openable channels; the open handler
runs the RFC state machine; target dials happen there or further
downstream). This is the
-Dservice over channels. - Consumer half — a side opens SOCKS5 channels against a produced
service and speaks RFC 1928 inside them. Optionally it may expose
the service locally: bind a real
socks5://127.0.0.1:portendpoint and relay each accepted connection into a channel (the ssh-Dlocal-exposure shape, and the path tun2proxy/curl/browser point at). - SOCKS client wrapper for QUIC runtimes —
AsyncUdpSocketimplementations (quinn and noq) so downstream clients (alknet) can route QUIC through any third-party SOCKS5 server — not just one produced by this crate. The motivating case is iroh's privacy posture: iroh relays see the real IP (intended for relay-assisted p2p), and a client that doesn't want to leak it needs a SOCKS hop ahead of the QUIC dial. This component is a plain client library (the alknet ADR-090 descendant); it neither produces nor consumes channels.
Components 1+2 form the producer/consumer protocol pair (channels in, RFC 1928 inside); component 3 is standalone and composes with any RFC 1928 server. Keep them structurally separate in the crate layout.
Scope resolution (2026-09-14, post-POC): component 3 moves to the
alknet rewrite. The QUIC-runtime client wrapper's natural home is
alknet — it is alknet's own ancestor (ADR-090 + the quinn-proxy POC
live there), it neither produces nor consumes channels, and the
planned alknet rewrite is where downstream clients (iroh privacy
posture) will actually consume it. That pins alksocks' scope to the
producer/consumer pair only: socks + channels. Consequence: the
OQ-SK-06 noq research (and its would-be POC #3) transfers to alknet's
Phase 0; the quinn 0.11 variant is already proven there
(quinn-proxy POC). This crate's client-side story reduces to the
channels-native consumer session (Socks5Stream::use_stream over a
BiStream — POC #1) plus the wrapper-aware associate session (POC
#2) and the optional front doors.
Guiding principles, inherited from the alk* family:
- "ALPN as a service," not "a server." The SOCKS5 service is a
produced, ACL-scoped resource on a channels connection — the same
shape as an alktty terminal or an alktunnels TCP tunnel. Kernel
binds exist only as explicit, optional assembly-layer shapes on
either side — the producer may serve from a real listener (a
feature-gated local backend, component 1's optional shape), and the
consumer may expose the resource on a local port (component 2's
optional shape, the ssh
-Dfront door). The protocol itself never binds; the binding decision belongs to the caller. - The
-Dconclusion, realized. alktunnels' Phase 0 settled that-Dcomposes at the assembly layer: "-Dis just tunnel a socks5 connection"; target selection lives in the SOCKS5 protocol at the producing side, governed by the same op-level ACL as the resource. This crate is that conclusion's realization as a first-class protocol crate, not an assembly-layer afterthought. - Wrap
fast-socks5, preserve its genericity.fast-socks5's explicit typestate server API (Socks5ServerProtocol<T, states::*>) is generic overT: AsyncRead + AsyncWrite + Unpin, and its interception points (run_tcp_proxy,run_udp_proxy_custom,transfer) accept any suchT. The channels adapter feeds the state machine aBiStream; a local backend feeds it aTcpStream. The wrapper must not leak either substrate into the protocol layer (alkttyTtyBackend/ alktunnels pump-halves inversion-point precedent). - Producer/consumer vocabulary. Both sides of a channels connection
can initiate; a producer registers openable SOCKS5 channels
(
ChannelCore::register_openable), a consumer opens them and speaks RFC 1928 inside. RFC 1928's own client/server roles keep their RFC names inside the protocol layer, but crate-level docs and API use producer/consumer. Avoid "SOCKS5 server" as a crate-level noun. - Extract-and-improve from alknet. alknet's SOCKS5 story is the
direct ancestor: the client side (
alknet-client/src/socks5.rs, ADR-090 —Socks5UdpSocketimplementsquinn::AsyncUdpSocketso QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the server side (the-Dcapability alktunnels deferred). This crate rehomes both halves behind one protocol crate and improves them. - The vpn-like endgame composition. A SOCKS5 server + tun2proxy is
the last step (ish) of "vpn-like without actually being a vpn": a
user tunnels a produced SOCKS5 resource to a local port (component
2's optional shape), then points tun2proxy (
/workspace/tun2proxy— it takes--proxy socks5://user:pass@host:portnatively,src/args.rs) at it, and the whole host's traffic rides the alk* egress. This crate is the SOCKS5 leg of that composition; alktunnels carries the tunnel; tun2proxy closes the loop. (The udpgw framing alktunnels' UDP format is based on is the same prior art OQ-SK-03's datagram codec draws on.)
What is already settled
The foundation is POC-validated and ADR-pinned upstream; this crate does not start from zero. It inherits:
- The channels data path — demux→Connection→handler→mux, validated
by the alknet-channels POC and production alktty/alktunnels. The SOCKS5
payload is raw bytes inside a channels data channel's
BiStream; channels strips its 8-byte header transparently (alknet ADR-093 / alkcall ADR-035). SOCKS5 CONNECT is oneBiStreamper session — the RFC conversation is the stream; no sub-demux, no framing, no flow key (one channel per SOCKS5 session). - The two-pump data plane — the CONNECT proxy loop is the canonical
two-pump shape;
alkcall::channels::pump_bidi(ADR-050) is pinned upstream and alktunnels-validated. Use it; do not hand-roll. - The establishment story — session opens use
register_openable_with_establisher(alkcall ADR-049 + amendment 2): the establisher runs as an awaited, bounded establishment phase; a refused session is a typedchannel:open_failedcall error (reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout), never a phantom channel. The negotiate/ auth half of the RFC conversation still runs in-stream over theBiStream— establishment covers only the producer's accept policy, not the RFC handshake. - The producer/consumer model — producer registers openable channels
(authorization for free via
AccessControl); consumer opens them viaChannelClient(alkcall ADR-037, ADR-043). Connection direction is independent of service direction. - The relay/hub story — a SOCKS5 resource traverses alkcall hub relays like any produced resource; the hub's terminate-and-re-produce proxy (alktunnels' refinement of alkcall ADR-042) applies per hop, and a SOCKS5 resource is exactly the "further downstream" shape alktunnels described. No socks5-specific relay work.
- The identity seam — alkcall 0.7.0's CF-005/CF-006: the per-call opener identity arrives on the open-op hooks. Auth mapping (identity → SOCKS5 credentials/ACL, if any) happens there, not via in-band SOCKS auth, unless RFC-facing username/password is needed for vanilla SOCKS5 clients (OQ-SK-02).
- The backend inversion point pattern — substrate-specific types
confined to feature-gated backend modules, injected at the assembly
layer (alktty
TtyBackend/ alktunnels no-trait precedent). - The wasm-clean default crate — protocol-only code should compile
to
wasm32-unknown-unknown(alktty/alktunnels precedent). Caveat:fast-socks5itself is not yet known to be wasm-clean (OQ-SK-04).
Prior art
alktunnels Phase 0 — the closest sibling
/workspace/@alkdev/alktunnels/docs/research/phase-0-findings.md. The
load-bearing conclusions this crate inherits:
- Hub-owns-the-connection model — role follows the resource; whoever can reach the target is the producer, whoever wants the bytes is the consumer. The "exposed port" is a virtual, ACL-scoped resource, not a bind. For alksocks: the producer is the side that dials targets (the egress side); the consumer is the side that wants proxied egress. "A SOCKS5 service is the same shape [as a produced tunnel resource], further downstream" is alktunnels' own wording — this crate exists to make it literal.
-Dcomposes at the assembly layer — the original half-answer, now with the mechanism named: a SOCKS5 server at some assembly layer is just a consumer opening channels with per-connection dynamic targets. The refinement this crate adds: the SOCKS5 service is itself a produced resource (producer half), so a vanilla SOCKS5 client (curl, a browser, ssh -D's own client half) can attach through the optional local backend, and channels-native consumers get the same service in-band. Both halves wrap the same protocol layer.- Target policy = resource policy. alktunnels OQ-TN-08 dissolved dynamic-target policy: whatever ACL governs the socks5 resource governs everything reachable through it, plus whatever policy the SOCKS5 implementation itself applies downstream. No target allowlists in the base crate unless Phase 1 wants them (OQ-SK-05).
- The codec conclusion does NOT transfer. alktunnels needed length-framing only for datagram substrates; a SOCKS5 CONNECT session is a byte stream end to end (RFC 1928 defines its own framing on the wire) — the channel payload is pure pass-through, 0 B overhead over the channels 8-byte header. UDP ASSOCIATE's datagram stage is the exception (OQ-SK-03).
fast-socks5 — the implementation to wrap
/workspace/fast-socks5 (v1.0.0, MIT, we own upstream). Read the source
before designing against it. Key surface points, verified:
- Explicit typestate server API (
src/server.rs):Socks5ServerProtocol<T, states::Opened|Authenticated|CommandRead>, generic overT: AsyncRead + AsyncWrite + Unpin. Flow:start(inner)→negotiate_auth(&methods)→finish_auth()/accept_no_auth/accept_password_auth→read_command()→ (reply_success,reply_error). The legacySocks5Server/Socks5Socket/IncomingAPI (binds aTcpListener) is deprecated — the wrapper uses the explicit API only. - Auth surface — the
AuthMethod<T>trait (metadata:method_id,new) +AuthMethodSuccessState<T>(carry the socket back out);StandardAuthenticationenum for NoAuth + Password with static dispatch; custom methods implement the trait. Username/password check is a closure (accept_password_auth). The auth decision is where the alkcall identity seam plugs in (OQ-SK-02). - Command handling is swappable — the interception points:
run_tcp_proxy(proto, addr, timeout, nodelay)— dials, replies, thentransfer(inbound, outbound)(acopy_bidirectionalwrapper, itself generic). This is where a channels-native dial replaces thetokio::netdial: the wrapper's producer half intercepts here, dials via alktunnels/alkcall primitives (or its own dial policy), and returns theTback.run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)— the customizable UDP ASSOCIATE handler. Caveat (verified 2026-09-13): the "custom" seam is narrower than it looks — it callsudp_bind_random_port(peer_bind_ip)unconditionally and derives the reply port from that kernel socket; the wrapper can customize the relay loop (transfer) and the reply IP only. The channels path therefore cannot use this handler: it drives the typestate directly (reply_success(sentinel_addr)+ own datagram stage). The unconditional bind is a fileable upstream ask (we own fast-socks5): make the bind optional or accept a caller-supplied socket.transfer(inbound, outbound)— plain two-pump copy; a channelsBiStreamis a legalTon either side.
- UDP support —
new_udp_header(target)/parse_udp_request(buf)are public and pure (no socket I/O) — the SOCKS5 UDP datagram header codec is reusable without theSocket2-based relay machinery. The defaultrun_udp_proxybinds two kernel sockets (Socket2, random ports) — the binds are in the default handler, not in the protocol, which is exactly the seam the no-bind requirement needs. - Client side —
Socks5Stream<S>(generic over the backing socket;use_streamupgrades anyAsyncRead + AsyncWrite + Unpinalready connected) andSocks5Datagram<S>(UDP associate; also accepts a caller-supplied socket viause_socket).Socks5StreamimplsAsyncRead + AsyncWriteitself, so a consumer session over a channelsBiStreamis the intended use, not a hack. Note:Socks5Stream::connectconvenience constructors dialtokio::net::TcpStreamand are non-wasm;use_streamis the substrate-free path. - Error surface —
ReplyError(the RFC reply codes,as_u8/from_u8) andSocksError;SocksServerErroron the explicit API. Map these faithfully; never collapse a refusal into a generic error. router.rsexample — the "conditional interception" template: a server that inspects the command/target, then decides whether to proxy, refuse, or handle in-process. Structurally the shape of the 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
Datagrams (UDP ASSOCIATE); producer-side dial + allow-list
(EndpointIds), 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/RecvStreamin relay signatures; no genericTseam), 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)
implements Socks5UdpSocket: quinn::AsyncUdpSocket — a SOCKS5 UDP
ASSOCIATE tunnel wrapped as the socket QUIC polls. The quinn-proxy POC
(/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/findings.md)
validated the approach end-to-end (5/5 clean runs) against quinn 0.11:
one public trait (AsyncUdpSocket), one public constructor
(Endpoint::new_with_abstract_socket), no fork. Known limitations
there, likely inherited: ECN is lost through the SOCKS5 header; the
proxy must support UDP ASSOCIATE; may_fragment() == true disables
path MTU discovery.
This crate's client half rehomes that work (cleaned up, ClientDialError
→ thiserror types, and the datagram codec sourced from fast-socks5
rather than hand-rolled inline). The open question is noq (OQ-SK-06).
noq — the quinn fork the client story must also fit
/workspace/noq (v1.2.0; iroh's fork of quinn — iroh depends on
noq = "1.2.0"). Surface verified:
noq::AsyncUdpSocketexists (noq/src/runtime/mod.rs:44) but with a different shape than quinn 0.11:create_io_poller+try_sendare replaced bycreate_sender() -> Pin<Box<dyn UdpSender>>withpoll_send(transmit, cx)— a sender-object split (any number ofUdpSenders per socket, each holding its own waker).RecvMetagained fields (interface_index,timestamp) but stays default-constructible;Transmitis unchanged in the fields the SOCKS5 wrapper touches (destination,contents,ecn,src_ip,segment_size).Endpoint::new_with_abstract_socketexists (noq/src/endpoint.rs:162) with the same doc-comment intent, but takesBox<dyn AsyncUdpSocket>rather thanArc<dyn AsyncUdpSocket>— the poller-removal reshuffle also changed the ownership shape.- The alknet
Socks5UdpSocketdoes not drop in unchanged: the impl must be rewritten againstcreate_sender/poll_send, and theArc→Boxchange ripples into construction. Whether one crate can serve both quinn and noq behind feature flags (shared core, two thin trait-impl shells) or whether the shapes have diverged enough to justify two impls is the research question (OQ-SK-06). iroh's own adoption makes noq support the practically-important target.
Anti-prior-art (what NOT to carry over)
- The alknet client's hand-rolled SOCKS5 codec —
socks5.rsinlines the greeting/auth/CONNECT/ASSOCIATE byte logic twice (handshake for UDP, again for CONNECT). fast-socks5's client types andnew_udp_header/parse_udp_requestsupersede it; the wrapper should not vendor a second codec. Socks5Server(the legacy bind-based API) — deprecated upstream; never the default path here.- In-band SOCKS auth as the primary auth story — alkcall's identity seam (CF-005/CF-006) authorizes at the open-op layer. In-band RFC username/password remains available only for vanilla-client compatibility (OQ-SK-02).
Open Questions
These are the design questions Phase 0 must resolve (or explicitly
defer) before the architecture spec. Numbered OQ-SK-01.. so they can be
referenced, tracked, and promoted into docs/architecture/ open-questions.md in Phase 1. Half-answers and hunches are marked as
such — the point of this document is to hold them without forcing
premature decisions.
OQ-SK-01: Producer dial policy — what dials the target?
When the producer's SOCKS5 state machine reads a CONNECT command, the target dial must happen somewhere. Options:
- Option A: dial via alktunnels — the producer composes with a
local alktunnels consumer: the SOCKS5 handler opens an
alk/tunnelchannel naming the target, and the two-pump data plane is tunnel-channel ↔ SOCKS5-BiStream. Maximum composition ("further downstream" made literal), but adds a runtime dependency and a hop. - Option B: dial directly — the producer dials the target itself
(
TcpStreambehind thelocalfeature, or an injected dial callback). No alktunnels dependency; the dial policy (allowlists, routing through another alksocks hop) is the caller's callback. - Option C: injected dialer trait — a
Dialer-shaped trait (async fn dial(target) -> impl AsyncRead + AsyncWrite) with alktunnels and local-TCP implementations behind features. Middle ground; the trait is a backend-inversion-point decision (compare alktunnels OQ-TN-05's resolution: no trait, a function producing boxed halves sufficed).
Considerations: substrate-agnostic-by-construction (AGENTS.md convention 7) favors injection over hardwiring; the "hub terminates and re-produces" story means a hub-hop SOCKS5 resource's dialer is just "open a channel further downstream," which is Option A's shape — so whatever is decided must not prevent A when composing. Hunch: a dial callback/trait at the protocol layer, with alktunnels and local-TCP providers feature-gated — but alktunnels' no-trait precedent warns against a trait unless two real implementations converge; decide with the Phase 1 spec.
OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth
The producer's open-op hooks receive the per-call opener identity (alkcall CF-005/CF-006). The SOCKS5 RFC conversation also carries its own optional username/password auth (RFC 1929). Questions:
- Is the identity seam the only auth story (the open op is
authorized; the in-band handshake is skipped via
skip_auth_this_is_not_rfc_compliantor a no-op NoAuth), with RFC-facing username/password available only for the optional local backend (where vanilla SOCKS5 clients connect)? - If both exist, how do they compose — does in-band auth replace the channel identity for target-policy purposes, or is it a second gate?
- Does the wrapper need a fast-socks5
AuthMethodimplementation that consults the alkcallAuthContext/Identity(filed upstream if the hook shape doesn't fit — AGENTS.md convention 17)?
(Answered in direction by the 2026-09-13 posture statement below: yes / planes-don't-compare / probably-not — Phase 1 formalizes.)
Posture — settled direction (2026-09-13 discussion): "both," split
by the door. The planned end-to-end story is the vpn-like
composition: a consumer connects to an endpoint producing the SOCKS5
resource, optionally binds it locally (component 2's front door —
ssh -D's shape, with the defining difference that no port is bound by
default), and points tun2proxy (--proxy socks5://user:pass@host:port
natively, per §Vision principle 6) at the local port. That makes "both"
auth mechanisms real, each on its own side of the door:
- Producer side: identity only. The channels-native open is authorized by the alkcall ACL/identity seam (CF-005/CF-006); a producer-side password is moot — the ACL system is strictly better than a shared secret, and the producer's in-band conversation runs NoAuth/no-op. A password there would add nothing the identity seam doesn't already do.
- Consumer side (the front door): RFC 1929 where vanilla clients
need it. The door is where the vanilla world meets the ACL world,
so it owns credential presentation: a vanilla client carrying
user:pass(tun2proxy, curl) gets a real RFC 1929 conversation at the door. The door then maps the accepted credential to local configuration — which channel identity/endpoint to open the producer channel with — and that identity reaches the producer via the CF-005/CF-006 seam. The SOCKS password never crosses the channel as a SOCKS password; the channel identity never reaches the vanilla client.
The "replace or second-gate" sub-question dissolves: the mechanisms
occupy different planes (in-band creds are door-local; identity is
channel-level). The NoAuth door (POC #1's raw-pipe shape, zero
protocol translation) remains valid for loopback-only or wrapper-aware
use; the cred-bearing door is the same bridge plus the greeting half
(fast-socks5's server typestate for auth + read_command, then the
consumer session in-band) — an assembly-layer choice, not a protocol
one.
Remaining for Phase 1: the door's credential→identity mapping shape
(static config vs pluggable lookup); whether the producer-side
optional local backend offers the same door surface (same code shape —
door = vanilla↔ACL boundary — so presumably yes); and whether the door
needs a fast-socks5 AuthMethod impl or simply checks credentials
itself before handing the stream to the typestate (likely the latter —
the door is this crate's code, the trait was designed for embedders
who don't own the accept loop).
OQ-SK-03: UDP ASSOCIATE without binding — the hard case
RFC 1928 §7: the client sends UDP ASSOCIATE over the TCP control
connection; the server replies with a UDP relay address (BND.ADDR/
BND.PORT); the client then sends UDP datagrams (SOCKS5 UDP header +
payload) to that address. On channels there is no UDP relay socket —
the datagram stage must ride a channel. Sub-questions:
- Where does the datagram stage live? Options:
- Same channel, extended protocol — after ASSOCIATE, the channel's
BiStreamcarries length-prefixed datagrams (the alktunnels UDP codec shape,[len: u16 BE]per datagram; 65507 < 65535 so u16 suffices). The reply to the client rewritesBND.ADDR/BND.PORT— originally imagined as a sentinel address meaning "same channel," but the handle swap below supersedes that: no virtualized address is needed at all, because the in-band relay handle is the channel itself, not an address. Vanilla SOCKS5 clients will literallysendto()whatever address the reply carries, 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; seedocs/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
BiStreamsemantics clean but adds an open-op round trip and needs correlation (params carry the association id?). - Local-backend bridge only — UDP ASSOCIATE is offered only through the optional local backend (which binds a real UDP relay socket as the assembly layer's explicit choice), never channels-natively. Simplest; weakens the "ALPN as a service" story for UDP.
- Same channel, extended protocol — after ASSOCIATE, the channel's
- What does
BND.ADDR/BND.PORTreply contain on the channels path? Largely resolved (2026-09-13, see the handle-swap discussion below): on the in-band path the relay handle is the channel itself — no address needs to be invented, and the reply'sBND.ADDR/BND.PORTare pure RFC-compatibility surface (zeros, or whatever the front-door bridge reports). Only the vanilla-client front door has a real socket address to report, and there it comes from the real relay socket the assembly layer chose to bind. - Per-datagram addressing — the SOCKS5 UDP header carries the
destination per datagram (ATYP + addr + port), so one association
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 in-band shape needs no relay address at all (the handle is the channel — see the handle swap below) and no virtualized address is ever invented.
The handle swap (2026-09-13 discussion) — "tell the client about a
UDP port" → "tell the client about the UDP tunnel." The design's
load-bearing reading of RFC 1928: the ASSOCIATE reply's BND.ADDR/
BND.PORT is just the handle for the relay, and one relay serves the
whole association — the per-datagram destination is carried in the
datagram header (ATYP/addr/port), not in the handle. The relay port was
never "a port that goes to one place"; it is a multiplexing endpoint.
So swapping the handle preserves every RFC property:
- one relay per association → one channel per association (the CONNECT shape; no second channel, no correlation params);
- per-datagram destinations →
{addr, data}frames in-band; - lifetime tied to the control connection → channel EOF;
- "bind locally if you want" → the optional front door on either side (a real UDP socket bridged to the channel, RFC codec at that boundary only).
Vanilla clients still literally sendto() the replied address, so the
in-band path is wrapper-aware-only by RFC necessity — the front-door
bridge is where a real socket address exists.
Producer egress — two variants (the "no ports" story has two
halves). The handle swap settles the consumer-facing half. The
producing-side half is: after {addr, data} frames exit the channel,
what gets each datagram to addr?
- Local egress — the handler binds a real
UdpSocketandsend_tos per datagram (iroh-socks5'srelay_udp_serverbehind this crate'slocalfeature). No flow table, native-only. - Composed egress (OQ-SK-01 Option A for the datagram stage) —
the dial callback opens an alktunnels
udp-substrate tunnel per destination: a lazy destination→tunnel table (open on first datagram, LRU-evict). The flow table is reborn as channel handles — because alktunnels' udp substrate is connected (connect_udp(target): one tunnel, one fixed target) while ASSOCIATE names a different destination per datagram. Cost: one open per destination (the per-connection/per-identity channel defaults are 256 — ADR-040/041 policy knobs, configurable without a wire change; theu32wire space is ~4 billion — so fine for DNS + browsing, and the per-destination open latency is a POC #2 measurement). Benefit: per-target ACL for free — the ACL governing the udp-tunnel resource governs egress per destination (dissolving most of OQ-SK-05 for the composed path), and hub relaying is per-target terminate-and-re-produce.
Both variants are dial-callback policy (OQ-SK-01's shape) — nothing in
the fork or the protocol layer sees the difference. No upstream ask
exists here: alktunnels' connected-udp substrate is correct as-is for
per-target composition; an "unconnected arbitrary-egress udp resource"
upstream would just be SOCKS5 again with different framing (circular).
Prior note retained: fast-socks5's run_udp_proxy_custom lets the
wrapper supply the reply and the relay half — the seam exists upstream;
the local-egress variant uses it or drives the typestate directly (the
unconditional-bind caveat above).
Phase model vs alktty-style demux (2026-09-12 discussion). An
alternative shape surfaced: alktty's logical demux (input/output/error/
control sub-streams inside one channel, type byte per chunk, up to the
u8 = 255 stream-type limit) applied to the SOCKS5 channel — a data
stream and a udp-relay stream type would carry the two phases
structurally. Analysis against RFC 1928's actual structure: RFC 1928
is one command per connection, and control and data never interleave in
either direction. CONNECT's control conversation ends at the reply,
after which the channel is a pure byte stream (alktunnels' 0 B
pass-through conclusion); ASSOCIATE's control stream goes silent after
the reply — fast-socks5's own wait_on_tcp (src/server.rs:1195)
treats any post-reply control byte as protocol garbage
(UnexpectedUdpControlGarbage). So the per-chunk type byte demuxes a
problem SOCKS5 doesn't have; a phase model (raw RFC conversation
until the reply, then a mode switch synchronized by the reply itself —
pass-through for CONNECT, [len: u16 BE] datagrams for ASSOCIATE)
subsumes it with no type byte and no sentinel address: wrapper-aware
clients know "post-reply = datagram mode," the same way they'd know
"relay stream = datagram mode" under the demux. What the demux genuinely
buys that the phase model does not: an additive in-band vocabulary —
new stream types extend the wire additively (no format break), whereas
adding a post-reply phase later is a wire break. alktunnels accepted the
opposite ("no in-band control path, ever — the escape hatch is a new
ALPN, a wire change is not"); the demux reintroduces that vocabulary at
2 B/chunk (type byte + length prefix) on every datagram. The one-way-door
cost is real either way: demux bakes the vocabulary in before it has a
user; phase model keeps 0 B overhead on CONNECT and 2 B on ASSOCIATE
datagrams. Hunch: phase model (simpler, RFC-shaped); the extensibility
argument is the honest case for the demux if Phase 1 wants the
vocabulary. Decide via ADR with the first consumer in sight.
Hunch (datagram stage, updated 2026-09-13 by the handle swap):
wrapper-aware client halves ride the same-channel {addr, data} frame
shape (one channel per association, per-datagram addressing in-band,
channel EOF = association lifetime); vanilla clients get UDP ASSOCIATE
only via the optional local front-door bridge. The remaining choice is
producer-egress policy (local socket vs per-target alktunnels
composition) — a dial-callback question owned by OQ-SK-01, with the
composed variant's ACL benefit making it the hunch for the base crate's
composed story. But this is still the crate's largest unknown — a POC
candidate (OQ-SK-07 #2), decided by an ADR before the first consumer
(the datagram framing is wire-stable once published).
OQ-SK-04: fast-socks5 wasm posture (blocks the wasm-clean invariant)
fast-socks5 uses tokio::net (TcpListener, TcpStream,
UdpSocket) and socket2 unconditionally (Cargo.toml: tokio features
io-util, net, time, macros; deps socket2 = "0.5.8"); wasm32- unknown-unknown has no tokio::net. The wrapper's own protocol layer
can stay wasm-clean (the typestate API is generic over T), but
depending on the crate at all may break cargo check --target wasm32-unknown-unknown at the dependency-graph level.
The decision tree (2026-09-12 clarification — two main forks, and the preference between them is conditional):
- Case 1: wasm matters for this crate → fork-first. Make the
minimal fork and use it — offer it upstream as a PR (merge if they
want it; we carry the fork regardless; never wait on approval).
The gating change is genuinely small (feature-gate
tokio::net/socket2behind a default-onnetfeature, per the alkttylocalpattern); the long-term cost of maintaining a minimal fork of a well-written lib trends toward zero. Concretely "fork" likely means vendoring the relevant subset into this crate (the codecs, typestate machinery, clientuse_streampath — the parts that are alreadyT-generic) and gating the native-net pieces behind the crate's ownlocalfeature, rather than publishing a divergent crate. fast-socks5 remains the reference checkout for differential testing of RFC edge cases (reply-code mapping, domain addressing, fragmentation) and the PR source; if upstream accepts the PR, the fork shrinks to a plain dependency. - Case 2: wasm doesn't matter for this crate → plain dependency, no
fork. Ride the
router.rsnative path exactly as-is (the alkhttp precedent — a crate in this suite can ship native-only). This is strictly better if its premise holds: no fork burden, no vendoring, upstream stays upstream. The alkhttp contrast is instructive — alkhttp's substance (axum/hyper/reqwest) is socket-native, so nobody misses wasm there.
The root question is therefore: does wasm make sense for alksocks? Not "how do we get wasm" — that is Case 1's solved problem. Analysis for the decision:
- What wasm would serve: the protocol layer (components 1+2 of the
three-component split, §Vision) in a sandboxed adapter — the
alktty/alktunnels posture that a wasm-compiled protocol crate is the
protocol layer for downstream TS/Python adapters. The natural wasm
shape is a consumer: speak RFC 1928 client-side over a channels
BiStream(pure byte framing; the producer dials targets, so no local sockets are needed on the client side). A wasm producer is odd standalone (no kernel egress) but composes — its dialer can open alktunnels channels further downstream, which is the hub story. - What wasm would not serve: component 3 (the quinn/noq client wrapper) is native-only in practice — it wraps kernel UDP sockets for UDP ASSOCIATE; no wasm story is expected there regardless.
- The alkhttp contrast, honestly weighed: for alkhttp,
"an HTTP client accessed over a channel" was judged weird, and the
crate ships native-only. alksocks differs structurally: its protocol
substance is byte-framing over a generic
T— the part that costs nothing to keep wasm-clean. But "costs nothing under the fork" is only an argument once Case 1 is chosen; it is not itself the use case. The use case is a planned sandboxed SOCKS consumer.
Decision inputs (what actually settles Case 1 vs Case 2):
- Is there a planned sandboxed (wasm) consumer of the SOCKS5 service? If the downstream TS/Python adapter story includes a channels-connected sandbox that wants proxied egress, wasm matters → Case 1. If not → Case 2.
- Is the fork genuinely minimal? POC #4 (OQ-SK-07) verifies
empirically how invasive the gating is. If the gating turns out
structural (the
T-generic surface can't be separated fromtokio::netwithout a rewrite), the fork cost rises and Case 2 gains weight — reimplementing the protocol to serve a wasm story nobody has yet defeats the purpose.
Both inputs point the same way when aligned: fork-first in Case 1 is the preferred branch because wasm is wanted there — not as a general preference for forks over upstream asks. If the adapter story never materializes, Case 2 (plain dep, router path) is the obvious choice, and no one should carry a fork for an invariant's sake.
OQ-SK-05: Target policy and egress scoping
SOCKS5 is arbitrary-egress by nature — the open gate plus the target policy are the security boundary (AGENTS.md convention 12). alktunnels resolved that whatever ACL governs the socks5 resource governs everything reachable through it; OQ-SK-01's dial policy is the mechanism. Residual questions for Phase 1 (note the 2026-09-13 OQ-SK-03 handle-swap finding: for the composed UDP egress path, per-target ACL comes free — each destination rides its own alktunnels udp-tunnel channel governed by the tunnel resource's ACL, dissolving most of this OQ for that path):
- Does the base crate ship a per-target allowlist hook (producer-side policy injected at registration), or is target policy entirely the dialer's concern (the dial callback refuses)? The dialer-refuses shape avoids a second policy layer (alktunnels' resolution pattern); the allowlist-hook shape makes a common policy declarative.
- Is there a
SOCKS5_OPEN_SCOPE(scope-gate on the open op, alkttyTTY_OPEN_SCOPEshape) — presumably yes, but the exact scope-string convention should follow alkcall's registry conventions. - Domain-form targets (RFC 1928 ATYP 0x03): resolve producer-side
(fast-socks5's
dns_resolveconfig), refuse, or pass through to the dialer unresolved? (DNS-on-the-producer is the SSH-Dsemantic; wasm producers may not have a resolver at all — another OQ-SK-04 interaction.)
Virtual address space / identity-scoped subnets (added 2026-09-14,
post-POC #2). A proposal surfaced during the findings review: give
identities scoped virtual subnets (e.g. 10.x.0.0/16 slices) so proxied
resources become IP-addressable — "BND.ADDR ↔ channel id" translation,
a hub exposing proxied resources per identity under ACL. The goal is
adoptable; the mechanism needs no wire change: DST.ADDR and
BND.ADDR are arbitrary RFC fields, so virtual addresses ride ordinary
CONNECT/BIND requests — the translation layer is the dial callback
(OQ-SK-01's shape: dial(target) matches a virtual subnet against a
per-identity resource table), per-identity scoping is resources + ACL
- target policy (this OQ), and hub exposure is the
terminate-and-re-produce story with no new wire vocabulary. BND.ADDR
stays per the handle swap (OQ-SK-03): in-band the handle is the
channel (compat surface only); a real network-B address exists
wherever a local backend binds — the listener's
local_addr(OQ-SK-08 variants 1–2 cover BIND's reply#1). The one place a virtual subnet becomes routable from vanilla apps is the full-tunnel composition: the host routes the subnet into the proxy (tun2proxy), soconnect(10.x.y.z:p)is an ordinary CONNECT the producer translates. Worth checking tun2proxy's virtual-network/virtual-DNS support as prior art before Phase 1 decides whether the crate ships a translation-table helper or leaves it entirely to assembly-layer policy.
OQ-SK-06: noq client support (and the quinn/noq shape split)
Scope clarification (2026-09-12): the SOCKS client wrapper is component 3 of the three-component split (§Vision) — a standalone client library for downstream users (alknet) that must work with any RFC 1928 server, not just one this crate produces. The motivating case is iroh's privacy posture: iroh relays (and the peer) see the client's real IP — intended for relay-assisted p2p, but a client that doesn't want to leak its IP needs a SOCKS hop ahead of the QUIC dial. This is the alknet ADR-090 use case generalized; it neither produces nor consumes channels.
The client wrapper's UDP story is "implement the QUIC runtime's
abstract socket trait over a SOCKS5 UDP association." quinn 0.11 is
proven (quinn-proxy POC, ADR-090). noq (iroh's fork, v1.2.0) has the
same extension point (AsyncUdpSocket,
Endpoint::new_with_abstract_socket) but the trait shape changed:
create_sender() -> Pin<Box<dyn UdpSender>> replaces
create_io_poller + try_send, and the constructor takes
Box<dyn AsyncUdpSocket> instead of Arc<dyn AsyncUdpSocket>.
Questions:
- Does this crate ship
Socks5UdpSocketagainst noq (behind a feature likenoq), quinn (behindquinn), or both? Both implies a shared substrate-free core (associate handshake, datagram codec, flow table) with two thin trait shells — feasible only if the shared core is genuinely trait-agnostic. iroh's adoption makes noq the practically-important target; alknet's precedent is quinn. - noq is not on crates.io at this version (iroh consumes it from the
n0 workspace/git) — how does this crate depend on it (git dep? wait
for publication? feature-gate so it is optional)? This may block
cargo publish --dry-runfor the client features; a publish-lean default (quinn optional, noq git-optional) may be needed. - Do the ECN/MTU limitations carry over (they should — the SOCKS5 UDP
header has no ECN field), and does noq's
may_fragmentdefault (true) behave the same as quinn's?
This is a research question first (read noq's endpoint/driver loops; write the trait-shape comparison), then possibly a POC (OQ-SK-07 #3).
Resolved by the scope decision (2026-09-14, §Vision): transferred to
the alknet rewrite. The client wrapper is alknet's ancestor-shaped
component (ADR-090 + quinn-proxy POC already live there), consumes no
channels, and has no consumers planned inside this crate — so its
research questions (noq trait-shape comparison, dependency posture,
ECN/MTU carry-over) move to alknet's Phase 0 wholesale. This crate's
client-side story is complete without it: the channels-native consumer
session is POC-validated (POC #1's use_stream over a BiStream,
POC #2's associate session). The noq POC (OQ-SK-07 #3) is dropped from
this crate's POC list accordingly.
OQ-SK-07: POC scope for what remains unvalidated
Candidates, in rough priority order (per the SDD process's "validate promising approaches"):
- Channels-native SOCKS5 CONNECT POC — the producer half over a
real alkcall channels connection: consumer opens a SOCKS5 channel,
speaks RFC 1928 CONNECT (via fast-socks5's client types), producer's
open handler runs the typestate machine over the
BiStream, dials a local echo target, two-pump proxy loop completes. Validates: theregister_openable_with_establisherfit,pump_bidias the data plane, params shape (trivial — probably no params), and theBiStream-as-Tgenericity claim. This is the crate's core value proposition and the cheapest to validate (alktunnels' forward POC is the template). Run 2026-09-13 — passed (alksocks-connect-poc, 8 tests + a curl front-door example; findings inpoc-connect-wasm-findings.md: dial-at-command-read layering resolved, identity seam works, refusal paths typed). - UDP ASSOCIATE POC — the OQ-SK-03 chosen shape, end to end: a
wrapper-aware client associates, sends length-prefixed datagrams
down the channel, producer relays to a real UDP endpoint. Validates:
the sentinel-reply semantics, the datagram codec (fast-socks5's
new_udp_header/parse_udp_requestover the length framing), the flow table, and empty-datagram handling (alktunnels F-2 transfers). (Reuses POC #1's producer skeleton; noterun_udp_proxy_customcannot 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 (noBND.ADDRneeded when the datagram stage needs no relay address). Baseline settled by the handle swap (2026-09-13, OQ-SK-03): one channel per association;{addr, data}frames in-band; channel EOF ends the association; RFC codec only at front doors. The POC should sketch both egress variants behind the dial callback — localUdpSocket(no flow table) and composed per-target alktunnels udp-tunnels (lazy destination→tunnel table, LRU) — and measure the per-destination open cost of the composed variant empirically. Run 2026-09-14 — passed (alksocks-udp-poc, 11 tests; findings inpoc-udp-associate-findings.md: the handle-swap data plane validated end to end, both egress variants implemented + composed per-destination open cost measured at ~0.8 ms, front-door RFC-codec composition confirmed, select!-driven stage with the egress readiness contract as the load-bearing API finding). - noq
AsyncUdpSocketimpl POC — the client-side story against noq 1.2: associate through a fast-socks5 server, wrap asnoq::AsyncUdpSocket, complete a QUIC handshake. Derisks OQ-SK-06 (thecreate_sender/Boxshape changes) before the client spec is written. (The quinn variant is already proven by the quinn-proxy POC — re-validating it here is optional.) Dropped from this crate (2026-09-14, the scope decision): the client wrapper transfers to the alknet rewrite (§Vision) — OQ-SK-06 and this POC go with it. (At the time of the decision the list was #1, #2, #4 — all passed; #5 (BIND) was added later with OQ-SK-08.) - fast-socks5 wasm check — minimal crate,
cargo check --target wasm32-unknown-unknown, confirm/inflect OQ-SK-04. Cheap; can fold into #1's worktree. Run 2026-09-13 — fork-minimality verified (~120 lines of#[cfg]insertions; wasm-clean--no-default- featuresbuild + functional RFC round-trip test pass; findings inpoc-connect-wasm-findings.md). - BIND POC — the RFC 1928 BIND command channels-native
(OQ-SK-08): the producer drives the typestate directly
(
read_command()→ matchTCPBind— fast-socks5 parses but refuses the command,lib.rs:384-386/server.rs:813-816; the wrapper owns the whole BIND shape, no upstream ask), the phase model extends naturally (request → reply#1 withBND.ADDR→ quiet wait → one inbound accept → reply#2 with the peer address → raw pass-through data plane, zero per-packet headers in-band; iroh-socks5's channels-native BIND is the convergent precedent,docs/research/iroh-socks5-eval.md§BIND). Sketch both accept variants behind the accept callback (OQ-SK-08): a feature-gated realTcpListenerbackend (faithful RFC — a real network-B address in reply#1, vanilla front-door clients work, FTP active mode as the canonical test) and the composed alktunnels listen-tunnel variant (register_tunnel_listen_openable+listen_establisher+AcceptQueue— per-listener ACL for free, hub-relayable). Plus a wrapper-aware in-band test and a vanilla front-door BIND test ("app server" dials the bound address after reply#1). Validates: the accept-side data plane (the mirror of the dial callback), reply#1/#2 sequencing on one channel, and the alktunnels listen-addr ask's shape. Completes the RFC 1928 command surface (all three commands) in the crate.
POC placement conventions (inherited from alktunnels): a POC that needs
code from this repo runs in a worktree/branch (.worktrees/research/ <task-id>/ per the SDD process); a self-contained POC runs as a
standalone crate in the global workspace with findings written into
docs/research/ here. Findings always land in docs/research/
regardless of where the code lives.
OQ-SK-08: BIND — the accept-side command (use case found)
Status: new (2026-09-14, post-POC #2). Both POCs refused BIND as RFC-correct; the findings review surfaced a real use case and a mechanism, so "out of scope until a use case exists" has been met.
The gap, precisely: fast-socks5 parses TCPBind
(Socks5Command::TCPBind, lib.rs:91) but every handler refuses it
(lib.rs:384-386, server.rs:813-816 — CommandNotSupported); no
bind machinery exists. The typestate flow (read_command() returning
cmd + TargetAddr) means the wrapper drives the command dispatch
itself — implementing BIND is wrapper-side code, zero upstream
ask (unlike UDP ASSOCIATE's run_udp_proxy_custom bind, which sits
inside an upstream handler).
The shape (convergent precedent: iroh-socks5's channels-native
BIND, docs/research/iroh-socks5-eval.md §BIND): one channel per
BIND session, phase model — request → reply#1 carrying BND.ADDR →
quiet wait (any control byte after reply#1 is protocol garbage, same
as ASSOCIATE's post-reply stage) → exactly one inbound accept →
reply#2 carrying the peer address → raw pass-through data plane (zero
per-packet headers in-band, same as CONNECT; only the ASSOCIATE
datagram stage is framed). RFC-refusal of BIND remains valid for old
consumers; support is purely additive (same ALPN, same params, no
framing change — no one-way-door exposure).
The mechanism — accept-side resources (the -R far-side listener
shape). The consumer's -R path in alktunnels
(register_tunnel_listen_openable + listen_establisher +
AcceptQueue, alktunnels/src/producer.rs:207,392) proves accept-side
resources exist without forced binds; SOCKS5 BIND is their mirror
image: the producer-side listener (network B — the app server must
reach it) is the accept resource, each popped accept two-pumps against
the SOCKS5 BiStream. The phase-0 hand-off note ("a producer-side
BIND wants the accept-side shape, not a dial") predicted exactly this;
the composition supplies the use case it lacked.
Accept variants (mirror of OQ-SK-03's egress variants) — the one real design question is address-first vs accept-first: RFC BIND is address-first (bind → advertise in reply#1 → client tells the app server → it connects → accept → reply#2), while the listen establisher is accept-first (opening the channel pops an already-accepted connection — you cannot open before reply#1 to learn the address). Three resolutions:
- Local backend binds for real (feature-gated
local) — the faithful RFC shape, iroh-socks5-faithful. A real network-B address goes in reply#1, vanilla front-door clients work; FTP active mode is the canonical use and the reason vanilla client libraries speak BIND at all. No upstream ask. - Composed + small alktunnels ask — the listener lives on a
listen resource (per-listener ACL for free, hub-relayable per the
terminate-and-re-produce story); needs "learn the bound address
without consuming an accept" — the plan carries
local_addr+ an inspect-only open, or alisten/addrquery op. Small ask; we own alktunnels (the same finding-first pattern that landed the E-01/E-02 sweep in alkcall 0.5.0). - Wrapper-aware only — zeros in reply#1, reply#2 in-band; simplest, but vanilla clients lose BIND and the use case mostly evaporates. Retained for completeness; weak hunch against.
Variant 1 is the POC #5 hunch (faithful + zero ask), with variant 2 sketched behind the same accept callback — the composed variant's value (hub story, per-listener ACL) is the reason to measure it, and its blocker is the address-inspection ask.
Security boundary note: BIND is an inbound listener — the
accept-side twin of arbitrary egress. Whatever ACL governs the socks5
resource now also governs who can mint listeners on the producer's
network; per-listener policy (address/port ranges allowed to bind) is
the accept-callback's target-policy twin (OQ-SK-05's mirror). The
open-op gate (SOCKS5_OPEN_SCOPE) must distinguish CONNECT/ASSOCIATE
from BIND, or any identity with proxy egress can also open ingress
listeners.
OQ-SK-09: Channel capacity for proxy workloads (the 256 default)
Status: new (2026-09-14, post-POC #2). The 256-channel default
cap (per-connection ADR-040; per-identity ADR-041) is tight for a
proxy workload: every proxied flow is a channel (a browser or
tun2proxy fan-out opens one CONNECT channel per flow; composed UDP
egress (OQ-SK-03) spends a downstream channel per destination; BIND
(OQ-SK-08) will spend an accept resource per listener). The caps are
policy knobs — configurable without a wire change, the u32 wire
space is ~4 billion — so the resolution is config, not wire:
- No wire change. Rejected permanently as unnecessary: the u32 channel-ID space is not the constraint; the caps are policy.
- Assembly-layer config is the mechanism (ADR-040/041 are explicitly "policy knobs, configurable without a wire change"). A proxy-shaped deployment raises the per-connection/per-identity caps; the default stays conservative for everyone else.
- Documented proxy-workload default for Phase 1: the crate docs should recommend a raised cap (and its memory arithmetic: a session is bounded + predictable — CONNECT/ASSOCIATE are bounded-buffer pumps, BIND adds one listener + one accept) so deployments don't discover the cap in production.
- Defense-in-depth posture: the cap's rationales both still apply under SOCKS5. Per-connection memory bound — arguably weaker here, since the three command shapes are well-known and bounded (a session's memory is predictable); per-identity DoS brake — arguably stronger here, since each SOCKS5 session maps to real producer-side resources (a dial, a downstream channel, an accept listener) and the cap is the brake on one identity opening thousands. Layered defense: ACL → per-connection cap → per-identity cap → dial/accept policy; relaxing the caps leans on the rest.
Considered and rejected: in-channel port multiplexing. A related
proposal (2026-09-14 review) would reframe the cap by multiplexing
"ports" inside one channel — a [port: u16][len][payload] per-chunk
prefix (channel-id-as-IP, port-as-port; modeled on alktty's wire).
Rejected on the established grounds: it re-opens the demux-vs-phase
trade OQ-SK-03 already resolved (phase model, no in-band control
vocabulary needed across both POCs), it breaks the 0-B pass-through
that lets fast-socks5's raw transfer drop in unchanged (POC #1's
validated fit), it imposes chunk boundaries on a stream RFC 1928
treats as unstructured, and it violates the one-channel-per-SOCKS5-
session convention (AGENTS.md #10). Channel-ID economy is not a wire
problem the u32 space + policy knobs fail at. Revisit trigger: if
a deployment hits a fixed upstream channel budget that config cannot
raise, the additive escape is a new multiplexed ALPN — never a
change to this ALPN's framing.
The related idea that survives — virtual address space / identity-scoped subnets — is recorded under OQ-SK-05 (added 2026-09-14): BND.ADDR/DST.ADDR translation via the dial callback, no wire change.
Survey / prior-art list
Candidate reading for the research specialist (to be expanded):
- RFC 1928 (SOCKS5) — the fixed protocol: method negotiation, CONNECT, BIND, UDP ASSOCIATE, reply codes. RFC 1929 (username/password).
- fast-socks5
/workspace/fast-socks5—src/server.rs(typestate API, interception points, auth traits),src/client.rs(Socks5Stream/Socks5Datagram,use_stream),src/lib.rs(new_udp_header/parse_udp_request,ReplyError),examples/router.rs(conditional interception),examples/ custom_auth_server.rs. - iroh-socks5 —
crates.io/crates/iroh-socks5v0.5.0, repogithub.com/mattgeddes/iroh-socks5(crates.io has no repository link; the published source matches). Evaluated indocs/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-Dcomposition conclusion, hub-owns-the-connection, the UDP codec decision + F-2),docs/architecture/(params/ALPN/ACL ADR template), POC summaries (POC placement and findings conventions). - alkcall —
docs/architecture/decisions/ADR-037/039/049/050 (channel ops, params-is-ALPN-specific, establishment, pump_bidi), ledger CF-005/CF-006 (identity seam),src/channels/operations.rs(ChannelCore,OpenHandler,Establishment,ChannelPlan),src/channels/pump.rs. - alknet —
crates/alknet-client/src/socks5.rs(ADR-090, the client ancestor),docs/research/quinn-quic-proxy/findings.md(the POC findings: trait + constructor surface, ECN/MTU limitations, BotBrowser production precedent). - noq —
/workspace/noq:noq/src/runtime/mod.rs(AsyncUdpSocket,UdpSender),noq/src/endpoint.rs(new_with_abstract_socket),noq-udp/src/lib.rs(RecvMeta,Transmit),Cargo.toml(workspace versioning / publication posture). - alktty — backend inversion point (
TtyBackend),TTY_OPEN_SCOPEscope-gating shape, feature-gatedlocalbackend, wasm-clean default-crate verification commands. - tun2proxy —
/workspace/tun2proxysrc/udpgw.rsand its SOCKS5 files (socks.rs,proxy_handler.rs): UDP-over-stream framing and flow-table prior art (analyzed in alktunnels phase-0-findings; the SOCKS5-specific parts feed OQ-SK-03). Also the endgame composition partner: it takes--proxy socks5://...upstream natively (src/args.rs) — the vpn-like endgame is tunnel + local bind + tun2proxy (§Vision, principle 6). - alkhttp —
/workspace/@alkdev/alkhttp: the native-only precedent in the suite (axum/reqwest/hyper, no wasm target) — cited in OQ-SK-04 as the "wasm-clean is preferred, not mandatory" escape hatch. - iroh —
/workspace/irohiroh/Cargo.toml(the noq dependency posture:noq = "1.2.0"from the n0 workspace) — context for OQ-SK-06's dependency question; also the privacy motivation for the client wrapper (relays/peers see the real IP).
Convergence checklist (what Phase 0 must produce)
- Vision + guiding principles captured (this doc, §Vision — 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; iroh-socks5 evaluated (structural sibling, not the base;
OQ-SK-03 prior art —
docs/research/iroh-socks5-eval.md) - 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 trait); final decision in Phase 1 against the spec
- OQ-SK-02 (auth mapping) — posture drafted (identity seam primary, in-band auth for the local backend); POC #1 validated the seam end to end (per-call opener identity at the establisher; channel-0 identity propagation is a producer harness wiring requirement); direction settled 2026-09-13 ("both," split by the door: producer-side identity only — a password there is moot against the ACL; consumer-side front door owns RFC 1929 for vanilla clients and maps credentials to the channel identity); Phase 1 formalizes the door's credential→identity mapping
- OQ-SK-03 (UDP ASSOCIATE) — the shape space written (including
the phase-model-vs-alktty-demux analysis); resolve via research
+ POC #2, ADR before the first consumer
(POC #2 run 2026-09-14 — passed; the handle-swap baseline is
POC-validated end to end, findings in
poc-udp-associate-findings.md; the datagram framing is wire-stable once published — the Phase 1 ADR decision remains) - OQ-SK-04 (fast-socks5 wasm) — the decision tree is written (Case 1: wasm wanted → fork-first; Case 2: wasm unwanted → plain dep, router path); POC #4 verified the fork is genuinely minimal (~120 lines of cfg gating; wasm-clean subset is functionally complete); the root question ("does wasm make sense for alksocks?") now reduces to the adapter-story input
- OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision; scope-gate convention pinned in Phase 1; the 2026-09-14 virtual-subnet proposal (dial-callback translation, no wire change) recorded for Phase 1
- OQ-SK-08 (BIND) — new (2026-09-14): the gap + use case + accept-side mechanism written; resolve via POC #5 + the Phase 1 ADR (ship in base crate? default accept variant?)
- OQ-SK-09 (channel capacity) — new (2026-09-14): the proxy- workload analysis written (wire change rejected; config is the mechanism; in-channel port multiplexing considered and rejected); Phase 1 pins the documented proxy-workload recommendation
- OQ-SK-06 (noq client) — resolved by the scope decision (2026-09-14, §Vision): transferred to the alknet rewrite (the client wrapper is alknet's ancestor-shaped component, consumes no channels, has no consumers planned here); its research questions move to alknet's Phase 0; this crate's channels-native client story is POC-complete without it
- Targeted POC(s) run + summaries in
docs/research/(three complete: #1 + #4 inpoc-connect-wasm-findings.md, #2 inpoc-udp-associate-findings.md; #3 dropped with the scope decision; #5 (BIND) added 2026-09-14 with OQ-SK-08 — pending) - Converge: recommended approach written up, ready to hand to the Architect for Phase 1 (§Convergence, 2026-09-14)
Convergence — the recommended approach
This is Phase 0's output: a clear WHAT/WHY with validated approaches, ready for the Architect.
The crate, scoped
alksocks = the SOCKS5 producer/consumer protocol pair on alkcall
channels (socks + channels, nothing else). The three-component split
resolved: components 1+2 are this crate; component 3 (the quinn/noq
AsyncUdpSocket client wrapper) moves to the alknet rewrite, where
its ancestor work (ADR-090, quinn-proxy POC) already lives. No local
binds except the two explicit, optional, feature-gated assembly shapes
(producer's local backend; consumer's front door).
The recommended approach (validated pieces → crate shape)
- Protocol base: fast-socks5's explicit typestate API — the
T: AsyncRead + AsyncWrite + Unpingenericity carries both substrates (POC #1: a channelsBiStreamdrops in directly; the local backend would feed aTcpStream). Drive the typestate directly for UDP ASSOCIATE (run_udp_proxy_customis unusable — unconditional kernel bind; file the upstream ask). - Wire shape: the handle swap (POC #2-validated) — one ALPN
(
alk/socks5), one open op (channels/socks5/sub), trivial params; the RFC conversation is the data plane; CONNECT =pump_bidipass-through post-reply (POC #1); ASSOCIATE = theselect!-driven datagram stage over[len: u16 BE][addr + data]frames (alktunnels' length codec; RSV/FRAG only at front doors; channel EOF ends the association; per-datagram addressing, no flow table). - Egress seam: the dial-callback shape, extended —
DialFnfor CONNECT (POC #1's command-read interception point) +EgressFactoryfor ASSOCIATE (POC #2), both function-not-trait, both returning boxed halves. Local-socket and composed (per-target alktunnels udp-tunnels) providers are assembly-layer policies; the composed variant's per-destination open cost is measured (~0.8 ms) and its per-target-ACL-for-free benefit is validated. - Auth: "both," split by the door (OQ-SK-02's settled posture) — producer side: the alkcall identity seam only (the in-band conversation runs NoAuth); the consumer's front door owns RFC 1929 for vanilla clients and maps credentials to the channel identity.
- Layering: the alktty/alktunnels precedents — substrate-agnostic
protocol modules at the crate core; feature-gated backend modules
(local binds) never imported by the protocol layer; producer +
consumer + params modules mirroring POC #1/#2's structure; the
fork posture for fast-socks5 is vendoring the
T-generic subset into this crate gated behind anet-shaped feature (POC #4: ~120 cfg lines, wasm-clean subset functionally complete). - Open for Phase 1 ADRs (inputs collected, decisions pending):
the demux-vs-phase extensibility trade (evidence now favors the
phase model — no in-band control vocabulary was needed across both
POCs); the params-format/ALPN naming ADRs (wire-stable once
published, no consumers exist yet);
SOCKS5_OPEN_SCOPEconvention (must distinguish BIND from CONNECT/ASSOCIATE — OQ-SK-08's security note); the door's credential→identity mapping shape; the wasm decision (Case 1 vs Case 2) once the adapter story is confirmed; BIND's base-crate inclusion + default accept variant (OQ-SK-08); the proxy-workload channel-cap recommendation (OQ-SK-09); the virtual-subnet translation-layer shape (OQ-SK-05 addendum). - Upstream asks (we own both upstreams; file early, land there):
fast-socks5 — the
netfeature gate (POC #4's ~120-line change) and therun_udp_proxy_customunconditional-bind seam. alktunnels — listen-addr inspection without consuming an accept (OQ-SK-08 variant 2's blocker). First-real-consumer asks, the alkcall E-01/E-02 precedent.
What Phase 0 could NOT answer (handed to Phase 1)
- The datagram-framing ADR (the one-way door) — now has empirical ground: framing validated, LRU/dedup semantics pinned, open cost measured. Decide with the first consumer in sight.
- The wasm root question (OQ-SK-04) — mechanism de-risked; the adapter-story input is a product decision, not a research one.
- Target-policy shape (OQ-SK-05) — the dialer-refuses shape is evidence-backed; the declarative-allowlist variant stays a Phase 1 choice. (The 2026-09-14 virtual-subnet proposal is recorded there — dial-callback translation, no wire change.)
- Channel capacity for proxy workloads (OQ-SK-09) — wire change rejected; assembly-layer config + a documented proxy-workload cap recommendation stay Phase 1 decisions. In-channel port multiplexing considered and rejected there.
- BIND — RFC-refused in both POCs; a use case and a mechanism were
found in the 2026-09-14 findings review — OQ-SK-08 records the
gap, the accept-side-resource shape (mirroring alktunnels'
-Rfar-side listener), the address-first-vs-accept-first design question, and POC #5. Deciding whether BIND ships in the base crate (and which accept variant is default) is a Phase 1 ADR — support is additive (old consumers still get the RFC refusal), so nothing here is a one-way door.