--- status: complete last_updated: 2026-09-13 --- # alksocks POC findings — channels-native CONNECT + fast-socks5 wasm posture POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13. - **POC #1** (channels-native SOCKS5 CONNECT): `alksocks-connect-poc` (standalone crate, `/workspace/alksocks-connect-poc/`), against alkcall 0.7.1 (crates.io, verified == local checkout) and fast-socks5 1.0.0 (crates.io). 8 tests + 1 example (`local_front_door`, the curl front door). All pass. - **POC #4** (fast-socks5 wasm fork-minimality): `fs5-wasm-poc` (scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` — not part of this repo; the finding is the artifact). fast-socks5's `src/` vendored, kernel-net pieces gated behind a `net` feature. ## POC #1 — channels-native SOCKS5 CONNECT ### What was validated 1. **`BiStream`-as-`T` genericity — confirmed.** The channels `BiStream` (`alkcall::core::types`, `Box`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`) satisfies fast-socks5's `Socks5ServerProtocol` server typestate and `Socks5Stream::use_stream` client path directly. No adapter, no wrapper stream, no boxing beyond what `BiStream` already is. 2. **`register_openable_with_establisher` fit — confirmed.** The producer registers `channels/socks5/sub` (ALPN `alk/socks5`, scope `socks5:open`) with an establisher + pump handler exactly as alktunnels does for `channels/tunnel/sub`. 3. **`pump_bidi` as the CONNECT data plane — confirmed.** Post-reply the handler awaits `pump_bidi(channel_bistream, target_read, target_write)` inline; echo round-trips clean through client → channel → pump → target → back. 4. **Session refusal is a typed call error — confirmed.** An identity lacking the open scope gets `FORBIDDEN` at the registry ACL gate; `ChannelOpenError::CallFailed` carries it; no phantom channel. The establisher's per-call identity arrives as expected (witnessed `"consumer"`; CF-005/CF-006 seam works). 5. **Dial failure → RFC reply code mapping — confirmed.** The dial refusal surfaces to the RFC client as `ConnectionRefused` (0x05) in the CONNECT reply — the faithful per-RFC error mapping AGENTS.md convention 2 requires — with no phantom data plane. 6. **The front door works with a vanilla client (stretch goal).** The consumer-side local exposure shape: bind a real TCP listener, pipe each accepted connection raw onto a channel `BiStream` (`copy_bidirectional`, zero protocol translation), and curl speaks RFC 1928 through the pipe against the producer's state machine. `curl --socks5-hostname ... http://...` completed end to end (curl's request bytes round-tripped through the channel to a real TCP echo target and back). The 0 B pass-through conclusion is literal: the door adds no framing. ### Structural findings (the load-bearing discovery) **The dial cannot live in the establisher — and that is correct, not a limitation.** The SOCKS5 *target* is unknown at open time: RFC 1928 method negotiation and the CONNECT command (with its target address) arrive in-stream, after channel establishment. The layering the POC validates: - **The establisher is a session gate** — it validates/records the opener identity (OQ-SK-02's seam) and does session-level policy. It never sees a target. - **The dial belongs at the command-read interception point**, inside the pump handler, after `read_command()` names the target. The injected `DialFn(target) -> boxed halves` closure (alktunnels ADR-004's function-not-trait shape) is called there. - **Per-target refusal is an RFC reply code** (`reply_error` with the mapped `ReplyError`), not an establishment failure. Session-level refusal stays channels-level (`channel:open_failed`). This resolves OQ-SK-01's shape question concretely: the dial callback exists and is called in-stream; `TargetHandle { read, write }` (boxed halves, `+ Sync`) satisfies the `ChannelPlan` payload bound and is the natural dialer return type. The no-trait hunch is now evidence-backed. **Identity propagation (CF-005 (b)) has a producer-side step the harness had to do:** the accept-side install hook must call `channel0_conn.set_identity(resolved)` before building the `CallConnection` — the transport identity does not flow to the channel-0 dispatch automatically. (alktunnels' harness does the same; modeled mTLS posture. A real mTLS transport would resolve the peer here.) Worth an upstream note: without it, scope-gated opens fail closed with `FORBIDDEN "authentication required"` — correct behavior, but the wiring requirement is implicit. ### Wire/API notes for the wrapper - `TargetAddr::Ip(addr).to_string()` is `ip:port` — when handing the dial closure a structured target, use `addr.ip().to_string()` + `addr.port()` separately (the POC hit this; the wrapper's target carrier will too). - `Socks5Stream::request(cmd, TargetAddr)` — the target must be constructed via `ToTargetAddr`; IP-string forms parse to IP targets (domain form passes through unresolved, per OQ-SK-05's DNS question). - `SocksServerError::AuthenticationRejected` is a unit variant (no message field) in 1.0.0. - `Establishment` with no plan (`Establishment::default()`) is the right shape for SOCKS5 — the dial happens post-establishment, so there is nothing to thread establisher→handler. - The open-op params are trivially empty (`{}`); the spec's input schema is `{type: object}`. Params stay empty unless a later ADR adds per-session options. ## POC #4 — fast-socks5 wasm fork-minimality ### Baseline `cargo check --target wasm32-unknown-unknown` against fast-socks5 1.0.0 as-is fails at the dependency graph: tokio's `net` feature (via `mio` + `socket2`) hard-errors on wasm (`"This wasm target is unsupported by mio"`). As predicted by OQ-SK-04's premise. (alksocks-connect-poc itself re-confirms: the dep chain pulls `mio`/`socket2`.) ### The gating change — measured, not guessed Vendored fast-socks5's `src/` into a scratch crate; feature-gated every kernel-net touch behind a `net` feature (default-on, exactly the alktty `local` pattern): | file | gating | notes | |---|---|---| | `src/server.rs` | imports (`socket2`, `tokio::net`, `try_join`, `tokio_stream`); legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls); `run_tcp_proxy`; `udp_bind_random_port`; `run_udp_proxy`/`run_udp_proxy_custom`; all `handle_udp_*`/`transfer_udp`; `DnsResolveHelper` trait + impl; the net-only test module | ~8 `#[cfg]` insertions; the typestate core (`Socks5ServerProtocol`, states, negotiate/read_command/reply, auth traits, `transfer`, `wait_on_tcp`) untouched | | `src/client.rs` | imports; `Socks5Datagram` (struct + impl); the `Socks5Stream` convenience impl (`connect`/`connect_with_password`/`connect_raw`) | `use_stream` path + `AsyncRead/AsyncWrite` impls untouched | | `src/util/stream.rs` | `tokio::net` import; `tcp_connect*` fns | `read_exact!`/`ready!` macros + `ConnectError` untouched | | `src/util/target_addr.rs` | `lookup_host` import; `resolve_dns` method | the codec (`to_be_bytes`, `read_address`, `ToTargetAddr`) untouched | | `src/lib.rs` | the net-heavy test module | consts/errors/UDP-header codec untouched | | `Cargo.toml` | `net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`, default-on | tokio stays `default-features = false, io-util/time/sync/macros` | Diff size vs upstream: **~120 changed lines across 5 files, all `#[cfg]` insertions** (client.rs 87 diff lines — inflated by the whitespace of moving the datagram block; server.rs 27; the rest ≤3). ### Results - `cargo check --no-default-features` (native): clean. - `cargo check --target wasm32-unknown-unknown --no-default-features`: **clean** — no `mio`, no `socket2`, no `tokio::net` in the graph. - `cargo test --no-default-features`: a full RFC 1928 CONNECT conversation (typestate server over a duplex ↔ `use_stream` client) passes — the wasm-clean subset is the *functional* protocol subset, not a husk. - `cargo check --target wasm32-unknown-unknown` with `net` on: fails in `mio` (expected — the target gate for wasm users is `default-features = false`). ### Verdict for OQ-SK-04 **Case 1's premise holds: the fork is genuinely minimal.** The `T`-generic surface separates from `tokio::net` with ~8 cfg insertions and zero code rewrites; the wasm-clean subset still implements the whole RFC conversation. tokio itself needs `default-features = false` with an explicit `net` opt-in feature (`net = ["tokio/net", ...]`) — that is the entire shape of the change. The one structural discovery: **`tokio_stream` (used for the legacy `Incoming` stream) and `socket2` must be optional deps keyed to the same feature**, and `try_join!` requires tokio's `macros` feature — all mechanical. Remaining for Case 1: the fork posture is vendoring *into this crate* (per phase-0: vendor the `T`-generic subset, gate the rest), keeping `/workspace/fast-socks5` as the differential reference; the upstream PR (feature-gate `net` behind a default-on feature) is small enough to offer as-is. ### Follow-on asks (upstream, we own it) 1. **Feature-gate kernel-net behind a default-on `net` feature** (POC #4's change, ~120 lines of `#[cfg]`). 2. **`run_udp_proxy_custom` binds a kernel socket unconditionally** (noted in phase-0 §Prior art, verified again here): the customizable seam covers the relay closure and reply IP only; the channels path must drive the typestate directly. Make the `udp_bind_random_port` step optional / accept a caller-supplied socket. ## OQ status after these POCs - **OQ-SK-01 (dial policy)**: shape resolved empirically — an injected dial callback invoked at the command-read interception point; boxed-halves return type; no trait (alktunnels' no-trait precedent holds). Where the callback *comes from* (alktunnels composition vs local TCP vs further hops) stays an assembly-layer question. - **OQ-SK-02 (auth mapping)**: the seam works — per-call opener identity arrives at the establisher, and channel-0 identity propagation is a wiring requirement the producer harness performs. In-band SOCKS auth was not exercised (NoAuth only); the RFC-facing username/password path for the local backend remains Phase 1. - **OQ-SK-04 (wasm)**: fork-minimality verified; the decision now reduces purely to "does wasm make sense for alksocks" (the adapter story), with the mechanism de-risked. - **OQ-SK-07**: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq) remain.