Files
alksocks/docs/research/poc-connect-wasm-findings.md
T
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- Rewrote em dash prose across the whole tree outside docs/reviews/
  (the historical reviews stay verbatim, matching the alktunnels and
  alktty precedent): src doc comments and inline comments (587),
  docs/architecture (319), docs/research + sdd_process (484), tests
  (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77),
  AGENTS.md (85), and the .opencode/agents prompts (109)
- Fenced content follows the precedent: comments, ASCII-diagram label
  separators, and template placeholders rewritten; wire data, JSON/BAST
  documents, byte diagrams, and transcripts survive verbatim; assert,
  expect, panic, error-display, and format message literals rewritten
  as prose; no code, fixture, or test data change
- src/rfc1928/LICENSE: the two dashes in alksocks' own provenance
  header rewritten; the MIT legal text untouched
- AGENTS.md adds Project Convention 18: no em dashes in prose; varied
  restructure, no spaced-hyphen substitutes, fenced prose rule, en
  dash numeric ranges exempt, docs/reviews/ exempt from the greps
- tasks/*.md frontmatter: the name values rewritten by the sweep are
  now quoted (a raw colon inside an unquoted YAML scalar parsed as
  null); taskgraph validate reports 37 tasks, no issues

Verification: cargo test; cargo clippy --all-targets -D warnings;
cargo fmt --check; cargo doc --no-deps; fuzz corpus replay
(cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
the repo returns matches only in docs/reviews/ and the bast.md BAST
JSON data fence
2026-11-03 19:31:53 +00:00

11 KiB

status, last_updated
status last_updated
complete 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) requires that the target 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. (2026-09-20: resolved; the inline vendor is the decision; the PR is an optional courtesy, never a wait. See phase-0 OQ-SK-04.)

Follow-on changes (posture corrected 2026-09-20: not upstream asks)

Ownership correction: fast-socks5 is third-party upstream; we do not own it, and it has not been forked. These are therefore not asks; they are changes the inline vendor carries in-tree (OQ-SK-04, resolved 2026-09-20). The wasm-gate change below is POC #4's; the other two were recorded as "asks" under the wrong ownership premise and move into the vendor with it.

  1. Feature-gate kernel-net behind a default-on net feature (POC #4's change, ~120 lines of #[cfg]). (2026-09-21: dissolved under the extraction (ADR-011 §Amendment); the kernel-net region is not copied, and POC #4's gate map survives as the extraction recipe, not in-tree 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. (Both #2 and the POC #5 courtesy item, publish new_reply, ride the vendor; see poc-bind-findings.md §ledger statuses.)

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 reduced to "does wasm make sense for alksocks" (the adapter story), with the mechanism de-risked. (2026-09-20: resolved, inline vendor, on the corrected ownership premise + the BIND/seam needs. See phase-0 OQ-SK-04.)
  • OQ-SK-07: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq) remain. (Both resolved later: #2 passed 2026-09-14, #3 dropped with the scope decision.)