diff --git a/docs/research/phase-0.md b/docs/research/phase-0.md index a6f0383..8e39ddd 100644 --- a/docs/research/phase-0.md +++ b/docs/research/phase-0.md @@ -1,7 +1,7 @@ --- status: draft -last_updated: 2026-09-15 (consistency pass: POC register in OQ-SK-07, -client-wrapper transfer residue cleaned, convergence checklist fixed) +last_updated: 2026-09-15 (POC #5 BIND passed — OQ-SK-08 POC-validated, +POC register closed, alktunnels ask ledger pinned at two items) --- # alksocks — Phase 0 (Exploration) @@ -822,7 +822,7 @@ one-place accounting of all numbered POCs: | 2 | UDP ASSOCIATE (handle-swap shape) | **Passed 2026-09-14** | `poc-udp-associate-findings.md` | | 3 | noq `AsyncUdpSocket` impl | **Dropped 2026-09-14** — transferred to alknet with OQ-SK-06 | — | | 4 | fast-socks5 wasm fork-minimality | **Passed 2026-09-13** — folded into #1's worktree, shares #1's findings file | `poc-connect-wasm-findings.md` §POC #4 | -| 5 | BIND (accept-side command) | **Added 2026-09-14 with OQ-SK-08 — pending** | (OQ-SK-08) | +| 5 | BIND (accept-side command) | **Passed 2026-09-15** | `poc-bind-findings.md` | Numbering is historical (do not renumber — findings files and commit messages reference these numbers). Detailed descriptions follow. @@ -909,7 +909,13 @@ Candidates, originally in rough priority order (the order is historical reply#1). Validates: the accept-side data plane (the mirror of the dial callback), reply#1/#2 sequencing on one channel, and the alktunnels listen-addr ask's shape. Completes the RFC 1928 command - surface (all three commands) in the crate. + surface (all three commands) in the crate. **Run 2026-09-15 — + passed** (`alksocks-bind-poc`, 9 tests; findings in + `poc-bind-findings.md`: the phase model extends end to end, both + accept variants implemented, the scope gate works, the + address-first-vs-accept-first deadlock is resolved in shape + (reply#1 first, the tunnel open rides the accept future), and the + alktunnels ask ledger is pinned at two items). POC placement conventions (inherited from alktunnels): a POC that needs code from this repo runs in a worktree/branch (`.worktrees/research/ @@ -920,9 +926,12 @@ regardless of where the code lives. ### OQ-SK-08: BIND — the accept-side command (use case found) -**Status: new (2026-09-14, post-POC #2).** Both POCs refused BIND as -RFC-correct; the findings review surfaced a real use case and a -mechanism, so "out of scope until a use case exists" has been met. +**Status: POC-validated (2026-09-15, POC #5 — +`poc-bind-findings.md`).** The shape space below is written as it +stood pre-POC; the POC resolved the design question (variants 1 + 2 +implemented; the sequencing pinned) and pinned the ask ledger. The +remaining decision is Phase 1's ADR (ship in base crate? default +accept variant?). **The gap, precisely:** fast-socks5 parses `TCPBind` (`Socks5Command::TCPBind`, `lib.rs:91`) but every handler refuses it @@ -931,7 +940,9 @@ bind machinery exists. The typestate flow (`read_command()` returning `cmd` + `TargetAddr`) means the wrapper drives the command dispatch itself — implementing BIND is wrapper-side code, **zero upstream ask** (unlike UDP ASSOCIATE's `run_udp_proxy_custom` bind, which sits -inside an upstream handler). +inside an upstream handler). **POC-confirmed:** `reply_success`'s +`sock_addr` parameter carries reply#1's `BND.ADDR` verbatim; the only +courtesy ask is publishing `new_reply` (the reply encoder is private). **The shape (convergent precedent: iroh-socks5's channels-native BIND, `docs/research/iroh-socks5-eval.md` §BIND):** one channel per @@ -984,6 +995,19 @@ sketched behind the same accept callback — the composed variant's value (hub story, per-listener ACL) is the reason to measure it, and its blocker is the address-inspection ask. +**POC #5's resolution (2026-09-15 — `poc-bind-findings.md`):** both +variants implemented and passing. The hunch was right, and variant 2's +sequencing is now pinned: **reply#1 first, the tunnel open rides the +accept future** (the first cut opened the listen-tunnel before +reply#1 and deadlocked into the establishment timeout — the app +server cannot dial until reply#1 tells it the address; the quiet wait +is exactly where the accept-pop block belongs). The ask ledger grew +to two items (address inspection; peer metadata on accepted handles — +reply#2's fidelity half), both POC-evidenced, both additive. The +scope gate (`socks5:bind`, checked at command dispatch from the +per-call opener identity) distinguishes ingress from egress and is +POC-validated. + **Security boundary note:** BIND is an *inbound* listener — the accept-side twin of arbitrary egress. Whatever ACL governs the socks5 resource now also governs who can mint listeners on the producer's @@ -1144,8 +1168,11 @@ Candidate reading for the research specialist (to be expanded): virtual-subnet proposal (dial-callback translation, no wire change) recorded for Phase 1 - [ ] OQ-SK-08 (BIND) — new (2026-09-14): the gap + use case + - accept-side mechanism written; resolve via POC #5 + the Phase 1 - ADR (ship in base crate? default accept variant?) + accept-side mechanism written; **POC #5 run 2026-09-15 — + passed** (the phase model extends end to end; both accept + variants implemented; the scope gate works; the ask ledger is + pinned); the Phase 1 ADR (ship in base crate? default accept + variant?) has empirical ground under it - [ ] OQ-SK-09 (channel capacity) — new (2026-09-14): the proxy- workload analysis written (wire change rejected; config is the mechanism; in-channel port multiplexing considered and @@ -1160,10 +1187,10 @@ Candidate reading for the research specialist (to be expanded): - [ ] Targeted POC(s) run + summaries in `docs/research/` (the convergence-blocking POCs are complete — see the OQ-SK-07 register: #1 + #4 in `poc-connect-wasm-findings.md`, #2 in - `poc-udp-associate-findings.md`; #3 dropped with the scope - decision. Left unchecked because **#5 (BIND), added 2026-09-14 - with OQ-SK-08, is still pending** — it gates Phase 1's BIND ADR, - not Phase 0's convergence.) + `poc-udp-associate-findings.md`, #5 (BIND) in + `poc-bind-findings.md`; #3 dropped with the scope decision. + The register is closed — no pending POCs remain for this + crate's Phase 0.) - [x] Converge: recommended approach written up, ready to hand to the Architect for Phase 1 (§Convergence, 2026-09-14) @@ -1229,11 +1256,15 @@ binds except the two explicit, optional, feature-gated assembly shapes the proxy-workload channel-cap recommendation (OQ-SK-09); the virtual-subnet translation-layer shape (OQ-SK-05 addendum). - **Upstream asks (we own both upstreams; file early, land there):** - fast-socks5 — the `net` feature gate (POC #4's ~120-line change) - and the `run_udp_proxy_custom` unconditional-bind seam. alktunnels — - listen-addr inspection without consuming an accept (OQ-SK-08 - variant 2's blocker). First-real-consumer asks, the alkcall - E-01/E-02 precedent. + fast-socks5 — the `net` feature gate (POC #4's ~120-line change), + the `run_udp_proxy_custom` unconditional-bind seam, and the + courtesy `new_reply` publication (the reply encoder is private; + POC #5 re-encoded it by hand). alktunnels — the one-version bump + ledger (POC #5's evidence, `poc-bind-findings.md` §Upstream-ask + ledger): (1) listen-addr inspection without consuming an accept + (OQ-SK-08 variant 2's address half), (2) accepted handles carrying + peer metadata (reply#2's fidelity half). First-real-consumer asks, + the alkcall E-01/E-02 precedent. ### What Phase 0 could NOT answer (handed to Phase 1) @@ -1251,10 +1282,12 @@ binds except the two explicit, optional, feature-gated assembly shapes recommendation stay Phase 1 decisions. In-channel port multiplexing considered and rejected there. - BIND — RFC-refused in both POCs; **a use case and a mechanism were - found in the 2026-09-14 findings review** — OQ-SK-08 records the - gap, the accept-side-resource shape (mirroring alktunnels' `-R` - far-side listener), the address-first-vs-accept-first design - question, and POC #5. Deciding whether BIND ships in the base + found in the 2026-09-14 findings review, and POC #5 (2026-09-15) + validated the shape end to end** — OQ-SK-08 records the gap, the + accept-side-resource shape (mirroring alktunnels' `-R` far-side + listener), the address-first-vs-accept-first resolution (reply#1 + first, the tunnel open rides the accept future), the two-item ask + ledger, and the scope gate. Deciding whether BIND ships in the base crate (and which accept variant is default) is a Phase 1 ADR — support is additive (old consumers still get the RFC refusal), so nothing here is a one-way door. \ No newline at end of file diff --git a/docs/research/poc-bind-findings.md b/docs/research/poc-bind-findings.md new file mode 100644 index 0000000..6c3e076 --- /dev/null +++ b/docs/research/poc-bind-findings.md @@ -0,0 +1,256 @@ +--- +status: complete +last_updated: 2026-09-15 +--- + +# 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 — 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 — 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 — 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 — 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 — 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 — 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` — 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 — 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. +- `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) + +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` (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. + +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. \ No newline at end of file