--- status: draft last_updated: 2026-09-12 --- # 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 runtimes** — `AsyncUdpSocket` 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. 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`) 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`, generic over `T: AsyncRead + AsyncWrite + Unpin`. Flow: `start(inner)` → `negotiate_auth(&methods)` → `finish_auth()` / `accept_no_auth` / `accept_password_auth` → `read_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` trait (metadata: `method_id`, `new`) + `AuthMethodSuccessState` (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: the wrapper supplies a custom `transfer` closure that owns the relay half (OQ-SK-03). - `transfer(inbound, outbound)` — plain two-pump copy; a channels `BiStream` is a legal `T` on 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 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 side** — `Socks5Stream` (generic over the backing socket; `use_stream` upgrades any `AsyncRead + AsyncWrite + Unpin` already connected) and `Socks5Datagram` (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 surface** — `ReplyError` (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). ### 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>` with `poll_send(transmit, cx)` — a sender-object split (any number of `UdpSender`s 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` rather than `Arc` — 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 `Arc`→`Box` 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 codec** — `socks5.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)? Hunch: identity-at-the-open-op is the primary gate for channels-native consumers; in-band username/password exists for the local-backend path only. Needs a decision (and possibly an upstream ask) in Phase 1. ### 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` to a sentinel that means "same channel" — but vanilla SOCKS5 clients will literally `sendto()` that address, 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). - **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?** RFC says the relay address; a channels path has none. fast-socks5's `run_udp_proxy_custom` lets the wrapper supply the reply and the relay half — the seam exists upstream; the semantics are ours to define. - **Per-datagram addressing** — the SOCKS5 UDP header carries the destination per datagram (ATYP + addr + port), so one association multiplexes many endpoints: per-endpoint flow table producer-side (the alknet `Socks5UdpSocket` model, and the udpgw prior art alktunnels documented). Boundary preservation is mandatory (empty datagram ≠ EOF — alktunnels F-2's lesson transfers directly). - **Do vanilla (wrapper-unaware) clients need channels-native UDP at all?** If the only UDP ASSOCIATE consumers are wrapper-aware, the sentinel-reply shape is fine and no virtualized relay address is ever invented. **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, unchanged): wrapper-aware client halves ride the same-channel length-prefixed-datagram shape; vanilla clients get UDP ASSOCIATE only via the optional local backend. But this is 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: - 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.) ### 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>` replaces `create_io_poller` + `try_send`, and the constructor takes `Box` instead of `Arc`. 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). ### 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). 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). 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.) 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. POC placement conventions (inherited from alktunnels): a POC that needs code from this repo runs in a worktree/branch (`.worktrees/research/ /` 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. ## 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`. - 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 - [ ] OQ-SK-01 (dial policy) — researched, half-answered (injected dialer hunch); decide in Phase 1 against the spec - [ ] OQ-SK-02 (auth mapping) — posture drafted (identity seam primary, in-band auth for the local backend); decide in Phase 1 - [ ] 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 - [ ] 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); the root question ("does wasm make sense for alksocks?") settles on the adapter-story input + POC #4's minimality check - [ ] OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision; scope-gate convention pinned in Phase 1 - [ ] OQ-SK-06 (noq client) — research pass (trait-shape comparison, dependency posture) + POC #3 if the research is not decisive - [ ] Targeted POC(s) run + summaries in `docs/research/` (OQ-SK-07; #1 first — it validates the core value proposition) - [ ] Converge: recommended approach written up, ready to hand to the Architect for Phase 1