docs: POC #5 (channels-native SOCKS5 BIND) findings

POC #5 run 2026-09-15 (alksocks-bind-poc, standalone, 9 tests) —
OQ-SK-08 POC-validated; the POC register (OQ-SK-07) is closed.

- The BIND phase model extends end to end on one channel: request →
  reply#1 (BND.ADDR) → quiet wait → one inbound accept → reply#2
  (peer) → pump_bidi pass-through (0 B overhead, like CONNECT)
- Both accept variants implemented: local (real TcpListener, faithful
  RFC) + composed (alktunnels listen-tunnel — per-listener ACL for
  free, hub-relayable)
- The address-first-vs-accept-first deadlock resolved in shape:
  reply#1 first, the tunnel open rides the accept future
- The socks5:bind scope gate distinguishes ingress from egress
  (RFC-faithful ConnectionNotAllowed refusal; CONNECT unaffected)
- Vanilla front door re-validated for the BIND conversation (raw pipe,
  zero translation); CONNECT + ASSOCIATE regression-guarded on the
  same registration — the RFC 1928 command surface is complete
- fast-socks5: zero BIND ask (typestate-driven; reply_success carries
  reply#1); courtesy ask: publish the private new_reply encoder
- alktunnels ask ledger pinned at two items for the one-version bump:
  (1) listen-addr inspection without consuming an accept, (2) accepted
  handles carrying peer metadata — both POC-evidenced, both additive

Verification: cargo test (9/9), clippy --all-targets -D warnings,
fmt --check clean in alksocks-bind-poc.
This commit is contained in:
glm-5.3-flash committed 2026-09-15 06:09:35 +00:00
1 parent aac63d17fc
commit cb4d58a2bf
2 files changed
+312 -23

No files matched your search

+56 -23
View File
@@ -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.
+256
View File
@@ -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<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 — 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<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.
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.