Files
alksocks/docs/research/poc-bind-findings.md
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

302 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.