docs: POC #1 (channels-native CONNECT) + POC #4 (fs5 wasm fork) findings

- 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
This commit is contained in:
2026-09-13 14:38:22 +00:00
parent a628715106
commit 7c6bc65618
2 changed files with 235 additions and 14 deletions
+36 -14
View File
@@ -1,6 +1,6 @@
--- ---
status: draft status: draft
last_updated: 2026-09-12 last_updated: 2026-09-13
--- ---
# alksocks — Phase 0 (Exploration) # 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), dials via alktunnels/alkcall primitives (or its own dial policy),
and returns the `T` back. and returns the `T` back.
- `run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)` - `run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer)`
— the customizable UDP ASSOCIATE handler: the wrapper supplies a — the customizable UDP ASSOCIATE handler. **Caveat (verified
custom `transfer` closure that owns the relay half (OQ-SK-03). 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 - `transfer(inbound, outbound)` — plain two-pump copy; a channels
`BiStream` is a legal `T` on either side. `BiStream` is a legal `T` on either side.
- **UDP support** — `new_udp_header(target)` / `parse_udp_request(buf)` - **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 plane, params shape (trivial — probably no params), and the
`BiStream`-as-`T` genericity claim. This is the crate's core value `BiStream`-as-`T` genericity claim. This is the crate's core value
proposition and the cheapest to validate (alktunnels' forward POC is 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 2. **UDP ASSOCIATE POC** — the OQ-SK-03 chosen shape, end to end: a
wrapper-aware client associates, sends length-prefixed datagrams wrapper-aware client associates, sends length-prefixed datagrams
down the channel, producer relays to a real UDP endpoint. Validates: down the channel, producer relays to a real UDP endpoint. Validates:
the sentinel-reply semantics, the datagram codec (fast-socks5's the sentinel-reply semantics, the datagram codec (fast-socks5's
`new_udp_header`/`parse_udp_request` over the length framing), the `new_udp_header`/`parse_udp_request` over the length framing), the
flow table, and empty-datagram handling (alktunnels F-2 transfers). 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 3. **noq `AsyncUdpSocket` impl POC** — the client-side story against
noq 1.2: associate through a fast-socks5 server, wrap as noq 1.2: associate through a fast-socks5 server, wrap as
`noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06 `noq::AsyncUdpSocket`, complete a QUIC handshake. Derisks OQ-SK-06
@@ -608,7 +620,10 @@ promising approaches"):
POC — re-validating it here is optional.) POC — re-validating it here is optional.)
4. **fast-socks5 wasm check** — minimal crate, `cargo check --target 4. **fast-socks5 wasm check** — minimal crate, `cargo check --target
wasm32-unknown-unknown`, confirm/inflect OQ-SK-04. Cheap; can fold 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 POC placement conventions (inherited from alktunnels): a POC that needs
code from this repo runs in a worktree/branch (`.worktrees/research/ 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 - [ ] Prior-art pass complete: fast-socks5 surface verified (§Prior
art), alktunnels/alknet/noq lineage mapped, anti-prior-art list art), alktunnels/alknet/noq lineage mapped, anti-prior-art list
written written
- [ ] OQ-SK-01 (dial policy) — researched, half-answered (injected - [x] OQ-SK-01 (dial policy) — researched, half-answered (injected
dialer hunch); decide in Phase 1 against the spec dialer hunch); **POC #1 resolved the shape empirically** (dial
- [ ] OQ-SK-02 (auth mapping) — posture drafted (identity seam callback at the command-read interception point, boxed halves, no
primary, in-band auth for the local backend); decide in Phase 1 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 - [ ] OQ-SK-03 (UDP ASSOCIATE) — the shape space written (including
the phase-model-vs-alktty-demux analysis); resolve via research the phase-model-vs-alktty-demux analysis); resolve via research
+ POC #2, ADR before the first consumer + POC #2, ADR before the first consumer
- [ ] OQ-SK-04 (fast-socks5 wasm) — the decision tree is written - [ ] OQ-SK-04 (fast-socks5 wasm) — the decision tree is written
(Case 1: wasm wanted → fork-first; Case 2: wasm unwanted → (Case 1: wasm wanted → fork-first; Case 2: wasm unwanted →
plain dep, router path); the root question ("does wasm make plain dep, router path); **POC #4 verified the fork is genuinely
sense for alksocks?") settles on the adapter-story input + minimal** (~120 lines of cfg gating; wasm-clean subset is
POC #4's minimality check 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; - [ ] OQ-SK-05 (target policy) — folded into the OQ-SK-01 decision;
scope-gate convention pinned in Phase 1 scope-gate convention pinned in Phase 1
- [ ] OQ-SK-06 (noq client) — research pass (trait-shape comparison, - [ ] OQ-SK-06 (noq client) — research pass (trait-shape comparison,
dependency posture) + POC #3 if the research is not decisive dependency posture) + POC #3 if the research is not decisive
- [ ] Targeted POC(s) run + summaries in `docs/research/` - [x] Targeted POC(s) run + summaries in `docs/research/`
(OQ-SK-07; #1 first — it validates the core value proposition) (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 - [ ] Converge: recommended approach written up, ready to hand to the
Architect for Phase 1 Architect for Phase 1
+199
View File
@@ -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<dyn AsyncReadWrite +
Unpin>`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`)
satisfies fast-socks5's `Socks5ServerProtocol<T>` server typestate
and `Socks5Stream<S>::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<TcpStream>` 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.