- 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
302 lines
17 KiB
Markdown
302 lines
17 KiB
Markdown
---
|
||
status: complete
|
||
last_updated: 2026-09-20 (ledger statuses updated with the graduation
|
||
landed; both alktunnels asks superseded in shape)
|
||
---
|
||
|
||
# alksocks POC findings: channels-native SOCKS5 BIND
|
||
|
||
POC #5 from phase-0 §OQ-SK-07, run 2026-09-15. Crate:
|
||
`alksocks-bind-poc` (standalone, `/workspace/alksocks-bind-poc/`), against
|
||
alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, `local` feature;
|
||
the listen surface is complete in the published version),
|
||
fast-socks5 1.0.0 (crates.io). 9 tests, all pass; `cargo clippy
|
||
--all-targets -- -D warnings` and `cargo fmt --check` clean.
|
||
|
||
Validates OQ-SK-08's design shape: **one channel per BIND session; the
|
||
phase model extends (request → reply#1 with `BND.ADDR` → quiet wait →
|
||
exactly one inbound accept → reply#2 with the peer address → raw
|
||
pass-through data plane); both accept variants behind the accept
|
||
callback; the `socks5:bind` scope gate.** fast-socks5 parses the
|
||
command but refuses it (`lib.rs:384-386`, `server.rs:813-816`); the
|
||
wrapper drives the typestate directly and owns the whole BIND shape:
|
||
**zero fast-socks5 upstream ask for BIND itself** (the typestate's
|
||
`read_command()` returns `(cmd, target)` and the wrapper branches;
|
||
`reply_success`'s `sock_addr` parameter is the reply#1 carrier;
|
||
`pump_bidi` is the data plane).
|
||
|
||
## What was validated
|
||
|
||
1. **The BIND phase model was confirmed end to end.** The RFC
|
||
conversation and the data plane ride the same channel `BiStream`:
|
||
the typestate runs `accept_no_auth → read_command` (BIND arrives
|
||
as `Socks5Command::TCPBind`), reply#1 carries `BND.ADDR`, the
|
||
quiet wait selects the one inbound accept against the control
|
||
stream, reply#2 carries the peer address, and `pump_bidi`
|
||
(inline, R-02) two-pumps the accepted connection against the
|
||
channel. Zero per-packet headers in-band; the CONNECT pass-through
|
||
conclusion extends literally (BIND's post-reply#2 stage is a
|
||
plain byte stream).
|
||
2. **The local accept variant (variant 1, the faithful RFC shape),
|
||
confirmed.** A real `TcpListener` per BIND session: reply#1
|
||
carries the listener's *actual* `local_addr` (a real network-B
|
||
address); the "app server" dials the advertised address; reply#2
|
||
carries the *real* peer address (`accept()`'s peer, byte-exact;
|
||
the test asserts the app's ephemeral port); the data plane echoes
|
||
both directions. Vanilla front-door clients work (test 6).
|
||
3. **The composed accept variant (variant 2) confirmed, with the
|
||
sequencing finding below.** The listener lives on an alktunnels
|
||
listen-tunnel resource (`register_tunnel_listen_openable` +
|
||
`AcceptQueue` + a real far-side `TcpListener`); the SOCKS5
|
||
producer opens the tunnel *during the quiet wait* and the popped
|
||
accept's halves become the data-plane peer. The full stack
|
||
round-trips: SOCKS5 consumer → SOCKS5 producer → composed accept →
|
||
`alk/tunnel` listen channel → tunnel producer → real listener →
|
||
the app server's connection → back through the pump. Per-listener
|
||
ACL for free (the tunnel resource's ACL governs who can mint the
|
||
listener), hub-relayable, the variant's whole point.
|
||
4. **The scope gate is confirmed.** The pump handler reads the
|
||
open-op `AuthContext`'s identity and requires `socks5:bind` before
|
||
serving a BIND; an identity without it gets the RFC-faithful
|
||
`ConnectionNotAllowed` (0x02) refusal, while CONNECT still works
|
||
for the same identity (the gate distinguishes ingress listeners
|
||
from proxy egress; one identity having egress does not imply
|
||
ingress). The gate is the open-op identity seam consumed at
|
||
command-dispatch time; no new ops, no params change, no
|
||
one-way-door exposure.
|
||
5. **The quiet wait's semantics are confirmed.** A control byte after
|
||
reply#1 is protocol garbage (the ASSOCIATE post-reply semantic
|
||
BIND shares; RFC 1928 §3's "BIND: no further control data"
|
||
reading); the session is refused with no data plane. A client
|
||
that BINDs and vanishes (EOF mid-wait) tears the listener down
|
||
with its session; the accept future (which owns the listener)
|
||
is dropped by the `select!` branch; a fresh dial to the
|
||
advertised address fails with connection refused. The session's
|
||
natural end (half-close post-data-plane) releases the listener
|
||
the same way.
|
||
6. **Failure mapping is confirmed RFC-faithful.** A factory failure
|
||
(pre-reply#1: an unbindable port, a held port) maps to reply#1
|
||
with the factory error's reply code (shortage → GeneralFailure);
|
||
an accept-stage failure (post-reply#1) maps to reply#2 with the
|
||
mapped refusal code and no data plane. No phantom listeners, no
|
||
phantom data planes; refusals are typed end to end.
|
||
7. **One registration, all three commands, regression-guarded.**
|
||
CONNECT (POC #1's data plane, unchanged) and UDP ASSOCIATE (the
|
||
POC #2 skeleton) still serve on the same `channels/socks5/sub` op
|
||
after the BIND branch lands. The RFC 1928 command surface is now
|
||
complete in the crate's shape: CONNECT + BIND + ASSOCIATE.
|
||
8. **The vanilla front door is confirmed for BIND.** The raw-pipe
|
||
bridge (POC #1's CONNECT front-door shape, reused verbatim) carries
|
||
a full vanilla BIND conversation: an in-process RFC 1928 client
|
||
(greeting → BIND command → reply#1 → reply#2 → data plane) through
|
||
a `copy_bidirectional` pipe onto the channel. Zero protocol
|
||
translation at the door; the RFC conversation passes through
|
||
transparently (0 B pass-through, exercised for the BIND
|
||
conversation this time).
|
||
|
||
## Structural findings (the load-bearing discoveries)
|
||
|
||
**The address-first-vs-accept-first deadlock: the POC's one real
|
||
discovery, and the composed variant's sequencing is now pinned.**
|
||
SOCKS5 BIND is address-first (reply#1 must carry `BND.ADDR` *before*
|
||
the app server can dial), while alktunnels' listen establisher is
|
||
accept-first (its establishment phase pops an already-accepted
|
||
connection, so the open op blocks until one arrives). The first cut
|
||
opened the listen-tunnel in the factory call (before reply#1) and
|
||
deadlocked into the establishment timeout: the app server cannot dial
|
||
until reply#1 tells it the address, and the open cannot complete until
|
||
it dials. **The resolution: reply#1 first, the tunnel open rides the
|
||
accept future.** The quiet wait is exactly where the establisher's
|
||
accept-pop block belongs. What remains upstream-blocked is *learning
|
||
the bound address without consuming an accept* (ledger ask #1): the
|
||
stand-in is the factory's carried `bind_addr` (the expected
|
||
`local_addr` the far-side real listener reports).
|
||
|
||
Consequence for the ADR: variant 2 needs TWO upstream asks, not one:
|
||
(1) listen-addr inspection without consuming an accept (the address
|
||
half), and (2) accepted handles carrying peer metadata (the reply#2
|
||
half). Both are additive surface additions, not changes; the ask
|
||
ledger below records both with the evidence.
|
||
|
||
**Establishment-bound interaction (the composed variant's wait
|
||
bound).** The accept future's tunnel open runs under the listen
|
||
establisher's default 10s establishment bound; a BIND wait longer
|
||
than that resolves as a refusal (reply#2, GeneralFailure via the
|
||
mapped open error). Correct-by-backstop, but a real BIND deployment
|
||
(FTP control sessions idle far longer than 10s) needs either a raised
|
||
bound (the open-op's `timeout` parameter; a registration choice, no
|
||
wire change) or the accept future structured so the bound covers only
|
||
the open, not the wait (POC note: the bound covered the whole accept
|
||
here; the crate's shape should bound only the open-op round trip).
|
||
Phase 1 pins this with the registration posture.
|
||
|
||
**The teardown-at-birth lesson repeats, and the fix is structural.**
|
||
The composed variant's first working cut dropped the
|
||
`TakenHalves::session` (the channel-entry owner) while destructuring
|
||
it; the tunnel EOF'd at birth and the data plane read `early eof`
|
||
(POC #2's findings predicted exactly this: "the session must live
|
||
inside the table entry; the *compiler* should enforce it"). The fix:
|
||
`BindTargetHandle` carries `_session: Option<TunnelSession>`; the
|
||
owner rides the handle and drops with it. This is now the third
|
||
witness of the pattern (POC #2's egress table, alktunnels ADR-005's
|
||
teardown contract, here); the crate's Phase 1 shape should make the
|
||
plan payload own its channel handles structurally, never by let-binding.
|
||
|
||
**Peer-address fidelity is lost through the composed path** (ledger
|
||
ask #2). The popped accept handle (`alktunnels::TargetHandle`) carries
|
||
boxed halves only; the real peer address exists at the tunnel
|
||
producer's listener; in-band the composed variant reports the sentinel
|
||
(`0.0.0.0:0`) for reply#2 (the handle-swap reading applied to BIND:
|
||
the reply is RFC-compatibility surface; the data plane is the
|
||
channel). The local variant reports the real peer (no hop). Ledger
|
||
item #2 is the fidelity ask.
|
||
|
||
**The quiet wait is `select!`-single-shot, not a loop** (unlike
|
||
ASSOCIATE's stage): the accept resolves at most once, and the control
|
||
stream's first byte ends the session (garbage). A loop was
|
||
unnecessary; clippy's `never_loop` caught the first cut's vestigial
|
||
`loop`; the phase model's clean statement is that BIND's quiet wait
|
||
has exactly two exits (the accept, or the control stream's
|
||
EOF/garbage/error). The data plane starts only from the accept
|
||
branch.
|
||
|
||
**`BindAccept`'s future-ownership shape is the accept-side twin of
|
||
the egress's readiness contract.** The accept future owns the
|
||
listener (local variant) or the tunnel-open + popped halves (composed
|
||
variant); the quiet wait's `select!` drops it on the control-stream
|
||
branch, and that drop IS the client-gone teardown. The crate's Phase 1
|
||
equivalent: the accept callback's future must own its resources
|
||
end-to-end (the same ownership lesson as POC #2's egress, mirrored).
|
||
|
||
## Wire/API notes for the wrapper
|
||
|
||
- fast-socks5's client `request()` reads exactly one reply: BIND's
|
||
reply#2 is hand-read with fast-socks5's own public
|
||
`read_address` codec (reply#1's codec applied again; no second
|
||
codec hand-rolled). The consumer's reply#2 reader (`read_reply2`)
|
||
is the Phase 1 client-session sketch.
|
||
- `Socks5ServerProtocol::reply_success(sock_addr)` is reply#1's
|
||
carrier: the `sock_addr` parameter carries `BND.ADDR` verbatim
|
||
(the local variant's real listener; the composed variant's
|
||
expected far-side address). No upstream change needed.
|
||
- fast-socks5's `new_reply` helper is private; the BIND reply#2
|
||
writer hand-rolls the 10-byte reply (VER, REP, RSV, ATYP, addr,
|
||
port; 10 bytes v4 / 22 v6). Ledger ask #3 (fast-socks5, courtesy):
|
||
publish `new_reply` (or a `reply(code, addr)` constructor) so
|
||
embedders don't re-encode replies. (2026-09-20: rides the inline
|
||
vendor; see §ledger statuses.)
|
||
- `ToTargetAddr::to_target_addr()` (not `From`) is the
|
||
`SocketAddr → TargetAddr` conversion; `TargetAddr::from` does not
|
||
exist (the POC hit this; the wrapper's accept layer will too).
|
||
- The listen establisher's default 10s bound covers the composed
|
||
variant's whole accept future in this POC's shape; see the
|
||
establishment-bound finding above for the Phase 1 posture.
|
||
- The scope gate is read from the `OpenHandler`'s `AuthContext`
|
||
(CF-005/CF-006's per-call opener identity), the same seam POC #1
|
||
validated for session-gating, now consumed for a *command-class*
|
||
distinction. `socks5:bind` as a scope string follows the
|
||
`socks5:open` convention (Phase 1 pins the exact convention).
|
||
|
||
## Upstream-ask ledger (the one-version alktunnels bump)
|
||
|
||
**Ledger statuses (2026-09-20, the graduation landed).** The user's
|
||
standing note held: alktunnels 0.2.0 (ADRs 007/008, over alkcall
|
||
0.8.0) shipped in one version bump, but adopted the ssh
|
||
`forwarded-tcpip`-shaped alternative over the per-ask carriers, so
|
||
both asks are **superseded in shape**, not implemented as posed:
|
||
|
||
1. **Listen-addr inspection without consuming an accept: dissolved.**
|
||
The bind-first establisher (`bindfirst_establisher`, ADR-008 §1)
|
||
binds during establishment, so the open-op reply natively carries
|
||
the real OS-chosen bound address (the `bound` reply field via
|
||
alkcall 0.8.0's `Establishment::with_reply_field`, ADR-049
|
||
amendment 3; consumed via `open_channel_with_reply` /
|
||
`TunnelListener::bound()`). There is no accept to avoid consuming;
|
||
this ask's stand-in (the factory's carried `bind_addr`) is retired,
|
||
as is the POC's "open rides the accept future" sequencing
|
||
workaround, and the 10s establishment-bound finding dissolves with
|
||
it (the accept wait lives in the pump phase now). The
|
||
accept-first surface (`listen_establisher` + `AcceptQueue`) remains
|
||
in 0.2.0 unchanged for the accept-as-open shape.
|
||
2. **Accepted handles carrying peer metadata: landed at the wire's
|
||
native moment.** The forwarded op (`channels/tunnel/forwarded`,
|
||
ADR-008 §3) carries `{listen, substrate, peer}` params per accept;
|
||
`TargetHandle` gained no peer field; the graduation chose the
|
||
forwarded-op shape over this item's option-i carrier. Correlation
|
||
is a producer-minted u32 token (Amendment 1, 2026-09-18), not the
|
||
originally-planned channel-ID key.
|
||
3. **(fast-socks5, courtesy)** `new_reply` is private; publish it
|
||
(or a constructor) so embedders don't re-encode the reply wire
|
||
form. Also still open from POC #4: the `net` feature gate and the
|
||
`run_udp_proxy_custom` unconditional-bind seam. (Posture note,
|
||
2026-09-20: fast-socks5 is third-party upstream and these are no
|
||
longer asks; all three move into the inline vendor, OQ-SK-04
|
||
resolved. 2026-09-21: under the extraction (ADR-011 §Amendment),
|
||
`new_reply` is the one that rides in-tree; the `net` gate and the
|
||
bind seam dissolve with the kernel-net region, which is not
|
||
copied.)
|
||
|
||
The POC's evidence stands as the record of what was validated against
|
||
alktunnels 0.1.0; the Phase 1 shape composes variant 2 against 0.2.0
|
||
(`register_tunnel_bindfirst_openable` + `BindFirstInjections`).
|
||
|
||
The user's standing note: collect alktunnels additions so one version
|
||
bump ships them all. The POC ran against published alktunnels 0.1.0
|
||
(no fork needed; the listen surface is complete there), and the asks
|
||
are additive surface, discovered in priority order:
|
||
|
||
1. **Listen-addr inspection without consuming an accept** (the
|
||
address half of address-first-vs-accept-first). The plan carries
|
||
`local_addr` + an inspect-only open, or a `listen/addr` query op
|
||
(OQ-SK-08 variant 2's original blocker, now POC-evidenced: the
|
||
factory's carried `bind_addr` stand-in is the exact shape the ask
|
||
retires). Evidence: the composed accept's reply#1 advertises an
|
||
*expected* address; without the ask, a producer whose listener
|
||
binds an OS-chosen port cannot advertise it before the accept.
|
||
2. **Accepted handles carrying peer metadata** (the reply#2 half).
|
||
`AcceptFn`'s `TargetHandle` carries boxed halves only; the real
|
||
peer address exists at the tunnel producer's listener. Evidence:
|
||
the composed accept's reply#2 reports the sentinel; the RFC's
|
||
"reply#2 carries the address of the incoming connection" is
|
||
unfilled on the composed path. Shape: `TargetHandle` gaining an
|
||
optional `peer: Option<SocketAddr>` (or a sibling
|
||
`AcceptedHandle`); additive, no wire change.
|
||
3. **(fast-socks5, courtesy)** `new_reply` is private; publish it
|
||
(or a constructor) so embedders don't re-encode the reply wire
|
||
form. Also still open from POC #4: the `net` feature gate and the
|
||
`run_udp_proxy_custom` unconditional-bind seam. (2026-09-21: under
|
||
the extraction, ADR-011 §Amendment, `new_reply` is the one that
|
||
rides in-tree; the `net` gate and the bind seam dissolve with the
|
||
kernel-net region, which is not copied.)
|
||
|
||
No asks block the POC's conclusions; 1–2 shape variant 2's *full*
|
||
fidelity, and the POC's stand-ins (carried `bind_addr`; sentinel
|
||
reply#2) are honest interim shapes.
|
||
|
||
## OQ status after this POC
|
||
|
||
- **OQ-SK-08 (BIND)**: POC-validated end to end: the phase model
|
||
extends (reply#1 → quiet wait → accept → reply#2 → pass-through);
|
||
both accept variants implemented (local faithful + composed
|
||
listen-tunnel); the scope gate (`socks5:bind`) distinguishes
|
||
ingress from egress; the address-first-vs-accept-first question is
|
||
*resolved in shape* (reply#1 first, the open rides the accept
|
||
future) with the two upstream asks pinned as the remaining blockers
|
||
for variant 2's full fidelity. Remaining for Phase 1: ship-in-base-
|
||
crate + default-accept-variant ADR (the support is additive, old
|
||
consumers still get the RFC refusal, so nothing here is a
|
||
one-way door); the establishment-bound posture; the exact
|
||
scope-string convention.
|
||
- **OQ-SK-07**: #1, #2, #4, #5 complete; #3 dropped (transferred with
|
||
OQ-SK-06). The register is closed for this crate's Phase 0.
|
||
- **OQ-SK-01 (dial policy)**: the accept seam is the dial-callback
|
||
shape's mirror (`BindAcceptFactory`: function-not-trait, boxed
|
||
halves, one instance per session). The no-trait precedent now holds
|
||
for the accept plane too.
|
||
|
||
## POC placement
|
||
|
||
Standalone crate (global workspace, `/workspace/alksocks-bind-poc/`)
|
||
per the placement conventions; it composes three published crates
|
||
(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code
|
||
from this repo. Findings land here; the crate is the artifact. |