- C-1: BindSession::await_inbound is take-guarded — the first read takes the session's stream; the returned BindDataPlane owns it (the TakenInner pairing posture; the session stays the teardown owner), so a second call returns the family's spent shape Ok(None) and can never re-read the live data plane. A failed read restores the inner (the phase is only consumed on success; the producer ends the session on every post-reply#1 failure exit, so a re-call EOFs — never a live data-plane re-read either way). Pinned by the double-await_inbound integration test (the spent shape + the data-plane bytes asserted untouched) and the spent-BIND unit test. - D-1: one normative reply#2 reader (option a) — the session's await_inbound; BindReply1::await_inbound is cut and reply1() is documented as a view (bind_addr + raw_mut), matching its actual usage; the reply1()-twice re-entry path is closed (pinned by the view test). - N-6: the three session constructors' prologues collapsed into one private open_and_negotiate helper (teardown-at-birth in one place); zero behavior change. - S-2: the EOF-propagation doc line on all three reap sites (the dropped teardown_channel handle is deliberate — the graceful shape; clear_all aborts instead). - BindDataPlane is un-lifetimed (owns the stream); into_inner returns the owned Socks5Stream<BiStream>; docs synced (module + session docs, consumer.md §The BIND client). Verification: cargo test 352 green (all-features too), clippy -D warnings clean, fmt clean, cargo doc clean, both wasm checks clean.
8.4 KiB
status: draft last_updated: 2026-09-24 (the BIND spent posture reconciled — the take-guarded reply#2 reader, review 002 C-1/D-1; the landed session signatures reconciled 2026-09-24; Phase 1 initial draft 2026-09-20)
alksocks — The Consumer Half
The consumer opens SOCKS5 channels against a produced service and speaks RFC 1928 inside them. Two consumption shapes, structurally separate (the Phase 0 scope resolution):
- The channels-native session — a typed client that opens
channels/socks5/suband drives the RFC conversation in-stream (wrapper-aware; the POC #1use_stream/ POC #2 associate session shapes). - The front door (optional, feature
local) — a real local endpoint (TCP for CONNECT/BIND; UDP for ASSOCIATE) that vanilla clients point at; the door bridges each accepted connection/socket onto a channel, with the RFC codec living only at that boundary.
Both wrap the same protocol layer; neither is required by the other.
The Channels-Native Sessions
Socks5ChannelSession (CONNECT)
Opens channels/socks5/sub via ChannelClient::open_channel
(alkcall ADR-037/043), then drives the client side of the RFC
conversation in-stream over the channel's BiStream:
- the forked client typestate's
use_streampath — generic over the backing stream (POC #1:Socks5StreamimplsAsyncRead + AsyncWriteitself, so the session over aBiStreamis the intended use, not a hack); - target construction via
ToTargetAddr(IP-string forms parse to IP targets; domain form passes through unresolved — ADR-012); - after the reply, the session is a raw byte pipe — the consumer reads/writes payload bytes directly (0 B framing, CONNECT).
The associate session
The UDP ASSOCIATE client (POC #2's shape):
- opens the channel, negotiates, sends ASSOCIATE, reads the reply —
receives the sentinel
BND.ADDRand never uses it for addressing (the handle is the channel; ADR-003); send_datagram(dst, bytes)frames once ([len: u16 BE][addr + data], the RFC 1928 address encoding) and writes the frame;recv_datagram()returnsSome((source_addr, bytes))per datagram — its incremental reader state (mid-frame position + batched-datagram pending queue) persists across calls (a datagram split across channel chunks survives a caller-driven receive loop; POC #2's finding);shutdown()half-closes the stream — the mux's implicit-EOF sentinel reaches the producer's stage as EOF and the association ends (association lifetime = channel lifetime);- teardown ownership: the session owns the channel; on Drop it closes/joins — the alktunnels ADR-005 posture (the consumer session type owns teardown), combined with ADR-004's ownership-rides-the-handle contract.
The BIND client
The BIND reply#2 reader (POC #5's read_reply2 sketch): the forked
client request() reads reply#1; reply#2 is hand-read with the
forked public read_address codec (reply#1's codec applied again;
no second codec). The session then rides the pass-through data
plane. The reply#2 phase is take-guarded: the one normative reader
is await_inbound — the first read consumes the session's stream
(the returned plane owns it; the session stays the teardown owner),
so a second call returns the family's spent shape (Ok(None), the
same posture the sibling sessions pin) and cannot re-read the live
data plane; a failed read restores the inner (the producer ends the
session on every post-reply#1 failure exit, so a re-call EOFs —
never a live data-plane re-read either way). The
reply1() handle is a view (the advertised BND.ADDR + the raw
pre-reply#2 stream), not a reader. The BIND client is a
wrapper-aware-consumer convenience — vanilla clients reach BIND
through the front door instead.
Error surface
SocksServerError / SocksError (the forked surface) mapped
faithfully; channel-open failures are the typed
channel:open_failed reasons (alkcall ADR-049). A refused session
never yields a phantom channel; a refused target is an RFC reply
code surfaced through the session type.
The Front Doors (feature local, optional)
The consumer's optional local exposure — the ssh -D shape (POC #1:
curl end-to-end; POC #2 test 8: the UDP door; POC #5 test 8: the
BIND door):
- The CONNECT/BIND door: bind a real TCP listener; for each
accepted connection, pipe it raw onto a channel
BiStream(copy_bidirectional, zero protocol translation — POC #1's literal 0 B pass-through conclusion). The door's credential half (below) is the only RFC work it does. - The UDP door: bind a real UDP socket; decode RFC-header'd
datagrams (
parse_udp_request), send{addr, data}frames in-band, return replies RFC-header'd (new_udp_header) — the RFC codec at the door boundary only (ADR-003). FRAG ≠ 0 → refuse (the v1 posture). - The BIND door's reply#2 fidelity: the local variant's real listener/peer addresses pass through the pipe unchanged (the conversation is the vanilla client's own); the door adds nothing.
Door variants and auth (ADR-005)
- The NoAuth door (POC #1's raw-pipe shape): zero protocol translation; for loopback-only or wrapper-aware use.
- The cred-bearing door (RFC 1929): a vanilla client carrying
user:passgets a real RFC 1929 conversation at the door; the door maps the accepted credential to local configuration — which channel identity/endpoint to open the producer channel with — via a static config table (ADR-005:user:pass →channel identity/endpoint selection). The SOCKS password never crosses the channel as a SOCKS password; the channel identity never reaches the vanilla client. The door checks credentials itself (no forkedAuthMethodimpl needed — the door owns the accept loop).
Door variant selection (NoAuth vs cred-bearing; the UDP door's bind address) is assembly-layer configuration.
Wiring (Assembly Layer)
let session = Socks5ChannelSession::connect(
&channel_client, target).await?;
let (mut read, mut write) = session.split(); // post-reply payload pipe
let assoc = AssociateSession::associate(&channel_client).await?;
assoc.send_datagram(dst, &bytes).await?; // frames once, ADR-003
let (src, bytes) = assoc.recv_datagram().await?.expect("a datagram");
let door = FrontDoor::builder()
.credentials(cred_table) // RFC 1929 mapping, ADR-005
.bind("127.0.0.1:1080")
.build();
door.serve(channel_client).await?;
The landed session signatures: the constructors take &ChannelClient
(shared — many sessions ride one client, the concurrency posture);
send_datagram takes impl ToTargetAddr (the CONNECT session's
target-construction posture, ADR-012); recv_datagram() returns
Result<Option<(TargetAddr, Bytes)>, SocksSessionError> — None is
the channel EOF (the association's clean end), never an empty
datagram (F-2: an empty payload is a legal datagram).
Design Decisions
| ADR | Decision | Summary |
|---|---|---|
| 001 | ALPN + op | the consumer opens the one op |
| 003 | The associate session | sentinel BND.ADDR; frames once; reader state persists |
| 004 | Ownership rides the handle | handle payloads own channel sessions; the session-teardown posture is inherited from alktunnels ADR-005 (the consumer session type owns teardown) |
| 005 | Door auth | RFC 1929 at the door; static credential→identity mapping |
| 007 | BIND client | the reply#2 reader; vanilla clients via the door |
| 011 | The extraction | the client use_stream path in-tree |
| 013 | The targeted fork | the core is owned (src/rfc1928/) |
Open Questions
Open questions are tracked in open-questions.md. None currently blocking this document.
References
- POC #1 (
poc-connect-wasm-findings.md— the raw-pipe door, theuse_streamfit), POC #2 (poc-udp-associate-findings.md— the associate session, the reader-state finding, the UDP door), POC #5 (poc-bind-findings.md— the reply#2 reader, the BIND door) - Upstream: alkcall ADR-037/043 (channel open), alktunnels ADR-005 (consumer session owns teardown — the posture inherited), the CF-005/CF-006 seam
- producer.md (the other half), wire.md (the normative wire)