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

+35 -35
View File
@@ -4,15 +4,15 @@ last_updated: 2026-09-25 (the assembly layer landed + the cross-cutting
assembly integration suite; the protocol core landed + the cross-cutting
integration suite 2026-09-24; the rfc1928 doc's targeted fork 2026-09-22; the
vendor extraction amendment ADR-011 §Amendment 2026-09-21; Phase 1
initial draft 2026-09-20 — the OQ-SK ledger promoted into ADRs
initial draft 2026-09-20; the OQ-SK ledger promoted into ADRs
001-012 and open-questions.md)
---
# alksocks — Architecture
# alksocks: Architecture
A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall
channels: an arbitrary-egress proxy service that never binds a port
unless explicitly configured to — the `alk/socks5`-family
unless explicitly configured to. It is the `alk/socks5`-family
producer/consumer protocol crate, the sibling of alktty
(`alk/tty`) and alktunnels (`alk/tunnel`). Where alktunnels
realizes the `-L`/`-R` forwarding flavors, alksocks realizes the
@@ -21,34 +21,34 @@ architecture spec docs and the ADRs.
## Current State
Phase 1 (Architecture) — the **protocol core and the assembly layer
Phase 1 (Architecture): the **protocol core and the assembly layer
are implemented**. Phase
0 (`docs/research/phase-0.md`) is converged (2026-09-14) and
POC-complete (all numbered POCs passed or were dropped with the scope
decision); the OQ-SK ledger's Phase 1 inputs are collected and
promoted into ADRs 001–014. All docs are `draft` — they transition
promoted into ADRs 001–014. All docs are `draft`; they transition
to `reviewed` when their open questions resolve and the architecture
review passes.
The landed surface: the RFC 1928 core in `src/rfc1928/` (the targeted
fork, ADR-013 — the vendor group's six tasks landed and passed the
fork, ADR-013; the vendor group's six tasks landed and passed the
extraction review), the ASSOCIATE datagram frame codec (`src/wire.rs`,
ADR-003), the open-op params + typed error surface (`src/params.rs` /
`src/error.rs`, ADR-001), the three seam types (`src/egress.rs`,
ADR-004), the producer half (`src/producer.rs` — the session-gate
ADR-004), the producer half (`src/producer.rs`: the session-gate
establisher, the command-dispatch pump, the three commands, the scope
gates), and the consumer half (`src/consumer.rs` — the
gates), and the consumer half (`src/consumer.rs`: the
connect/associate/bind session types with teardown ownership). The
assembly layer (batch 2) landed with it: the `local` providers
(`src/local/` — tcp_dial / udp_egress / bind_accept, the producer-side
`ProducerDoor`, ADR-008/009), the front doors (`src/door.rs` — the
assembly layer (batch 2) landed with it: the `local` providers in
`src/local/` (tcp_dial / udp_egress / bind_accept, the producer-side
`ProducerDoor`, ADR-008/009), the front doors in `src/door.rs` (the
NoAuth raw-pipe + RFC 1929 cred-bearing TCP door, the BIND door, the
UDP door, ADR-005/003/014), and the composed providers
(`src/composed/` — the direct-op egress + the bind-first accept over
UDP door, ADR-005/003/014), and the composed providers in
`src/composed/` (the direct-op egress + the bind-first accept over
alktunnels 0.2.0, ADR-007/008). Both cross-cutting integration suites
pin the behavior end to end: `tests/core-integration.rs` (the
protocol core) and `tests/assembly-integration.rs` (the assembly
layer — the full `-D` story through the doors, the composed variant
layer: the full `-D` story through the doors, the composed variant
set, mixed assembly, the assembly teardown matrix).
## Documents
@@ -56,33 +56,33 @@ set, mixed assembly, the assembly teardown matrix).
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, feature gates, module map |
| [wire.md](wire.md) | draft | The wire format: the open op (trivial params — ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels |
| [wire.md](wire.md) | draft | The wire format: the open op (trivial params, ADR-001), the phase model, the data planes (CONNECT pass-through / ASSOCIATE frames / BIND phases), sentinels |
| [producer.md](producer.md) | draft | The producer half: the session-gate establisher, command dispatch, the three seams (dial/egress/accept), scope gates, the local backend |
| [consumer.md](consumer.md) | draft | The consumer half: the channels-native sessions (connect/associate/bind), the front doors, door auth (ADR-005), teardown ownership |
| [vendor.md](rfc1928.md) | draft | The RFC 1928 core: the targeted fork of fast-socks5's `T`-generic subset (ADR-013; the extraction mechanics in ADR-011) — the fork's scope, the deliberate modifications, the maintenance posture |
| [vendor.md](rfc1928.md) | draft | The RFC 1928 core: the targeted fork of fast-socks5's `T`-generic subset (ADR-013; the extraction mechanics in ADR-011), covering the fork's scope, the deliberate modifications, the maintenance posture |
| [bast.md](bast.md) | draft | The BAST document for the ASSOCIATE datagram frame (ADR-003); pass-through stages have no binary framing to describe |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.. — the SK2 numbering is not contiguous from 01, working-draft numbers were consumed) |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-SK-01..09 promoted; new questions as OQ-SK2-06.., where the SK2 numbering is not contiguous from 01 because working-draft numbers were consumed) |
## ADR Table
| ADR | Title | Status |
|-----|-------|--------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN, Open Op, and Trivial Params | Accepted |
| [002](decisions/002-protocol-base.md) | Protocol Base — the Vendored fast-socks5 Typestate Subset | Accepted |
| [003](decisions/003-udp-handle-swap.md) | The Handle Swap — one Channel per Association, Frames In-Band | Accepted |
| [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape — Function, not Trait, at Three Seams | Accepted |
| [005](decisions/005-auth-split-by-door.md) | Auth Mapping — "Both," Split by the Door | Accepted |
| [006](decisions/006-access-control.md) | Access-Control Posture — the Open Gate Plus the Command Gate | Accepted |
| [007](decisions/007-bind-in-base.md) | BIND in the Base Crate — the Phase Model Extended | Accepted |
| [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op) | Accepted |
| [009](decisions/009-feature-gates.md) | Feature-Gate Layout — `local` and the Composition Boundary | Accepted (amended 2026-09-21) |
| [010](decisions/010-channel-capacity.md) | Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change | Accepted |
| [011](decisions/011-inline-vendor.md) | the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree | Superseded by ADR-013 (extraction mechanics remain authoritative) |
| [002](decisions/002-protocol-base.md) | Protocol Base: the Vendored fast-socks5 Typestate Subset | Accepted |
| [003](decisions/003-udp-handle-swap.md) | The Handle Swap: one Channel per Association, Frames In-Band | Accepted |
| [004](decisions/004-dial-callback-shape.md) | The Dial-Callback Shape: Function, not Trait, at Three Seams | Accepted |
| [005](decisions/005-auth-split-by-door.md) | Auth Mapping: "Both," Split by the Door | Accepted |
| [006](decisions/006-access-control.md) | Access-Control Posture: the Open Gate Plus the Command Gate | Accepted |
| [007](decisions/007-bind-in-base.md) | BIND in the Base Crate: the Phase Model Extended | Accepted |
| [008](decisions/008-provider-defaults.md) | Egress/Accept Provider Defaults: Local Socket and Composed (Direct Op) | Accepted |
| [009](decisions/009-feature-gates.md) | Feature-Gate Layout: `local` and the Composition Boundary | Accepted (amended 2026-09-21) |
| [010](decisions/010-channel-capacity.md) | Channel Capacity: Proxy-Workload Recommendation, Not a Wire Change | Accepted |
| [011](decisions/011-inline-vendor.md) | the Inline Vendor: fast-socks5's `T`-Generic Subset, In-Tree | Superseded by ADR-013 (extraction mechanics remain authoritative) |
| [012](decisions/012-target-policy.md) | Target Policy and the Virtual-Subnet Translation Layer | Accepted |
| [013](decisions/013-targeted-fork.md) | The Targeted Fork — the Extracted Subset Is Owned, Not Vendored | Accepted |
| [013](decisions/013-targeted-fork.md) | The Targeted Fork: the Extracted Subset Is Owned, Not Vendored | Accepted |
Upstream ADRs (alkcall/alktunnels/alknet) are referenced by their own
numbers and are NOT ported — the alkcall originals at
numbers and are NOT ported; the alkcall originals at
`/workspace/@alkdev/alkcall/docs/architecture/decisions/` remain
authoritative for the shared surfaces (establishment, channels wire
format, `pump_bidi`, identity seam, the channel caps).
@@ -98,7 +98,7 @@ format, `pump_bidi`, identity seam, the channel caps).
(the handle swap, ADR-003).
2. **The RFC conversation is the data plane.** One ALPN, one open
op, trivial params (ADR-001); the phase model needs no mode byte
— the reply synchronizes the switch; no in-band control
(the reply synchronizes the switch); no in-band control
vocabulary (the escape hatch is a new ALPN, never a framing
change).
3. **The seams are functions, not traits** (ADR-004): DialFn /
@@ -109,7 +109,7 @@ format, `pump_bidi`, identity seam, the channel caps).
seam; the front door = RFC 1929 + the credential→identity static
mapping.
5. **Two grants, clean separation** (ADR-006): `socks5:open`
(egress) and `socks5:bind` (ingress listeners) — never implied by
(egress) and `socks5:bind` (ingress listeners), never implied by
each other; target policy = the dialer.
6. **Backpressure and limits are inherited, not redefined** (the
alkcall channels invariants); the one-channel-per-session
@@ -117,14 +117,14 @@ format, `pump_bidi`, identity seam, the channel caps).
posture is ADR-010 (config, not wire).
7. **Wasm-clean default crate** (ADR-009): the default crate is
protocol-only; the RFC 1928 core is an extraction with zero
kernel-net code — there is no `net` feature (ADR-011 §Amendment);
kernel-net code; there is no `net` feature (ADR-011 §Amendment);
`local` is opt-in and non-wasm; `composed` rides alktunnels'
wasm-clean default (verified with the wasm check at
implementation).
8. **The RFC 1928 core is owned** (ADR-013, superseding ADR-011's
vendor posture): the extracted `T`-generic subset is a targeted
fork (`src/rfc1928/`, fork point `5b18d4d`) — project
conventions apply unconditionally, the lint-allow/verbatim
fork (`src/rfc1928/`, fork point `5b18d4d`); project
conventions apply unconditionally, and the lint-allow/verbatim
apparatus is dissolved; `/workspace/fast-socks5` stays the
differential-testing behavioral reference and the only place the
full upstream surface (the kernel-net region) exists; nothing
@@ -152,5 +152,5 @@ format, `pump_bidi`, identity seam, the channel caps).
- Siblings: alktty (`/workspace/@alkdev/alktty`),
alktunnels (`/workspace/@alkdev/alktunnels`)
- Reference checkout: `/workspace/fast-socks5` (v1.0.0, MIT,
third-party — the differential-testing reference)
third-party; the differential-testing reference)
- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`)
+19 -19
View File
@@ -3,11 +3,11 @@ status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
---
# alksocks — BAST Document for the `alk/socks5` Wire Format
# alksocks: BAST Document for the `alk/socks5` Wire Format
This document is the **BAST (Binary Abstract Syntax Tree)**
specification for the binary portions of the `alk/socks5` wire
format — per the alk* family BAST convention (alktunnels AGENTS.md
format, per the alk* family BAST convention (alktunnels AGENTS.md
convention 12, carried from alktty; alktunnels `bast.md` is the
precedent), the mandatory ASSOCIATE
datagram framing ([ADR-003](decisions/003-udp-handle-swap.md)) is
@@ -28,36 +28,36 @@ feeds it.
## Scope
The `alk/socks5` wire format is: a trivial JSON open op on the
channels call plane (a UTF-8 JSON object — `{}`; specified in
channels call plane (a UTF-8 JSON object, `{}`; specified in
[wire.md](wire.md) §The Open Op and
[ADR-001](decisions/001-alpn-and-open-op.md)), and a data plane
inside the channel `BiStream` that is *mostly* RFC 1928 wire rules
plus raw pass-through.
Per the BAST format spec, a BAST document describes **binary data
layouts** — not JSON payloads. The RFC 1928 conversation itself
layouts**, not JSON payloads. The RFC 1928 conversation itself
(negotiation, command, replies, the RFC UDP datagram header at front
doors) is specified by RFC 1928 and implemented by the forked
RFC 1928 codecs ([rfc1928.md](rfc1928.md)) — it is not re-specified
RFC 1928 codecs ([rfc1928.md](rfc1928.md)); it is not re-specified
here. This document covers exactly the alksocks-specific binary
framing:
1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`) —
1. **The ASSOCIATE datagram frame** (`[len: u16 BE][addr + data]`),
the one framed stage (ADR-003). CONNECT's pass-through and BIND's
pass-through have no framing for BAST to describe.
What this BAST deliberately does **not** cover:
- **The RFC 1928 wire rules** — method negotiation, command/reply
- **The RFC 1928 wire rules**: method negotiation, command/reply
encoding, the `addr` sub-layout (ATYP + addr + port), the RFC UDP
datagram header (RSV/FRAG/ATYP/addr/port, front doors only). The
RFC is normative; the forked codecs implement it.
- **The open-op params JSON shape** (`{}`) — UTF-8 JSON on the call
- **The open-op params JSON shape** (`{}`): UTF-8 JSON on the call
plane; ADR-001.
- **The channels 8-byte chunk header** — the channels layer's own
- **The channels 8-byte chunk header**: the channels layer's own
framing (alknet ADR-071/093); stripped transparently before the
alksocks data plane sees bytes. Specified upstream, not here.
- **The EOF sentinel** — the channels-level zero-length chunk
- **The EOF sentinel**: the channels-level zero-length chunk
(upstream); the alksocks codecs never emit it (the F-2 invariant).
## The BAST document
@@ -81,13 +81,13 @@ What this BAST deliberately does **not** cover:
}
```
(Note: `addr + data` together are exactly `len` bytes — the two
(Note: `addr + data` together are exactly `len` bytes; the two
`encoding: length-prefixed` fields are the *decoder's* internal
split of one frame's payload; on the wire `len` covers both, and
`data`'s length is `len − len(addr)`, decodable because the ATYP
determines the address length. The two-field split documents the
decoder's contract, not extra wire bytes. **Joint constraint:** a
frame is legal only when `len(addr) + len(data) ≤ 65535` — the
frame is legal only when `len(addr) + len(data) ≤ 65535`; the
per-field maxima are not independent (a 65507-byte datagram to a
255-char domain would need 65766 frame bytes and must be rejected
`Oversize` at frame time; wire.md §The Data Plane carries the
@@ -95,7 +95,7 @@ arithmetic).)
## Annotations
### `AssociateFrame` — binary, exact
### `AssociateFrame`: binary, exact
Maps 1:1 to the on-wire bytes: `len` as `uint16` big-endian (2
bytes), then exactly `len` bytes = `addr + data`. The `addr`
@@ -109,10 +109,10 @@ One frame per datagram, in both directions of the channel
`BiStream`, for the whole datagram stage (association lifetime =
channel lifetime). Frames are self-delimiting on the byte stream; a
decoder is an incremental state machine across chunk boundaries
(boundary preservation is mandatory — an empty datagram is a legal
(boundary preservation is mandatory; an empty datagram is a legal
zero-length payload, never EOF; alktunnels F-2's invariant, met
structurally and stated at payload level: `len == 0` — zero inner
bytes — is unreachable from a compliant peer, since `addr` is always
structurally and stated at payload level: `len == 0`, zero inner
bytes, is unreachable from a compliant peer, since `addr` is always
≥ 7 bytes).
### Layering note
@@ -120,7 +120,7 @@ bytes — is unreachable from a compliant peer, since `addr` is always
The frame codec sits in the wire layer, applied **exactly once**:
`Egress::next_reply` returns *inner* bytes (`addr + data`); the
producer's frame writer adds the single length prefix (ADR-003's
framing-once invariant — the double-framing bug class POC #2
framing-once invariant; the double-framing bug class POC #2
caught). The consumer side mirrors it: the session's send frames
once, its reader state persists across receives. The BAST describes
the wire inside the `BiStream` only; `pump_bidi` (ADR-050) is
@@ -130,10 +130,10 @@ upstream's, not re-specified here.
- [ADR-003](decisions/003-udp-handle-swap.md) (the handle swap + the
frame), [ADR-001](decisions/001-alpn-and-open-op.md) (the JSON
open-op shape — outside BAST scope),
open-op shape, outside BAST scope),
[ADR-011](decisions/011-inline-vendor.md) (the forked codecs)
- [wire.md](wire.md) (the normative prose; §The Data Plane)
- alktunnels `bast.md` (the BAST precedent — same meta-schema, same
- alktunnels `bast.md` (the BAST precedent: same meta-schema, same
"binary framing only" scope rule; the length codec's original
document) and alktunnels ADR-003 (the codec alksocks reuses)
- POC #2 `poc-udp-associate-findings.md` (the frame's validation
+31 -31
View File
@@ -1,23 +1,23 @@
---
status: draft
last_updated: 2026-09-25 (the cred-bearing door's call-connection
contract reconciled — TokenClient's door-only posture, one command per
accepted connection; the BIND spent posture reconciled — the
contract reconciled: TokenClient's door-only posture, one command per
accepted connection; the BIND spent posture reconciled: the
take-guarded reply#2 reader, review 002 C-1/D-1; the landed session
signatures reconciled 2026-09-24; Phase 1 initial draft 2026-09-20)
---
# alksocks — The Consumer Half
# alksocks: The Consumer Half
The consumer opens SOCKS5 channels against a produced service and
speaks RFC 1928 inside them. Two consumption shapes, structurally
separate (the Phase 0 scope resolution):
1. **The channels-native session** — a typed client that opens
1. **The channels-native session**: a typed client that opens
`channels/socks5/sub` and drives the RFC conversation in-stream
(wrapper-aware; the POC #1 `use_stream` / POC #2 associate
session shapes).
2. **The front door** (optional, feature `local`) — a real local
2. **The front door** (optional, feature `local`): a real local
endpoint (TCP for CONNECT/BIND; UDP for ASSOCIATE) that vanilla
clients point at; the door bridges each accepted
connection/socket onto a channel, with the RFC codec living only
@@ -33,35 +33,35 @@ Opens `channels/socks5/sub` via `ChannelClient::open_channel`
(alkcall ADR-037/043), then drives the client side of the RFC
conversation in-stream over the channel's `BiStream`:
- the forked client typestate's `use_stream` path — generic over
- the forked client typestate's `use_stream` path, generic over
the backing stream (POC #1: `Socks5Stream` impls
`AsyncRead + AsyncWrite` itself, so the session over a `BiStream`
is the intended use, not a hack);
- target construction via `ToTargetAddr` (IP-string forms parse to
IP targets; domain form passes through unresolved — ADR-012);
- after the reply, the session is a raw byte pipe — the consumer
IP targets; domain form passes through unresolved, ADR-012);
- after the reply, the session is a raw byte pipe: the consumer
reads/writes payload bytes directly (0 B framing, CONNECT).
### The associate session
The UDP ASSOCIATE client (POC #2's shape):
- opens the channel, negotiates, sends ASSOCIATE, reads the reply —
- opens the channel, negotiates, sends ASSOCIATE, reads the reply;
**receives the sentinel `BND.ADDR` and never uses it for
addressing** (the handle is the channel; ADR-003);
- `send_datagram(dst, bytes)` frames once
(`[len: u16 BE][addr + data]`, the RFC 1928 address encoding) and
writes the frame;
- `recv_datagram()` returns `Some((source_addr, bytes))` per
datagram — its incremental reader state (mid-frame position +
datagram; its incremental reader state (mid-frame position +
batched-datagram pending queue) **persists across calls** (a
datagram split across channel chunks survives a caller-driven
receive loop; POC #2's finding);
- `shutdown()` half-closes the stream — the mux's implicit-EOF
- `shutdown()` half-closes the stream; the mux's implicit-EOF
sentinel reaches the producer's stage as EOF and the association
ends (association lifetime = channel lifetime);
- **teardown ownership**: the session owns the channel; on Drop it
closes/joins — the alktunnels ADR-005 posture (the consumer
closes/joins (the alktunnels ADR-005 posture: the consumer
session type owns teardown), combined with ADR-004's
ownership-rides-the-handle contract.
@@ -72,16 +72,16 @@ client `request()` reads reply#1; reply#2 is hand-read with the
forked public `read_address` codec (reply#1's codec applied again;
no second codec). The session then rides the pass-through data
plane. The reply#2 phase is take-guarded: the one normative reader
is `await_inbound` — the first read consumes the session's stream
is `await_inbound`, and the first read consumes the session's stream
(the returned plane owns it; the session stays the teardown owner),
so a second call returns the family's spent shape (`Ok(None)`, the
same posture the sibling sessions pin) and cannot re-read the live
data plane; a failed read restores the inner (the producer ends the
session on every post-reply#1 failure exit, so a re-call EOFs —
session on every post-reply#1 failure exit, so a re-call EOFs;
never a live data-plane re-read either way). The
`reply1()` handle is a view (the advertised BND.ADDR + the raw
pre-reply#2 stream), not a reader. The BIND client is a
wrapper-aware-consumer convenience — vanilla clients reach BIND
wrapper-aware-consumer convenience; vanilla clients reach BIND
through the front door instead.
### Error surface
@@ -94,18 +94,18 @@ code surfaced through the session type.
## The Front Doors (feature `local`, optional)
The consumer's optional local exposure — the ssh `-D` shape (POC #1:
The consumer's optional local exposure, the ssh `-D` shape (POC #1:
curl end-to-end; POC #2 test 8: the UDP door; POC #5 test 8: the
BIND door):
- **The CONNECT/BIND door**: bind a real TCP listener; for each
accepted connection, pipe it raw onto a channel `BiStream`
(`copy_bidirectional`, **zero protocol translation** — POC #1's
(`copy_bidirectional`, **zero protocol translation**, POC #1's
literal 0 B pass-through conclusion). The door's credential half
(below) is the only RFC work it does.
- **The UDP door**: bind a real UDP socket; decode RFC-header'd
datagrams (`parse_udp_request`), send `{addr, data}` frames
in-band, return replies RFC-header'd (`new_udp_header`) — the RFC
in-band, return replies RFC-header'd (`new_udp_header`): the RFC
codec at the door boundary only (ADR-003). FRAG ≠ 0 → refuse (the
v1 posture).
- **The BIND door's reply#2 fidelity**: the local variant's real
@@ -118,19 +118,19 @@ BIND door):
translation; for loopback-only or wrapper-aware use.
- **The cred-bearing door** (RFC 1929): a vanilla client carrying
`user:pass` gets a real RFC 1929 conversation at the door; the
door maps the accepted credential to *local* configuration —
which channel identity/endpoint to open the producer channel
with — via a **static config table** (ADR-005: `user:pass →`
door maps the accepted credential to *local* configuration (which
channel identity/endpoint to open the producer channel with) via
a **static config table** (ADR-005: `user:pass →`
channel identity/endpoint selection). The SOCKS password never
crosses the channel as a SOCKS password; the channel identity
never reaches the vanilla client. The door checks credentials
itself (no forked `AuthMethod` impl needed — the door owns the
itself (no forked `AuthMethod` impl needed; the door owns the
accept loop). The mapped identity rides the open-op call's
`auth_token` payload (CF-005 precedence (1)) via
`TokenClient::wrap` — the client's channel-0 `CallConnection` is
`TokenClient::wrap`; the client's channel-0 `CallConnection` is
taken once per door (the door-only contract: a cred-bearing
door's client is a door client; other shapes — the UDP door's
association, channels-native sessions — ride their own
door's client is a door client; other shapes, such as the UDP door's
association and channels-native sessions, ride their own
connections to the same producer registration, one registration,
many connections). The vanilla conversation is
**one command per accepted connection** (the door's RFC half
@@ -159,10 +159,10 @@ door.serve(channel_client).await?;
```
The landed session signatures: the constructors take `&ChannelClient`
(shared — many sessions ride one client, the concurrency posture);
(shared; many sessions ride one client, the concurrency posture);
`send_datagram` takes `impl ToTargetAddr` (the CONNECT session's
target-construction posture, ADR-012); `recv_datagram()` returns
`Result<Option<(TargetAddr, Bytes)>, SocksSessionError>` — `None` is
`Result<Option<(TargetAddr, Bytes)>, SocksSessionError>`; `None` is
the channel EOF (the association's clean end), never an empty
datagram (F-2: an empty payload is a legal datagram).
@@ -185,12 +185,12 @@ None currently blocking this document.
## References
- POC #1 (`poc-connect-wasm-findings.md` — the raw-pipe door, the
`use_stream` fit), POC #2 (`poc-udp-associate-findings.md` — the
- POC #1 (`poc-connect-wasm-findings.md`: the raw-pipe door, the
`use_stream` fit), POC #2 (`poc-udp-associate-findings.md`: the
associate session, the reader-state finding, the UDP door), POC #5
(`poc-bind-findings.md` — the reply#2 reader, the BIND door)
(`poc-bind-findings.md`: the reply#2 reader, the BIND door)
- Upstream: alkcall ADR-037/043 (channel open), **alktunnels
ADR-005** (consumer session owns teardown — the posture
ADR-005** (consumer session owns teardown; the posture
inherited), the CF-005/CF-006 seam
- [producer.md](producer.md) (the other half),
[wire.md](wire.md) (the normative wire)
@@ -9,10 +9,10 @@ Accepted (2026-09-20, Phase 1)
The crate rides alkcall channels: a produced SOCKS5 resource is an
openable ALPN-op, opened by consumers via `ChannelClient` (alkcall
ADR-037/043). The ALPN string is wire-stable once published
(AGENTS.md conventions 5/16 — decide via ADR before the first
(AGENTS.md conventions 5/16: decide via ADR before the first
consumer). Phase 0's POCs (#1, #2, #5) all ran one ALPN
(`alk/socks5`), one open op (`channels/socks5/sub`), with trivially
empty params — the natural reading of "the RFC conversation is the
empty params, the natural reading of "the RFC conversation is the
data plane": the SOCKS5 target is unknown at open time (it arrives
in-stream post-negotiation), so there is nothing for params to carry
(`poc-connect-wasm-findings.md` §Wire/API notes).
@@ -20,26 +20,26 @@ in-stream post-negotiation), so there is nothing for params to carry
## Decision
- **ALPN: `alk/socks5`** (provisional → stable on first publish;
the alk/ prefix per the alkcall ADR-004 ALPN-string convention —
the alk/ prefix per the alkcall ADR-004 ALPN-string convention;
AGENTS.md convention 16's "ADR-004/ADR-006" shorthand cites the
naming family, of which ADR-004 is the operative one). One ALPN
per protocol.
- **One open op: `channels/socks5/sub`** — the SOCKS5 session op,
- **One open op: `channels/socks5/sub`**: the SOCKS5 session op,
flavor-form like alktunnels' (`…/sub`; alkcall ADR-047: openable
ALPNs are operations). All three RFC 1928 commands (CONNECT,
BIND, UDP ASSOCIATE) ride this one op, selected in-stream at
command dispatch — the command is an RFC-1928 wire fact, not a
wire-format choice of ours.
- **Params: the trivial object** `{}` — schema `{type: object}`,
additionalProperties false. Nothing else is needed at open time
(the establisher is a session gate, not a dial — the target is
command dispatch (the command is an RFC-1928 wire fact, not a
wire-format choice of ours).
- **Params: the trivial object** `{}` (schema `{type: object}`,
additionalProperties false). Nothing else is needed at open time
(the establisher is a session gate, not a dial; the target is
unknown). Extensibility: if a later need surfaces per-session
options (e.g. a requested local bind hint), that is an
**additive** params schema change while no params were ever
required; consumers that send `{}` stay valid. Params are a
self-contained JSON object identifying the produced resource
(alkcall ADR-039's shape, following alktunnels ADR-001's template).
- **Reply: `{"channel_id": <u32>}`** — the Sub-typed operation
- **Reply: `{"channel_id": <u32>}`**: the Sub-typed operation
reply, as with alktunnels. No extra reply fields in v1 (the
establishment phase is a session gate and has nothing to project;
the alkcall 0.8.0 `with_reply_field` seam remains available
@@ -49,12 +49,12 @@ in-stream post-negotiation), so there is nothing for params to carry
- The wire surface is minimal and stable: one ALPN + one op + empty
params + one reply field. The one-way-door surface is the ALPN
string, the op id, and the (trivial) params shape — all frozen at
string, the op id, and the (trivial) params shape, all frozen at
first publish.
- Command-level differentiation (CONNECT vs ASSOCIATE vs BIND)
lives in the RFC 1928 stream and in the ACL scope check at
command dispatch (see ADR-006), not in the op set. No
per-command ops — that would re-encode the RFC's own command byte
per-command ops: that would re-encode the RFC's own command byte
in our wire format for no benefit.
- POC-validated: all three POCs served CONNECT/ASSOCIATE/BIND on
the same registration (`connect_still_served_on_the_same_
@@ -62,7 +62,7 @@ in-stream post-negotiation), so there is nothing for params to carry
## References
- OQ-SK-01 (params context), OQ-SK-03 (the handle swap — why no
- OQ-SK-01 (params context), OQ-SK-03 (the handle swap, why no
correlation params), OQ-SK-08 (BIND rides the same op)
- POC #1 `poc-connect-wasm-findings.md` (the trivial-params finding),
POC #2 `poc-udp-associate-findings.md` (one-op regression guard),
@@ -1,9 +1,9 @@
# ADR-002: Protocol Base — the Vendored fast-socks5 Typestate Subset
# ADR-002: Protocol Base: the Vendored fast-socks5 Typestate Subset
## Status
Accepted (2026-09-20, Phase 1; the extracted-and-forked posture is
OQ-SK-04's resolution — the extraction mechanics in
OQ-SK-04's resolution: the extraction mechanics in
[ADR-011](011-inline-vendor.md), the ownership posture in
[ADR-013](013-targeted-fork.md))
@@ -13,7 +13,7 @@ Phase 0 evaluated the protocol-base candidates: fast-socks5 (the
full-fidelity RFC 1928 implementation, explicit typestate server API,
generic over `T`), iroh-socks5 (structurally the closest sibling, but
iroh-coupled and an RFC subset), and a hand-rolled codec (rejected as
anti-prior-art — alknet's client hand-rolls and it is the known
anti-prior-art; alknet's client hand-rolls, and it is the known
weakness). POC #1 confirmed the load-bearing property: fast-socks5's
`Socks5ServerProtocol<T, states::*>` accepts a channels `BiStream`
directly (`T: AsyncRead + AsyncWrite + Unpin`), and POC #4 measured
@@ -24,13 +24,13 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
## Decision
- **The protocol layer is built on fast-socks5's explicit typestate
API** — `Socks5ServerProtocol<T, …>` (negotiate/finish_auth/
API**: `Socks5ServerProtocol<T, …>` (negotiate/finish_auth/
read_command/reply_success/reply_error), the client side
`Socks5Stream<S>::use_stream` / the associate session, and the
pure codecs (`new_udp_header`/`parse_udp_request`).
- **Only the explicit API is used.** The legacy
`Socks5Server`/`Socks5Socket`/`Incoming` bind-based API is
deprecated upstream and is out — never forked into the default
deprecated upstream and is out; never forked into the default
path.
- **Base = v1.0.0, extracted in-tree and forked** (ADR-011 for the
extraction mechanics and the two carried changes; ADR-013 for the
@@ -38,7 +38,7 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
`/workspace/fast-socks5` remains the read-only reference for
differential testing of RFC edge cases; the fork point is noted
in the module docs and the bundled license notice.
- **Refusals map faithfully per RFC 1928** — `ReplyError` codes
- **Refusals map faithfully per RFC 1928**: `ReplyError` codes
(`as_u8`/`from_u8`) are the error vocabulary at the command plane;
refusals are never collapsed into generic errors (AGENTS.md
convention 2). POC #1 validated the mapping (dial refusal → 0x05).
@@ -46,11 +46,11 @@ functionally complete (a full RFC 1928 CONNECT conversation passes
## Consequences
- The crate owns its protocol code in-tree (the fork), so BIND and
the `new_reply` publication ride the same tree — no upstream waits.
the `new_reply` publication ride the same tree; no upstream waits.
- The typestate's interception points are the layering seams:
command-read (the dial/accept dispatch), `transfer` (the raw
two-pump), and — where the default handlers are unusable (UDP
ASSOCIATE's unconditional bind) — direct typestate driving. The
two-pump), and, where the default handlers are unusable (UDP
ASSOCIATE's unconditional bind), direct typestate driving. The
interception-point map is spec'd in [producer.md](../producer.md).
- iroh-socks5's design ideas (the `{addr, data}` frame, control-EOF
association lifetime, FRAG≠0 refusal) were adopted at the design
@@ -1,4 +1,4 @@
# ADR-003: The Handle Swap — one Channel per Association, Frames In-Band
# ADR-003: The Handle Swap: one Channel per Association, Frames In-Band
## Status
@@ -9,12 +9,12 @@ validated end to end 2026-09-14)
UDP ASSOCIATE is the hard case for the no-bind requirement: RFC 1928
replies with a UDP relay address (`BND.ADDR`/`BND.PORT`) and the
client sends datagrams to it — which seems to demand a producer-side
client sends datagrams to it, which seems to demand a producer-side
kernel bind, the one thing the protocol layer must never do.
The design's load-bearing reading of RFC 1928 (OQ-SK-03, settled
2026-09-13): the ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is *just the
handle for the relay*, and one relay serves the whole association —
handle for the relay*, and one relay serves the whole association:
the per-datagram destination is carried in the datagram header
(ATYP/addr/port), not in the handle. The relay port was never "a port
that goes to one place"; it is a multiplexing endpoint. Swapping the
@@ -26,27 +26,27 @@ including the front door.
The in-band (channels-native) UDP ASSOCIATE shape is:
- **One channel per association** — the CONNECT shape; no second
- **One channel per association**: the CONNECT shape; no second
channel, no correlation params (the op's params stay trivial,
ADR-001).
- **Per-datagram destinations ride in-band frames:** every datagram
is `[len: u16 BE][addr + data]` — the boundary-preserving
is `[len: u16 BE][addr + data]`, the boundary-preserving
length codec shaped on alktunnels' (`frame_datagram`/
`DatagramReader`, re-implemented in first-party `src/wire.rs`;
behavior pinned by the same F-2 tests)
over the RFC address encoding (`TargetAddr::to_be_bytes()` — the
over the RFC address encoding (`TargetAddr::to_be_bytes()`, the
RFC UDP header minus RSV/FRAG). The RFC header codec
(`new_udp_header`/`parse_udp_request`) exists only at vanilla
front-door boundaries. RSV/FRAG do not ride the tunnel wire —
front-door boundaries. RSV/FRAG do not ride the tunnel wire;
fragmentation is refused at front doors (FRAG ≠ 0 → refuse, the
iroh-socks5 v1 posture; it cannot even be expressed in-band).
- **Association lifetime = channel lifetime** — channel EOF is the
- **Association lifetime = channel lifetime**: channel EOF is the
end-of-life signal; no extra control vocabulary (POC #2 confirmed
post-reply garbage is refused structurally: the frame's first byte
is ATYP, so any post-reply control byte parses as a garbage ATYP
and fails the association loud).
- **The reply's `BND.ADDR`/`BND.PORT` on the in-band path are pure
RFC-compatibility surface** — the sentinel `127.0.0.1:0` (POC #2's
RFC-compatibility surface** (the sentinel `127.0.0.1:0`, POC #2's
shape). Only a front door with a real socket reports a real
address.
- **Producer egress is the `EgressFactory` seam** (ADR-004, the
@@ -55,7 +55,7 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
via the alktunnels direct op) are policy, not protocol.
- **The datagram stage is `select!`-driven** with the egress
readiness contract (next_reply is `Pending` when nothing is
ready — an instantly-ready `Ok(None)` starves a biased select into
ready; an instantly-ready `Ok(None)` starves a biased select into
livelock; POC #2's readiness findings are load-bearing API design
for the crate). Framing is applied exactly once, by the wire
layer: `Egress::next_reply` returns *inner* bytes; the producer's
@@ -64,7 +64,7 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
reader state persists.** The associate session's
`send_datagram` frames once; its `recv_datagram`'s incremental
reader state (mid-frame position + batched-datagram pending
queue) persists across calls — a datagram split across channel
queue) persists across calls; a datagram split across channel
chunks survives a caller-driven receive loop (POC #2: the first
cut dropped the reader state per call; the 2-byte-prefix chunk
test exposed it). This is part of the datagram stage's
@@ -76,22 +76,22 @@ The in-band (channels-native) UDP ASSOCIATE shape is:
BIND need no socket at all; ASSOCIATE's "bind" is virtualized as
the channel (the handle swap). Local binds remain optional
assembly-layer shapes (front doors; the local egress variant).
- The datagram framing is **wire-stable once published** — the
- The datagram framing is **wire-stable once published**; the
one-way door is now closed with empirical ground under it (open
cost ~0.8 ms per composed destination, measured; LRU + dedup
pinned by test).
- Boundary preservation is mandatory (empty datagram ≠ EOF —
- Boundary preservation is mandatory (empty datagram ≠ EOF;
alktunnels F-2's lesson transfers directly; the frame shape meets
it structurally). The BAST document for the frame is
[bast.md](../bast.md).
## References
- OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound —
- OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound;
see ADR-010)
- POC #2 `poc-udp-associate-findings.md` (the validation record +
the readiness/framing/ownership findings)
- alktunnels ADR-003 (the length codec + F-2), alktunnels
ADR-007 (the direct op — the composed egress vehicle)
- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md` — the frame
ADR-007 (the direct op, the composed egress vehicle)
- iroh-socks5 eval (`docs/research/iroh-socks5-eval.md`, the frame
design prior art)
@@ -1,9 +1,9 @@
# ADR-004: The Dial-Callback Shape — Function, not Trait, at Three Seams
# ADR-004: The Dial-Callback Shape: Function, not Trait, at Three Seams
## Status
Accepted (2026-09-20, Phase 1; shape resolved empirically — POC #1
(2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15))
Accepted (2026-09-20, Phase 1; shape resolved empirically, with
POC #1 (2026-09-13), POC #2 (2026-09-14), POC #5 (2026-09-15))
## Context
@@ -14,23 +14,23 @@ inversion point for the three I/O planes a SOCKS5 server touches:
the CONNECT dial, the ASSOCIATE datagram egress, and the BIND
accept. alktunnels resolved the same question for its establisher
with the no-trait finding (OQ-TN-05 → alktunnels ADR-004): "produce
boxed halves for a resource" is a function, not a trait — two real
boxed halves for a resource" is a function, not a trait; two real
implementations existed as callbacks, and the trait added nothing.
The POCs extended that finding to all three SOCKS5 seams:
- **CONNECT**: the dial cannot live in the establisher — the target
- **CONNECT**: the dial cannot live in the establisher; the target
is unknown at open time (it arrives in-stream at command read).
The injected `DialFn(target) -> TargetHandle {read, write}`
closure is called at the command-read interception point
(`poc-connect-wasm-findings.md` §Structural findings). Per-target
refusal is an RFC reply code; session-level refusal stays
channels-level.
- **ASSOCIATE**: the `EgressFactory` — called once per association;
- **ASSOCIATE**: the `EgressFactory`, called once per association;
the datagram stage owns the egress; stage exit drops it
(`poc-udp-associate-findings.md` §The per-association egress
instance).
- **BIND**: the `BindAcceptFactory` — one instance per session; its
- **BIND**: the `BindAcceptFactory`, one instance per session; its
future owns the listener/tunnel and is dropped by the quiet
wait's `select!` on the control-stream branch (the client-gone
teardown; `poc-bind-findings.md` §The quiet wait is
@@ -40,20 +40,20 @@ The POCs extended that finding to all three SOCKS5 seams:
- **No dialer/egress/accept trait.** All three seams are
function-shaped callbacks injected at registration/assembly:
- `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync` —
satisfies the `ChannelPlan` payload bound; the bound binds
handle/plan payloads downstream — the SOCKS5 establisher
- `DialFn(target) -> TargetHandle` (boxed halves, `+ Sync`,
satisfying the `ChannelPlan` payload bound; the bound binds
handle/plan payloads downstream; the SOCKS5 establisher
itself carries no plan, `Establishment::default()`)
- `EgressFactory() -> impl Egress` (per-association instance;
`next_reply` readiness contract: `Pending` when nothing is
ready)
- `BindAcceptFactory` (per-session; the future owns its resources
end-to-end — the ownership-rides-the-handle lesson)
end-to-end, the ownership-rides-the-handle lesson)
- **Ownership rides the handle structurally.** Plan/handle payloads
own their channel sessions and socket halves inside the payload
(the third witness of the teardown-at-birth pattern: POC #2's
egress table, alktunnels ADR-005, POC #5's `BindTargetHandle`).
The compiler should enforce it — no `_` bindings of owning
The compiler should enforce it: no `_` bindings of owning
values.
- **The trait re-evaluation threshold is the same as alktunnels
ADR-004's**: revisit if three real implementations exist as trait
@@ -69,7 +69,7 @@ The POCs extended that finding to all three SOCKS5 seams:
the module map in [overview.md](../overview.md)).
- The composed providers (alktunnels direct-op egress, bind-first
listen accept) are assembly-layer compositions, not crate
dependencies of the protocol layer — the protocol crate depends
dependencies of the protocol layer; the protocol crate depends
on alkcall only (see [overview.md](../overview.md) §Dependencies;
the composed-provider helpers that reference alktunnels types live
behind the composition feature, ADR-009).
@@ -79,7 +79,7 @@ The POCs extended that finding to all three SOCKS5 seams:
## References
- OQ-SK-01 (the dial policy — the A/B/C options; this ADR closes
- OQ-SK-01 (the dial policy, the A/B/C options; this ADR closes
the dialer-trait-vs-callback half), OQ-SK-03 (the egress seam),
OQ-SK-08 (the accept seam)
- POC #1/#2/#5 findings (the three seams' empirical records)
@@ -1,23 +1,23 @@
# ADR-005: Auth Mapping — "Both," Split by the Door
# ADR-005: Auth Mapping: "Both," Split by the Door
## Status
Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13 —
OQ-SK-02 — and POC #1 validated the identity seam end to end)
Accepted (2026-09-20, Phase 1; the posture settled 2026-09-13
(OQ-SK-02), and POC #1 validated the identity seam end to end)
## Context
Two auth mechanisms are real: alkcall's identity seam (the per-call
opener identity arrives on the open-op hooks — alkcall ledger
opener identity arrives on the open-op hooks; alkcall ledger
CF-005/CF-006) and RFC 1929's in-band username/password. Phase 0
resolved the posture: both mechanisms are real, each on its own side
of the door — the consumer's optional front door (where the vanilla
of the door: the consumer's optional front door (where the vanilla
world meets the ACL world). A producer-side password would add
nothing the identity seam doesn't already do, and the ACL system is
strictly better than a shared secret.
POC #1's wiring finding (the accept-side install hook must call
`channel0_conn.set_identity(resolved)` — the transport identity does
`channel0_conn.set_identity(resolved)`; the transport identity does
not flow to channel-0 dispatch automatically) is a producer-harness
requirement worth surfacing in the spec, not a protocol-layer
concern.
@@ -27,12 +27,12 @@ concern.
- **Producer side: identity only.** The channels-native open is
authorized by the alkcall ACL/identity seam. The producer's
in-band RFC conversation runs NoAuth via the typestate's normal
path — `accept_no_auth` (the greeting and method selection still
path: `accept_no_auth` (the greeting and method selection still
happen; POC #2/#5's validated shape: `accept_no_auth →
read_command`). The negotiation is real but the auth decision is
vacuous in-band because authorization already happened at the
open gate. **`skip_auth_this_is_not_rfc_compliant` is excluded
from the in-band path** — it wraps a stream already marked
from the in-band path**; it wraps a stream already marked
authenticated, skipping the greeting entirely; feeding it a
client that speaks the RFC greeting would misparse the greeting
as command bytes. (At most it belongs to a raw-pipe door that
@@ -41,26 +41,26 @@ concern.
where vanilla clients need it.** The door owns credential
presentation: a vanilla client carrying `user:pass` gets a real
RFC 1929 conversation at the door. The door then maps the
accepted credential to *local* configuration — which channel
identity/endpoint to open the producer channel with — and that
accepted credential to *local* configuration (which channel
identity/endpoint to open the producer channel with), and that
identity reaches the producer via the CF-005/CF-006 seam. **The
SOCKS password never crosses the channel as a SOCKS password; the
channel identity never reaches the vanilla client.**
- **No fast-socks5 `AuthMethod` impl is required.** The door checks
credentials itself before/around handing the stream to the
typestate — the door is this crate's code; the trait was designed
typestate; the door is this crate's code; the trait was designed
for embedders who don't own the accept loop.
- **The door's credential→identity mapping shape: static config.**
The mapping is a caller-supplied configuration table
(`username/password → channel identity or endpoint selection`)
— static, declarative, assembly-layer. A pluggable lookup
(`username/password → channel identity or endpoint selection`),
static, declarative, assembly-layer. A pluggable lookup
(callback) remains possible as an additive extension if a
deployment needs dynamic resolution; v1 ships the static shape
because the front door's use cases (tun2proxy's fixed
`user:pass@host`, curl's `--proxy-user`) are single-credential or
small-table shapes.
- **The NoAuth door remains valid** for loopback-only or
wrapper-aware use (POC #1's raw-pipe shape — zero protocol
wrapper-aware use (POC #1's raw-pipe shape: zero protocol
translation); the cred-bearing door is the same bridge plus the
greeting half. Door variant selection is an assembly-layer
choice.
@@ -70,15 +70,15 @@ concern.
- Vanilla-client compatibility at the front door is real (RFC 1929),
without polluting the channels-native path with shared secrets.
- The producer-side optional local backend (a front door in the
other direction — vanilla clients → a locally-served SOCKS5
other direction: vanilla clients → a locally-served SOCKS5
resource) offers the same door surface: door = vanilla↔ACL
boundary, same code shape.
- The mapping is not wire-visible: no params, no reply fields, no
ALPN changes — the door is entirely assembly-layer.
ALPN changes; the door is entirely assembly-layer.
## References
- OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved —
- OQ-SK-02 (the full reasoning: "replace or second-gate" dissolved;
the mechanisms occupy different planes)
- POC #1 `poc-connect-wasm-findings.md` (the identity-seam wiring
finding; the raw-pipe front door)
@@ -1,20 +1,20 @@
# ADR-006: Access-Control Posture — the Open Gate Plus the Command Gate
# ADR-006: Access-Control Posture: the Open Gate Plus the Command Gate
## Status
Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape —
Accepted (2026-09-20, Phase 1; scope gates POC-validated in shape:
POC #1 (`socks5:open`), POC #5 (`socks5:bind` at command dispatch);
consequence asymmetry pinned by the integration suite, 2026-09-24)
## Context
SOCKS5 is arbitrary-egress by nature — the open gate plus the target
SOCKS5 is arbitrary-egress by nature; the open gate plus the target
policy are the security boundary (AGENTS.md convention 12).
alktunnels resolved the sibling posture (its ADR-006): whatever ACL
governs the resource governs everything reachable through it, plus
whatever policy the SOCKS5 implementation itself applies downstream.
For SOCKS5, BIND adds an *inbound* capability — the accept-side twin
of arbitrary egress — so the open gate must distinguish it
For SOCKS5, BIND adds an *inbound* capability, the accept-side twin
of arbitrary egress, so the open gate must distinguish it
(OQ-SK-08's security note): an identity with proxy egress must not
implicitly get ingress listeners. POC #5 validated exactly this
distinction at command dispatch from the per-call opener identity.
@@ -23,10 +23,10 @@ distinction at command dispatch from the per-call opener identity.
- **The open gate is the boundary.** The open op's
`AccessControl.required_scopes` is `["socks5:open"]`
(`SOCKS5_OPEN_SCOPE`) — scope-gated via
(`SOCKS5_OPEN_SCOPE`), scope-gated via
`ChannelCore::register_openable` (the registry runs the ACL
before the open handler). Scope-string convention:
`<alpn-family>:<capability>` (`socks5:open`, `socks5:bind`) —
`<alpn-family>:<capability>` (`socks5:open`, `socks5:bind`),
following alktty's `TTY_OPEN_SCOPE` / alktunnels'
`tunnel:open`/`tunnel:direct` shape. Stable once published.
- **The command gate: BIND requires `socks5:bind` at command
@@ -38,7 +38,7 @@ distinction at command dispatch from the per-call opener identity.
`ConnectionNotAllowed` (0x02) refusal while CONNECT still serves
the same identity (POC #5 test 4).
- **Target policy is the dialer's concern.** No target allowlists
in the base crate — the dial callback refuses (per-target refusal
in the base crate; the dial callback refuses (per-target refusal
is an RFC reply code; ADR-004's seams). The dialer-refuses shape
avoids a second policy layer (alktunnels OQ-TN-08's resolution
pattern, evidence-backed). Whatever ACL governs the socks5
@@ -49,23 +49,23 @@ distinction at command dispatch from the per-call opener identity.
by the `tunnel:direct` grant's ACL (POC #2 validated); the dial
callback governs per-destination checks.
- **Ownership**: optionally consult `OwnershipProvider` for
resource ownership (alknet ADR-050's seam) — a registration-time
resource ownership (alknet ADR-050's seam), a registration-time
choice, not a protocol concern.
- **Hub relaying**: the hub's terminate-and-re-produce applies per
hop (alkcall ADR-051's `ChannelRelay`/`HubLegTemplate`); the hub's
ACL governs the final dial — the hub is the network boundary. No
ACL governs the final dial; the hub is the network boundary. No
socks5-specific relay work.
## Consequences
- Two grants, clean separation: `socks5:open` (egress) and
`socks5:bind` (ingress listeners) — never implied by each other
`socks5:bind` (ingress listeners), never implied by each other
(the alktunnels ADR-006 Amendment 1 posture: a capability on its
own scope). The implication asymmetry is pinned by the integration
suite (`tests/core-integration.rs`, the identity/scope matrix):
`socks5:open` alone serves CONNECT + ASSOCIATE and is refused BIND
(0x02) at the command gate; `socks5:bind` alone grants *nothing*
on the op — the open gate's `required_scopes = ["socks5:open"]`
on the op: the open gate's `required_scopes = ["socks5:open"]`
fails closed for every command (the establisher never runs, no
phantom channel); both scopes, granted explicitly, serve
everything. A BIND-capable deployment grants both.
@@ -73,7 +73,7 @@ distinction at command dispatch from the per-call opener identity.
same op (ADR-001): the command gate is the mechanism, and it is
POC-validated (POC #5).
- The allowlist-hook variant (a declarative per-target policy at
registration) is *not* shipped in v1 — the dial callback is the
registration) is *not* shipped in v1: the dial callback is the
policy point; see OQ-SK-05's residue (OQ-SK2-07 in the tracker)
for the virtual-subnet translation-layer proposal, which is the
same dial-callback shape.
+12 -12
View File
@@ -1,4 +1,4 @@
# ADR-007: BIND in the Base Crate — the Phase Model Extended
# ADR-007: BIND in the Base Crate: the Phase Model Extended
## Status
@@ -15,7 +15,7 @@ asks for a listener on the producer's network; the producer replies
replies #2 with the peer address before the data plane. The use case
that makes it real (FTP active mode is the canonical vanilla reason)
plus the composed variant's hub story were found in Phase 0
(OQ-SK-08), and POC #5 validated the whole shape end to end — both
(OQ-SK-08), and POC #5 validated the whole shape end to end: both
accept variants, the scope gate, the failure mapping, the vanilla
front door.
@@ -28,8 +28,8 @@ Nothing here is a one-way door.
- **BIND ships in the base crate** (protocol layer), phase-model
style: request → reply#1 carrying `BND.ADDR` → quiet wait (any
control byte after reply#1 is protocol garbage; the session ends —
exactly two exits: the accept, or the control stream's EOF/
control byte after reply#1 is protocol garbage; the session ends,
with exactly two exits: the accept, or the control stream's EOF/
garbage/error) → exactly one inbound accept → reply#2 carrying the
peer address → raw pass-through data plane (zero per-packet
headers in-band, same as CONNECT; only the ASSOCIATE datagram
@@ -37,27 +37,27 @@ Nothing here is a one-way door.
- **The accept seam is `BindAcceptFactory`** (ADR-004). Two
provider-backed accept variants (plus the wrapper-aware in-band
default when neither is wired), all POC #5-validated:
1. **Local backend binds for real** (feature-gated) — the faithful
1. **Local backend binds for real** (feature-gated): the faithful
RFC shape: a real network-B address in reply#1 (the listener's
`local_addr`), a real peer in reply#2, vanilla front-door
clients work. **This is the default accept variant** for the
optional local backend.
2. **Composed (alktunnels listen resource)** — the listener lives
2. **Composed (alktunnels listen resource)**: the listener lives
on a bind-first listen resource (`register_tunnel_bindfirst_
openable` + `BindFirstInjections`, alktunnels 0.2.0); per-
listener ACL for free, hub-relayable. **Vanilla-fidelity with
no stand-ins** since the graduation: the factory no longer
carries an expected `bind_addr` — reply#1 advertises the real
OS-chosen `bound` (the open-op reply's `bound` field,
no stand-ins** since the graduation: the factory no longer
carries an expected `bind_addr`; reply#1 advertises the real
OS-chosen `bound` (the open-op reply's `bound` field,
alkcall 0.8.0 reply projection); reply#2 carries the real peer
(the forwarded op's `peer` param).
3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2) —
3. **Wrapper-aware only** (zeros in reply#1, sentinel reply#2),
retained for completeness as the in-band default when neither
provider is wired: the handle-swap reading applied to BIND
(the reply is RFC-compatibility surface; the data plane is the
channel).
- **The establishment bound covers only the open-op round trip.**
The accept wait lives in the teardown-governed pump phase — FTP-
The accept wait lives in the teardown-governed pump phase; FTP-
length BIND waits stop resolving as spurious establishment
refusals (the bind-first graduation's stated second win;
`poc-bind-findings.md` §Establishment-bound, superseded in shape).
@@ -71,7 +71,7 @@ Nothing here is a one-way door.
- The RFC 1928 command surface is complete: CONNECT + BIND +
ASSOCIATE, all three on one op (POC #5 test 7 regression-guarded
the other two).
- The composed accept path composes against alktunnels 0.2.0 — the
- The composed accept path composes against alktunnels 0.2.0: the
bind-first establisher + forwarded op (ADR-008) are the substrate;
the POC's pinned workaround (reply#1 first, the tunnel open rides
the accept future) is retired with the graduation.
@@ -1,9 +1,9 @@
# ADR-008: Egress/Accept Provider Defaults — Local Socket and Composed (Direct Op)
# ADR-008: Egress/Accept Provider Defaults: Local Socket and Composed (Direct Op)
## Status
Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice —
OQ-SK-01's residue — with the composed vehicle settled by
Accepted (2026-09-20, Phase 1; the A/B assembly-layer choice
(OQ-SK-01's residue), with the composed vehicle settled by
alktunnels 0.2.0 ADR-007)
## Context
@@ -11,12 +11,12 @@ alktunnels 0.2.0 ADR-007)
OQ-SK-01's dialer-trait-vs-callback half is closed (ADR-004); what
remained was the **default provider set**: where does the dial
callback come from in the base crate's optional shapes? Option A
(dial via alktunnels — the composed shape), Option B (dial directly
— local TCP), Option C (injected dialer trait — rejected, ADR-004).
(dial via alktunnels, the composed shape), Option B (dial directly,
local TCP), Option C (injected dialer trait, rejected, ADR-004).
alktunnels 0.2.0 removed Option A's blocker (the direct op
`channels/tunnel/direct` is the dynamic-target channel — one open
`channels/tunnel/direct` is the dynamic-target channel: one open
per CONNECT target, what it was graduated for), and POC #2 measured
its per-destination open cost (~0.8 ms, loopback) — fine for DNS +
its per-destination open cost (~0.8 ms, loopback), fine for DNS +
browsing.
## Decision
@@ -25,7 +25,7 @@ browsing.
- **Local-socket providers** (behind the `local` feature): a real
`TcpStream` dial (CONNECT), a real `UdpSocket` egress
(ASSOCIATE, one socket per association, per-datagram
`send_to`/`recv_from` — no flow table; domain destinations
`send_to`/`recv_from`; no flow table; domain destinations
resolve at the egress, the ssh `-D` semantic), a real
`TcpListener` accept (BIND, ADR-007 variant 1). These are the
faithful-RFC shapes and the simplest assembly story.
@@ -33,10 +33,10 @@ browsing.
dial callback opens an alktunnels direct-op channel naming the
target (`open_direct`, `Substrate::Tcp`/`Substrate::Udp`); the
UDP table is a lazy destination→tunnel table (open on first
datagram, LRU-evict at a cap — POC #2's `ComposedEgress` shape).
datagram, LRU-evict at a cap, POC #2's `ComposedEgress` shape).
Hub relaying is per-target terminate-and-re-produce
(alkcall ADR-051).
- **Neither provider is a dependency of the protocol layer** —
- **Neither provider is a dependency of the protocol layer**;
both are assembly-layer wiring (ADR-004); the features exist so
the providers ship *in the crate* rather than forcing every
deployment to hand-roll them. The feature gates and module
@@ -46,11 +46,11 @@ browsing.
The feature-gated providers are convenience constructors over
the same seams. A missing callback means that command refuses
RFC-faithfully (`CommandNotSupported`-class reply), never a
panic — the command gate is the open handler's dispatch, and a
panic: the command gate is the open handler's dispatch, and a
provider-less command is an unprovided capability, not an error
condition.
- **Per-target ACL is the composed variant's benefit** (each
destination rides its own `tunnel:direct`-governed channel —
destination rides its own `tunnel:direct`-governed channel,
ADR-006); the local variant's target policy is the dial
callback itself.
@@ -1,11 +1,11 @@
# ADR-009: Feature-Gate Layout — `local` and the Composition Boundary
# ADR-009: Feature-Gate Layout: `local` and the Composition Boundary
## Status
Accepted (2026-09-20, Phase 1; the wasm-clean default-crate invariant
is the alk* family posture — AGENTS.md convention 4). Amended
is the alk* family posture, AGENTS.md convention 4). Amended
2026-09-21 (pre-decomposition): the vendor's `net` gate dissolves
under ADR-011's extraction amendment — the vendor is
under ADR-011's extraction amendment; the vendor is
wasm-clean by construction; features collapse to `local` +
`composed`; see §Amendment.
@@ -13,20 +13,20 @@ wasm-clean by construction; features collapse to `local` +
The default crate must compile to `wasm32-unknown-unknown` (the
alktty/alktunnels precedent; the wasm shape is a sandboxed
SOCKS5 consumer — pure byte framing, no local sockets needed
SOCKS5 consumer (pure byte framing, no local sockets needed
client-side; phase-0 OQ-SK-04's analysis). Two groups of
platform-specific or sibling-dependent code need gating:
1. **The crate's own local backends** (dial/listen/egress providers
touching real sockets — ADR-008's local variants).
touching real sockets, ADR-008's local variants).
2. **The composed providers** (the alktunnels-composed egress/accept
helpers — ADR-008's composed variants), which need the
helpers, ADR-008's composed variants), which need the
alktunnels dependency.
The vendor originally contributed a third gate: its kernel-net
region was to ride a default-on `net` feature (POC #4's measured
shape, ~120 cfg lines across 5 files). ADR-011's 2026-09-21
amendment pins the vendor as *extraction* — the kernel-net region is
amendment pins the vendor as *extraction*; the kernel-net region is
not copied, so that gate no longer exists (see §Amendment). POC #4's
gate map survives as the extraction recipe, not as in-tree cfg.
@@ -38,26 +38,26 @@ gate map survives as the extraction recipe, not as in-tree cfg.
feature-gated, never imported by the protocol layer.
- **The default build has no `net` feature**: the extracted vendor
contains zero kernel-net code, so the wasm-clean subset is the
whole crate — there is nothing to gate (ADR-011 §Amendment).
- **`local` (default-off)**: the crate's first-party local backends
— the TCP dial, the UDP egress socket, the BIND listener, and the
front doors (the consumer's ssh `-D` local exposure; the
whole crate; there is nothing to gate (ADR-011 §Amendment).
- **`local` (default-off)**: the crate's first-party local backends,
namely the TCP dial, the UDP egress socket, the BIND listener, and
the front doors (the consumer's ssh `-D` local exposure; the
producer's local backend). Brings `tokio/net` + `socket2` into the
graph. Non-wasm by design (enabling it on wasm is a build error,
the alktty posture).
- **`composed` (default-off)**: the composed providers over
alktunnels 0.2.0 (direct-op egress, bind-first listen accept) —
alktunnels 0.2.0 (direct-op egress, bind-first listen accept),
the only feature that adds the alktunnels dependency. The
protocol layer never references alktunnels types; the composed
module does, injected at the assembly layer (ADR-004/ADR-008).
- **tokio stays `default-features = false`** with the wasm-clean
subset (`rt`, `sync`, `io-util`, `macros`, `time`) — **never
subset (`rt`, `sync`, `io-util`, `macros`, `time`); **never
`features = ["full"]`** (AGENTS.md convention 4); `net`-shaped
additions ride `local` alone.
## Amendment (2026-09-21, pre-decomposition — the vendor's `net` gate dissolves)
## Amendment (2026-09-21, pre-decomposition: the vendor's `net` gate dissolves)
The original decision carried a third feature — `net`
The original decision carried a third feature, `net`
(default-on), the vendored subset's kernel-net gate, with `local`
implying it. ADR-011's extraction amendment dissolves it:
@@ -66,12 +66,12 @@ implying it. ADR-011's extraction amendment dissolves it:
`Socks5Server`/`Incoming` API, `Socks5Datagram`'s kernel path, the
DNS-resolve helper, the net-only tests), the `socks4` module, and
the legacy `Config`/`Authentication` layer are not copied into the
tree — the extraction is wasm-clean by construction, with no
tree; the extraction is wasm-clean by construction, with no
in-tree cfg gate.
- Features collapse to two: **`local`** (first-party socket
providers + front doors, ADR-008; brings `tokio/net` + `socket2`
into the graph itself) and **`composed`** (the alktunnels
providers). `local` no longer implies a separate `net` feature —
providers). `local` no longer implies a separate `net` feature;
it is the sole gate for socket/platform I/O.
- POC #4's gate map survives as the *extraction recipe* (its
ungated inventory is what to copy), not as in-tree cfg.
@@ -81,24 +81,24 @@ implying it. ADR-011's extraction amendment dissolves it:
## Consequences
- Wasm consumers (the sandboxed adapter story — a product input,
- Wasm consumers (the sandboxed adapter story; a product input,
nothing waits on it) build the default crate clean; the protocol
substance *is* the wasm-relevant surface.
- The verification commands cover the gates: `cargo test` (default),
`cargo test --all-features`, and both wasm checks
(`cargo check/clippy --target wasm32-unknown-unknown`) — the
(`cargo check/clippy --target wasm32-unknown-unknown`), the
structural guard for the invariant.
- The vendor's carried changes (BIND, `new_reply` publication) live
in the extracted region where they touch codecs/typestate; nothing
vendored is gated — the only feature-gated socket code is the
vendored is gated; the only feature-gated socket code is the
crate's own first-party `local` module.
## References
- OQ-SK-04 (resolved: the extracted-and-forked base — ADR-011/
- OQ-SK-04 (resolved: the extracted-and-forked base; ADR-011/
ADR-013), OQ-SK-01/03/08
(the providers the features gate)
- POC #4 `poc-connect-wasm-findings.md` (the measured gate — now the
- POC #4 `poc-connect-wasm-findings.md` (the measured gate, now the
extraction recipe),
alktty's `local` feature (the posture precedent), alktunnels
ADR-004 + overview.md (the same feature split)
@@ -1,4 +1,4 @@
# ADR-010: Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change
# ADR-010: Channel Capacity: Proxy-Workload Recommendation, Not a Wire Change
## Status
@@ -14,12 +14,12 @@ CONNECT channel per flow; composed UDP egress spends a downstream
channel per destination; BIND spends an accept resource per
listener). A proxy-shaped deployment will discover the cap in
production unless the crate documents the posture. The caps are
policy knobs — configurable without a wire change (the `u32`
channel-ID wire space is ~4 billion) — so the resolution is config
policy knobs, configurable without a wire change (the `u32`
channel-ID wire space is ~4 billion), so the resolution is config
plus documentation, never wire.
Considered and rejected: in-channel port multiplexing (a
`[port: u16][len][payload]` per-chunk prefix) — it re-opens the
`[port: u16][len][payload]` per-chunk prefix); it re-opens the
demux-vs-phase trade ADR-003 (the handle swap) resolved, breaks
the 0-B pass-through that lets the forked `transfer` drop in
unchanged, imposes chunk boundaries on a stream RFC 1928 treats as
@@ -28,14 +28,14 @@ convention (AGENTS.md #10).
## Decision
- **No wire change — rejected permanently.** The caps are policy
- **No wire change (rejected permanently).** The caps are policy
knobs (alkcall ADR-040/041); the resolution is config.
- **Assembly-layer config is the mechanism**: a proxy-shaped
deployment raises the per-connection/per-identity caps at
registration; the default stays conservative for everyone else.
- **Documented proxy-workload recommendation**: the crate docs
recommend a raised cap for proxy deployments, with the memory
arithmetic — a session's memory is bounded + predictable (CONNECT/
arithmetic: a session's memory is bounded + predictable (CONNECT/
ASSOCIATE are bounded-buffer pumps; BIND adds one listener + one
accept), so the per-connection cap can rise safely relative to a
general-purpose deployment. A concrete recommended figure is a
@@ -44,12 +44,12 @@ convention (AGENTS.md #10).
cap → per-identity cap → dial/accept policy. Relaxing the caps
leans on the rest. The per-identity DoS brake is arguably
*stronger* here (each SOCKS5 session maps to real producer-side
resources — a dial, a downstream channel, an accept listener);
resources (a dial, a downstream channel, an accept listener);
the per-connection memory bound is arguably *weaker* (the three
command shapes are well-known and bounded).
- **Revisit trigger** (OQ-SK-09's): if a deployment hits a fixed
upstream channel budget that config cannot raise, the additive
escape is a *new multiplexed ALPN* — never a change to this
escape is a *new multiplexed ALPN*, never a change to this
ALPN's framing.
## Consequences
@@ -57,7 +57,7 @@ convention (AGENTS.md #10).
- Deployments get the guidance before they hit the cap; no protocol
surface changes.
- The rejected multiplexing proposal has a recorded rationale and a
revisit trigger — the escape hatch is additive (new ALPN), never
revisit trigger; the escape hatch is additive (new ALPN), never
a framing change.
## References
@@ -1,12 +1,12 @@
# ADR-011: the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree
# ADR-011: the Inline Vendor: fast-socks5's `T`-Generic Subset, In-Tree
## Status
Superseded by [ADR-013](013-targeted-fork.md) (2026-09-22): the
maintenance posture (the vendor framing — re-base/re-vendor
maintenance posture (the vendor framing, re-base/re-vendor
discipline, the verbatim/lint-allow apparatus) is retired; the
extracted region is a targeted fork, module `rfc1928`. **The
extraction mechanics here remain authoritative** — the cut table,
extraction mechanics here remain authoritative**; the cut table,
the two carried changes (BIND, `new_reply`), the §Amendment
(extraction, not wholesale copy; the net gate dissolves) describe
what was copied and what was not; only the post-copy maintenance
@@ -16,57 +16,57 @@ OQ-SK-04's resolution), Amended 2026-09-21 (pre-decomposition).
## Context
fast-socks5 is **third-party upstream** (v1.0.0, MIT;
`/workspace/fast-socks5` is a read-only reference checkout — we do
`/workspace/fast-socks5` is a read-only reference checkout; we do
not own it, and it has not been forked). The wrapper's needs go
beyond what it publishes:
1. **The wasm gate** — `tokio::net` + `socket2` (via `mio`) hard-error
1. **The wasm gate**: `tokio::net` + `socket2` (via `mio`) hard-error
on `wasm32-unknown-unknown`; the default crate must compile there
(AGENTS.md convention 4). POC #4 measured the gate: ~120 changed
lines across 5 files, all `#[cfg]` insertions; the wasm-clean
subset is functionally complete (a full RFC 1928 CONNECT
conversation passes `--no-default-features`).
2. **BIND is absent** — `Socks5Command::TCPBind` is parsed but every
2. **BIND is absent**: `Socks5Command::TCPBind` is parsed but every
handler refuses it; POC #5 proved the wrapper must drive the
typestate itself for the BIND shape (ADR-007).
3. **The `run_udp_proxy_custom` bind seam** — the "custom" seam is
3. **The `run_udp_proxy_custom` bind seam**: the "custom" seam is
narrower than it looks: it calls `udp_bind_random_port`
unconditionally and derives the reply port from that kernel
socket; the channels path cannot use it (verified 2026-09-13).
The channels path drives the typestate directly; making the bind
optional / accepting a caller-supplied socket is an in-tree fix.
4. **`new_reply` is private** — the reply encoder is private; the
4. **`new_reply` is private**: the reply encoder is private; the
BIND reply#2 writer otherwise hand-rolls the 10-byte reply
(POC #5's courtesy item, now a vendor change).
Two facts settled the posture: the ownership premise was wrong
(no cheap "file asks / PR" path), and the carried needs (2–4) exist
regardless of the wasm question — so the vendor pays for itself
regardless of the wasm question, so the vendor pays for itself
without leaning on the adapter story. The change set is exactly the
shape POC #4 measured as minimal. (Need 1 later dissolves —
shape POC #4 measured as minimal. (Need 1 later dissolves,
§Amendment; needs 3's handler is not extracted with it.)
## Decision
- **Extract the `T`-generic subset into this crate** (see §Amendment
for the mechanical reading of "extract"): the codecs, the typestate
machinery, the client `use_stream` path — the parts already generic
machinery, the client `use_stream` path, the parts already generic
over `T: AsyncRead + AsyncWrite + Unpin`. The extraction contains
zero kernel-net code and is wasm-clean by construction.
- **Carry the four needs in the same vendor**: the net gate, BIND
(the typestate-driven shape, ADR-007), the `run_udp_proxy_custom`
seam fix, and the `new_reply` publication. (Post-Amendment: the
net gate dissolves — see §Amendment; BIND and `new_reply` are the
net gate dissolves; see §Amendment; BIND and `new_reply` are the
two that touch extracted code.)
- **Note the vendor point (v1.0.0) in the vendored code**; keep the
MIT license obligations (upstream is MIT; preserve the license
header per file or a bundled notice).
- **`/workspace/fast-socks5` stays the differential-testing
reference** for RFC edge cases (reply-code mapping, domain
addressing, fragmentation) — keep `/workspace/fast-socks5` for
addressing, fragmentation); keep `/workspace/fast-socks5` for
differential testing; never wait on an upstream merge.
- **Offering the net-gate upstream as a courtesy PR stays optional**
(POC #4: it is small enough to offer as-is) — never a wait, never
(POC #4: it is small enough to offer as-is), never a wait, never
a dependency.
## Consequences
@@ -75,7 +75,7 @@ shape POC #4 measured as minimal. (Need 1 later dissolves —
cadence, no feature waits, and the vendored code co-evolves with
the wrapper (the carried changes are first-class, not patches).
- The cost is maintenance of the extracted subset (the `T`-generic
region + codecs — a fraction of upstream's ~4k LoC; POC #4's
region + codecs, a fraction of upstream's ~4k LoC; POC #4's
ungated inventory is the shopping list). The diff vs upstream is
deliberate and bounded: the extraction cut + the carried changes +
the license/provenance notes.
@@ -86,7 +86,7 @@ shape POC #4 measured as minimal. (Need 1 later dissolves —
- Differential testing against `/workspace/fast-socks5` guards the
RFC edge cases the extracted code must keep passing.
## Amendment (2026-09-21, pre-decomposition — "extract" pinned, the `net` gate dissolves)
## Amendment (2026-09-21, pre-decomposition: "extract" pinned, the `net` gate dissolves)
The decision's wording ("vendor the `T`-generic subset") supported
two mechanical readings; this amendment pins the intent so the
@@ -109,12 +109,12 @@ decomposition task is unambiguous:
- **Why**: convention 17's letter ("vendor the `T`-generic subset");
no crate consumer routes through the kernel-net region (the
producer/consumer/doors drive the typestate and the pure codecs;
the local backends are first-party tokio code — ADR-004/ADR-008);
the local backends are first-party tokio code, ADR-004/ADR-008);
and the BIND + `new_reply` needs touch only the extracted region
(BIND's driving is wrapper-side code per POC #5 — "zero upstream
(BIND's driving is wrapper-side code per POC #5, "zero upstream
ask for BIND itself").
- **The `net` feature gate dissolves.** With no kernel-net code
in-tree, the extraction is wasm-clean by construction — no
in-tree, the extraction is wasm-clean by construction; no
`#[cfg]` gate needed inside the vendored files. Features collapse
to two: `local` (the crate's first-party socket providers and
front doors, ADR-008) and `composed` (the alktunnels providers).
@@ -123,11 +123,11 @@ decomposition task is unambiguous:
stays `default-features = false` with the wasm-clean subset
(AGENTS.md convention 4); `tokio/net` rides `local` alone.
- **The carried-changes list re-reads under extraction**: (1) the
net gate — dissolves (no net code); (2) BIND — stays (the typestate
net gate, dissolves (no net code); (2) BIND, stays (the typestate
driving + reply#2 writer are in-tree; ADR-007); (3) the
`run_udp_proxy_custom` bind seam — dissolves (the handler isn't
`run_udp_proxy_custom` bind seam, dissolves (the handler isn't
extracted; the typestate-driving shape POC #2 validated is the
crate's ASSOCIATE path); (4) `new_reply` publication — stays
crate's ASSOCIATE path); (4) `new_reply` publication, stays
(it is inside the extracted codec region). Net: **two carried
changes** touch extracted code.
- **Extraction completeness is compiler-checked**: a missed
@@ -139,10 +139,10 @@ decomposition task is unambiguous:
upstream's `log` calls in the extracted region are dropped or
re-pointed at the crate's `tracing` (the alk* convention); the
extracted region's `anyhow::Context` error wrapping is dropped
(error surface is the crate's `thiserror` types — the vendored
(error surface is the crate's `thiserror` types; the vendored
`SocksError`/`SocksServerError` stay the wire-level vocabulary);
`async-trait` is dropped (its four upstream uses are all on the
legacy `Authentication` layer, which is cut — the typestate's
legacy `Authentication` layer, which is cut; the typestate's
`AuthMethod<T>` traits are native async-fn-in-trait); the
`socks4` module and the legacy `Config`/`Authentication` layer are
out (above). These are extraction-time mechanical adjustments to
@@ -157,8 +157,8 @@ wasm check a hard gate as in alktty/alktunnels.
- OQ-SK-04 (the resolved posture; the reasoning trail),
OQ-SK-08 (the BIND + `new_reply` needs), OQ-SK-03 (the bind seam)
- POC #4 + POC #1 (`poc-connect-wasm-findings.md` — the measured
gate), POC #5 (`poc-bind-findings.md` — the BIND typestate
- POC #4 + POC #1 (`poc-connect-wasm-findings.md`: the measured
gate), POC #5 (`poc-bind-findings.md`: the BIND typestate
driving + the `new_reply` finding)
- ADR-002 (the protocol base), ADR-007 (BIND), ADR-009 (the feature
gates); `/workspace/fast-socks5` (the reference checkout, v1.0.0)
@@ -3,7 +3,7 @@
## Status
Accepted (2026-09-20, Phase 1; the dialer-refuses shape is
OQ-SK-05's evidence-backed resolution — see ADR-006 — and the
OQ-SK-05's evidence-backed resolution (see ADR-006), and the
2026-09-14 virtual-subnet proposal is resolved as assembly-layer
policy)
@@ -11,15 +11,15 @@ policy)
OQ-SK-05's residual Phase 1 questions: (a) does the base crate ship
a per-target allowlist hook, or is target policy entirely the
dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03) —
dialer's concern; (b) domain-form targets (RFC 1928 ATYP 0x03):
resolve producer-side, refuse, or pass through unresolved; (c) the
2026-09-14 virtual-subnet proposal (identity-scoped subnets so
proxied resources become IP-addressable — "BND.ADDR ↔ channel id"
proxied resources become IP-addressable ("BND.ADDR ↔ channel id"
translation, a hub exposing proxied resources per identity under
ACL).
The proposal's key property: the mechanism needs **no wire change**
— DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual
The proposal's key property: the mechanism needs **no wire change**;
DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual
addresses ride ordinary CONNECT/BIND requests; the translation layer
is the dial callback (`dial(target)` matches a virtual subnet
against a per-identity resource table).
@@ -34,13 +34,13 @@ against a per-identity resource table).
dialer-refuses.
- **Domain-form targets: pass through unresolved to the dialer.**
Resolution is the egress's/dialer's concern (POC #2 resolved the
local-egress half: "the egress owns resolution" — the ssh `-D`
local-egress half: "the egress owns resolution" (the ssh `-D`
semantic). The protocol layer never resolves: a wasm producer may
have no resolver at all, and the composed variant's targets ride
the alktunnels direct op (producer-side resolution there, per
alktunnels ADR-007's own posture). fast-socks5's `dns_resolve`
config (and its `resolve_dns` step) is not part of the extracted
vendor region at all — the crate's producer never resolves
vendor region at all; the crate's producer never resolves
(ADR-006/ADR-012), so the extraction drops both.
- **The virtual-subnet translation layer is assembly-layer policy,
not a crate feature.** The crate ships no translation-table
@@ -48,12 +48,12 @@ against a per-identity resource table).
wanting identity-scoped virtual subnets implements the
subnet→resource table there (per-identity scoping = resources +
ACL + target policy; hub exposure = the terminate-and-re-produce
story — no new wire vocabulary). The full-tunnel composition (the
story; no new wire vocabulary). The full-tunnel composition (the
host routes the subnet into the proxy via tun2proxy) is where a
virtual subnet becomes routable from vanilla apps:
`connect(10.x.y.z:p)` is an ordinary CONNECT the dialer
translates. tun2proxy's virtual-network/virtual-DNS support is
the prior art to check *if and when* a deployment builds this —
the prior art to check *if and when* a deployment builds this;
the crate's obligation is only that the seams support it, which
ADR-004's shape already does.
@@ -66,7 +66,7 @@ against a per-identity resource table).
translation layer rides the dial callback; if a deployment
materializes with concrete needs, a translation-table helper can
be added additively (assembly-layer code, possibly later
graduated into the crate behind a feature) — the seams don't
graduated into the crate behind a feature); the seams don't
change.
- DNS posture is uniform: producer-side resolution everywhere
(local egress resolves; composed egress hands the name to the
@@ -77,11 +77,11 @@ against a per-identity resource table).
- OQ-SK-05 (the full question set + the proposal's record),
OQ-SK2-07 (the deferred helper question this ADR's resolution
feeds), OQ-SK-01
(the dial callback), OQ-SK-09 (the multiplexing rejection — the
(the dial callback), OQ-SK-09 (the multiplexing rejection; the
virtual-subnet idea that survives is recorded in the tracker)
- POC #2 (`poc-udp-associate-findings.md` — the egress-owns-DNS
- POC #2 (`poc-udp-associate-findings.md`: the egress-owns-DNS
resolution), ADR-004 (the seams), ADR-006 (the ACL posture),
ADR-008 (the provider defaults)
- alktunnels phase-0-findings OQ-TN-08 (the target-policy
dissolution precedent); tun2proxy (`/workspace/tun2proxy` — the
dissolution precedent); tun2proxy (`/workspace/tun2proxy`, the
virtual-network prior art + the endgame composition partner)
@@ -1,17 +1,17 @@
# ADR-013: The Targeted Fork — fast-socks5's `T`-Generic Subset Is Owned, Not Vendored
# ADR-013: The Targeted Fork: fast-socks5's `T`-Generic Subset Is Owned, Not Vendored
## Status
Accepted (2026-09-22, post-extraction-review; supersedes the ADR-011
maintenance posture — ADR-011 stays for the extraction mechanics and
maintenance posture; ADR-011 stays for the extraction mechanics and
the cut table; the ADR-011 §Amendment is subsumed here)
## Context
ADR-011 pinned the inline-vendor posture as **extraction** (the
`T`-generic subset copied in-tree, kernel-net region not copied; the
net gate dissolving), and the vendor group — five implementation
tasks plus the review gate — landed it in commits `57b107b..5b18d4d`,
net gate dissolving), and the vendor group (five implementation
tasks plus the review gate) landed it in commits `57b107b..5b18d4d`,
with the review (`tasks/implementation/
review-vendor-extraction.md`) as the fidelity gate: the file-by-file
diff vs `/workspace/fast-socks5` v1.0.0 categorizes every hunk, and
@@ -20,23 +20,23 @@ every review criterion passed.
The review also made the maintenance posture's cost visible. The
tree is now permanently diverged from upstream in five categories:
1. **The extraction cut** — the kernel-net region, `socks4`, the
1. **The extraction cut**: the kernel-net region, `socks4`, the
legacy `Config`/`Authentication` layer, `Socks5Datagram`, the
convenience dialers, the DNS helper: gone.
2. **The carried changes** — BIND's reply surface, the published
2. **The carried changes**: BIND's reply surface, the published
`new_reply`.
3. **The panic translations** — `ReplyError::from_u8` (the review's
3. **The panic translations**: `ReplyError::from_u8` (the review's
F-1), `use_password_auth`, `reply_error`'s parse.
4. **The error-pruning adjustments** — anyhow/log/async-trait out,
4. **The error-pruning adjustments**: anyhow/log/async-trait out,
error variants pruned, `MissingTargetAddr` added.
5. **The lint-allow posture** — module-level `#[allow]`s preserving
5. **The lint-allow posture**: module-level `#[allow]`s preserving
upstream style (`u8::max_value()`, needless lifetimes,
`match_like_matches_macro`, `io_other_error`, `wrong_self_
convention`, the F-4 verbatim string) to keep the diff mechanical.
Category 5 exists solely to keep categories 1–4 reconcilable by a
line-based diff. But a future re-base against an upstream release
was never going to be a clean mechanical diff again — it was always
was never going to be a clean mechanical diff again; it was always
going to be manual reconciliation across all five categories. The
vendor posture's maintenance apparatus (verbatim preservation, the
lint allows, the re-vendor/adjustment-list discipline) pays a
@@ -44,7 +44,7 @@ permanent tax to serve a re-base path that would never be used.
The "vendor" framing also produced a real defect: the review's
F-1 (`ReplyError::from_u8`'s upstream `unreachable!()` arm kept
verbatim — wire-reachable, a misbehaving peer could panic the
verbatim; wire-reachable, a misbehaving peer could panic the
consumer task) escaped precisely because the vendor posture
maintained a second, weaker instruction set ("verbatim + adjustments
list" instead of "project conventions, unconditionally"). The
@@ -52,7 +52,7 @@ fidelity-vs-conventions tension is structural to the vendor framing;
the review caught the escape, but the seam itself was the vendor
framing's.
The fork point is now — post-review, post-F-1 — the cleanest line
The fork point is now, post-review and post-F-1, the cleanest line
the tree will ever present: the extraction provenance is in git
history (`57b107b..5b18d4d`), the review is the recorded fidelity
gate, and the five divergence categories are documented in ADR-011,
@@ -76,8 +76,8 @@ vendor.md, and the review task. "The tree as of the review commit
(now gone); "socks5" would stutter with the crate name
(`alksocks::socks5`) and collide with the future module structure
(the producer/consumer/adapter layers are also SOCKS5-shaped).
`rfc1928` names what the module *is* — the RFC 1928 wire
vocabulary and the state machines — permanent and collision-free.
`rfc1928` names what the module *is* (the RFC 1928 wire
vocabulary and the state machines), permanent and collision-free.
- **The lint-allow posture dissolves.** The module-level `#[allow]`s
and the "verbatim lint posture" module-doc paragraphs are removed;
the suppressed smells are fixed per project conventions:
@@ -100,7 +100,7 @@ vendor.md, and the review task. "The tree as of the review commit
pins bytes, not upstream text, so it survives the rename
untouched. Textual-diff discipline is retired; behavioral
differential testing is retained.
- **The cut table is re-read as the not-forked inventory** — the
- **The cut table is re-read as the not-forked inventory**, the
map of what to hand-port if a future need surfaces (e.g. the
kernel-net region for a standalone vanilla-server example). The
hand-port path replaces the re-vendor path: port against the
@@ -109,7 +109,7 @@ vendor.md, and the review task. "The tree as of the review commit
- **The honest cost**: the cheap upstream-fix path closes. A future
fast-socks5 fix (a reply-code mapping correction, a codec fix)
arrives as a hand-port against the reference checkout, not a
diff absorb. Risk is assessed low — fast-socks5 is a quiet
diff absorb. Risk is assessed low; fast-socks5 is a quiet
project, and the extracted surface is the well-trodden RFC
1928 path.
@@ -118,13 +118,13 @@ vendor.md, and the review task. "The tree as of the review commit
- **Positive**: one instruction set (project conventions,
unconditionally); the lint-allow tax and the adjustment-list
apparatus dissolve; smells are fixed at the only cost that
matters — a one-time cleanup verified by the existing suite.
matters: a one-time cleanup verified by the existing suite.
The F-1 seam class (conventions-vs-fidelity gaps) is closed
structurally.
- **Positive**: the module name is stable for the crate's future
(no `alksocks::socks5` stutter, no `socks5::producer` collision
pressure).
- **Negative (accepted)**: the cheap upstream-fix path closes — a
- **Negative (accepted)**: the cheap upstream-fix path closes; a
future upstream fix is a hand-port against the reference
checkout, guarded by the differential suite. Assessed low-risk
(quiet upstream, well-trodden RFC surface).
@@ -139,10 +139,10 @@ vendor.md, and the review task. "The tree as of the review commit
## References
- ADR-011 (the extraction mechanics, the cut table, the carried
changes — the mechanics remain authoritative), ADR-002 (the
changes; the mechanics remain authoritative), ADR-002 (the
protocol base), ADR-009 (feature gates, no `net` feature)
- `tasks/implementation/review-vendor-extraction.md` (the fidelity
gate + F-1), `docs/architecture/rfc1928.md` (the owned-core
spec)
- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral
- `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral
reference for differential testing)
@@ -1,10 +1,10 @@
# ADR-014: The Idle-Timeout Posture — Teardown-Governed Phases, No Protocol-Layer Idle Bound
# ADR-014: The Idle-Timeout Posture: Teardown-Governed Phases, No Protocol-Layer Idle Bound
## Status
Accepted (2026-09-24, review 002 C-2's decision task — the deliverable
Accepted (2026-09-24, review 002 C-2's decision task: the deliverable
is this decision, not code; it lands before the batch-2 `local`/
`composed` doors exist, whose tasks may depend on it — ADR-008/
`composed` doors exist, whose tasks may depend on it, per ADR-008/
ADR-009's provider posture)
## Context
@@ -13,33 +13,33 @@ Review 002's C-2 (major): the pre-command RFC handshake and the
association have no timeout surface. After the channel opens, the
pump handler's spawned task parks on the RFC handshake
(`Socks5ServerProtocol::accept_no_auth` → `read_command`), and the
datagram stage's loop exits only on EOF/error/data — no timer exists
datagram stage's loop exits only on EOF/error/data; no timer exists
anywhere in the protocol layer. The alkcall 10s establishment bound
(`register_openable_with_establisher`'s default, `timeout: None`,
alkcall ADR-049 §2) governs only the open-op round trip, not the
in-stream conversation. A silent peer therefore holds — per idle
channel — one spawned task and one channel entry (bounded buffers,
in-stream conversation. A silent peer therefore holds, per idle
channel, one spawned task and one channel entry (bounded buffers,
ADR-040) indefinitely. BIND's quiet wait got an explicit decision
(ADR-007: teardown-governed — "FTP-length waits stay legal"); the
(ADR-007: teardown-governed, "FTP-length waits stay legal"); the
handshake and the association lifetime got "inherited from the
channel" — implicit, never decided.
channel", implicit, never decided.
The mitigations are real: the 256-channel per-connection cap
(ADR-040) and the per-identity cap (ADR-041) bound the count,
per-session memory is bounded, and teardown fires on channel close —
per-session memory is bounded, and teardown fires on channel close;
nothing leaks, nothing grows. The aggregate remains: any identity
with `socks5:open` can hold 256 parked tasks + 256 channel entries
indefinitely *without sending a byte*. And batch 2's providers
(`local`, `composed`, ADR-008) are about to route *vanilla clients*
through these same phases via the front doors, where the peer is a
kernel socket often outside the channel-cap regime entirely — a
kernel socket often outside the channel-cap regime entirely; a
slowloris-shaped hold (open, greet, stop) becomes the assembly
layer's problem, and the protocol layer is where a timeout seam would
live.
The review offered two options: (1) idle timeout is the substrate's/
assembly's job — the protocol layer never times out, the DoS posture
recorded explicitly; (2) a producer-side idle bound — an optional
assembly's job: the protocol layer never times out, the DoS posture
recorded explicitly; (2) a producer-side idle bound: an optional
per-session inactivity timeout at the pump level (config on
`Socks5ProducerBuilder`), racing the handshake/stage reads against a
sleep, timing out as a clean EOF-shaped teardown.
@@ -51,61 +51,61 @@ Two facts sharpen the choice beyond the review's sketch:
idle CONNECT tunnel (`pump_bidi` parked on a quiet target), a
quiet-but-alive association (association lifetime = channel
lifetime, wire.md §UDP ASSOCIATE), BIND's quiet wait (ADR-007).
The resource cost is identical to the pre-command hold — one
The resource cost is identical to the pre-command hold, one
parked task + one channel entry. A handshake-only idle bound
protects only the pre-auth sliver while the dominant hold (the
post-reply idle) stays contractually open — incoherent; a bound on
post-reply idle) stays contractually open; incoherent. A bound on
both is a full inactivity timeout, which contradicts the landed
contracts (association lifetime = channel lifetime; FTP-length
BIND waits stay legal, ADR-007). The unbounded-time aggregate is
therefore a property of every idle session the protocol serves —
therefore a property of every idle session the protocol serves:
bounded by the cap regime, not by a timer.
- **The two sides differ in composability.** The consumer sessions
are plain futures at the caller — a caller wanting an idle bound
wraps the constructor (or the session's reads) in
are plain futures at the caller: a caller wanting an idle
bound wraps the constructor (or the session's reads) in
`tokio::time::timeout` itself; no decision is needed consumer-side.
The producer's parked task lives inside the alkcall-spawned open
handler — not caller-composable; only the producer-side question
handler, not caller-composable; only the producer-side question
needed an ADR.
Also recorded (an upstream-fidelity correction to the review's
sketch): upstream's connect-timeout path maps its *dial* timeout to
`ReplyError::ConnectionTimeout` → reply code 0x06 (TTL expired), not
0x01 as the sketch suggested — the mapping table lives in
0x01 as the sketch suggested; the mapping table lives in
`src/rfc1928/mod.rs` (carried from `/workspace/fast-socks5`
`util/stream.rs`). Relevant only if option 2 is ever built.
## Decision
**Option 1 — the protocol layer never times out.** Every phase of the
**Option 1: the protocol layer never times out.** Every phase of the
RFC 1928 conversation is teardown-governed: the pre-command
handshake, the BIND quiet wait, and the association lifetime end on
the channels-level sentinels (EOF / garbage / error), never on a
protocol-layer inactivity timer. The posture is substrate-uniform —
protocol-layer inactivity timer. The posture is substrate-uniform:
the same for a channel peer and for a kernel socket a door relays.
- **No inactivity bound exists in `src/`.** The handshake and stage
reads park like any other phase: no idle timer, no keepalive, no
in-band expiry vocabulary. "No in-band control vocabulary"
(wire.md) holds — this decision adds none.
(wire.md) holds; this decision adds none.
- **The DoS posture is explicit and layered** (the ADR-010 pattern:
ACL → per-connection cap → per-identity cap → door/dial policy):
- **Channels shapes**: parked-task count is bounded by the
per-connection cap (ADR-040) and the per-identity cap (ADR-041);
per-session memory is bounded; teardown fires on channel close.
Time is unbounded by design — accepted, the same posture every
Time is unbounded by design; accepted, the same posture every
idle-but-open session already has.
- **Doors** (the batch-2 obligation this ADR records before they
exist): an accepted TCP connection is *not a channel* — the cap
exist): an accepted TCP connection is *not a channel*; the cap
regime does not bound a door's accepted-socket hold. The door's
accept path owns its defense; the batch-2 door author inherits
these obligations knowingly (the wire.md §UDP ASSOCIATE
"inherits this bound knowingly" pattern):
1. an **accept-stage idle bound** — an accepted connection that
1. an **accept-stage idle bound**: an accepted connection that
sends no first byte within a configured window is closed
(door-observable, protocol-agnostic; this is the slowloris
sliver the protocol layer will not bound);
2. **per-credential / per-peer session caps** — the RFC 1929
2. **per-credential / per-peer session caps**: the RFC 1929
cred-bearing door's credential→identity config table
(ADR-005) is the natural knob;
3. **accept-queue / relay-task bounds** on the door's own accept
@@ -115,10 +115,10 @@ the same for a channel peer and for a kernel socket a door relays.
the producer-side local backend's accept loop, and a composed
bind-first listener's front.
- The protocol layer neither provides nor re-derives these knobs
(limits are inherited, not redefined — AGENTS.md convention 10);
(limits are inherited, not redefined, per AGENTS.md convention 10);
no timeout helper ships speculatively.
- **Relationship to ADR-007 (the BIND quiet wait): same regime.**
ADR-007 decided the quiet wait's ends as part of BIND's shape — the
ADR-007 decided the quiet wait's ends as part of BIND's shape: the
`select!` needed exactly two exits, and "FTP-length waits stay
legal" was the explicit call. ADR-014 records the *same* resolution
for the phases ADR-007 left implicit. The principle is one and the
@@ -129,33 +129,33 @@ the same for a channel peer and for a kernel socket a door relays.
difference is only decidedness: ADR-007 decided one phase's ends by
designing BIND; ADR-014 decides the rest by recording the inherited
resolution as the decision. No new regime is introduced.
- **Revisitable — not a one-way door.** A producer-side idle seam is
- **Revisitable, not a one-way door.** A producer-side idle seam is
additive if a deployment ever proves this posture insufficient: an
optional per-session inactivity bound (builder config defaulting to
`None` = this posture), enforced by racing the handshake/stage
reads against a sleep timer (`tokio::time` is already in the
wasm-clean dependency subset), timing out as a clean EOF-shaped
teardown. No wire change, no API break, no spec contradiction —
teardown. No wire change, no API break, no spec contradiction:
the spec lines record the *default* posture, and an opt-in bound
contradicts nothing. The RFC has no timeout reply code; a pre-reply
timeout, if the seam is ever built, would surface as the EOF shape
(or the closest mapped code — upstream's dial-timeout precedent is
(or the closest mapped code; upstream's dial-timeout precedent is
0x06, see Context). Revisit trigger: a deployment that cannot
enforce the bound at its doors or assembly — a peer class outside
both the cap regime and any door policy — with measured evidence
enforce the bound at its doors or assembly (a peer class outside
both the cap regime and any door policy), with measured evidence
the aggregate hold is unacceptable.
## Consequences
- No code changes this round; the posture is recorded. The spec lines
(wire.md §UDP ASSOCIATE's association-lifetime bullet;
producer.md §The Open Handler) carry the decided posture — the
producer.md §The Open Handler) carry the decided posture; the
implicit residue is gone.
- The batch-2 door tasks inherit the recorded accept-path obligations;
this ADR is the timeout-posture gate those tasks may depend on
(it precedes any `local`/`composed` door work).
- Review 002 C-2 closes as decided. The adjacent S-1 residual (the
datagram stage's hot-`Ok(None)` tolerance — unbounded *rate*, not
datagram stage's hot-`Ok(None)` tolerance: unbounded *rate*, not
unbounded *time*) stays its own accepted-uncovered posture; this
decision does not touch it.
- Consumer-side timeout policy stays where it always was: caller-
@@ -167,7 +167,7 @@ the same for a channel peer and for a kernel socket a door relays.
- docs/reviews/002-protocol-core-implementation-review.md §C-2 (the
finding), §S-1 (the adjacent rate posture), §N-2 (the
"inherits this bound knowingly" pattern this ADR extends)
- [ADR-007](007-bind-in-base.md) (the quiet wait's decision — the
- [ADR-007](007-bind-in-base.md) (the quiet wait's decision; the
contrast case), [ADR-010](010-channel-capacity.md) (the
layered-defense + config-not-protocol pattern this follows),
[ADR-008](008-provider-defaults.md) / [ADR-009](009-feature-gates.md)
@@ -179,7 +179,7 @@ the same for a channel peer and for a kernel socket a door relays.
- [wire.md](../wire.md) §UDP ASSOCIATE,
[producer.md](../producer.md) §The Open Handler (the spec lines
updated)
- POC findings (the stage's measured shapes — the "sufficient for
- POC findings (the stage's measured shapes; the "sufficient for
relay postures" lineage)
- `/workspace/fast-socks5` (`src/util/stream.rs` — the dial-timeout
- `/workspace/fast-socks5` (`src/util/stream.rs`: the dial-timeout
mapping precedent, 0x06)
+40 -40
View File
@@ -1,46 +1,46 @@
---
status: draft
last_updated: 2026-09-28 (OQ-SK2-09 recorded — the UDP door's
last_updated: 2026-09-28 (OQ-SK2-09 recorded: the UDP door's
peer-pin posture; option 2 landed in 0.1.0, option 3 deferred;
OQ-SK2-06's trigger ownership checked — genuinely external; the
OQ-SK2-06's trigger ownership checked, genuinely external; the
OQ-SK ledger OQ-SK-01..09 promoted 2026-09-20; new questions
continue as OQ-SK2-06..)
---
# alksocks — Open Questions
# alksocks: Open Questions
Centralized tracker. The Phase 0 OQ ledger (`docs/research/
phase-0.md`, OQ-SK-01..09) is promoted here with statuses current as
of the Phase 1 spec; new Phase 1 questions continue the OQ-SK2
numbering (SK2 = the architecture-set ledger, distinct from the
Phase 0 ledger's OQ-SK- prefix, which stays historical). Note: the
SK2 numbering is not contiguous from 01 — OQ-SK2-01..05 were
SK2 numbering is not contiguous from 01: OQ-SK2-01..05 were
consumed in working drafts before the first committed set; the
sequence starts at the numbers in use. Format per
`docs/sdd_process.md`.
## Theme 1: Wire format and phases
### OQ-SK-01: Producer dial policy — what dials the target?
### OQ-SK-01: Producer dial policy (what dials the target?)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-01)
- **Status**: resolved — [ADR-004](decisions/004-dial-callback-shape.md)
- **Status**: resolved: [ADR-004](decisions/004-dial-callback-shape.md)
(the shape), [ADR-008](decisions/008-provider-defaults.md) (the
provider set)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1). The dialer-trait-vs-callback
half closed on the POC evidence (function-not-trait at three
seams — DialFn/EgressFactory/BindAcceptFactory, boxed halves,
seams: DialFn/EgressFactory/BindAcceptFactory, boxed halves,
ownership rides the handle). The provider-default half: the base
crate ships local-socket providers (`local`) and composed
providers (`composed`, the alktunnels direct op); no implicit
dialer — registration always takes the callbacks explicitly.
dialer; registration always takes the callbacks explicitly.
- **Cross-references**: OQ-SK-03, OQ-SK-08, ADR-004, ADR-008
### OQ-SK-03: UDP ASSOCIATE without binding
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-03)
- **Status**: resolved — [ADR-003](decisions/003-udp-handle-swap.md)
- **Status**: resolved: [ADR-003](decisions/003-udp-handle-swap.md)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1; baseline settled 2026-09-13,
POC #2 validated 2026-09-14). The handle swap: one channel per
@@ -52,15 +52,15 @@ sequence starts at the numbers in use. Format per
dedup pinned by test).
- **Cross-references**: OQ-SK-01, ADR-003, ADR-008
### OQ-SK-08: BIND — the accept-side command
### OQ-SK-08: BIND (the accept-side command)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-08)
- **Status**: resolved — [ADR-007](decisions/007-bind-in-base.md)
- **Status**: resolved: [ADR-007](decisions/007-bind-in-base.md)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1; POC #5 validated 2026-09-15).
BIND ships in the base crate, phase-model style; the accept
variants: local listener (default, faithful), composed
(alktunnels bind-first listen + forwarded op — vanilla-fidelity
(alktunnels bind-first listen + forwarded op, vanilla-fidelity
since the graduation), wrapper-aware (in-band default). The
establishment bound covers only the open-op round trip; the
accept wait lives in the teardown-governed pump phase.
@@ -68,10 +68,10 @@ sequence starts at the numbers in use. Format per
## Theme 2: Crate architecture
### OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth
### OQ-SK-02: Auth mapping (identity seam vs in-band SOCKS auth)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-02)
- **Status**: resolved — [ADR-005](decisions/005-auth-split-by-door.md)
- **Status**: resolved: [ADR-005](decisions/005-auth-split-by-door.md)
- **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1; the posture settled
2026-09-13). "Both," split by the door: producer = identity seam
@@ -85,7 +85,7 @@ sequence starts at the numbers in use. Format per
### OQ-SK-04: fast-socks5 wasm posture
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-04)
- **Status**: resolved — [ADR-011](decisions/011-inline-vendor.md)
- **Status**: resolved: [ADR-011](decisions/011-inline-vendor.md)
(the extraction mechanics), [ADR-013](decisions/
013-targeted-fork.md) (the ownership posture)
- **Priority**: high
@@ -97,14 +97,14 @@ sequence starts at the numbers in use. Format per
changes (BIND, `new_reply` publication); fork point = the tree as
of the extraction-review commit (`5b18d4d`), module `src/rfc1928/`;
differential testing against `/workspace/fast-socks5` (behavioral
reference — the cheap upstream-fix path closes; a future upstream
reference; the cheap upstream-fix path closes; a future upstream
fix is a hand-port, guarded by the suite).
- **Cross-references**: ADR-002, ADR-009, ADR-011, ADR-013
### OQ-SK-07: POC register
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-07)
- **Status**: resolved — the register is closed (process artifact,
- **Status**: resolved: the register is closed (process artifact,
not a design question)
- **Priority**: low
- **Resolution**: 2026-09-14 (Phase 0). All POCs resolved: #1, #2,
@@ -117,13 +117,13 @@ sequence starts at the numbers in use. Format per
### OQ-SK-09: Channel capacity for proxy workloads (the 256 default)
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-09)
- **Status**: resolved — [ADR-010](decisions/010-channel-capacity.md)
- **Status**: resolved: [ADR-010](decisions/010-channel-capacity.md)
- **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1). No wire change (rejected
permanently); assembly-layer config is the mechanism; the crate
docs carry the proxy-workload recommendation with the memory
arithmetic; in-channel port multiplexing rejected (the rationale +
the revisit trigger recorded — the additive escape is a new
the revisit trigger recorded; the additive escape is a new
multiplexed ALPN).
- **Cross-references**: OQ-SK2-08, ADR-010
@@ -132,7 +132,7 @@ sequence starts at the numbers in use. Format per
### OQ-SK-05: Target policy and egress scoping
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05)
- **Status**: resolved — [ADR-006](decisions/006-access-control.md)
- **Status**: resolved: [ADR-006](decisions/006-access-control.md)
(the gate posture), [ADR-012](decisions/012-target-policy.md)
(target policy + the virtual-subnet layer)
- **Priority**: high
@@ -147,14 +147,14 @@ sequence starts at the numbers in use. Format per
### OQ-SK-06: noq client support
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-06)
- **Status**: resolved — transferred to the alknet rewrite (the
- **Status**: resolved: transferred to the alknet rewrite (the
2026-09-14 scope decision)
- **Priority**: low
- **Resolution**: 2026-09-14 (Phase 0). The quinn/noq
`AsyncUdpSocket` client wrapper is alknet's ancestor-shaped
component (ADR-090 + the quinn-proxy POC live there); it neither
produces nor consumes channels. This crate's client story is the
channels-native sessions (POC-validated) — complete without it.
channels-native sessions (POC-validated), complete without it.
- **Cross-references**: ADR-002 (the base), phase-0 §Scope
### OQ-SK2-06: The sandboxed-wasm-consumer adapter story
@@ -166,27 +166,27 @@ sequence starts at the numbers in use. Format per
- **Priority**: low
- **Impacts**: how much wasm surface to exercise in the default
crate (a product/docs input). Blocks nothing in the protocol
design — the wasm-clean invariant (ADR-009) holds regardless.
- **Blocked on**: a concrete sandboxed-adapter use case — a TS/Python
design; the wasm-clean invariant (ADR-009) holds regardless.
- **Blocked on**: a concrete sandboxed-adapter use case: a TS/Python
(or other) adapter embedding the alksocks consumer session in a
wasm runtime. Tracker task id:
`architecture/oq-sk2-06-wasm-adapter-story`
(`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`).
- **Trigger ownership (checked 2026-09-28)**: the trigger is
genuinely external — no planned suite-internal surface (alktty,
genuinely external: no planned suite-internal surface (alktty,
alktunnels, alkcall) needs a sandboxed alksocks consumer, and the
`alknodes` assembly concept carries no named wasm-adapter plan.
Nothing suite-side waits on this OQ, and this OQ waits on nothing
suite-side (the adapter's substrate — the wasm-clean default crate
— is complete and continuously gate-verified; a downstream glue
suite-side (the adapter's substrate, the wasm-clean default crate,
is complete and continuously gate-verified; a downstream glue
crate can embed it today with zero further alksocks work). The
Schrödinger-bind check (both sides deferring to each other
forever) came up empty; the OQ stays an unowned external trigger
by verified default. Re-check the ownership claim if a product
surface names a sandboxed adapter — the amendment then converts
surface names a sandboxed adapter; the amendment then converts
this to an owned dependency.
- **Resolution**: Not yet decidable. The use case isn't concrete yet
— the adapter story is a product input whose shape would tell us
- **Resolution**: Not yet decidable. The use case isn't concrete yet:
the adapter story is a product input whose shape would tell us
whether the wasm consumer needs surface beyond the
channels-native session (e.g. bindings ergonomics). The
architecture decision (wasm-clean default, ADR-009) is already
@@ -200,7 +200,7 @@ sequence starts at the numbers in use. Format per
- **Status**: deferred(scope)
- **Door type**: two-way
- **Priority**: low
- **Impacts**: nothing in the crate — the seams already support the
- **Impacts**: nothing in the crate; the seams already support the
translation layer (the dial callback, ADR-004/ADR-012). This
tracks whether a *translation-table helper* should graduate into
the crate if a deployment materializes with concrete needs.
@@ -209,7 +209,7 @@ sequence starts at the numbers in use. Format per
at that point). Tracker task id:
`architecture/oq-sk2-07-virtual-subnet-helper`
(`tasks/architecture/oq-sk2-07-virtual-subnet-helper.md`).
- **Resolution**: Not yet decidable. The use case isn't concrete —
- **Resolution**: Not yet decidable. The use case isn't concrete:
the mechanism is assembly-layer policy today (ADR-012), and a
crate-level helper would need real deployment requirements to
shape (subnet sizing, per-identity tables, hub exposure).
@@ -236,7 +236,7 @@ sequence starts at the numbers in use. Format per
the pre-publish session's escalation (2026-09-28): the residual is
a real deployment concern (NAT is common), and the RFC's own drop
rule was unimplemented
- **Status**: partially resolved — option 2 landed in 0.1.0 (commit
- **Status**: partially resolved: option 2 landed in 0.1.0 (commit
`31fb11c`); option 3 deferred (below)
- **Door type**: two-way (additive; the current shape is RFC-faithful)
- **Priority**: low (post-fix: the common cases are covered)
@@ -244,23 +244,23 @@ sequence starts at the numbers in use. Format per
**IP** on the first parsed datagram and sends replies to the
**latest-seen port** from that IP (the NAT port-rebind case keeps
receiving); datagrams from other source IPs are dropped (RFC 1928's
MUST, previously unimplemented — the spoof primitive shrank from
MUST, previously unimplemented, so the spoof primitive shrank from
any-source to same-IP off-path). The **remaining residual**: a full
client IP change mid-association (a NAT *address* rebind) stops
receiving replies — the RFC's expected-peer posture (the RFC pins
receiving replies (the RFC's expected-peer posture: the RFC pins
the expected IP per association too).
- **Option 3 (the complete fix, deferred)**: per-ASSOCIATE relay
sockets spawned by the TCP door, with the expected peer IP derived
from the TCP control connection's remote address (the RFC's
"acquire from the SOCKS server" reading) — RFC-faithful end to end
"acquire from the SOCKS server" reading): RFC-faithful end to end
and kills the same-IP spoof residual too. Deferred because it
restructures the doors' client-correlation story (currently the
loose assembly-layer wiring seam) and needs its own ADR — not
loose assembly-layer wiring seam) and needs its own ADR; not
buildable speculatively without a deployment that actually spells
the wildcard bind *and* suffers address-level rebinds.
- **Blocked on**: a concrete wildcard-bind deployment with
address-level client mobility (QUIC connection migration is the
likely driver — the channels association survives transport
likely driver: the channels association survives transport
migration while a pinned IP does not).
- **Cross-references**: review 003 S-1, ADR-003 (the handle swap),
ADR-014 (the doors own the accept-path defense), OQ-SK-03
@@ -271,11 +271,11 @@ sequence starts at the numbers in use. Format per
|----|--------|------------|--------------|
| OQ-SK2-06 | deferred(scope) | a concrete sandboxed-adapter use case | `architecture/oq-sk2-06-wasm-adapter-story` |
| OQ-SK2-07 | deferred(scope) | a concrete virtual-subnet deployment | `architecture/oq-sk2-07-virtual-subnet-helper` |
| OQ-SK2-09 | partially resolved (option 3 deferred) | a wildcard-bind deployment with address-level client mobility | — (the posture is recorded here; a tracker task spawns with the deployment driver) |
| OQ-SK2-09 | partially resolved (option 3 deferred) | a wildcard-bind deployment with address-level client mobility | none (the posture is recorded here; a tracker task spawns with the deployment driver) |
## References
- Phase 0 ledger: `docs/research/phase-0.md` §Open Questions
(OQ-SK-01..09 — the full reasoning trails live there)
(OQ-SK-01..09; the full reasoning trails live there)
- The SDD process: `docs/sdd_process.md` (the OQ format; the
deferred-OQ blocker-task half)
+47 -47
View File
@@ -1,14 +1,14 @@
---
status: draft
last_updated: 2026-09-28 (OQ-SK2-09 indexed — the UDP door's peer-pin
posture; OQ-SK2-06's trigger ownership checked; ADR-014 indexed —
the idle-timeout posture; the targeted fork — ADR-013, the module is
`src/rfc1928/`; the vendor extraction amendment — no `net` feature —
2026-09-21; Phase 1 initial draft 2026-09-20 — the OQ-SK ledger
last_updated: 2026-09-28 (OQ-SK2-09 indexed: the UDP door's peer-pin
posture; OQ-SK2-06's trigger ownership checked; ADR-014 indexed: the
idle-timeout posture; the targeted fork, ADR-013, the module is
`src/rfc1928/`; the vendor extraction amendment, no `net` feature,
2026-09-21; Phase 1 initial draft 2026-09-20; the OQ-SK ledger
promoted into ADRs 001-012 and open-questions.md)
---
# alksocks — Overview
# alksocks: Overview
A SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall
channels: an arbitrary-egress proxy service that **never binds a port
@@ -16,7 +16,7 @@ unless explicitly configured to**, carrying the RFC conversation
inside a channels data channel like any other produced resource. The
sibling of alktty (`alk/tty`) and alktunnels (`alk/tunnel`) on the
alkcall substrate; where alktunnels realizes the `-L`/`-R` forwarding
flavors, alksocks realizes the `-D` flavor (dynamic / SOCKS5 proxy) —
flavors, alksocks realizes the `-D` flavor (dynamic / SOCKS5 proxy),
the one alktunnels explicitly deferred to composition. This document
covers the crate's purpose, the resource model in brief, dependencies,
the ALPN, feature gates, and the module map; component details are in
@@ -33,9 +33,9 @@ front doors:
`channels/socks5/sub` open op (registered via
`ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049). The establisher is a *session gate* (validates the
opener identity; the SOCKS5 target is unknown at open time); the
opener identity; the SOCKS5 target is unknown at open time); the
open handler runs the RFC 1928 state machine in-stream over the
channel's `BiStream` — the forked RFC 1928 core
channel's `BiStream`; the forked RFC 1928 core
([rfc1928.md](rfc1928.md)) drives negotiation, command read, and
replies; the dial/egress/accept seams (ADR-004) supply the I/O
planes; `alkcall::channels::pump_bidi` (ADR-050) is the data plane
@@ -46,14 +46,14 @@ front doors:
the associate session and the BIND reply#2 reader). *Wrapper-aware*
means a client that knows the in-band conventions (the sentinel
BND.ADDR, in-band frames) rather than a vanilla RFC 1928 client.
Optionally it
exposes the service locally: the **front door** — a real
`socks5://127.0.0.1:port` endpoint relaying each accepted
connection into a channel (the ssh `-D` local-exposure shape; the
path curl/browser/tun2proxy point at).
Optionally it
exposes the service locally through the **front door**, a real
`socks5://127.0.0.1:port` endpoint relaying each accepted
connection into a channel (the ssh `-D` local-exposure shape; the
path curl/browser/tun2proxy point at).
- **The optional producer local backend**: the producer may serve
from a real listener (feature-gated; the same door shape in the
other direction — vanilla clients → a locally-served SOCKS5
other direction: vanilla clients → a locally-served SOCKS5
resource).
The defining posture (the crate's reason to exist as a separate
@@ -76,7 +76,7 @@ binding decision belongs to the caller.
SOCKS5 leg; alktunnels carries the tunnel; tun2proxy closes the
loop.
- The substrate-agnostic protocol layer: the forked RFC 1928 core is
generic over `T: AsyncRead + AsyncWrite + Unpin` — a channels
generic over `T: AsyncRead + AsyncWrite + Unpin`; a channels
`BiStream` drops in directly (POC #1), a `TcpStream` drops in at
the local backends. Neither substrate leaks into the protocol
layer (the alktty `TtyBackend` / alktunnels pump-halves
@@ -89,7 +89,7 @@ A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
1. **Open (the establishment phase).** The consumer opens
`channels/socks5/sub` with trivial params (ADR-001). The
establisher is the session gate — it checks/records the opener
establisher is the session gate: it checks/records the opener
identity and does session-level policy; it never sees a target.
Failure is a typed `channel:open_failed` (alkcall ADR-049); no
phantom channel.
@@ -99,8 +99,8 @@ A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
dispatch: CONNECT/ASSOCIATE require `socks5:open`; BIND requires
`socks5:bind` (the command gate, ADR-006).
3. **Data plane.** CONNECT: the dial seam (ADR-004) produces boxed
halves; `pump_bidi` two-pumps channel ↔ target — raw
pass-through, 0 B overhead. ASSOCIATE: the reply's BND.ADDR is a
halves; `pump_bidi` two-pumps channel ↔ target (raw
pass-through, 0 B overhead). ASSOCIATE: the reply's BND.ADDR is a
pure RFC handle (the sentinel in-band); the datagram stage rides
`[len: u16 BE][addr + data]` frames (ADR-003, the handle swap) to
the egress seam. BIND: reply#1 → quiet wait → exactly one accept
@@ -119,34 +119,34 @@ protocol layer; crate-level vocabulary is producer/consumer.
## Dependencies
```
alksocks (default — wasm-clean)
alksocks (default: wasm-clean)
├── alkcall 0.10.1 (core types: Connection, ProtocolHandler, BiStream,
│ AuthContext, Identity, AccessControl, OwnershipProvider;
│ channels: ChannelCore/ChannelClient,
│ register_openable_with_establisher, pump_bidi,
│ Establishment/ChannelPlan + reply projection,
│ ServingConfig identity seam — CF-005/006)
│ the ServingConfig identity seam (CF-005/006))
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── serde/serde_json (params schema), thiserror (error surface), tracing
└── (no backend deps — sockets live in feature-gated modules)
└── (no backend deps: sockets live in feature-gated modules)
alksocks (local feature) — non-wasm by design
alksocks (local feature): non-wasm by design
└── adds: tokio/net + socket2 (the feature's own socket providers) and
the first-party local backends + front doors
alksocks (composed feature)
└── adds: alktunnels 0.4.1 (the direct op egress; the bind-first
listen accept; ADR-008's composed providers) — its default
listen accept; ADR-008's composed providers), whose default
crate is wasm-clean; the composed providers use only the
ungated channels surface (verify with the wasm check)
```
Same posture as alktty/alktunnels: the default crate is
protocol-only and wasm-clean — the RFC 1928 core is an *extraction*
protocol-only and wasm-clean; the RFC 1928 core is an *extraction*
of fast-socks5's `T`-generic subset, containing zero kernel-net code,
so there is no `net` feature at all (ADR-011 §Amendment);
substrate I/O rides `local` alone (ADR-009 §Amendment).
`fast-socks5` is NOT a Cargo dependency — it was extracted in-tree
`fast-socks5` is NOT a Cargo dependency: it was extracted in-tree
and forked (ADR-013); `/workspace/fast-socks5` stays as the
differential-testing behavioral reference.
@@ -154,44 +154,44 @@ differential-testing behavioral reference.
| Feature | Contents | Wasm |
|---------|----------|------|
| *(default)* | the RFC 1928 core (`src/rfc1928/`, ADR-013 — codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types — protocol only; zero kernel-net code, wasm-clean by construction | yes |
| *(default)* | the RFC 1928 core (`src/rfc1928/`, ADR-013: codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types; protocol only, zero kernel-net code, wasm-clean by construction | yes |
| `local` | the crate's first-party local backends: TCP dial, UDP egress socket, BIND `TcpListener` accept, the front doors (consumer + producer local shapes); brings `tokio/net` + `socket2` into the graph | no (build error on wasm, the alktty posture) |
| `composed` | the composed providers over alktunnels 0.4.1: direct-op egress (lazy destination→tunnel table), bind-first listen accept — adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface — verify with the wasm check at implementation) |
| `composed` | the composed providers over alktunnels 0.4.1: direct-op egress (lazy destination→tunnel table), bind-first listen accept; adds the alktunnels dep | yes (alktunnels' default crate is wasm-clean; the composed providers use only its ungated channels surface: verify with the wasm check at implementation) |
## Module Map
```
src/
lib.rs — re-exports; crate docs
rfc1928/ — the RFC 1928 core: the targeted fork of
lib.rs : re-exports; crate docs
rfc1928/ : the RFC 1928 core: the targeted fork of
fast-socks5's T-generic subset (ADR-013;
extraction per ADR-011; fork point noted
in-tree): consts, codecs, errors, typestate,
client use_stream path; the fork's two
carried changes (BIND reply surface,
new_reply publication); no kernel-net code
params.rs — the trivial params schema + SOCKS5_OPEN_SCOPE/
params.rs : the trivial params schema + SOCKS5_OPEN_SCOPE/
SOCKS5_BIND_SCOPE constants (ADR-001/006)
wire.rs — the ASSOCIATE datagram frame codec, re-implemented
wire.rs : the ASSOCIATE datagram frame codec, re-implemented
in first-party code shaped on alktunnels' length
codec (ADR-003; framing is wire-layer, applied
exactly once)
producer.rs — the open op spec (scope-gated), the session-gate
producer.rs : the open op spec (scope-gated), the session-gate
establisher, the command-dispatch pump handler
(typestate driving + the three seams), teardown
consumer.rs — the channels-native session types (connect/associate/
consumer.rs : the channels-native session types (connect/associate/
bind reply#2 reader), teardown ownership
door.rs — the front doors (feature `local`): the raw-pipe
door.rs : the front doors (feature `local`): the raw-pipe
bridge + the RFC 1929 cred-bearing door + the
credential→identity mapping (ADR-005); the UDP
front door's RFC codec boundary (ADR-003)
egress.rs — the egress/accept seam types (DialFn/EgressFactory/
BindAcceptFactory contracts — ADR-004)
local/ — feature `local`: the socket-backed provider
egress.rs : the egress/accept seam types (DialFn/EgressFactory/
BindAcceptFactory contracts, ADR-004)
local/ : feature `local`: the socket-backed provider
constructors (dial/egress/accept)
composed/ — feature `composed`: the alktunnels-composed
composed/ : feature `composed`: the alktunnels-composed
providers (direct-op egress, bind-first accept)
error.rs — SocksError + the typed open-error surface
error.rs : SocksError + the typed open-error surface
(alkcall ADR-049 §4)
```
@@ -205,7 +205,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}` — the RFC conversation is the data plane |
| [001](decisions/001-alpn-and-open-op.md) | ALPN + open op + trivial params | `alk/socks5`, `channels/socks5/sub`, `{}`: the RFC conversation is the data plane |
| [002](decisions/002-protocol-base.md) | Protocol base | fast-socks5's explicit typestate API (extracted + forked, `src/rfc1928/`); faithful reply-code mapping |
| [003](decisions/003-udp-handle-swap.md) | The handle swap | one channel per association; `{addr, data}` frames in-band; RFC codec at front doors only |
| [004](decisions/004-dial-callback-shape.md) | The dial-callback shape | function-not-trait at three seams (dial/egress/accept); ownership rides the handle |
@@ -213,7 +213,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [006](decisions/006-access-control.md) | Access-control posture | `socks5:open` gate + `socks5:bind` command gate; target policy = the dialer |
| [007](decisions/007-bind-in-base.md) | BIND in the base crate | phase model extended (reply#1 → quiet wait → accept → reply#2 → pass-through); additive |
| [008](decisions/008-provider-defaults.md) | Provider defaults | local-socket + composed (direct op) providers, both feature-gated; no implicit dialer |
| [009](decisions/009-feature-gates.md) | Feature gates | `local` + `composed`; no `net` feature (the RFC 1928 core is wasm-clean by construction — ADR-011 §Amendment); default crate wasm-clean |
| [009](decisions/009-feature-gates.md) | Feature gates | `local` + `composed`; no `net` feature (the RFC 1928 core is wasm-clean by construction; ADR-011 §Amendment); default crate wasm-clean |
| [010](decisions/010-channel-capacity.md) | Channel capacity | no wire change; assembly-layer config + the documented proxy-workload recommendation |
| [011](decisions/011-inline-vendor.md) | The extraction | the `T`-generic subset copied in-tree; two carried changes (BIND, `new_reply`); v1.0.0 noted (superseded as a posture by ADR-013) |
| [013](decisions/013-targeted-fork.md) | The targeted fork | the extracted region is owned; `src/rfc1928/`; fork point `5b18d4d`; hand-port replaces re-vendor |
@@ -225,13 +225,13 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this document:
- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope)
— a product input, nothing waits on it; the trigger is external by
- **OQ-SK2-06**: sandboxed-wasm-consumer adapter story (deferred(scope);
a product input, nothing waits on it; the trigger is external by
verified default)
- **OQ-SK2-08**: recommended proxy-workload cap figure (open — a docs
- **OQ-SK2-08**: recommended proxy-workload cap figure (open, a docs
tuning note; ADR-010 pins the posture)
- **OQ-SK2-09**: the UDP door's peer-pin posture (option 2 — the
same-IP lock + reply-port refresh — landed in 0.1.0; option 3, the
- **OQ-SK2-09**: the UDP door's peer-pin posture (option 2, the
same-IP lock + reply-port refresh, landed in 0.1.0; option 3, the
control-conn-derived expected peer, deferred on a concrete
wildcard-bind deployment)
+37 -37
View File
@@ -1,10 +1,10 @@
---
status: draft
last_updated: 2026-09-24 (the open handler's phase lifetimes carry the
decided timeout posture — ADR-014; Phase 1 initial draft 2026-09-20)
decided timeout posture, ADR-014; Phase 1 initial draft 2026-09-20)
---
# alksocks — The Producer Half
# alksocks: The Producer Half
The producer exposes the SOCKS5 service as a produced, ACL-scoped
resource: it registers the `channels/socks5/sub` open op (ADR-001)
@@ -22,33 +22,33 @@ Registration uses `ChannelCore::register_openable_with_establisher`
- the op spec `channels/socks5/sub` on the `alk/socks5` ALPN,
`required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006);
- params schema `{type: object}`, `additionalProperties: false`
(trivial — ADR-001);
(trivial, ADR-001);
- the establisher: validates/records the opener identity (the CF-005/
CF-006 seam — ADR-005) and applies session-level policy. It runs
`Establishment::default()`-shaped (no plan payload) — the dial
CF-006 seam, ADR-005) and applies session-level policy. It runs
`Establishment::default()`-shaped (no plan payload): the dial
happens post-establishment, so there is nothing to thread
establisher→handler (POC #1's wire note).
Failure semantics: a scope-less identity is `FORBIDDEN` at the
registry ACL gate; establisher refusal is a typed
`channel:open_failed` (`handler_error`-class or the mapped reason) —
`channel:open_failed` (`handler_error`-class or the mapped reason),
never a phantom channel. Session-level refusal is channels-level;
target-level refusal is in-stream RFC (below).
**Harness wiring requirement** (POC #1, POC #2 re-witnessed): the
accept-side install hook must call `channel0_conn.set_identity
(resolved)` before building the `CallConnection` — the transport
(resolved)` before building the `CallConnection`; the transport
identity does not flow to channel-0 dispatch automatically. A real
mTLS transport resolves the peer here. Without it, scope-gated opens
fail closed with `FORBIDDEN "authentication required"` — correct
fail closed with `FORBIDDEN "authentication required"`: correct
behavior, but the wiring requirement is implicit.
## The Open Handler: RFC Conversation + Command Dispatch
The open handler runs the forked typestate over the channel's
`BiStream` (`T`-generic — the POC #1 fit). Every phase is
`BiStream` (`T`-generic, the POC #1 fit). Every phase is
teardown-governed, never idle-expired (ADR-014): the handler parks on
the handshake / stage reads like any other phase — no protocol-layer
the handshake / stage reads like any other phase; no protocol-layer
inactivity timer exists (the alkcall 10s establishment bound covers
only the open-op round trip, ADR-049 §2); a silent peer holds the
task + channel entry until the channels-level sentinels arrive, and
@@ -57,28 +57,28 @@ wants an idle bound enforces it outside the protocol layer (an
accept-path bound at a door; a caller-side timeout around a
substrate-free `serve_socks5_over`):
1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)` —
1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)`;
NoAuth as a no-op (ADR-005: the producer side is identity-only;
authorization already happened at the open gate).
2. **Command read**: `read_command()` returns `(cmd, target)` —
the dispatch point:
2. **Command read**: `read_command()` returns `(cmd, target)`, the
dispatch point:
- **CONNECT** → the command gate (`socks5:open` already checked
at the open gate — no extra check) → invoke `DialFn(target)`
at the open gate, no extra check) → invoke `DialFn(target)`
(ADR-004). Success: `reply_success` then the pass-through data
plane. Refusal: `reply_error` with the mapped RFC code
(dial-failure → `ConnectionRefused` 0x05; scope-class refusal
→ `ConnectionNotAllowed` 0x02) — no phantom data plane.
→ `ConnectionNotAllowed` 0x02); no phantom data plane.
- **TCP BIND** → **the command gate**: require `socks5:bind`
from the per-call opener identity (ADR-006) — an identity
from the per-call opener identity (ADR-006); an identity
without it gets `ConnectionNotAllowed` (0x02) while CONNECT
still serves the same identity (POC #5 test 4). Then the BIND
shape (ADR-007, below).
- **UDP ASSOCIATE** → `reply_success(sentinel)` (the
RFC-compatibility handle, ADR-003) then the datagram stage.
3. **Data plane** per command (wire.md §The Data Plane):
- CONNECT: `pump_bidi` inline inside the handler task (the
returned `JoinHandle` tracks the data-plane lifetime — R-02,
alkcall review 007,
- CONNECT: `pump_bidi` inline inside the handler task (the
returned `JoinHandle` tracks the data-plane lifetime: R-02,
alkcall review 007,
`/workspace/@alkdev/alkcall/docs/reviews/
007-establishment-follow-ups-review.md`; early return =
teardown-at-birth).
@@ -101,7 +101,7 @@ substrate-free `serve_socks5_over`):
Contracts (POC-pinned, load-bearing):
- **Ownership rides the handle structurally** — handle payloads own
- **Ownership rides the handle structurally**: handle payloads own
their channel sessions/sockets; the compiler should enforce it (no
`_` bindings of owning values; the third teardown-at-birth
witness).
@@ -109,21 +109,21 @@ Contracts (POC-pinned, load-bearing):
frames once.
- **The accept future owns its resources end-to-end**; the quiet
wait's `select!` drop IS the client-gone teardown.
- **No trait** — the re-evaluation threshold is three real
- **No trait**: the re-evaluation threshold is three real
implementations as trait impls (the alktunnels ADR-004 posture).
## Scope Gating (ADR-006)
- `SOCKS5_OPEN_SCOPE = "socks5:open"` — the open op's
- `SOCKS5_OPEN_SCOPE = "socks5:open"`: the open op's
`required_scopes`; egress capability (CONNECT + ASSOCIATE).
- `SOCKS5_BIND_SCOPE = "socks5:bind"` — the BIND command gate,
- `SOCKS5_BIND_SCOPE = "socks5:bind"`: the BIND command gate,
checked at command dispatch from the per-call opener identity;
ingress capability, never implied by `socks5:open`.
- Target policy: the dialer/egress/accept callbacks refuse (RFC
reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever
ACL governs the socks5 resource governs everything reachable
through it, plus the callbacks' policy.
- Optional: consult `OwnershipProvider` for resource ownership — a
- Optional: consult `OwnershipProvider` for resource ownership, a
registration-time choice.
## Producer-Side Local Backend (feature `local`, optional)
@@ -131,40 +131,40 @@ Contracts (POC-pinned, load-bearing):
The optional shape where the producer serves from a real listener:
a feature-gated backend binds `socks5://127.0.0.1:port` (or any
address) and hands each accepted TCP connection to the landed
substrate-free entry `Socks5Producer::serve_socks5_over` directly —
substrate-free entry `Socks5Producer::serve_socks5_over` directly:
the RFC conversation runs on the accepted socket (the NoAuth
in-stream shape: greeting, command, reply, data plane, all on the
accepted socket), and the seams dial/egress/accept locally. The same
door shape in reverse: this is a *front door for the producer side*
— vanilla clients reach a locally-served SOCKS5 resource through it
door shape in reverse: this is a *front door for the producer side*;
vanilla clients reach a locally-served SOCKS5 resource through it
(`src/local/producer_door.rs`, the `ProducerDoor`/builder/guard
surface). A cred-bearing variant (RFC 1929) would wrap
`accept_password_auth` before `serve_socks5_over` — a documented
`accept_password_auth` before `serve_socks5_over`, a documented
follow-up, not landed.
The door's accept path owns the idle defense the protocol layer does
not provide (ADR-014): an accepted connection that sends no first byte
within a configured window is closed door-side (an accepted socket is
not a channel — the cap regime does not bound it), plus the
not a channel; the cap regime does not bound it), plus the
max-concurrent-sessions cap (an accept beyond the bound is refused
door-side, no session spawned) — the two constructor knobs.
door-side, no session spawned); these are the two constructor knobs.
The producer-side local *backend* for the I/O planes (the
local-socket providers — TCP dial, UDP egress, BIND listener) is
local-socket providers: TCP dial, UDP egress, BIND listener) is
ADR-008's variant set, also feature `local`. The BIND command gate
on this path is the builder's `bind_policy` (there is no open-op
identity on an accepted socket — the accepted socket is the whole
caller; the pump handler's identity-derived gate does not apply).
identity on an accepted socket; the accepted socket is the whole
caller, and the pump handler's identity-derived gate does not apply).
## Wiring (Assembly Layer)
```rust
let producer = Socks5Producer::builder()
.dial(dial_fn) // DialFn — required for CONNECT
.egress(egress_factory) // EgressFactory — required for ASSOCIATE
.accept(bind_accept_factory) // BindAcceptFactory — required for BIND
.dial(dial_fn) // DialFn: required for CONNECT
.egress(egress_factory) // EgressFactory: required for ASSOCIATE
.accept(bind_accept_factory) // BindAcceptFactory: required for BIND
.build();
// register(&core, &registry, auth, witness) — the establisher rides
// register(&core, &registry, auth, witness): the establisher rides
// the registration (the optional witness is the CF-006 test probe).
producer.register(&core, &registry, auth, None)?;
```
+44 -44
View File
@@ -1,22 +1,22 @@
---
status: draft
last_updated: 2026-09-24 (the keep-or-cut sweep + the refusal
vocabulary variant — the fork's owned post-fork changes section;
review 002 N-1/N-4; the targeted fork 2026-09-22 — ADR-013
supersedes ADR-011's maintenance posture; the module is
vocabulary variant: the fork's owned post-fork changes section;
review 002 N-1/N-4; the targeted fork 2026-09-22, with ADR-013
superseding ADR-011's maintenance posture; the module is
`src/rfc1928/`, the fork point is `5b18d4d`, the lint-allow posture
dissolves; extraction review F-1: `ReplyError::from_u8`'s panic arm
mapped to `GeneralFailure`; Phase 1 initial draft 2026-09-20)
---
# alksocks — The RFC 1928 Core (the targeted fork)
# alksocks: The RFC 1928 Core (the targeted fork)
The crate owns its RFC 1928 protocol code in-tree: the **targeted
fork** of fast-socks5's `T`-generic subset ([ADR-013](decisions/
013-targeted-fork.md); the extraction mechanics in
[ADR-011](decisions/011-inline-vendor.md); OQ-SK-04's resolution).
This document is the fork's scope — what was extracted, what was
not, the deliberate modifications — and the maintenance posture.
This document is the fork's scope (what was extracted, what was
not, the deliberate modifications) and the maintenance posture.
The protocol base decision is ADR-002; the feature gating is
ADR-009.
@@ -30,25 +30,25 @@ review-vendor-extraction.md`) is the recorded fidelity gate.
The code arrived by **extraction** (ADR-011 §Amendment): copy *only*
the `T`-generic inventory from fast-socks5 v1.0.0 into the tree; the
kernel-net handlers are **not copied** — they stay in
kernel-net handlers are **not copied**; they stay in
`/workspace/fast-socks5` (the read-only reference checkout).
Concretely: POC #4's cfg map (its five-file inventory of what is
kernel-net-gated) is the base of the extraction recipe — copy what
kernel-net-gated) is the base of the extraction recipe: copy what
POC #4 left *ungated*, minus the two recorded exclusions (the
`socks4` module and the legacy `Config`/`Authentication` layer —
see the not-forked inventory). The extraction contains zero
`socks4` module and the legacy `Config`/`Authentication` layer; see
the not-forked inventory). The extraction contains zero
kernel-net code, so it is wasm-clean by construction; there is no
in-tree `net`-shaped feature gate.
Post-extraction (ADR-013), the region is **owned code**: project
conventions apply unconditionally, there is no upstream tree to
stay reconcilable with, and the module is `src/rfc1928/` — named
stay reconcilable with, and the module is `src/rfc1928/`, named
for what it is (the RFC 1928 wire vocabulary and state machines),
not for an ownership relation it no longer has.
## What was Extracted (now the forked core)
From fast-socks5 **v1.0.0** (MIT, third-party upstream —
From fast-socks5 **v1.0.0** (MIT, third-party upstream;
`/workspace/fast-socks5` is the read-only reference checkout; the
fork point is noted in the module docs and the license notice):
@@ -58,34 +58,34 @@ fork point is noted in the module docs and the license notice):
| The auth traits (`AuthMethod<T>`/`AuthMethodSuccessState<T>`) | the negotiation machinery (the crate uses `accept_no_auth` in-band; the traits stay) | `T`-generic |
| `transfer` + `wait_on_tcp` | the typestate's own two-pump seam (the crate's data plane is `pump_bidi`; `transfer` is the interception-point shape) | `T`-generic |
| The codecs: `new_udp_header`/`parse_udp_request`, `TargetAddr`/`to_be_bytes`/`read_address`, `ReplyError`/`SocksError`/`SocksServerError` | the RFC wire vocabulary everywhere (front doors, associate frames, reply#2 reader) | pure; `new_reply` published in-tree (fork change) |
| The client types: `Socks5Stream<S>::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit) — the first-party associate session in `consumer.rs` was informed by this shape | `T`-generic |
| The client types: `Socks5Stream<S>::use_stream` + the `AsyncRead/AsyncWrite` impls | the consumer half's stream session (ADR-002; POC #1's fit); the first-party associate session in `consumer.rs` was informed by this shape | `T`-generic |
| The non-net test modules (where extractable as unit tests) | RFC edge-case coverage rides in-tree | adjusted to the crate's test layout |
## What was NOT Forked (the cut, re-read as the hand-port map)
These stay in `/workspace/fast-socks5` (the differential-testing
reference); the table below records the boundary — under the fork
reference); the table below records the boundary; under the fork
posture it is the **hand-port map**: what to port from the
reference checkout if a future need surfaces (port it, adapt it to
the crate's conventions, let the differential suite guard it —
the crate's conventions, let the differential suite guard it;
ADR-013). Rows 1–5 are POC #4's five-file cfg map (what POC #4
gated is what was not extracted); the socks4 row and the legacy
`Config`/`Authentication` layer are the recipe's two additions —
`Config`/`Authentication` layer are the recipe's two additions:
the socks4 module is out of RFC 1928 scope, and the legacy auth
layer was left ungated by POC #4 (it compiles without net) but no
extraction consumer reached it. None of it is forked:
| Upstream file | Region NOT in `src/rfc1928/` |
|---|---|
| `src/server.rs` | `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, the legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls), `DnsResolveHelper` trait + impl, `handle_udp_*`/`transfer_udp`, the net-only test module — plus the legacy `Config<A>` + `Authentication` trait + impls (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, `authenticate_callback`), which POC #4 left ungated but which only this cut region consumes (rows 1–5 are POC #4's gate map; the `Config`/`Authentication` layer and the socks4 row are this recipe's two additions) |
| `src/server.rs` | `run_tcp_proxy`, `run_udp_proxy`/`run_udp_proxy_custom`, `udp_bind_random_port`, the legacy `Socks5Server`/`Incoming`/`Socks5Socket` (struct + impls), `DnsResolveHelper` trait + impl, `handle_udp_*`/`transfer_udp`, the net-only test module; plus the legacy `Config<A>` + `Authentication` trait + impls (`SimpleUserPassword`/`DenyAuthentication`/`AcceptAuthentication`, `authenticate_callback`), which POC #4 left ungated but which only this cut region consumes (rows 1–5 are POC #4's gate map; the `Config`/`Authentication` layer and the socks4 row are this recipe's two additions) |
| `src/client.rs` | `Socks5Datagram` (struct + impl), the `Socks5Stream<TcpStream>` convenience impl (`connect`/`connect_with_password`/`connect_raw`) |
| `src/util/stream.rs` | `tcp_connect*` fns (the `read_exact!`/`ready!` macros + `ConnectError` stay — `ConnectError` kept-with-note, review 001 C-1: constructed nowhere in-tree, kept as the hand-port map's companion for a future kernel-net port) |
| `src/util/stream.rs` | `tcp_connect*` fns (the `read_exact!`/`ready!` macros + `ConnectError` stay; `ConnectError` kept-with-note, review 001 C-1: constructed nowhere in-tree, kept as the hand-port map's companion for a future kernel-net port) |
| `src/util/target_addr.rs` | the `resolve_dns` method (the codec stays) |
| `src/lib.rs` | the net-heavy test module (consts/errors/UDP-header codec stay) |
| `src/socks4/` | the whole module (upstream's separate default-off `socks4` cargo feature; out of RFC 1928 scope) |
**Why none of it is needed in-tree** (ADR-011 §Amendment): no crate
consumer routes through the kernel-net region — the producer drives
consumer routes through the kernel-net region; the producer drives
the typestate directly (the ASSOCIATE path is the POC #2
typestate-driving shape; `run_udp_proxy_custom` is unusable on
channels regardless), the consumer sessions use `use_stream` plus
@@ -99,16 +99,16 @@ reference checkout, as an additive decision (ADR-013).
## The Deliberate Modifications (the fork's diff vs upstream v1.0.0)
The fork's diff vs upstream v1.0.0 is deliberate and bounded —
The fork's diff vs upstream v1.0.0 is deliberate and bounded:
**two changes touch the forked code**; the other two of the original
four dissolve with the kernel-net region:
1. **BIND** — absent upstream (every handler refuses
1. **BIND**: absent upstream (every handler refuses
`Socks5Command::TCPBind`). The crate drives the typestate itself
for the BIND shape (ADR-007); the fork carries the shape in-tree
(`reply_success`'s `sock_addr` as the reply#1 carrier; the
reply#2 writer using the published `new_reply`).
2. **`new_reply` publication** — the reply encoder becomes public
2. **`new_reply` publication**: the reply encoder becomes public
(a `reply(code, addr)`-shaped constructor) so the BIND reply#2
writer doesn't re-encode the 10/22-byte reply form.
@@ -116,10 +116,10 @@ Dissolved: the `net` feature gate (no net code in-tree) and the
`run_udp_proxy_custom` bind seam (the handler isn't forked).
Plus the **owned-code post-fork changes** (the fork is first-class
code — extend this list, never replace it; convention 17):
code; extend this list, never replace it; convention 17):
- **The keep-or-cut sweep** (review 002 N-1): the producer-replaced
machinery is cut from the typestate — the `auth_method_enums!`
machinery is cut from the typestate: the `auth_method_enums!`
macro (upstream's enum generator) with its generated
`StandardAuthentication` enum + `allow_no_auth`,
`skip_auth_this_is_not_rfc_compliant`, and `CheckResult`'s
@@ -130,11 +130,11 @@ code — extend this list, never replace it; convention 17):
typestate's). Kept with note (the hand-port map, ADR-013):
`ConnectError` (constructed nowhere in-tree; the dial-error →
reply-code mapping a future kernel-net port reuses) and
`transfer`/`wait_on_tcp` (differential-suite-consumed — their
`transfer`/`wait_on_tcp` (differential-suite-consumed; their
semantics stay pinned there). The upstream `Bug` variant is
likewise kept, its display intact.
- **The refusal vocabulary variant** (review 002 N-4):
`SocksServerError::Refused(&'static str)` — the honest
`SocksServerError::Refused(&'static str)`: the honest
log/observability vocabulary for the producer's in-stream policy
refusals (the command gate + the four seam-refusal sites). Upstream
has no such variant; it is an owned addition, never reaching the
@@ -144,7 +144,7 @@ code — extend this list, never replace it; convention 17):
Plus the provenance notes: the fork point (upstream v1.0.0,
extraction `57b107b..5b18d4d`, fork at the review commit) and the
license notice (MIT, extended — ADR-013's license mechanics).
license notice (MIT, extended; ADR-013's license mechanics).
Plus the **extraction-time adjustments** (mechanical edits applied
at extraction, before the fork):
@@ -154,8 +154,8 @@ at extraction, before the fork):
out of RFC 1928 scope). `SocksError`'s `ReplySocks4Error` variant
(also `#[cfg(feature = "socks4")]` upstream) goes with it.
- The legacy `Config<A>`/`Authentication` auth layer is out
(ungated upstream, but consumed only by the cut legacy/net region
— `Socks5Server`/`Socks5Socket`/the `run_*` handlers; the
(ungated upstream, but consumed only by the cut legacy/net region:
`Socks5Server`/`Socks5Socket`/the `run_*` handlers; the
typestate's own `accept_no_auth`/`accept_password_auth` are the
crate's in-band auth surface). With it go `async-trait` (its four
upstream uses are all on this layer), the example auth impls
@@ -165,14 +165,14 @@ at extraction, before the fork):
- Upstream `log` calls in the extracted region are dropped or
re-pointed at the crate's `tracing`.
- Upstream `anyhow` error wrapping (`Context` / `Other(#[from]
anyhow::Error)`) is dropped — the crate's error surface is
anyhow::Error)`) is dropped; the crate's error surface is
`thiserror`-typed; the forked `SocksError`/`SocksServerError`
stay the wire-level vocabulary.
- The panic paths in the extracted region are translated (AGENTS.md
convention 2 — no panics in library code): `ReplyError::from_u8`'s
convention 2: no panics in library code): `ReplyError::from_u8`'s
upstream `unreachable!()` arm for codes outside the RFC table
maps unassigned codes (`0x09`–`0xFF`, RFC 1928 §6) to
`GeneralFailure` instead — the value comes off the wire
`GeneralFailure` instead; the value comes off the wire
(`Socks5Stream::read_request_reply` feeds the raw reply byte
there), so a misbehaving peer surfaces as a typed error, never a
panic (the extraction review's F-1 correction).
@@ -186,28 +186,28 @@ at extraction, before the fork):
a request header would corrupt the conversation; byte-identical on
a healthy stream). `reply_success`/`reply_error` were likewise
converted at the fork review (001 F-1): replies are small,
single-shot frames that must be all-or-nothing — a partial write
single-shot frames that must be all-or-nothing; a partial write
desynchronizes the conversation silently. (The
`clippy::unused_io_amount` lint — deny-by-default — does flag bare
`clippy::unused_io_amount` lint, deny-by-default, does flag bare
`.write()` on `AsyncWriteExt`, but the `err_when`-chaining idiom
type-infers the result through a generic blanket impl and suppresses
it; the linter is not a substitute for the conversion.)
Plus the **post-fork convention fixes** (ADR-013 — the lint-allow
Plus the **post-fork convention fixes** (ADR-013; the lint-allow
posture dissolved, project conventions applied): the module-level
`#[allow]`s removed and the suppressed smells fixed
(`u8::max_value()` → `u8::MAX`, needless lifetimes elided,
`match_like_matches_macro` applied, `io::Error::new(Other)` →
`io::Error::other`, `wrong_self_convention` fixed on `AuthMethod::
new`, the `ToSocketAddrs` error string re-worded — it no longer
names the cut `resolve_dns()` — and the `#[allow(dead_code)]` on
new`, and the `ToSocketAddrs` error string re-worded (it no longer
names the cut `resolve_dns()`); the `#[allow(dead_code)]` on
`AuthenticationMethod` swept). These are one-time, recorded for
completeness; the fork is first-class code thereafter.
## Maintenance Posture
- **The fork is first-class code**: it co-evolves with the wrapper
under the project conventions, unconditionally — no second
under the project conventions, unconditionally: no second
instruction set, no adjustment-list apparatus, no verbatim
preservation (ADR-013).
- **Differential testing**: `/workspace/fast-socks5` stays the
@@ -215,19 +215,19 @@ completeness; the fork is first-class code thereafter.
domain addressing, fragmentation). The crate's test suite covers
the edge cases the forked code must keep passing; when in doubt,
diff behavior against the reference checkout. The kernel-net
region lives there too — the reference checkout is the only place
region lives there too; the reference checkout is the only place
the full upstream surface exists.
- **Upstream posture**: fast-socks5 is third-party; nothing waits
on it, nothing is absorbed from it. A future upstream fix that
matters is **hand-ported** against the reference checkout and
guarded by the differential suite (ADR-013's honest cost —
guarded by the differential suite (ADR-013's honest cost,
assessed low-risk: a quiet upstream, a well-trodden RFC surface).
The offering-a-PR-upstream courtesy item is retired with the
vendor posture (the net gate dissolved with the kernel-net
region; there is no in-tree need to offer).
- **Provenance is permanent**: the module docs and the bundled
license notice always name the origin (fast-socks5 v1.0.0, the
extraction range, the fork point) — orthogonal to ownership, kept
extraction range, the fork point), orthogonal to ownership, kept
so future readers can always see where the code came from
(ADR-013).
- **No second codec, ever**: the forked codecs are the only RFC
@@ -252,10 +252,10 @@ None currently blocking this document.
- OQ-SK-04 (the resolved posture), OQ-SK-08 (BIND + `new_reply`),
OQ-SK-03 (the bind seam)
- POC #4 (`poc-connect-wasm-findings.md` — the measured gate), POC
#5 (`poc-bind-findings.md` — the BIND driving + the `new_reply`
- POC #4 (`poc-connect-wasm-findings.md`: the measured gate), POC
#5 (`poc-bind-findings.md`: the BIND driving + the `new_reply`
finding), POC #2 (the typestate-driving shape for ASSOCIATE)
- `tasks/implementation/review-vendor-extraction.md` (the fidelity
gate; F-1)
- `/workspace/fast-socks5` (v1.0.0, MIT — the behavioral reference);
- `/workspace/fast-socks5` (v1.0.0, MIT, the behavioral reference);
`docs/research/iroh-socks5-eval.md` (why not the alternative base)
+41 -41
View File
@@ -1,19 +1,19 @@
---
status: draft
last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided —
last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided:
teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1
initial draft 2026-09-20 — the POC-validated wire shapes promoted
initial draft 2026-09-20; the POC-validated wire shapes promoted
into the normative spec)
---
# alksocks — Wire Format
# alksocks: Wire Format
The `alk/socks5` wire surface. Two layers: the **open op** (JSON
params on the channels call plane — trivial here, ADR-001) and the
params on the channels call plane; trivial here, ADR-001) and the
**data plane** (the RFC 1928 conversation + the data streams inside
the channel `BiStream`). The channels layer strips its own 8-byte
header transparently (alknet ADR-093 / alkcall ADR-035); alksocks
owns everything inside the `BiStream` — which is, per the phase
owns everything inside the `BiStream`, which is, per the phase
model (ADR-003/ADR-007), the RFC conversation itself plus raw
pass-through, with exactly one framed stage (ASSOCIATE datagrams).
@@ -23,14 +23,14 @@ Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.md),
[014](decisions/014-idle-timeout-posture.md)); this document is the normative
WHAT. RFC 1928's own wire rules (method negotiation, command encoding,
reply encoding, the RFC UDP datagram header) are specified by the RFC
itself — this document covers the channels-specific surface and the
itself; this document covers the channels-specific surface and the
phase model's stage boundaries.
## The Open Op (`channels/socks5/sub`)
`Sub`-typed operation (alkcall ADR-047): the reply carries
`{ "channel_id": <u32> }` once; everything else — the entire RFC 1928
conversation and the data plane — flows on the channel's `BiStream`,
`{ "channel_id": <u32> }` once; everything else (the entire RFC 1928
conversation and the data plane) flows on the channel's `BiStream`,
never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
### Input (params)
@@ -41,21 +41,21 @@ never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
- Trivial: the SOCKS5 target is unknown at open time (it arrives
in-stream post-negotiation); the establisher is a session gate, not
a dial (POC #1's structural finding — ADR-004).
a dial (POC #1's structural finding, ADR-004).
- Schema: `{type: object}` with `additionalProperties: false`. Empty
params stay valid forever; a future per-session option would be an
additive schema extension (ADR-001).
### Reply / errors
- Success: `{ "channel_id": <u32> }` — the establishment phase (the
- Success: `{ "channel_id": <u32> }`: the establishment phase (the
session gate) succeeded, the open handler is running.
- Failure: a typed `channel:open_failed` call error with
`details: { reason, message }` — `reason ∈ dial_failed` /
`details: { reason, message }` where `reason ∈ dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
`timeout` (alkcall ADR-049 §3). The registry's ACL gate runs
before the wrapper (`FORBIDDEN` for a scope-less identity —
POC #1). **A failed open never returns a `channel_id`** — no
before the wrapper (`FORBIDDEN` for a scope-less identity;
POC #1). **A failed open never returns a `channel_id`**: no
phantom channel. Session-level refusal is always channels-level;
*target-level* refusal happens later, in-stream, as an RFC reply
code (ADR-004).
@@ -71,7 +71,7 @@ never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
RFC 1928 is one command per connection, and control and data never
interleave in either direction. The channel's `BiStream` therefore
has a **phase structure with no mode byte** — the reply itself
has a **phase structure with no mode byte**: the reply itself
synchronizes the switch (POC #2 confirmed post-reply garbage is
refused structurally; the demux-vs-phase trade is resolved by
ADR-003/ADR-007):
@@ -88,18 +88,18 @@ ADR-003/ADR-007):
any post-reply control byte parses as a garbage ATYP and fails the
association loud (`udp_undecodable_frame_ends_association`, POC
#2). BIND: any byte in the quiet wait ends the session with no
data plane (POC #5 — the RFC 1928 §3 reading).
data plane (POC #5, the RFC 1928 §3 reading).
- **No in-band control vocabulary.** The alktunnels resolution
("no in-band control path, ever — the escape hatch is a new ALPN")
("no in-band control path, ever; the escape hatch is a new ALPN")
holds: no KEEPALIVE, no error frames, no flow-expiry frames. No
idle/inactivity vocabulary either — every phase's lifetime is
idle/inactivity vocabulary either: every phase's lifetime is
teardown-governed (EOF / garbage / error sentinels), never
protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet
wait's ends the same way). A deployment enforces idle policy at
its edges, not in-band. Errors are EOF-shaped (channels-level) or
reply-code-shaped (in-stream, pre-data-plane).
- **One channel per SOCKS5 session** — no sub-demux, no flow key
(AGENTS.md convention 10; the ASSOCIATE handle swap makes the
- **One channel per SOCKS5 session** (no sub-demux, no flow key;
AGENTS.md convention 10; the ASSOCIATE handle swap makes the
channel the relay handle, ADR-003).
## The Data Plane
@@ -107,7 +107,7 @@ ADR-003/ADR-007):
### CONNECT: raw pass-through
Post-reply, the handler runs `alkcall::channels::pump_bidi(channel_
bistream, target_read, target_write)` (ADR-050) — the canonical
bistream, target_read, target_write)` (ADR-050), the canonical
two-pump shape (alknet ADR-078); each pump shuts down the opposite
sink on completion (half-close semantics fall out); EOF sentinels
are the channels layer's. Zero protocol framing in-band: the RFC
@@ -122,31 +122,31 @@ interception point with the parsed target. Per-target refusal:
### UDP ASSOCIATE: the handle swap + framed datagram stage
The ASSOCIATE reply's `BND.ADDR`/`BND.PORT` is the sentinel
`127.0.0.1:0` on the in-band path — pure RFC-compatibility surface;
`127.0.0.1:0` on the in-band path (pure RFC-compatibility surface);
the channel is the relay handle (ADR-003). The datagram stage rides
the same channel:
- **Frame:** `[len: u16 BE][addr + data]` where `addr + data` is the
RFC address encoding (`TargetAddr::to_be_bytes()` — ATYP + addr +
RFC address encoding (`TargetAddr::to_be_bytes()`: ATYP + addr +
port; the RFC UDP header minus RSV/FRAG). One frame per datagram,
both directions. **A zero-length payload is a legal empty datagram
(never EOF — the F-2 invariant, shaped on alktunnels ADR-003's
codec, re-implemented in first-party `src/wire.rs`); `len == 0` —
zero inner bytes — is unreachable from a compliant peer, since
(never EOF; the F-2 invariant, shaped on alktunnels ADR-003's
codec, re-implemented in first-party `src/wire.rs`); `len == 0`
(zero inner bytes) is unreachable from a compliant peer, since
`addr` is always ≥ 7 bytes, so the operative invariant lives at
payload level.** The length covers
`addr + data`, not data alone — the joint bound is the
`addr + data`, not data alone; the joint bound is the
constraint:** a frame is rejected (`Oversize`, at frame time)
whenever `len(addr) + len(data) > 65535`. The UDP payload bound
alone (65507) does *not* fit u16 unconditionally: a 65507-byte
datagram to a 255-character domain is 65507 + 259 (max domain-form
address: ATYP + length byte + 255 + port) = 65766 bytes of frame
payload — a legal RFC 1928 datagram that would overflow u16. The
payload, a legal RFC 1928 datagram that would overflow u16. The
joint rejection keeps the length field honest: max data is 65276
for domain-form destinations, 65516 for IPv6, 65528 for IPv4.
Implementations must not use unchecked u16 arithmetic on the
payload bound.
- **RSV/FRAG do not ride the tunnel wire** — fragmentation cannot be
- **RSV/FRAG do not ride the tunnel wire**: fragmentation cannot be
expressed in-band; FRAG ≠ 0 is refused at front doors (the
iroh-socks5 v1 posture).
- **The RFC header codec (`new_udp_header`/`parse_udp_request`)
@@ -157,7 +157,7 @@ the same channel:
channels-level sentinel) ends the association; no extra control
vocabulary (POC #2 test 4: a fresh associate succeeds immediately
after a half-close teardown). No inactivity timer exists at the
protocol layer — every phase (the pre-command handshake, the BIND
protocol layer: every phase (the pre-command handshake, the BIND
quiet wait, the association) is teardown-governed, never
idle-expired (ADR-014); a deployment wanting an idle bound enforces
it outside the protocol layer (door accept paths, caller-side
@@ -165,7 +165,7 @@ the same channel:
- **The stage is `select!`-driven** (channel reads ∥ egress replies),
with the egress readiness contract: `next_reply` is `Pending` when
nothing is ready (an instantly-ready `Ok(None)` starves a biased
select into livelock — POC #2's load-bearing API finding).
select into livelock; POC #2's load-bearing API finding).
**Framing is applied exactly once, by the wire layer:**
`Egress::next_reply` returns *inner* bytes (`addr + data`); the
frame writer adds the single length prefix (the double-framing bug
@@ -178,7 +178,7 @@ the same channel:
open cost ~0.8 ms measured, per-target ACL for free).
**Per-datagram `send` is awaited inline** (the stage interleaves
sends with reply drains); a slow egress applies backpressure to
the association, not to other channels — the batch-2
the association, not to other channels; the batch-2
composed-egress author inherits this bound knowingly.
### BIND: the phase model extended (ADR-007)
@@ -187,13 +187,13 @@ the same channel:
variant's real listener address; the sentinel on the wrapper-aware
in-band path).
- **The quiet wait**: no bytes are legal; the `select!` is
single-shot with exactly two exits — the one inbound accept (from
single-shot with exactly two exits: the one inbound accept (from
the `BindAcceptFactory` seam, whose future owns the listener), or
the control stream's EOF/garbage/error (the client-gone teardown
riding the accept future's drop).
- **reply#2 carries the peer address** (the real peer on both
non-sentinel variants — local: `accept()`'s peer, byte-exact;
composed: the forwarded op's `peer` param since the bind-first
non-sentinel variants: local is `accept()`'s peer, byte-exact;
composed is the forwarded op's `peer` param since the bind-first
graduation, alktunnels `docs/research/tunnels-graduation.md`;
sentinel `0.0.0.0:0` wrapper-aware).
- **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape)
@@ -204,7 +204,7 @@ the same channel:
### Sentinels and lifecycle (all commands)
- **EOF** is the channels-level zero-length chunk — per-direction
- **EOF** is the channels-level zero-length chunk, per-direction
(half-close); written by `pump_bidi`'s shutdown-on-completion or
teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE
association ends on EOF.
@@ -212,7 +212,7 @@ the same channel:
whole point); pump-phase failures are EOF-shaped (alkcall
ADR-049 §6).
- **BAST:** the ASSOCIATE datagram frame is the one binary framing
beyond pass-through — its BAST document is [bast.md](bast.md).
beyond pass-through; its BAST document is [bast.md](bast.md).
## Normative byte diagrams
@@ -227,7 +227,7 @@ UDP ASSOCIATE (the datagram stage, one direction's view):
```
[len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ...
zero-length data: an empty datagram (legal payload, NOT EOF —
zero-length data: an empty datagram (legal payload, NOT EOF;
the F-2 invariant at payload level)
len == 0 (no addr bytes): unreachable from a compliant peer
EOF: the channels-level sentinel, outside the codec
@@ -244,9 +244,9 @@ BIND (the phase sequence, one channel's view):
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
None currently open against this document — the datagram framing
None currently open against this document: the datagram framing
(the one-way door) is decided (ADR-003); the phase lifetimes'
timeout posture is decided (ADR-014 — teardown-governed, no
timeout posture is decided (ADR-014: teardown-governed, no
protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet
translation layer) rides the dial callback and does not touch this
surface.
@@ -256,7 +256,7 @@ surface.
- ADR-001 (op/params), ADR-003 (the handle swap + frame),
ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND),
ADR-011 (the forked codecs), ADR-014 (the teardown-governed
phase lifetimes — no protocol-layer idle timeout)
phase lifetimes; no protocol-layer idle timeout)
- POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through,
refusal mapping), `poc-udp-associate-findings.md` (the stage,
readiness contract, framing-once invariant, association lifetime),
@@ -266,4 +266,4 @@ surface.
(channel open), ADR-049 (establishment/typed errors), ADR-050
(`pump_bidi`); alktunnels ADR-003 (the length codec + F-2)
- RFC 1928 (the protocol's own wire rules), iroh-socks5 eval
(`docs/research/iroh-socks5-eval.md` — the frame prior art)
(`docs/research/iroh-socks5-eval.md`, the frame prior art)
+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.
+31 -31
View File
@@ -18,7 +18,7 @@ leverages:
4. **Task-Driven**: Structured task graphs with dependency analysis
5. **Safe Exit**: Always have a way to unblock progress when stuck
6. **Categorical Estimates**: Use risk/scope/impact categories, not time
estimates. These are structurally important — upstream failures multiply
estimates. These are structurally important: upstream failures multiply
downstream damage regardless of developer type (human or LLM). See the
cost-benefit framework in taskgraph's framework docs.
@@ -47,11 +47,11 @@ approaches
**Process**:
1. Architect creates the architecture documentation structure:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
- `docs/architecture/overview.md` — package purpose, exports, dependencies
- `docs/architecture/<component>.md` — one focused doc per component/area
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs
- `docs/architecture/README.md` (index with doc table, ADR table, lifecycle)
- `docs/architecture/overview.md` (package purpose, exports, dependencies)
- `docs/architecture/<component>.md` (one focused doc per component/area)
- `docs/architecture/decisions/` (numbered ADR files: ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` (centralized tracker with OQ-IDs)
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
inline rationale) and OQs by number (not inline questions).
3. Architecture Review validates for ambiguities, risks, and structural issues
@@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
- Extract decisions into numbered ADRs in `decisions/` directory
- Centralize open questions in `open-questions.md` with OQ-IDs
- Maintain `README.md` as the architecture index
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them
- Keep spec documents focused (~500 lines); reference ADRs and OQs, don't inline them
- Redirect exploration work to Research Specialist
- Iterate based on review feedback
**Deliverables**:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md` — focused spec documents
- `docs/architecture/decisions/` — numbered ADR files
- `docs/architecture/open-questions.md` — centralized OQ tracker
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md`: focused spec documents
- `docs/architecture/decisions/`: numbered ADR files
- `docs/architecture/open-questions.md`: centralized OQ tracker
---
@@ -179,17 +179,17 @@ sessions.
**Mode**: Primary (manages worktrees and agent sessions)
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator
Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
sessions get the full operation set, spawned implementation sessions get a
limited set (current, notify, status). No mode toggle required.
**Tools**:
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort,
- `worktree({action, args})`, which dispatches spawn, sessions, dashboard, message, abort,
cleanup
- Bash (opencode CLI for session interaction)
- Read (monitor task files)
- `memory` / `memory_compact` — context management and session history (via
- `memory` / `memory_compact` for context management and session history (via
@alkdev/open-memory, when available)
**Key Behaviors**:
@@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required.
**Tools**:
- Read, Write, Edit, Glob, Grep, Bash
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator
- `worktree({action: "current"})` — verify worktree assignment
- `worktree({action: "notify", ...})` to report progress/blockers to coordinator
- `worktree({action: "current"})` to verify worktree assignment
- webSearch (documentation lookup)
- `memory` / `memory_compact` — context management (via @alkdev/open-memory,
- `memory` / `memory_compact` for context management (via @alkdev/open-memory,
when available)
**Key Behaviors**:
@@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction.
### Categorical Estimates
These fields are structurally important, not optional metadata. They power
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, commands that
reveal structural problems in the task graph. A task missing `scope`, `risk`,
`impact`, or `level` is a red flag indicating incomplete decomposition. See the
cost-benefit framework in taskgraph's framework docs for the reasoning.
@@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
### Architecture work and the `level` field
Architecture work uses the same taskgraph format as implementation work —
Architecture work uses the same taskgraph format as implementation work;
there is no separate `architecture` level. The `level` enum
(`planning, decomposition, implementation, review, research`) already covers
architecture activities:
| Architecture activity | `level` | Rationale |
|---|---|---|
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. |
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable: the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit (breaking a large architecture area into decideable units). |
| Architecture review pass | `review` | Direct fit. |
| Research / POC before a decision | `research` | Direct fit. |
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
@@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync:
2. **The machine-readable half** (in `tasks/architecture/`): an
external-trigger tracker task represents the unblocking condition. It is
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
`level: research` (it is not actionable work — it tracks whether the
`level: research` (it is not actionable work; it tracks whether the
external condition has arrived), and its `id` is referenced from the OQ's
`Blocked on:` text. This is the surface for the task graph: `taskgraph`
tools can reason about it, and downstream work that depends on the
@@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
specced), the tracker task is marked `completed` and the OQ transitions from
`deferred(scope)` to `open` (or directly to `resolved` if no architecture
decision remains). The two halves serve different audiences and use one edge
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not
type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
add a `blocks:` field; it duplicates the edge and creates a sync hazard.
## Review Injection
@@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen:
### Current (open-coordinator plugin)
The Coordinator uses the `worktree` tool from the open-coordinator opencode
plugin. It's a single tool with `{action, args}` dispatch — no separate
plugin. It's a single tool with `{action, args}` dispatch; there are no separate
enable/toggle steps. Role is auto-detected from session state.
```
@@ -563,13 +563,13 @@ a worktree.
When the open-memory plugin is available alongside open-coordinator, the
coordinator gains:
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent
- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
sessions spawned from the coordinator
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned
- `memory({tool: "messages", args: {sessionId: "..."}})` to read a spawned
session's conversation for debugging
- `memory({tool: "context"})` — check context window usage before long
- `memory({tool: "context"})` to check context window usage before long
monitoring sessions
- `memory_compact()` — proactively compact at natural breakpoints
- `memory_compact()` to compact proactively at natural breakpoints
Implementation agents can also use `memory({tool: "context"})` and
`memory_compact()` to manage their context during long tasks.
@@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations:
```
State moves from in-process tracking to Postgres `mappings` table. The
open-coordinator plugin becomes unnecessary — the hub provides the same
open-coordinator plugin becomes unnecessary; the hub provides the same
capabilities as server-side operations accessible from any environment.
## Document Structure
@@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs)
```
Numbering starts at 001 within each project. ADRs are stable — once Accepted,
Numbering starts at 001 within each project. ADRs are stable: once Accepted,
they don't revert. If superseded, mark the old one and create a new one.
### Open Questions Format
@@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one.
- **Status**: open | resolved | deferred(scope) | partially resolved
- **Priority**: high | medium | low
- **Resolution**: (when resolved)
- **Blocked on**: (when deferred — concrete dependency that would unblock)
- **Blocked on**: (when deferred; the concrete dependency that would unblock)
- **Cross-references**: OQ-NN, ADR-NNN
```