- 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
10 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'ssrc/vendored, kernel-net pieces gated behind anetfeature.
POC #1 — channels-native SOCKS5 CONNECT
What was validated
BiStream-as-Tgenericity — confirmed. The channelsBiStream(alkcall::core::types,Box<dyn AsyncReadWrite + Unpin>, implsAsyncRead + AsyncWrite, isUnpin+Send) satisfies fast-socks5'sSocks5ServerProtocol<T>server typestate andSocks5Stream<S>::use_streamclient path directly. No adapter, no wrapper stream, no boxing beyond whatBiStreamalready is.register_openable_with_establisherfit — confirmed. The producer registerschannels/socks5/sub(ALPNalk/socks5, scopesocks5:open) with an establisher + pump handler exactly as alktunnels does forchannels/tunnel/sub.pump_bidias the CONNECT data plane — confirmed. Post-reply the handler awaitspump_bidi(channel_bistream, target_read, target_write)inline; echo round-trips clean through client → channel → pump → target → back.- Session refusal is a typed call error — confirmed. An identity
lacking the open scope gets
FORBIDDENat the registry ACL gate;ChannelOpenError::CallFailedcarries it; no phantom channel. The establisher's per-call identity arrives as expected (witnessed"consumer"; CF-005/CF-006 seam works). - 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. - 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 injectedDialFn(target) -> boxed halvesclosure (alktunnels ADR-004's function-not-trait shape) is called there. - Per-target refusal is an RFC reply code (
reply_errorwith the mappedReplyError), 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()isip:port— when handing the dial closure a structured target, useaddr.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 viaToTargetAddr; IP-string forms parse to IP targets (domain form passes through unresolved, per OQ-SK-05's DNS question).SocksServerError::AuthenticationRejectedis a unit variant (no message field) in 1.0.0.Establishmentwith 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 — nomio, nosocket2, notokio::netin the graph.cargo test --no-default-features: a full RFC 1928 CONNECT conversation (typestate server over a duplex ↔use_streamclient) passes — the wasm-clean subset is the functional protocol subset, not a husk.cargo check --target wasm32-unknown-unknownwithneton: fails inmio(expected — the target gate for wasm users isdefault-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)
- Feature-gate kernel-net behind a default-on
netfeature (POC #4's change, ~120 lines of#[cfg]). run_udp_proxy_custombinds 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 theudp_bind_random_portstep 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.