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
This commit is contained in:
glm-5.3-flash committed 2026-11-03 19:31:53 +00:00
1 parent 9ca8c52601
commit e196ed43e3
115 files changed
+3593 -3531

No files matched your search

+78 -78
View File
@@ -1,52 +1,52 @@
# Fuzzing alksocks: Research and Plan
**Status:** adopted — wave-1 targets (`datagram_codec`, `rfc1928_codec`,
**Status:** adopted; wave-1 targets (`datagram_codec`, `rfc1928_codec`,
`params_parse`) implemented, seeds committed, campaigns run clean (§7).
Wave 2 (`session_pumps`) deferred to post-0.1 (§4). Campaigns run via the
detached runner (§6) — **never as a foreground child of an agent
detached runner (§6): **never as a foreground child of an agent
session** (§5).
**Date:** 2026-09-28
**Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md`
(one real finding: the duplicate adopt/open destroying the live channel —
(one real finding: the duplicate adopt/open destroying the live channel,
fixed in 0.8.1) and
`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md` (wave-1/wave-2
adopted, six harness-model findings, no crate bugs). Tooling, corpus
policy, runner, and the no-hosted-CI posture are inherited from those
docs wholesale — this doc does not re-litigate them; it scopes them to
docs wholesale; this doc does not re-litigate them. It scopes them to
this crate.
---
## 1. Should this crate be fuzzed?
**Yes — and alkcall's fuzz doc already assigned this to us.** Three
**Yes, and alkcall's fuzz doc already assigned this to us.** Three
lines, all pointing the same way:
1. **alkcall's fuzz doc §5 named this crate's surfaces as downstream
targets.** Its "non-surfaces (deliberately)" note ends: *the channels
layer carries opaque `Bytes` and the handler owns sub-stream framing
(ADR-035) — those downstream parsers are downstream crates' fuzz
targets.* Our `src/wire.rs` datagram codec **is** that named
(ADR-035); those downstream parsers are downstream crates' fuzz
targets. Our `src/wire.rs` datagram codec **is** that named
downstream target (shaped on alktunnels' codec, which alktunnels
fuzzed as its wave-1 first target — the same assignment, landed
fuzzed as its wave-1 first target; the same assignment, landed
here). Fuzzing it completes the upstream scope rather than
duplicating it.
2. **We own an entire RFC handshake state machine fed by arbitrary
peers — on both sides.** The targeted fork (`src/rfc1928/`, ADR-013)
peers, on both sides.** The targeted fork (`src/rfc1928/`, ADR-013)
is fully owned code with project conventions applying unconditionally
(convention 17). Its parse surfaces — `read_address` (the address
(convention 17). Its parse surfaces, namely `read_address` (the address
codec), `negotiate_auth` (method negotiation), `read_command` (the
command encoding), `read_username_password` (RFC 1929), the client's
`read_request_reply` — are fed bytes produced by **arbitrary peers**
command encoding), `read_username_password` (RFC 1929) and the client's
`read_request_reply`, are fed bytes produced by **arbitrary peers**
in both directions: the producer drives the server typestate against
consumer bytes, and the consumer sessions + front doors drive the
client path against producer bytes. An RFC state machine is exactly
the shape the quinn-proto CVE class (RUSTSEC-2026-0037 — remote DoS
the shape the quinn-proto CVE class (RUSTSEC-2026-0037, remote DoS
via `unwrap()` in parsing, the upstream doc §1's strongest
datapoint) lives in: hand-rolled state transitions, length arithmetic,
and boundary handling. One fork-carried correction is already of this
class — upstream's `ReplyError::from_u8` ended in `unreachable!()`
class: upstream's `ReplyError::from_u8` ended in `unreachable!()`
for unassigned reply codes; the fork made it a typed catch-all (the
review's F-1) precisely because the value comes off the wire. A fuzz
target is how that posture is *demonstrated* across the whole codec,
@@ -54,13 +54,13 @@ lines, all pointing the same way:
3. **The differential suite's blind spot is exactly fuzzing's
strength.** `tests/rfc1928-differential.rs` pins our fork's behavior
against the reference checkout on *valid* RFC edge cases — curated
against the reference checkout on *valid* RFC edge cases, curated
inputs a human thought of. What no example-based suite provides:
random *malformed* input exploration (the panic-on-attacker-bytes
class), stateful interleavings, and resource-bound assertions under
adversarial sequences. The fork is about to be published (0.1) as
wire-stable; after that, targets are regression protection for a
frozen surface — cheapest now.
frozen surface (cheapest now).
**The honest caveat** (mirroring both upstream docs): the crate has no
`unsafe` (checked at the crate root), the codec's decode direction is
@@ -72,7 +72,7 @@ more than 255), and the fork's error vocabulary is typed end-to-end
Expected yield is therefore not memory-safety bugs; it is (a) the
demonstration that the infallibility/no-panic contracts hold on
adversarial input, (b) the decoder-state bound assertion (alktunnels
§1.3's finding class — a peer chunk amplifying into unbounded state),
§1.3's finding class: a peer chunk amplifying into unbounded state),
and (c) regression protection for the published wire surface. If the
campaigns find nothing, that is the successful-negative-result posture
alkcall's §1 codified.
@@ -84,7 +84,7 @@ Identical to both upstream docs: **cargo-fuzz 0.13.2 + `arbitrary`
`fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88.
AFL optional/deferred; bolero deferred (the async-friction argument);
libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address`
with debug assertions (cargo-fuzz defaults) — no MSAN (no `unsafe` in
with debug assertions (cargo-fuzz defaults); no MSAN (no `unsafe` in
this crate).
**Layout (the upstream pattern, load-bearing):**
@@ -97,7 +97,7 @@ fuzz/
│ ├── datagram_codec.rs
│ ├── rfc1928_codec.rs
│ └── params_parse.rs
├── shared/ alksocks-fuzz-shared — STABLE-toolchain library
├── shared/ alksocks-fuzz-shared (STABLE-toolchain library)
│ └── src/… invariant logic + corpus replay (§3)
├── corpus/<target>/ committed hand-made seeds (§3)
├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern)
@@ -106,16 +106,16 @@ fuzz/
```
- **Invariant logic lives in `fuzz/shared/`**, not inline in the target
binaries — the stable-toolchain corpus replay
binaries: the stable-toolchain corpus replay
(`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME
invariant functions the fuzzer runs, on stable, in the release
verification checklist. This is alkcall §7.2 tier-3's deliverable
minus automation; per alkcall §7.9 (adopted here identically) this
repo has no hosted CI and will not get one — corpus replay as plain
repo has no hosted CI and will not get one. Corpus replay as plain
`cargo test` is the fuzz gate.
- **Root `Cargo.toml` needs the explicit `[workspace]` table**
(`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the
publish exclude list — without it cargo auto-discovers `fuzz/shared/`
publish exclude list; without it cargo auto-discovers `fuzz/shared/`
into the main workspace and the MSRV toolchain tries to build the
nightly-consumed dev-deps (the exact failure alkcall hit).
- **Corpus policy:** commit hand-made seeds, gitignore grown corpora
@@ -129,23 +129,23 @@ Input: raw `&[u8]`. Drives `DatagramReader::feed` + `frame` /
`frame_inner` / `addr_encoding_len`. This is the alktunnels wave-1
`datagram_codec` target's shape, scoped to this crate's codec (the
frame carries `addr + data` inner bytes and decodes to
`(TargetAddr, payload)`, not opaque datagrams — the invariants follow
`(TargetAddr, payload)`, not opaque datagrams; the invariants follow
the inner structure). Invariants:
- **no-panic on any byte sequence** (the baseline).
- **infallible reassembly**: `feed` never errors at the reassembly
layer — every frame completes as `Ok` or as one of the four
layer; every frame completes as `Ok` or as one of the four
*decode-garbage* `WireError` variants (`EmptyFrame` / `BadAtyp` /
`ShortAddress` / `DomainNotUtf8`); `Oversize`/`Address` are
unreachable from `feed` (they are frame-time errors — a shape
unreachable from `feed` (they are frame-time errors; a shape
assertion pins the partition).
- **empty-payload F-2 layering**: a decoded `(addr, empty)` pair comes
only from a real `[len][addr]` frame whose payload region is empty —
only from a real `[len][addr]` frame whose payload region is empty,
never invented by the codec; a `len == 0` frame (zero inner bytes)
always surfaces as `EmptyFrame`, never as an empty datagram.
- **bad-ATYP loudness**: every `BadAtyp` error's byte is the actual
first inner byte of its frame, and it is not one of the three
RFC ATYPs (the post-reply control-byte semantic — any non-ATYP
RFC ATYPs (the post-reply control-byte semantic: any non-ATYP
control byte must fail loud, never guess).
- **consumption accounting / splitting transparency**: feeding in
3-byte slices yields exactly the datagram sequence the whole-input
@@ -155,14 +155,14 @@ the inner structure). Invariants:
well-framed input re-frames byte-for-byte.
- **joint bound**: `frame` rejects iff
`addr_encoding_len(addr) + payload.len() > MAX_INNER_LEN`, echoing
`(total, max)` exactly — including the max-domain case where a legal
`(total, max)` exactly, including the max-domain case where a legal
RFC payload (65507 to a 255-char domain = 65766) must reject (the
joint-bound reason the codec exists); `frame_inner` rejects iff
`inner.len() > MAX_INNER_LEN`. The `checked_add` path (payload
overflow wrapping the u16) rejects, never wraps.
- **decoder-state bound**: `buf` + in-flight `current` stay bounded by
the fed chunk size plus one frame (header + `MAX_INNER_LEN`) on any
adversarial input — the alktunnels §1.3 finding-class assertion
adversarial input, the alktunnels §1.3 finding-class assertion
(state must never grow with input). The crate's `DatagramReader`
exposes this via `is_mid_frame()` + a new `retained_bytes()`
diagnostic (the alktunnels `retained_bytes()` posture).
@@ -175,11 +175,11 @@ batched frames, split frames, garbage ATYP, non-UTF-8 domains, the
### 3.2 `rfc1928_codec` (the forked RFC 1928 core, `src/rfc1928/`)
Input: raw `&[u8]`. Drives the fork's byte-level parsers over tokio
duplex streams (the in-tree test shape — mock bytes, no sockets):
duplex streams (the in-tree test shape, mock bytes, no sockets):
- **`read_address`** (the address codec, both directions consume it):
no-panic on any ATYP/bytes; every failure is a typed `AddrError`
(`IncorrectAddressType` for unknown ATYP — the F-1 class, already
(`IncorrectAddressType` for unknown ATYP, the F-1 class, already
fixed; never a panic); round-trip for every well-formed address
(`to_be_bytes` → parse → equal).
- **the server handshake** (`negotiate_auth` → `read_command` over a
@@ -190,7 +190,7 @@ duplex streams (the in-tree test shape — mock bytes, no sockets):
command + target.
- **the client reply path** (`read_request_reply`): no-panic on any
server byte sequence; unassigned reply codes map to the
`GeneralFailure` catch-all (the F-1 pin — never `unreachable!()`);
`GeneralFailure` catch-all (the F-1 pin, never `unreachable!()`);
version mismatches are typed errors.
- **`parse_udp_request`** (the RFC UDP header codec, front doors only
but still wire-reachable): no-panic; garbage RSV / unknown ATYP /
@@ -198,7 +198,7 @@ duplex streams (the in-tree test shape — mock bytes, no sockets):
`new_udp_header`.
- **auth sub-handshakes** (`read_username_password`, RFC 1929):
no-panic; the u8 length fields bound allocations structurally
(≤ 255 bytes — asserted); empty username/password are typed errors;
(≤ 255 bytes, asserted); empty username/password are typed errors;
round-trip accept/reject status bytes.
- **`ReplyError::from_u8`** full-domain table: every `u8` maps, no
panic, unassigned → catch-all (the direct probe of the F-1 class).
@@ -222,16 +222,16 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` +
- **no-panic on any JSON-shaped input** (`deny_unknown_fields`
dispatch, the registry-spec builder).
- **the trivial-params pin**: `{}` parses, `Socks5Params::as_json()` ==
`{}`, and unknown fields always reject (the loud-rejection posture —
`{}`, and unknown fields always reject (the loud-rejection posture:
the serde pin and the schema's `additionalProperties: false` agree).
- **the spec is a constant**: `socks5_open_spec()`'s wire-visible
fields (op id, ALPN, scopes, schemas) are identical on every
execution — the open-op surface is wire-stable (ADR-001); the
execution; the open-op surface is wire-stable (ADR-001); the
reason-enum table equals the typed parse table (the schema/typed-view
drift guard, mirrored from the in-suite test).
- **the reason surface is a pass-through extraction, not a validated
table**: `establishment_reason` yields `Some(details.reason)` iff the
code is `channel:open_failed` and the reason is a string — any string
code is `channel:open_failed` and the reason is a string; any string
is surfaced as-is (the alktunnels harness-model finding #2, adopted
here as the modeled contract); `SocksEstablishmentReason::parse`
never panics and is total.
@@ -239,29 +239,29 @@ generator) → `Socks5Params` deserialization + `socks5_open_spec()` +
### 3.4 What is deliberately NOT a target (yet)
- **The producer pump (`serve_socks5_over`) / consumer sessions as
stateful op-sequence targets** — wave 2, deferred (§4). The
stateful op-sequence targets**: wave 2, deferred (§4). The
handshake-level target (§3.2) covers the byte surface; the
session-level interleavings (associate stage select-shape, BIND quiet
wait, the two-pump CONNECT plane) are stateful shapes over a live
channels stack — the alktunnels wave-2 pattern, worth its own session
channels stack (the alktunnels wave-2 pattern), worth its own session
post-0.1.
- **The doors (`door.rs`, `local/`, `composed/`)** — feature-gated
- **The doors (`door.rs`, `local/`, `composed/`)** are feature-gated
assembly; their RFC-codec boundary (`parse_udp_request` /
`new_udp_header`) is covered by the §3.2 target. The rest is I/O
plumbing over the fuzzed cores, not parse surfaces.
- **`front_door` RFC 1929 negotiation** — covered by §3.2's auth
- **`front_door` RFC 1929 negotiation**: covered by §3.2's auth
sub-handshake invariants (the door drives the same forked parsers).
## 4. Scope: two waves
**Wave 1 (this session):** the three targets above. All drive
already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching
already-`pub` APIs; no `#[cfg(fuzzing)]` exposure needed (matching
both upstream docs' experience across all eight targets to date). One
diagnostic addition to the crate: `DatagramReader::retained_bytes()`
(the decoder-state byte count §3.1's bound asserts against, in
alktunnels' `retained_bytes()` style).
**Wave 2 (deferred — post-0.1):** `session_pumps`, the stateful
**Wave 2 (deferred to post-0.1):** `session_pumps`, the stateful
op-sequence target over the producer pump + consumer sessions in the
alkcall `manager_routing` / alktunnels `session_pumps` style (live
channels stack; ops over CONNECT/ASSOCIATE/BIND interleavings; the
@@ -269,7 +269,7 @@ two-pump shutdown-on-completion contract, the egress readiness
contract, and the datagram-stage exit matrix as invariants). Deferred
because wave 1 covers the wire byte surface completely, the session
harness is a large build, and the crate is one commit from its first
publish — the alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption,
publish. The alktunnels sequencing (wave 1 at 0.2.0's fuzz adoption,
wave 2 the same session) was possible there because its session shape
was the crate's whole surface; here the byte-level targets carry the
attacker-reachable load and the stateful session target is a separate
@@ -282,108 +282,108 @@ wave 2 when scheduled.
## 5. Operational isolation: campaigns never share fate with the agent session
**Adopted verbatim from the upstream docs (alkcall §7.6, alktunnels
§5)** — an environment constraint, not a nice-to-have. Agent sessions
§5)**: an environment constraint, not a nice-to-have. Agent sessions
run fuzzer builds/campaigns through a bash tool that spawns the fuzzer
as a child of the session's own process tree and cgroup; an OOM in a
fuzz target must cost the fuzzer, never the session. Three layers, all
mandatory: (1) libFuzzer soft limits (`-rss_limit_mb=2048`
`-malloc_limit_mb=2048`) — never `ulimit -v` under ASAN; (2) fork mode
`-malloc_limit_mb=2048`); never `ulimit -v` under ASAN; (2) fork mode
(`-fork=1`) as blast-radius containment (crash/timeout/OOM kills only
the short-lived child; the harness survives and persists the artifact;
also the deadlock answer); (3) process detachment — the runner script
also the deadlock answer); (3) process detachment: the runner script
wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a
log file; poll, never `wait`.
Also inherited: the operational note that `cargo fuzz run` returns
before its last fork-mode children fully exit — prefer `pgrep -af` to
before its last fork-mode children fully exit; prefer `pgrep -af` to
LIST, kill by explicit pid from the runner's echoed `pid=`, and never
pattern-kill-and-recheck in one call.
## 6. No hosted CI
Per alkcall §7.9 / alktunnels §6 — the standing policy applies to this
Per alkcall §7.9 / alktunnels §6, the standing policy applies to this
repo identically: no hosted CI will exist; do not add Actions/Gitea
Actions workflow files. **Corpus replay is the fuzz gate as plain
`cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml`
replays every committed seed through the invariant functions — add it
replays every committed seed through the invariant functions, so add it
to the release verification checklist (AGENTS.md). Campaigns run
manually via the detached runner (§6) — e.g. before a release, or after
manually via the detached runner (§6); e.g. before a release, or after
touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`.
## 7. Implementation log
- **2026-09-28 — proposal adopted** (this doc). Wave-1 targets
- **2026-09-28: proposal adopted** (this doc). Wave-1 targets
`datagram_codec` + `rfc1928_codec` + `params_parse`; wave 2 deferred
(§4).
- **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1,
- **2026-09-28: `alkcall` bumped 0.8.0 → 0.8.1,
`alktunnels` 0.2.0 → 0.2.1.** Bug-fix releases, no API/wire change;
alkcall's fix (the duplicate adopt/open destroying the live channel)
is directly relevant to this crate's consumer sessions — every
is directly relevant to this crate's consumer sessions: every
session's teardown reaps via the manager, and the
open-op-response-replayed scenario alkcall's changelog names is the
consumer open path. Verification: full suite (default + all-features)
green, clippy, fmt, wasm check (§8 commands).
- **2026-09-28 — wave-1 implemented.** The `fuzz/` workspace laid out
- **2026-09-28: wave-1 implemented.** The `fuzz/` workspace laid out
per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner,
dict, generator); the three targets over already-`pub` APIs — no
dict, generator); the three targets over already-`pub` APIs, with no
`#[cfg(fuzzing)]` exposure needed (the upstream docs' experience,
now across eleven targets). One diagnostic addition to the crate:
`DatagramReader::retained_bytes()` (§3.1's bound target). Seeds
committed: 45 + 65 + 19 generated (fuzz/gen_fuzz_seeds.py) + 2
campaign-found crash inputs promoted (`datagram_codec/seed-900`,
`seed-901` — the §7 harness findings below).
- **2026-09-28 — two harness-model defects caught by corpus replay
`seed-901`, the §7 harness findings below).
- **2026-09-28: two harness-model defects caught by corpus replay
pre-campaign** (the alkcall §7.7 fuzzer-as-harness-checker pattern):
1. **A `len == 0` frame is well-framed but decodes to
`Err(EmptyFrame)`** — the re-frame round-trip first expected every
`Err(EmptyFrame)`**: the re-frame round-trip first expected every
well-framed frame to decode `Ok`. The frame count still holds over
every frame; the byte-for-byte re-frame holds over the decodable
ones; `EmptyFrame` is pinned as the only zero-inner-byte shape.
The same arm widened for `ShortAddress`/`DomainNotUtf8`/
`BadAtyp`: a well-framed frame can carry any addr-garbage shape —
`BadAtyp`: a well-framed frame can carry any addr-garbage shape;
the decode refuses the addr, and the raw frame is the only
re-frame possible.
2. **A fieldless `deny_unknown_fields` struct deserializes from ANY
empty JSON composite** (the serde seq form `[]` included) — not
empty JSON composite** (the serde seq form `[]` included), not
only `{}`. Not wire-reachable (the registry's `type: object`
input schema is the gate that keeps non-object forms from ever
reaching the type), but the harness must model the serde reality:
the trivial-params invariant is now "an empty composite parses,
everything non-empty rejects, the canonical round-trip is `{}`."
- **2026-09-28 — first campaign finding (fork-mode containment as
designed): a third harness-model defect.** The `datagram_codec`
- **2026-09-28: first campaign finding, fork-mode containment as
designed (a third harness-model defect).** The `datagram_codec`
campaign crashed at ~35 s on input `0005 0301 1203 03bb 00`
(`BadAtyp(0)` shape) — the re-frame walk's `Err` arm (fixed in the
(`BadAtyp(0)` shape); the re-frame walk's `Err` arm (fixed in the
replay pass above) still omitted `BadAtyp`. Fixed; the crash input is
committed as `seed-900`; the campaign relaunched clean.
- **2026-09-28 — second campaign finding (also harness, fork-mode
containment): the count walk's `declared >= 7` gate excluded short
domains.** Input `0005 0301 1203 03bb 00` — a domain frame with a
- **2026-09-28: second campaign finding (also harness, fork-mode
containment); the count walk's `declared >= 7` gate excluded short
domains.** Input `0005 0301 1203 03bb 00`, a domain frame with a
1-byte domain (`addr_len = 5`) is a legal empty-payload frame, but
the walk's `declared >= 7` gate (meant to skip addr-less frames)
rejected it; the per-ATYP `addr_len` computation already does that
job. The gate lowered to `>= 4` (the minimum ATYP + length-byte
prefix the walk reads); the crash input is committed as `seed-901`;
the campaign relaunched clean.
- **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6
- **2026-09-28: wave-1 campaigns run (10-min detached runs, §6
runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048
-timeout=25`; codec `-max_len=65536`, rfc1928
`-max_len=4096 -use_value_profile=1`, params
`-dict=json.dict -use_value_profile=1). All three exited 0 with
empty artifact directories — no crashes, hangs, OOMs, or leaks.**
- `datagram_codec` (post-harness-fixes) — ~1.9M execs (~3k exec/s),
empty artifact directories: no crashes, hangs, OOMs, or leaks.**
- `datagram_codec` (post-harness-fixes), ~1.9M execs (~3k exec/s),
33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage
saturated at 481 edges (ft 2222, corp 209) — the 2-byte-header
saturated at 481 edges (ft 2222, corp 209); the 2-byte-header
state machine is fully enumerated (the alktunnels wave-1
saturation profile).
- `rfc1928_codec` — ~2.0M execs (~3.6k exec/s), 33 fork jobs,
- `rfc1928_codec`, ~2.0M execs (~3.6k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 1429
edges (ft 7987, corp 704) — the handshake state machines'
edges (ft 7987, corp 704); the handshake state machines'
byte-level shape space is fully enumerated; both protocol
directions held across every adversarial sequence.
- `params_parse` — ~3.1M execs (~5.5k exec/s), 33 fork jobs,
- `params_parse`, ~3.1M execs (~5.5k exec/s), 33 fork jobs,
`oom/timeout/crash: 0/0/0` throughout. Coverage 1512 edges (ft
12970, in-memory corpus 2981 from the 19 committed seeds) — the
12970, in-memory corpus 2981 from the 19 committed seeds); the
serde/untagged shape space was still expanding at budget end; the
spec-constant and pass-through-reason invariants held everywhere.
- The decoder-state bound (§3.1) held everywhere:
@@ -392,13 +392,13 @@ touching `src/wire.rs`, `src/rfc1928/`, or `src/params.rs`.
committed seeds (the §2 policy).
- **NO crate bugs found by wave 1.** All three findings this session
were harness-model defects, each invisible to example-based review
and caught by fuzzing/replay — the upstream "fuzzer as a
and caught by fuzzing/replay: the upstream "fuzzer as a
harness-checker" pattern, confirmed a third time (alkcall §7.7,
alktunnels §7, here §7). The codec's contracts (infallible
reassembly, the F-2 layering, the joint bound, the retained-bytes
bound) and the RFC core's no-panic posture held across every
adversarial input the campaigns generated.
- (progress appended as implementation proceeds — see git log)
- (progress appended as implementation proceeds; see git log)
## 8. Verification commands
@@ -425,11 +425,11 @@ Campaign flags per target: `datagram_codec -max_len=65536`;
runner, corpus policy, no-CI posture):
`/workspace/@alkdev/alkcall/docs/research/fuzzing.md`,
`/workspace/@alkdev/alktunnels/docs/research/fuzzing.md`
- alkcall 0.8.1 changelog — the finding our consumer open/adopt paths
- alkcall 0.8.1 changelog, the finding our consumer open/adopt paths
are exposed to
- This crate: ADR-001 (op/params), ADR-003 (the frame codec), ADR-013
(the targeted fork), `docs/architecture/wire.md`,
`docs/architecture/rfc1928.md`
- External: RUSTSEC-2026-0037 (quinn-proto — the maintainers'
- External: RUSTSEC-2026-0037 (quinn-proto, the maintainers'
fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust
Fuzz Book (all linked from the upstream doc's §9)
+32 -32
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-13
---
# alksocks research — iroh-socks5 as an alternative base
# alksocks research: iroh-socks5 as an alternative base
Evaluation of `iroh-socks5` 0.5.0 (crates.io, published 2026-08-31 by
Matthew Geddes) as a potential base for alksocks, in place of or in
@@ -62,7 +62,7 @@ Module map:
Better than the README's self-deprecation suggests ("LLM-assisted... All
bugs are mine"). The wire codecs are clean, bounded, and unit-tested;
the integration suite (`tests/integration.rs`, 12 tests under
`test-utils`) exercises real iroh connections — direct and via-relay
`test-utils`) exercises real iroh connections (direct and via-relay
dials, CONNECT (IP + domain), refused-target error mapping, full UDP
ASSOCIATE round-trip, BIND, allow-list accept/deny, greeting edge cases.
Doc comments are genuinely explanatory. Notable specifics:
@@ -72,29 +72,29 @@ Doc comments are genuinely explanatory. Notable specifics:
serde (not hand-rolled enums) for the inner types.
- **UDP header codec** (`socks5.rs` `parse_udp_packet`/
`format_udp_packet`): correct ATYP handling, uses `get(..)?` bounds
checks, propagates the FRAG byte, domain targets supported — same
checks, propagates the FRAG byte, domain targets supported; same
functional slot as fast-socks5's `new_udp_header`/`parse_udp_request`.
- **Half-close propagation** in `relay_tcp`: each direction signals
`finish()`/`shutdown()` on completion — a hand-rolled two-pump that
`finish()`/`shutdown()` on completion, a hand-rolled two-pump that
*does* shut down the opposite sink (the ADR-050 contract), then joins
both halves (`tokio::join!` + propagate both errors).
- **UDP association lifecycle**: the client side ends the association
when the SOCKS5 control connection closes (`wait_until_closed`
drains-and-detects EOF on the control TCP), not merely when the UDP
socket goes quiet — the RFC's "TCP control connection closes →
socket goes quiet; it implements the RFC's "TCP control connection closes →
association ends" semantic, which many proxies get wrong.
- **Reply-code mapping** is faithful where it matters (refused vs
unreachable distinguishes `ErrorKind::ConnectionRefused`).
Weaknesses (acceptable for its scope, relevant if borrowed):
- **RFC 1929 username/password is absent** — no-auth only
(`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth
- **RFC 1929 username/password is absent** (no-auth only:
`negotiate` writes `0xFF` if the client doesn't offer no-auth). Auth
is the iroh `EndpointId` allow-list at the connection level.
- **BIND's second-incoming-connection shape** accepts exactly one
inbound connection and has no timeout or multi-accept handling (RFC
1928 BIND is notoriously underspecified; this is a reasonable minimal
read, but it binds a real `TcpListener` — the exact thing alksocks'
read, but it binds a real `TcpListener`, the exact thing alksocks'
no-bind principle forbids in the protocol layer).
- **UDP relay is one socket + flow-table-free on the server side**: all
datagrams from the association share one `UdpSocket`, replies are
@@ -102,10 +102,10 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
back as `TargetAddr::Ip(from)`). Fine for the common case; no
per-endpoint flow table like alknet's `Socks5UdpSocket` model.
- **`allowlist_denies_unknown_peer`** closes the QUIC connection without
an RFC reply — a vanilla client sees a hard close, not a SOCKS5 error
an RFC reply; a vanilla client sees a hard close, not a SOCKS5 error
code. (In alksocks this maps to the typed `channel:open_failed`
establisher refusal, which POC #1 already handles better.)
- Client-side UDP binds a real socket per association (necessarily —
- Client-side UDP binds a real socket per association (necessarily:
it's the front door for vanilla clients); server-side UDP likewise
binds.
@@ -117,7 +117,7 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
+ `Router::accept(ALPN, handler)` is the same posture as alkcall's
`register_openable` (ALPN-scoped produced resource, handler per
connection). iroh 1.0's `ProtocolHandler` is even *single-method*
(`accept(Connection)`) — structurally the open-handler shape POC #1
(`accept(Connection)`), structurally the open-handler shape POC #1
used. The crate's ALPN string convention (`iroh-socks5/1`) matches
the alk/ ALPN convention too.
2. **One stream per session, no sub-demux.** The `Request`/`Reply`
@@ -129,10 +129,10 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
dials), `Client` = consumer half with the optional local TCP front
door (`bind_listener`/`serve` is the ssh `-D` shape POC #1's
`local_front_door` example validated). The *only* consumer path is
the local bind — there is no in-band (channels-native) client session
the local bind; there is no in-band (channels-native) client session
type, which is where alksocks' wrapper differs.
4. **Target resolution on the producing side.** Domains are carried
unresolved (`TargetAddr::Domain`) and resolved at the exit node —
unresolved (`TargetAddr::Domain`) and resolved at the exit node,
the ssh `-D` semantic phase-0 §OQ-SK-05 notes, with a pluggable
resolver (`DnsResolver`) and a privacy rationale (DoH so the exit
host's network can't observe names) that mirrors the iroh-privacy
@@ -144,13 +144,13 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
`client.rs` are written against `iroh::endpoint::{Connection,
SendStream, RecvStream}` concretely; `relay.rs` signatures take
`&mut SendStream`. Extracting a substrate-agnostic core would mean
keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs) — and
keeping only `protocol.rs` + `socks5.rs` (~550 LoC of codecs); and
those are the parts fast-socks5 already provides in stricter, more
complete form (typestate, auth traits, reply-code enums, generic `T`).
2. **Not generic over the stream.** The whole reason POC #1 could drop a
channels `BiStream` into fast-socks5's `Socks5ServerProtocol<T>` with
zero adaptation is fast-socks5's `T: AsyncRead + AsyncWrite + Unpin`
genericity (AGENTS.md convention 7). iroh-socks5 has no such seam —
genericity (AGENTS.md convention 7). iroh-socks5 has no such seam;
the equivalent refactor would be a rewrite, not a fork.
3. **RFC 1928 subset.** No username/password auth (OQ-SK-02's RFC-facing
path), no auth-method negotiation machinery (fast-socks5's
@@ -158,13 +158,13 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
(reply codes are bare `u8` consts). fast-socks5 is a conformant
implementation with a test suite covering the RFC edge cases;
iroh-socks5 is a pragmatic subset.
4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) — heavy
4. **tokio "full" + iroh pin** (`iroh = "=1.0.3"`, exact pin) is a heavy
dependency for the wasm-clean default-crate invariant (OQ-SK-04);
the vendored-subset posture would reduce it to the two codec files
anyway, at which point fast-socks5's already-POC'd fork (POC #4:
~120 cfg lines, functional wasm-clean subset) dominates.
5. **No in-band client session type.** The only consumer path is the
local TCP front door — there is no client half that speaks the
local TCP front door; there is no client half that speaks the
framed protocol from inside a process without a kernel listener
(fast-socks5's `Socks5Stream::use_stream` slot). alksocks'
consumer half needs exactly that; iroh-socks5 doesn't offer it.
@@ -172,18 +172,18 @@ Weaknesses (acceptable for its scope, relevant if borrowed):
### The valuable part: OQ-SK-03 evidence
iroh-socks5 implements channels-native UDP ASSOCIATE without binding a
relay-visible port on the producer side beyond the real egress socket —
relay-visible port on the producer side beyond the real egress socket;
the exact question OQ-SK-03 poses. Its answers:
- **Datagram stage rides the same stream** (its "second channel" option
is not used; the association's stream carries framed `Datagram`s
post-reply). Matches the phase-model hunch, including "control stream
goes silent after the reply" — nothing else is ever sent on the
goes silent after the reply": nothing else is ever sent on the
control stream after the ASSOCIATE reply; the client-side relay
monitors the control connection's EOF (`wait_until_closed`) as the
association's end-of-life, and the RFC's "post-reply control data is
garbage" semantic holds.
- **The datagram framing is a length prefix, not the RFC UDP header** —
- **The datagram framing is a length prefix, not the RFC UDP header**:
the RFC UDP header (`RSV/FRAG/ATYP/addr/port`) is applied only at the
*front door* (the local UDP socket a vanilla client sends to), where
it belongs: `relay_udp_client` parses/`format_udp_packet`s at the
@@ -191,22 +191,22 @@ the exact question OQ-SK-03 poses. Its answers:
This is a cleaner cut than phase-0's hunch (which put the length
prefix inside the same stream as RFC headers): the alkcall-channel
wire carries `{addr, data}` postcard frames (boundary-preserving by
construction — an empty datagram is a frame with empty `data`, never
construction (an empty datagram is a frame with empty `data`, never
EOF; alktunnels F-2's requirement is met structurally), and RFC header
encode/decode exists only where a vanilla-client socket boundary
exists (the optional local backends on either side).
- **Server-side `BND.ADDR` semantics**: the ASSOCIATE reply reports the
*real* bound UDP socket address (`udp.local_addr()`) — because in
*real* bound UDP socket address (`udp.local_addr()`), because in
this design the server-side relay socket is real. On alksocks'
channels path (no socket), phase-0's sentinel question remains open;
iroh-socks5 sidesteps it by having the vanilla-facing side be the
*client* half. A producer-side vanilla UDP front door (OQ-SK-03's
local-backend-bridge option) would need the same real-socket
treatment; the wrapper-aware in-band path can carry a sentinel or
(better, per this evidence) no address at all — the framed `Datagram`
(better, per this evidence) no address at all; the framed `Datagram`
stream needs no `BND.ADDR` to function; the address is pure
RFC-compatibility surface.
- **No per-endpoint flow table on the producer side** — the relay
- **No per-endpoint flow table on the producer side**; the relay
socket is one `send_to`/`recv_from` loop with `TargetAddr` carried
per datagram. This is *simpler* than alknet's flow-table model and
sufficient: per-datagram addressing in the frame does the
@@ -224,13 +224,13 @@ the exact question OQ-SK-03 poses. Its answers:
| Stream genericity | `T: AsyncRead + AsyncWrite + Unpin` throughout | concrete iroh `SendStream`/`RecvStream` |
| Server structure | explicit typestate + interception points | single `accept()` method, monolithic |
| Auth extension seam | `AuthMethod` trait | none (allow-list only) |
| UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`) — equivalent |
| UDP codec | public pure fns (`new_udp_header`, `parse_udp_request`) | public pure fns (`parse_udp_packet`, `format_udp_packet`), equivalent |
| UDP ASSOCIATE over the transport | default handler binds sockets; custom seam narrow (phase-0 caveat) | clean framed-datagram design, no producer-side virtualization needed |
| Producer dial seam | interception points (`run_tcp_proxy`, command-read typestate) | none (dial hardwired in `handle_connection`) |
| Client session genericity | `Socks5Stream::use_stream` (any `T`) | none (local TCP front door only) |
| wasm posture | fork verified minimal (POC #4) | moot — iroh + tokio-full are native-only |
| wasm posture | fork verified minimal (POC #4) | moot (iroh + tokio-full are native-only) |
| Tests | unit + examples; router/custom-auth examples | strong integration suite over real iroh infra |
| License | MIT (third-party upstream; inline-vendored — OQ-SK-04) | MIT OR Apache-2.0 |
| License | MIT (third-party upstream; inline-vendored, OQ-SK-04) | MIT OR Apache-2.0 |
| LOC | ~4k | ~1.3k |
## Verdict
@@ -240,20 +240,20 @@ stand); **adopt iroh-socks5's UDP design as prior art for OQ-SK-03**:
1. The framed `{addr, data}` datagram stream (no RFC header in-band, RFC
header only at vanilla front doors) is the cleaner expression of the
phase model — the RFC UDP header becomes a front-door codec concern,
phase model: the RFC UDP header becomes a front-door codec concern,
not a channels-wire concern.
2. Per-datagram addressing with no producer-side flow table suffices
for the *local egress* variant (revisit alknet's flow-table posture
in light of this). Caveat added 2026-09-13: under *composed* egress
(per-target alktunnels udp-tunnels, the OQ-SK-03 handle-swap
discussion) the flow table returns as a destination→tunnel-channel
table — a dial-callback policy, not a protocol-layer concern.
3. Control-stream EOF ends the association (`wait_until_closed` shape —
table, a dial-callback policy, not a protocol-layer concern.
3. Control-stream EOF ends the association (`wait_until_closed` shape:
on channels, the channel's EOF is the same signal; no extra control
vocabulary needed).
4. FRAG ≠ 0 refused is an acceptable v1 posture.
Attribution if code is reused (the two codec files are the candidates —
Attribution if code is reused (the two codec files are the candidates,
though fast-socks5 already covers them): MIT OR Apache-2.0 dual license,
copyright Matthew Geddes; keep LICENSE-MIT/LICENSE-APACHE entries and a
provenance note in any vendored file header.
@@ -261,6 +261,6 @@ provenance note in any vendored file header.
Also worth noting (posture corrected 2026-09-20: fast-socks5 is
third-party upstream, so nothing is "filed" there as of right;
iroh-socks5 is upstream's own crate): nothing blocking. Optionally a
repository link on the crates.io entry would help discoverability — the
repository link on the crates.io entry would help discoverability; the
repo exists at `github.com/mattgeddes/iroh-socks5` but `repository` is
null on crates.io.
+256 -253
View File
File diff suppressed because it is too large. Load diff
+62 -62
View File
@@ -1,14 +1,14 @@
---
status: complete
last_updated: 2026-09-20 (ledger statuses updated — the graduation
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
# 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 —
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.
@@ -19,7 +19,7 @@ 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 —
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;
@@ -27,24 +27,24 @@ wrapper drives the typestate directly and owns the whole BIND shape —
## What was validated
1. **The BIND phase model — confirmed end to end.** The RFC
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
(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) —
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 —
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
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
@@ -54,38 +54,38 @@ wrapper drives the typestate directly and owns the whole BIND shape —
`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
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
`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
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
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 — confirmed RFC-faithful.** A factory failure
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.**
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
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
@@ -96,23 +96,23 @@ wrapper drives the typestate directly and owns the whole BIND shape —
## Structural findings (the load-bearing discoveries)
**The address-first-vs-accept-first deadlock — the POC's one real
**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
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 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 —
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
@@ -120,23 +120,23 @@ 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
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
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 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`
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
`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
@@ -144,7 +144,7 @@ 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
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
@@ -154,7 +154,7 @@ 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
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
@@ -164,48 +164,48 @@ branch.
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
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
- 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
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):
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.)
vendor; see §ledger statuses.)
- `ToTargetAddr::to_target_addr()` (not `From`) is the
`SocketAddr → TargetAddr` conversion — `TargetAddr::from` does not
`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
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
(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
**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
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.**
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
@@ -214,24 +214,24 @@ both asks are **superseded in shape**, not implemented as posed:
`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
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
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
`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
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 —
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.)
@@ -242,7 +242,7 @@ alktunnels 0.1.0; the Phase 1 shape composes variant 2 against 0.2.0
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
(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
@@ -259,13 +259,13 @@ are additive surface, discovered in priority order:
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
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
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.)
@@ -275,7 +275,7 @@ reply#2) are honest interim shapes.
## OQ status after this POC
- **OQ-SK-08 (BIND)**: POC-validated end to end — the phase model
- **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
@@ -283,20 +283,20 @@ reply#2) are honest interim shapes.
*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
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
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
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.
+34 -34
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-13
---
# alksocks POC findings — channels-native CONNECT + fast-socks5 wasm posture
# alksocks POC findings: channels-native CONNECT + fast-socks5 wasm posture
POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
@@ -13,37 +13,37 @@ POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
fast-socks5 1.0.0 (crates.io). 8 tests + 1 example
(`local_front_door`, the curl front door). All pass.
- **POC #4** (fast-socks5 wasm fork-minimality): `fs5-wasm-poc`
(scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` — not
part of this repo; the finding is the artifact). fast-socks5's
(scratch crate, kept at `/workspace/alksocks-fs5-wasm-poc/` (not
part of this repo; the finding is the artifact)). fast-socks5's
`src/` vendored, kernel-net pieces gated behind a `net` feature.
## POC #1 — channels-native SOCKS5 CONNECT
## POC #1: channels-native SOCKS5 CONNECT
### What was validated
1. **`BiStream`-as-`T` genericity — confirmed.** The channels
1. **`BiStream`-as-`T` genericity confirmed.** The channels
`BiStream` (`alkcall::core::types`, `Box<dyn AsyncReadWrite +
Unpin>`, impls `AsyncRead + AsyncWrite`, is `Unpin` + `Send`)
satisfies fast-socks5's `Socks5ServerProtocol<T>` server typestate
and `Socks5Stream<S>::use_stream` client path directly. No adapter,
no wrapper stream, no boxing beyond what `BiStream` already is.
2. **`register_openable_with_establisher` fit — confirmed.** The
2. **`register_openable_with_establisher` fit confirmed.** The
producer registers `channels/socks5/sub` (ALPN `alk/socks5`, scope
`socks5:open`) with an establisher + pump handler exactly as
alktunnels does for `channels/tunnel/sub`.
3. **`pump_bidi` as the CONNECT data plane — confirmed.** Post-reply
3. **`pump_bidi` as the CONNECT data plane confirmed.** Post-reply
the handler awaits `pump_bidi(channel_bistream, target_read,
target_write)` inline; echo round-trips clean through
client → channel → pump → target → back.
4. **Session refusal is a typed call error — confirmed.** An identity
4. **Session refusal is a typed call error, confirmed.** An identity
lacking the open scope gets `FORBIDDEN` at the registry ACL gate;
`ChannelOpenError::CallFailed` carries it; no phantom channel.
The establisher's per-call identity arrives as expected
(witnessed `"consumer"`; CF-005/CF-006 seam works).
5. **Dial failure → RFC reply code mapping — confirmed.** The dial
5. **Dial failure → RFC reply code mapping confirmed.** The dial
refusal surfaces to the RFC client as `ConnectionRefused` (0x05)
in the CONNECT reply — the faithful per-RFC error mapping
AGENTS.md convention 2 requires — with no phantom data plane.
in the CONNECT reply, the faithful per-RFC error mapping
AGENTS.md convention 2 requires, with no phantom data plane.
6. **The front door works with a vanilla client (stretch goal).**
The consumer-side local exposure shape: bind a real TCP listener,
pipe each accepted connection raw onto a channel `BiStream`
@@ -56,13 +56,13 @@ POCs #1 and #4 from phase-0 §OQ-SK-07, run 2026-09-13.
### Structural findings (the load-bearing discovery)
**The dial cannot live in the establisher — and that is correct, not
**The dial cannot live in the establisher, and that is correct, not
a limitation.** The SOCKS5 *target* is unknown at open time: RFC 1928
method negotiation and the CONNECT command (with its target address)
arrive in-stream, after channel establishment. The layering the POC
validates:
- **The establisher is a session gate** — it validates/records the
- **The establisher is a session gate**: it validates/records the
opener identity (OQ-SK-02's seam) and does session-level policy. It
never sees a target.
- **The dial belongs at the command-read interception point**, inside
@@ -81,32 +81,32 @@ natural dialer return type. The no-trait hunch is now evidence-backed.
**Identity propagation (CF-005 (b)) has a producer-side step the
harness had to do:** the accept-side install hook must call
`channel0_conn.set_identity(resolved)` before building the
`CallConnection` — the transport identity does not flow to the
`CallConnection`; the transport identity does not flow to the
channel-0 dispatch automatically. (alktunnels' harness does the same;
modeled mTLS posture. A real mTLS transport would resolve the peer
here.) Worth an upstream note: without it, scope-gated opens fail
closed with `FORBIDDEN "authentication required"` — correct behavior,
closed with `FORBIDDEN "authentication required"`, correct behavior,
but the wiring requirement is implicit.
### Wire/API notes for the wrapper
- `TargetAddr::Ip(addr).to_string()` is `ip:port` — when handing the
- `TargetAddr::Ip(addr).to_string()` is `ip:port`; when handing the
dial closure a structured target, use `addr.ip().to_string()` +
`addr.port()` separately (the POC hit this; the wrapper's target
carrier will too).
- `Socks5Stream::request(cmd, TargetAddr)` — the target must be
- `Socks5Stream::request(cmd, TargetAddr)` requires that the target be
constructed via `ToTargetAddr`; IP-string forms parse to IP targets
(domain form passes through unresolved, per OQ-SK-05's DNS question).
- `SocksServerError::AuthenticationRejected` is a unit variant (no
message field) in 1.0.0.
- `Establishment` with no plan (`Establishment::default()`) is the
right shape for SOCKS5 — the dial happens post-establishment, so
right shape for SOCKS5; the dial happens post-establishment, so
there is nothing to thread establisher→handler.
- The open-op params are trivially empty (`{}`); the spec's input
schema is `{type: object}`. Params stay empty unless a later ADR
adds per-session options.
## POC #4 — fast-socks5 wasm fork-minimality
## POC #4: fast-socks5 wasm fork-minimality
### Baseline
@@ -117,7 +117,7 @@ but the wiring requirement is implicit.
OQ-SK-04's premise. (alksocks-connect-poc itself re-confirms: the dep
chain pulls `mio`/`socket2`.)
### The gating change — measured, not guessed
### The gating change, measured not guessed
Vendored fast-socks5's `src/` into a scratch crate; feature-gated
every kernel-net touch behind a `net` feature (default-on, exactly the
@@ -133,20 +133,20 @@ alktty `local` pattern):
| `Cargo.toml` | `net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`, default-on | tokio stays `default-features = false, io-util/time/sync/macros` |
Diff size vs upstream: **~120 changed lines across 5 files, all
`#[cfg]` insertions** (client.rs 87 diff lines — inflated by the
`#[cfg]` insertions** (client.rs 87 diff lines, inflated by the
whitespace of moving the datagram block; server.rs 27; the rest ≤3).
### Results
- `cargo check --no-default-features` (native): clean.
- `cargo check --target wasm32-unknown-unknown --no-default-features`:
**clean** — no `mio`, no `socket2`, no `tokio::net` in the graph.
**clean**: no `mio`, no `socket2`, no `tokio::net` in the graph.
- `cargo test --no-default-features`: a full RFC 1928 CONNECT
conversation (typestate server over a duplex ↔ `use_stream` client)
passes — the wasm-clean subset is the *functional* protocol subset,
passes; the wasm-clean subset is the *functional* protocol subset,
not a husk.
- `cargo check --target wasm32-unknown-unknown` with `net` on:
fails in `mio` (expected — the target gate for wasm users is
fails in `mio` (expected; the target gate for wasm users is
`default-features = false`).
### Verdict for OQ-SK-04
@@ -156,24 +156,24 @@ whitespace of moving the datagram block; server.rs 27; the rest ≤3).
insertions and zero code rewrites; the wasm-clean subset still
implements the whole RFC conversation. tokio itself needs
`default-features = false` with an explicit `net` opt-in feature
(`net = ["tokio/net", ...]`) — that is the entire shape of the change.
(`net = ["tokio/net", ...]`); that is the entire shape of the change.
The one structural discovery: **`tokio_stream` (used for the legacy
`Incoming` stream) and `socket2` must be optional deps keyed to the
same feature**, and `try_join!` requires tokio's `macros` feature —
same feature**, and `try_join!` requires tokio's `macros` feature;
all mechanical.
Remaining for Case 1: the fork posture is vendoring *into this crate*
(per phase-0: vendor the `T`-generic subset, gate the rest), keeping
`/workspace/fast-socks5` as the differential reference; the upstream
PR (feature-gate `net` behind a default-on feature) is small enough
to offer as-is. **(2026-09-20: resolved — the inline vendor is the
to offer as-is. **(2026-09-20: resolved; the inline vendor is the
decision; the PR is an optional courtesy, never a wait. See phase-0
OQ-SK-04.)**
### Follow-on changes (posture corrected 2026-09-20: not upstream asks)
**Ownership correction:** fast-socks5 is third-party upstream — we do
**Ownership correction:** fast-socks5 is third-party upstream; we do
not own it, and it has not been forked. These are therefore not asks;
they are changes the inline vendor carries in-tree (OQ-SK-04, resolved
2026-09-20). The wasm-gate change below is POC #4's; the other two
@@ -182,7 +182,7 @@ into the vendor with it.
1. **Feature-gate kernel-net behind a default-on `net` feature**
(POC #4's change, ~120 lines of `#[cfg]`). (2026-09-21: dissolved
under the extraction — ADR-011 §Amendment; the kernel-net region
under the extraction (ADR-011 §Amendment); the kernel-net region
is not copied, and POC #4's gate map survives as the extraction
recipe, not in-tree cfg.)
2. **`run_udp_proxy_custom` binds a kernel socket unconditionally**
@@ -190,24 +190,24 @@ into the vendor with it.
customizable seam covers the relay closure and reply IP only; the
channels path must drive the typestate directly. Make the
`udp_bind_random_port` step optional / accept a caller-supplied
socket. (Both #2 and the POC #5 courtesy item — publish `new_reply`
— ride the vendor; see `poc-bind-findings.md` §ledger statuses.)
socket. (Both #2 and the POC #5 courtesy item, publish `new_reply`,
ride the vendor; see `poc-bind-findings.md` §ledger statuses.)
## OQ status after these POCs
- **OQ-SK-01 (dial policy)**: shape resolved empirically — an injected
- **OQ-SK-01 (dial policy)**: shape resolved empirically: an injected
dial callback invoked at the command-read interception point;
boxed-halves return type; no trait (alktunnels' no-trait precedent
holds). Where the callback *comes from* (alktunnels composition vs
local TCP vs further hops) stays an assembly-layer question.
- **OQ-SK-02 (auth mapping)**: the seam works — per-call opener
- **OQ-SK-02 (auth mapping)**: the seam works; per-call opener
identity arrives at the establisher, and channel-0 identity
propagation is a wiring requirement the producer harness performs.
In-band SOCKS auth was not exercised (NoAuth only); the RFC-facing
username/password path for the local backend remains Phase 1.
- **OQ-SK-04 (wasm)**: fork-minimality verified; the decision reduced
to "does wasm make sense for alksocks" (the adapter story), with
the mechanism de-risked. **(2026-09-20: resolved — inline vendor,
the mechanism de-risked. **(2026-09-20: resolved, inline vendor,
on the corrected ownership premise + the BIND/seam needs. See
phase-0 OQ-SK-04.)**
- **OQ-SK-07**: #1 and #4 complete; #2 (UDP ASSOCIATE) and #3 (noq)
+36 -36
View File
@@ -3,7 +3,7 @@ status: complete
last_updated: 2026-09-14
---
# alksocks POC findings — channels-native UDP ASSOCIATE
# alksocks POC findings: channels-native UDP ASSOCIATE
POC #2 from phase-0 §OQ-SK-07, run 2026-09-14. Crate:
`alksocks-udp-poc` (standalone, `/workspace/alksocks-udp-poc/`), against
@@ -20,47 +20,47 @@ composed variant's per-destination open cost is measured.
## What was validated
1. **The handle-swap data plane — confirmed end to end.** The
1. **The handle-swap data plane was confirmed end to end.** The
ASSOCIATE conversation and the datagram stage ride the same
channel `BiStream`. The producer drives the typestate directly
(`accept_no_auth → read_command → reply_success(sentinel)`) —
(`accept_no_auth → read_command → reply_success(sentinel)`);
`run_udp_proxy_custom` is not used (its unconditional
`udp_bind_random_port` makes it unusable on channels, per the
corrected §Prior art caveat; driving the typestate directly is the
workaround the upstream ask would retire).
2. **The in-band frame codec — confirmed.** The wire is alktunnels'
boundary-preserving length codec (`frame_datagram`/`DatagramReader`
— empty payload legal, never EOF; frames split/batch across channel
2. **The in-band frame codec is confirmed.** The wire is alktunnels'
boundary-preserving length codec (`frame_datagram`/`DatagramReader`;
empty payload legal, never EOF; frames split/batch across channel
chunks transparently) over the RFC address encoding
(`TargetAddr::to_be_bytes()` — ATYP + addr + port). RSV/FRAG do not
(`TargetAddr::to_be_bytes()`: ATYP + addr + port). RSV/FRAG do not
ride the tunnel wire: fragmentation cannot even be expressed
in-band; it is refused at front doors (FRAG ≠ 0 → refuse, the
iroh-socks5 v1 posture). The RFC-header codecs
(`new_udp_header`/`parse_udp_request`) are used only at the
front-door boundary, exactly as the baseline specifies.
3. **Per-datagram addressing, no flow table — confirmed.** One relay
3. **Per-datagram addressing without a flow table was confirmed.** One relay
loop per association; each frame carries its destination; the
local-egress variant relays through one real `UdpSocket` with
per-datagram `send_to`/`recv_from` (iroh-socks5's cut). Five
in-order datagrams round-trip over one association
(`udp_multiple_datagrams_one_association`).
4. **Association lifetime = channel lifetime — confirmed.** The
4. **Association lifetime = channel lifetime, confirmed.** The
consumer's `shutdown()` half-closes the stream; the mux's
implicit-EOF sentinel (REQ-CH-01) reaches the producer's stage read
as EOF; the stage exits and the connection stays healthy (a fresh
associate succeeds immediately after). No extra control vocabulary
needed — the channels-level EOF is the association's end-of-life
signal, as the iroh-socks5 eval predicted.
5. **Control silence is structurally enforced — confirmed.** The
as EOF; the stage exits and the connection stays healthy (a fresh
associate succeeds immediately after). No extra control vocabulary
needed; the channels-level EOF is the association's end-of-life
signal, as the iroh-socks5 eval predicted.
5. **Control silence is structurally enforced, confirmed.** The
frame's first byte is ATYP, so any post-reply control byte parses
as a garbage ATYP and fails the association loud
(`udp_undecodable_frame_ends_association`, with the unassigned ATYP
0x7F). The phase model needs no explicit mode byte: the reply
itself synchronizes the mode switch.
6. **Both egress variants — implemented and passing.**
6. **Both egress variants: implemented and passing.**
- **Local egress** (`LocalEgress`): one real UDP socket per
association; per-datagram addressing; no flow table. Domain-form
destinations resolve at the egress (producer-side DNS — the ssh
destinations resolve at the egress (producer-side DNS, the ssh
`-D` semantic, POC-resolved as "the egress owns resolution").
- **Composed egress** (`ComposedEgress`): a lazy
destination→alktunnels-udp-tunnel table (each new destination
@@ -80,7 +80,7 @@ composed variant's per-destination open cost is measured.
destination fan-out; the wire space itself is `u32`. The LRU cap +
the dedup assertion (`open_count == 1` for repeated same-resource
destinations) are pinned by `udp_composed_egress_lazy_table_lru_and_open_cost`.
8. **The front door — RFC codec composition at the vanilla boundary,
8. **The front door: RFC codec composition at the vanilla boundary,
confirmed.** An in-process bridge exposes the association on a real
UDP socket; the door's pump decodes RFC-header'd datagrams
(`parse_udp_request`), sends `{addr, data}` frames in-band, and
@@ -91,14 +91,14 @@ composed variant's per-destination open cost is measured.
shape (the CONNECT front door in POC #1 already validated that
pipe shape); this test pins the UDP-specific claim: RFC codec at
the door, frames in-band, zero payload translation.
9. **CONNECT on the same registration — regression-guarded.** One ALPN
9. **CONNECT on the same registration, regression-guarded.** One ALPN
(`alk/socks5`), one op (`channels/socks5/sub`), trivial params; the
CONNECT path is the POC #1 data plane unchanged
(`connect_still_served_on_the_same_registration`).
10. **The identity-seam wiring requirement re-confirmed** for the
composed path: the tunnel producer's channel-0 accept hook must
set the opener identity (`set_identity`), or scope-gated tunnel
opens fail closed with `FORBIDDEN "authentication required"` —
opens fail closed with `FORBIDDEN "authentication required"`;
the same POC #1 finding, now witnessed from the consumer side of
the composed stack.
@@ -107,22 +107,22 @@ composed variant's per-destination open cost is measured.
**The datagram stage is `select!`-driven, and the egress reply
future's readiness contract is load-bearing.** The stage runs channel
reads and the egress's reply stream concurrently (`tokio::select!`).
Three readiness hazards were hit and fixed in this POC — all of them
Three readiness hazards were hit and fixed in this POC, all of them
generalizable API-design notes for the crate's Phase 1:
1. **The poll-then-block window loses replies.** The first iteration
interleaved "drain egress replies (non-blocking), then block on the
channel read" — the echo's reply arriving during the channel block
channel read"; the echo's reply arriving during the channel block
was never picked up. The fix is the concurrent select shape, which
is also the honest full-duplex shape (an egress may produce
unsolicited inbound datagrams at any time).
2. **An instantly-ready `Ok(None)` starves the other branch with
`biased`.** A `next_reply` that returns `Ok(None)` immediately
(drained) makes the biased select take it every iteration — a
(drained) makes the biased select take it every iteration, a
livelock that starves the mux/demux on a current-thread runtime.
The egress contract is therefore: *`next_reply` must be `Pending`
when no reply is ready* (`Ok(None)` is a spurious wakeup, not a hot
loop). The `LocalEgress` parks on `readable()` — note `writable()`
loop). The `LocalEgress` parks on `readable()`; note `writable()`
is the wrong readiness for UDP (always ready, the send-readiness,
spins forever); `readable()` is the datagram-arrival readiness.
The `ComposedEgress` parks on the shared inbound queue's `recv()`;
@@ -131,7 +131,7 @@ generalizable API-design notes for the crate's Phase 1:
3. **The length framing is applied exactly once, by the wire layer.**
`Egress::next_reply` returns the *inner* bytes (`addr + data`);
the producer's `write_frame` adds the single length prefix. (The
first iteration framed in the egress and prefixed in the producer —
first iteration framed in the egress and prefixed in the producer,
double framing; the client parsed the length field as ATYP 0x00.)
**Double framing is the one wire bug the POC caught**, and it
@@ -139,7 +139,7 @@ localizes a Phase 1 API decision: the egress (dial-callback) layer
speaks *inner* bytes; the length codec is the wire layer's. The
consumer side mirrors it: `UdpAssociation::send_datagram` frames once,
its `DatagramReader` state (mid-frame position + batched-datagram
pending queue) persists across `recv_datagram` calls — a datagram
pending queue) persists across `recv_datagram` calls; a datagram
split across channel chunks survives a caller-driven receive loop
(the first cut dropped the reader state per call; the echo test's
2-byte-prefix chunks exposed it).
@@ -150,7 +150,7 @@ drops it (composed tunnels' write halves EOF their channels; the
adopted channel entries are reaped by the sessions' `Drop`, alktunnels
ADR-005). One structural bug here: the first cut bound the opened
session to a local `let _session = ...` binding, which dropped at the
end of the insert block — tearing the channel down at birth (the
end of the insert block, tearing the channel down at birth (the
relay task saw EOF instantly). The session must live inside the table
entry. The crate's Phase 1 equivalent: the egress owns its tunnels
end-to-end, and the *compiler* should enforce it (no `_` bindings).
@@ -158,13 +158,13 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## Wire/API notes for the wrapper
- `TargetAddr::to_be_bytes()` is the in-band address encoding (ATYP +
addr + port) — the RFC UDP header minus RSV/FRAG. `unframe` decodes
addr + port), the RFC UDP header minus RSV/FRAG. `unframe` decodes
it by hand (synchronously over a borrowed slice);
`fast_socks5::util::target_addr::read_address` is the async in-stream
equivalent. `frame`/`inner`/`unframe` in the POC's `datagram.rs` are
the Phase 1 codec's direct sketch.
- `alktunnels::wire::frame_datagram`/`DatagramReader` are the length
codec, reused unchanged — the shared framing story with
codec, reused unchanged, the shared framing story with
alktunnels' udp substrate (per the user's codec decision: depend on
alktunnels, do not hand-roll).
- The ASSOCIATE reply's `BND.ADDR` is the sentinel `127.0.0.1:0` on
@@ -173,14 +173,14 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
(`relay_addr`) and never uses it for addressing. Only the front
door has a real address to report.
- `LocalEgress::next_reply` frames replies with the *sender's* address
(the RFC's per-datagram source semantics, `recv_from`'s `from`) —
(the RFC's per-datagram source semantics, `recv_from`'s `from`),
not the original destination. For echo targets the two coincide;
a real producer would attribute by egress socket (the composed
variant's table entry carries the destination explicitly — its
variant's table entry carries the destination explicitly; its
replies are attributed correctly by construction).
- The composed egress's per-destination attribution: the table's
`reply_addr` is recorded at tunnel-open time (the datagram's
original destination) — the tunnel IS the per-destination
original destination); the tunnel IS the per-destination
association, the eval's noted advantage of the composed shape.
- `parse_udp_request` is async (though it completes immediately over a
borrowed slice); a sync mirror (`unframe`) is used where the decode
@@ -191,19 +191,19 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## OQ status after this POC
- **OQ-SK-03 (UDP ASSOCIATE)**: the handle-swap baseline is
POC-validated end to end — one channel per association, `{addr,
POC-validated end to end: one channel per association, `{addr,
data}` frames in-band (RSV/FRAG only at front doors), channel EOF =
association lifetime, per-datagram addressing without a producer
flow table. The datagram framing is wire-stable once published —
flow table. The datagram framing is wire-stable once published, and
the Phase 1 ADR has empirical ground under it. The demux-vs-phase
question (the one-way-door extensibility argument) stays an ADR
decision for Phase 1 with the first consumer in sight, but the POC
adds evidence for the phase model: no in-band control vocabulary was
needed, and post-reply garbage is refused structurally.
- **OQ-SK-01 (dial policy)**: the egress seam is the dial-callback
shape extended to the datagram stage (`EgressFactory` — the `DialFn`
shape extended to the datagram stage (`EgressFactory`, the `DialFn`
analogue). Both egress variants behind the callback, the protocol
layer sees neither — the no-trait, boxed-halves precedent now holds
layer sees neither; the no-trait, boxed-halves precedent now holds
for the datagram plane too.
- **OQ-SK-05 (target policy)**: the composed egress's per-target-ACL-
for-free claim is validated (each destination rides its own tunnel
@@ -214,6 +214,6 @@ end-to-end, and the *compiler* should enforce it (no `_` bindings).
## POC placement
Standalone crate (global workspace, `/workspace/alksocks-udp-poc/`)
per the placement conventions — it composes three published crates
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.