Files
alksocks/docs/research/phase-0.md
T
glm-5.3-flash 601afa3922 docs: BIND use case found (OQ-SK-08, POC #5) + capacity OQ-SK-09
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).
2026-09-15 03:28:15 +00:00

69 KiB
Raw Blame History


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):

  1. 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 -D service over channels.
  2. 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:port endpoint and relay each accepted connection into a channel (the ssh -D local-exposure shape, and the path tun2proxy/curl/browser point at).
  3. SOCKS client wrapper for QUIC runtimesAsyncUdpSocket implementations (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:

  1. "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 -D front door). The protocol itself never binds; the binding decision belongs to the caller.
  2. The -D conclusion, realized. alktunnels' Phase 0 settled that -D composes at the assembly layer: "-D is 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.
  3. Wrap fast-socks5, preserve its genericity. fast-socks5's explicit typestate server API (Socks5ServerProtocol<T, states::*>) is generic over T: AsyncRead + AsyncWrite + Unpin, and its interception points (run_tcp_proxy, run_udp_proxy_custom, transfer) accept any such T. The channels adapter feeds the state machine a BiStream; a local backend feeds it a TcpStream. The wrapper must not leak either substrate into the protocol layer (alktty TtyBackend / alktunnels pump-halves inversion-point precedent).
  4. 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.
  5. Extract-and-improve from alknet. alknet's SOCKS5 story is the direct ancestor: the client side (alknet-client/src/socks5.rs, ADR-090 — Socks5UdpSocket implements quinn::AsyncUdpSocket so QUIC rides UDP ASSOCIATE, validated by the quinn-proxy POC) and the server side (the -D capability alktunnels deferred). This crate rehomes both halves behind one protocol crate and improves them.
  6. 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:port natively, 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 one BiStream per 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 typed channel:open_failed call 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 the BiStream — 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 via ChannelClient (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-socks5 itself 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.
  • -D composes 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 over T: AsyncRead + AsyncWrite + Unpin. Flow: start(inner)negotiate_auth(&methods)finish_auth() / accept_no_auth / accept_password_authread_command() → (reply_success, reply_error). The legacy Socks5Server/Socks5Socket/Incoming API (binds a TcpListener) 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); StandardAuthentication enum 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, then transfer(inbound, outbound) (a copy_bidirectional wrapper, itself generic). This is where a channels-native dial replaces the tokio::net dial: the wrapper's producer half intercepts here, dials via alktunnels/alkcall primitives (or its own dial policy), and returns the T back.
    • 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 calls udp_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 channels BiStream is a legal T on either side.
  • UDP supportnew_udp_header(target) / parse_udp_request(buf) are public and pure (no socket I/O) — the SOCKS5 UDP datagram header codec is reusable without the Socket2-based relay machinery. The default run_udp_proxy binds 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 sideSocks5Stream<S> (generic over the backing socket; use_stream upgrades any AsyncRead + AsyncWrite + Unpin already connected) and Socks5Datagram<S> (UDP associate; also accepts a caller-supplied socket via use_socket). Socks5Stream impls AsyncRead + AsyncWrite itself, so a consumer session over a channels BiStream is the intended use, not a hack. Note: Socks5Stream::connect convenience constructors dial tokio::net::TcpStream and are non-wasm; use_stream is the substrate-free path.
  • Error surfaceReplyError (the RFC reply codes, as_u8/ from_u8) and SocksError; SocksServerError on the explicit API. Map these faithfully; never collapse a refusal into a generic error.
  • router.rs example — 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/RecvStream in relay signatures; no generic T seam), 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::AsyncUdpSocket exists (noq/src/runtime/mod.rs:44) but with a different shape than quinn 0.11: create_io_poller + try_send are replaced by create_sender() -> Pin<Box<dyn UdpSender>> with poll_send(transmit, cx) — a sender-object split (any number of UdpSenders per socket, each holding its own waker).
  • RecvMeta gained fields (interface_index, timestamp) but stays default-constructible; Transmit is unchanged in the fields the SOCKS5 wrapper touches (destination, contents, ecn, src_ip, segment_size).
  • Endpoint::new_with_abstract_socket exists (noq/src/endpoint.rs:162) with the same doc-comment intent, but takes Box<dyn AsyncUdpSocket> rather than Arc<dyn AsyncUdpSocket> — the poller-removal reshuffle also changed the ownership shape.
  • The alknet Socks5UdpSocket does not drop in unchanged: the impl must be rewritten against create_sender/poll_send, and the ArcBox change 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 codecsocks5.rs inlines the greeting/auth/CONNECT/ASSOCIATE byte logic twice (handshake for UDP, again for CONNECT). fast-socks5's client types and new_udp_header/parse_udp_request supersede 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/tunnel channel 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 (TcpStream behind the local feature, 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_compliant or 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 AuthMethod implementation that consults the alkcall AuthContext/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 BiStream carries length-prefixed datagrams (the alktunnels UDP codec shape, [len: u16 BE] per datagram; 65507 < 65535 so u16 suffices). The reply to the client rewrites BND.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 literally sendto() 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; see docs/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 BiStream semantics 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.
  • What does BND.ADDR/BND.PORT reply 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's BND.ADDR/ BND.PORT are 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?

  1. Local egress — the handler binds a real UdpSocket and send_tos per datagram (iroh-socks5's relay_udp_server behind this crate's local feature). No flow table, native-only.
  2. 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; the u32 wire 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/socket2 behind a default-on net feature, per the alktty local pattern); 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, client use_stream path — the parts that are already T-generic) and gating the native-net pieces behind the crate's own local feature, 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.rs native 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):

  1. 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.
  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 from tokio::net without 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, alktty TTY_OPEN_SCOPE shape) — 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_resolve config), refuse, or pass through to the dialer unresolved? (DNS-on-the-producer is the SSH -D semantic; 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 12 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), so connect(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 Socks5UdpSocket against noq (behind a feature like noq), quinn (behind quinn), 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-run for 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_fragment default (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"):

  1. 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: the register_openable_with_establisher fit, pump_bidi as the data plane, params shape (trivial — probably no params), and the BiStream-as-T genericity 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 in poc-connect-wasm-findings.md: dial-at-command-read layering resolved, identity seam works, refusal paths typed).
  2. 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_request over the length framing), the flow table, and empty-datagram handling (alktunnels F-2 transfers). (Reuses POC #1's producer skeleton; note run_udp_proxy_custom cannot 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 (no BND.ADDR needed 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 — local UdpSocket (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 in poc-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).
  3. noq AsyncUdpSocket impl POC — the client-side story against noq 1.2: associate through a fast-socks5 server, wrap as noq::AsyncUdpSocket, complete a QUIC handshake. Derisks OQ-SK-06 (the create_sender/Box shape 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.)
  4. 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- features build + functional RFC round-trip test pass; findings in poc-connect-wasm-findings.md).
  5. BIND POC — the RFC 1928 BIND command channels-native (OQ-SK-08): the producer drives the typestate directly (read_command() → match TCPBind — 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 with BND.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 real TcpListener backend (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-816CommandNotSupported); 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:

  1. 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.
  2. 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 a listen/addr query op. Small ask; we own alktunnels (the same finding-first pattern that landed the E-01/E-02 sweep in alkcall 0.5.0).
  3. 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-socks5src/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-socks5 v0.5.0, repo github.com/mattgeddes/iroh-socks5 (crates.io has no repository link; the published source matches). Evaluated in docs/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 -D composition 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_SCOPE scope-gating shape, feature-gated local backend, wasm-clean default-crate verification commands.
  • tun2proxy — /workspace/tun2proxy src/udpgw.rs and 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/iroh iroh/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 in poc-connect-wasm-findings.md, #2 in poc-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)

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).

  • Protocol base: fast-socks5's explicit typestate API — the T: AsyncRead + AsyncWrite + Unpin genericity carries both substrates (POC #1: a channels BiStream drops in directly; the local backend would feed a TcpStream). Drive the typestate directly for UDP ASSOCIATE (run_udp_proxy_custom is 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_bidi pass-through post-reply (POC #1); ASSOCIATE = the select!-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, extendedDialFn for CONNECT (POC #1's command-read interception point) + EgressFactory for 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 a net-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_SCOPE convention (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 net feature gate (POC #4's ~120-line change) and the run_udp_proxy_custom unconditional-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' -R far-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.