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

17 KiB
Raw Permalink Blame History


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.