- tests/assembly-integration.rs (7 tests, local + composed): the full -D story (vanilla cred-bearing client -> TCP door + UDP door -> producer w/ local providers -> real echo targets; CONNECT + BIND + ASSOCIATE live, one-command-per-connection, teardown + fresh sessions), the composed story (direct_egress + bindfirst_accept + real TCP dial; all three commands; the lazy-table dial-count witness), mixed assembly (channels-native + door clients on one registration; composed seams beside native sessions), the assembly teardown matrix (UDP-door idle bound; channel reaped; fresh association after), the producer door driving all three commands, and the -D domain-target pin (resolution at the producer's seam, ADR-012). - Fix the drift the suite surfaced: ComposedEgress::next_reply ended fresh (never-opened-table) associations at birth — the ended-relay Err now fires only once a tunnel existed and every relay ended; the fresh table parks (the readiness contract). Regression-pinned in tests/composed-egress.rs (fresh_association_parks_before_any_ tunnel_is_opened). - Harness: the composed-accept machinery single-sourced into tests/harness.rs (the composed suites' shapes + the five-gate composed identity); composed-accept.rs consumes the shared shapes. - Docs sync: README Current State (the assembly layer landed; both suites named), lib.rs crate docs (the assembly-layer paragraph), consumer.md (the TokenClient door-only contract + the one-command-per-connection posture reconciled). - Task assembly-integration-suite: completed; Notes + Summary filled (the local-on-wasm failure observed: mio's unsupported-wasm error). Verification: cargo test (375), cargo test --all-features (615), clippy --all-targets -D warnings (default + all-features), fmt --check, doc --no-deps (0 warnings), both wasm checks (default + composed), local-on-wasm fails as designed.
196 lines
9.1 KiB
Markdown
196 lines
9.1 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-09-25 (the cred-bearing door's call-connection
|
|
contract reconciled — TokenClient's door-only posture, one command per
|
|
accepted connection; 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):
|
|
|
|
1. **The channels-native session** — a typed client that opens
|
|
`channels/socks5/sub` and drives the RFC conversation in-stream
|
|
(wrapper-aware; the POC #1 `use_stream` / POC #2 associate
|
|
session shapes).
|
|
2. **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_stream` path — generic over
|
|
the backing stream (POC #1: `Socks5Stream` impls
|
|
`AsyncRead + AsyncWrite` itself, so the session over a `BiStream`
|
|
is 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.ADDR` and 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()` returns `Some((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:pass` gets 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 forked `AuthMethod` impl needed — the door owns the
|
|
accept loop). The mapped identity rides the open-op call's
|
|
`auth_token` payload (CF-005 precedence (1)) via
|
|
`TokenClient::wrap` — the client's channel-0 `CallConnection` is
|
|
taken once per door (the door-only contract: a cred-bearing
|
|
door's client is a door client; other shapes — the UDP door's
|
|
association, channels-native sessions — ride their own
|
|
connections to the same producer registration, one registration,
|
|
many connections). The vanilla conversation is
|
|
**one command per accepted connection** (the door's RFC half
|
|
consumes the greeting + one command; the data plane rides to
|
|
EOF).
|
|
|
|
Door variant selection (NoAuth vs cred-bearing; the UDP door's bind
|
|
address) is assembly-layer configuration.
|
|
|
|
## Wiring (Assembly Layer)
|
|
|
|
```rust
|
|
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](decisions/001-alpn-and-open-op.md) | ALPN + op | the consumer opens the one op |
|
|
| [003](decisions/003-udp-handle-swap.md) | The associate session | sentinel BND.ADDR; frames once; reader state persists |
|
|
| [004](decisions/004-dial-callback-shape.md) | 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](decisions/005-auth-split-by-door.md) | Door auth | RFC 1929 at the door; static credential→identity mapping |
|
|
| [007](decisions/007-bind-in-base.md) | BIND client | the reply#2 reader; vanilla clients via the door |
|
|
| [011](decisions/011-inline-vendor.md) | The extraction | the client `use_stream` path in-tree |
|
|
| [013](decisions/013-targeted-fork.md) | The targeted fork | the core is owned (`src/rfc1928/`) |
|
|
|
|
## Open Questions
|
|
|
|
Open questions are tracked in [open-questions.md](open-questions.md).
|
|
None currently blocking this document.
|
|
|
|
## References
|
|
|
|
- POC #1 (`poc-connect-wasm-findings.md` — the raw-pipe door, the
|
|
`use_stream` fit), 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](producer.md) (the other half),
|
|
[wire.md](wire.md) (the normative wire) |