From 7c6bc656181f989c4cdd77eb00041a02cd0c7487 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 13 Sep 2026 14:38:22 +0000 Subject: [PATCH] docs: POC #1 (channels-native CONNECT) + POC #4 (fs5 wasm fork) findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POC #1 passed (alksocks-connect-poc, 8 tests + curl front-door example): BiStream-as-T genericity, register_openable_with_ establisher fit, pump_bidi data plane, typed session refusal, RFC reply-code dial-refusal mapping, vanilla-client front door. Structural resolution: the dial belongs at the command-read interception point (target is unknowable at open time); the establisher is the session gate. OQ-SK-01/02 shapes resolved empirically. - POC #4 passed (fs5-wasm-poc, kept at /workspace/alksocks-fs5-wasm-poc): the fork is genuinely minimal — ~120 lines of #[cfg] insertions gate kernel-net behind a default-on 'net' feature; the wasm-clean subset compiles for wasm32-unknown-unknown (--no-default-features) and still completes a full RFC 1928 CONNECT conversation. OQ-SK-04 reduces to the adapter-story question. - Corrected the run_udp_proxy_custom prior-art claim: it binds a kernel socket unconditionally (relay closure + reply IP only); the channels path drives the typestate directly. Fileable ask. - Findings: docs/research/poc-connect-wasm-findings.md --- docs/research/phase-0.md | 50 ++++-- docs/research/poc-connect-wasm-findings.md | 199 +++++++++++++++++++++ 2 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 docs/research/poc-connect-wasm-findings.md diff --git a/docs/research/phase-0.md b/docs/research/phase-0.md index 7795121..a705873 100644 --- a/docs/research/phase-0.md +++ b/docs/research/phase-0.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-09-12 +last_updated: 2026-09-13 --- # alksocks — Phase 0 (Exploration) @@ -209,8 +209,15 @@ before designing against it. Key surface points, verified: 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). + — 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 support** — `new_udp_header(target)` / `parse_udp_request(buf)` @@ -593,13 +600,18 @@ promising approaches"): 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). + 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.) 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 @@ -608,7 +620,10 @@ promising approaches"): 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. + 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`). POC placement conventions (inherited from alktunnels): a POC that needs code from this repo runs in a worktree/branch (`.worktrees/research/ @@ -671,23 +686,30 @@ Candidate reading for the research specialist (to be expanded): - [ ] 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 +- [x] 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 +- [x] 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); 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 + 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 - [ ] 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) +- [x] Targeted POC(s) run + summaries in `docs/research/` + (OQ-SK-07 #1 + #4 complete, `poc-connect-wasm-findings.md`; + #2 UDP ASSOCIATE and #3 noq remain) - [ ] Converge: recommended approach written up, ready to hand to the Architect for Phase 1 \ No newline at end of file diff --git a/docs/research/poc-connect-wasm-findings.md b/docs/research/poc-connect-wasm-findings.md new file mode 100644 index 0000000..1516a36 --- /dev/null +++ b/docs/research/poc-connect-wasm-findings.md @@ -0,0 +1,199 @@ +--- +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. \ No newline at end of file