docs: Phase 1 architecture — specs, ADRs 001-012, open-questions tracker

- docs/architecture/: README index, overview, wire, producer,
  consumer, vendor, bast specs + open-questions.md (the OQ-SK-01..09
  ledger promoted; new questions as OQ-SK2-06..)
- ADRs 001-012: ALPN/op/params, protocol base, the handle swap,
  the dial-callback shape, auth split by the door, access control
  (open gate + BIND command gate), BIND in base, provider defaults,
  feature gates, channel capacity, the inline vendor, target policy
- deferred OQs (SK2-06, SK2-07) with tracker tasks in
  tasks/architecture/ per the sdd_process deferred-OQ protocol
- review pass applied: frame-length joint bound (65766 corner),
  accept_no_auth vs skip_auth corrected against fast-socks5 source,
  composed-feature wasm posture, cross-reference fixes

Verified: cross-references (all ADR/OQ refs resolve), doc sizes
under split threshold, Phase 0 fidelity spot-checks
This commit is contained in:
glm-5.3-flash committed 2026-09-20 18:41:56 +00:00
1 parent 3044abe296
commit 9587670138
22 files changed
+2488

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft — the OQ-SK ledger
promoted into ADRs 001-012 and open-questions.md)
---
# 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
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
`-D` flavor (dynamic / SOCKS5 proxy). This directory holds the
architecture spec docs and the ADRs.
## Current State
Phase 1 (Architecture) — initial draft. 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–012. All docs are `draft` — they transition
to `reviewed` when their open questions resolve and the architecture
review passes. `src/` does not exist yet; implementation follows the
decomposition phase.
## Documents
| 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 |
| [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](vendor.md) | draft | The inline vendor: the vendored fast-socks5 subset's scope, the four carried changes, 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) |
## 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 — `net`, `local`, and the Composition Boundary | Accepted |
| [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 | Accepted |
| [012](decisions/012-target-policy.md) | Target Policy and the Virtual-Subnet Translation Layer | Accepted |
Upstream ADRs (alkcall/alktunnels/alknet) are referenced by their own
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).
## Key Design Principles
1. **The protocol never binds.** The SOCKS5 service is a produced,
ACL-scoped resource on a channels connection ("ALPN as a
service"); kernel binds exist only as the two explicit, optional,
feature-gated assembly shapes (the producer's local backend; the
consumer's front door). The binding decision belongs to the
caller. The UDP ASSOCIATE "bind" is virtualized as the channel
(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
vocabulary (the escape hatch is a new ALPN, never a framing
change).
3. **The seams are functions, not traits** (ADR-004): DialFn /
EgressFactory / BindAcceptFactory at the three I/O planes; the
protocol layer never names a substrate type; ownership rides the
handle structurally.
4. **Auth is split by the door** (ADR-005): producer = the identity
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
each other; target policy = the dialer.
6. **Backpressure and limits are inherited, not redefined** (the
alkcall channels invariants); the one-channel-per-session
convention holds for all three commands; the proxy-workload cap
posture is ADR-010 (config, not wire).
7. **Wasm-clean default crate** (ADR-009): the default crate is
protocol-only; the vendored subset rides the default-on `net`
gate (ADR-011); `local` is opt-in and non-wasm; `composed` rides
alktunnels' wasm-clean default (verified with the wasm check at
implementation).
8. **The vendor is first-class** (ADR-011): the `T`-generic subset
in-tree with the four carried changes; `/workspace/fast-socks5`
stays the differential-testing reference; nothing waits on
upstream.
## Document Lifecycle
| Status | Meaning | Transitions |
|--------|---------|-------------|
| `draft` | Under active development; may change significantly | → `reviewed` when the doc's open questions resolve |
| `reviewed` | Architecture is final; implementation may begin | → `stable` when implementation is complete and verified |
| `stable` | Locked; changes require review, may warrant an ADR | → `deprecated` when superseded |
| `deprecated` | Superseded; kept for reference | Removed when no longer referenced |
## References
- Phase 0: `docs/research/phase-0.md` (the OQ ledger + convergence),
the POC findings files, `docs/research/iroh-socks5-eval.md`
- The SDD process: `docs/sdd_process.md` (Phase 1's process; the
task format; the deferred-OQ blocker-task half)
- Upstream: alkcall ADR-037/039/047/049/050/040/041/051, ledger
CF-005/CF-006; alktunnels ADR-003/004/006/007/008 + the graduation
research; alknet ADR-078/090
- Siblings: alktty (`/workspace/@alkdev/alktty`),
alktunnels (`/workspace/@alkdev/alktunnels`)
- Reference checkout: `/workspace/fast-socks5` (v1.0.0, MIT,
third-party — the differential-testing reference)
- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`)
+138
View File
@@ -0,0 +1,138 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
---
# 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
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
binary framing beyond pass-through, so the BAST document exists even
though the format is small. It is a normative JSON document
conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`
(JSON Schema Draft 2020-12); any Draft 2020-12 validator can check
well-formedness.
**alksocks does not depend on alktype.** The runtime codec is the
vendored fast-socks5 address encoding + the length-framing codec
(shaped on alktunnels' `frame_datagram`/`DatagramReader`, ADR-003);
this BAST document is the human-readable contract describing what
those round-trip. If runtime validation against the BAST becomes
desirable later, alktype becomes an optional dep and this document
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
[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
(negotiation, command, replies, the RFC UDP datagram header at front
doors) is specified by RFC 1928 and implemented by the vendored
fast-socks5 codecs ([vendor.md](vendor.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]`) —
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
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 vendored codecs implement it.
- **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
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
(upstream); the alksocks codecs never emit it (the F-2 invariant).
## The BAST document
```json
{
"$schema": "https://alk.dev/bast/v1/schema",
"$defs": {
"AssociateFrame": {
"kind": "struct",
"endian": "big",
"fields": [
{ "name": "len", "kind": "uint16" },
{ "name": "addr", "kind": "bytes", "encoding": "length-prefixed",
"maxLength": 259, "description": "RFC 1928 address encoding: ATYP + addr + port (TargetAddr::to_be_bytes); v4 = 1 + 4 + 2 = 7, v6 = 1 + 16 + 2 = 19, domain = 1 + 1 + 255 + 2 = 259 (the length byte of the domain form counts)" },
{ "name": "data", "kind": "bytes", "encoding": "length-prefixed",
"maxLength": 65535, "description": "the datagram payload; len == 0 is a legal empty datagram, NOT EOF" }
]
}
}
}
```
(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
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
arithmetic).)
## Annotations
### `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`
sub-layout is the RFC 1928 address encoding as vendored
(`TargetAddr::to_be_bytes()`: ATYP `0x01` IPv4 / `0x03` domain /
`0x04` IPv6, then the address, then the u16 BE port). RSV/FRAG do
not ride the tunnel wire (ADR-003); FRAG ≠ 0 is refused at front
doors.
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 frame
with `len == 0`, never EOF; alktunnels F-2's invariant, met
structurally).
### Layering note
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
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
upstream's, not re-specified here.
## References
- [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),
[ADR-011](decisions/011-inline-vendor.md) (the vendored codecs)
- [wire.md](wire.md) (the normative prose; §The Data Plane)
- 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
matrix: empty datagrams, splits/batches, garbage-ATYP refusal)
+163
View File
@@ -0,0 +1,163 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
---
# 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
`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
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
at that boundary.
Both wrap the same protocol layer; neither is required by the other.
## The Channels-Native Sessions
### `Socks5ChannelSession` (CONNECT)
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 vendored 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
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 —
**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 vendored address encoding) and
writes the frame;
- `recv_datagram()` returns `Some((source_addr, bytes))` per
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
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
session type owns teardown), combined with ADR-004's
ownership-rides-the-handle contract.
### The BIND client
The BIND reply#2 reader (POC #5's `read_reply2` sketch): the vendored
client `request()` reads reply#1; reply#2 is hand-read with the
vendored public `read_address` codec (reply#1's codec applied again;
no second codec). The session then rides the pass-through data
plane. The BIND client is a wrapper-aware-consumer convenience —
vanilla clients reach BIND through the front door instead.
### Error surface
`SocksServerError` / `SocksError` (the vendored surface) mapped
faithfully; channel-open failures are the typed
`channel:open_failed` reasons (alkcall ADR-049). A refused session
never yields a phantom channel; a refused target is an RFC reply
code surfaced through the session type.
## The Front Doors (feature `local`, optional)
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
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
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
listener/peer addresses pass through the pipe unchanged (the
conversation is the vanilla client's own); the door adds nothing.
### Door variants and auth (ADR-005)
- **The NoAuth door** (POC #1's raw-pipe shape): zero protocol
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 →`
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 vendored `AuthMethod` impl needed — the door owns the
accept loop).
Door variant selection (NoAuth vs cred-bearing; the UDP door's bind
address) is assembly-layer configuration.
## Wiring (Assembly Layer)
```rust
let session = Socks5ChannelSession::connect(
&mut channel_client, target)?;
let (mut read, mut write) = session.split(); // post-reply payload pipe
let assoc = AssociateSession::associate(&mut channel_client)?;
assoc.send_datagram(dst, &bytes).await?;
let (src, bytes) = assoc.recv_datagram().await?;
let door = FrontDoor::builder()
.credentials(cred_table) // RFC 1929 mapping, ADR-005
.bind("127.0.0.1:1080")
.build();
door.serve(channel_client).await?;
```
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN + op | the consumer opens the one op |
| [003](decisions/003-udp-handle-swap.md) | The associate session | sentinel BND.ADDR; frames once; reader state persists |
| [004](decisions/004-dial-callback-shape.md) | Ownership rides the handle | handle payloads own channel sessions; the session-teardown posture is inherited from **alktunnels ADR-005** (the consumer session type owns teardown) |
| [005](decisions/005-auth-split-by-door.md) | Door auth | RFC 1929 at the door; static credential→identity mapping |
| [007](decisions/007-bind-in-base.md) | BIND client | the reply#2 reader; vanilla clients via the door |
| [011](decisions/011-inline-vendor.md) | The vendor | the client `use_stream` path in-tree |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
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
associate session, the reader-state finding, the UDP door), POC #5
(`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
inherited), the CF-005/CF-006 seam
- [producer.md](producer.md) (the other half),
[wire.md](wire.md) (the normative wire)
@@ -0,0 +1,72 @@
# ADR-001: ALPN, Open Op, and Trivial Params
## Status
Accepted (2026-09-20, Phase 1)
## Context
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
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
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).
## Decision
- **ALPN: `alk/socks5`** (provisional → stable on first publish;
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,
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
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, 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
additively if a future need appears).
## Consequences
- 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
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
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_
registration`, POC #2; test 7, POC #5).
## References
- 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),
POC #5 `poc-bind-findings.md` (one-registration-three-commands)
- Upstream: alkcall ADR-037 (channel open ops), ADR-039
(params is ALPN-specific), ADR-047 (flavor-form op ids)
- Sibling template: alktunnels ADR-001/ADR-002
@@ -0,0 +1,62 @@
# ADR-002: Protocol Base — the Vendored fast-socks5 Typestate Subset
## Status
Accepted (2026-09-20, Phase 1; the inline-vendor posture is
OQ-SK-04's resolution, recorded in [ADR-011](011-inline-vendor.md))
## Context
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
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
the wasm gating at ~120 cfg lines with the wasm-clean subset
functionally complete (a full RFC 1928 CONNECT conversation passes
`--no-default-features`).
## Decision
- **The protocol layer is built on fast-socks5's explicit typestate
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 vendored into the default
path.
- **Base = v1.0.0, inline-vendored** (see ADR-011 for the vendor
mechanics and the four carried changes). `/workspace/fast-socks5`
remains the read-only reference for differential testing of RFC
edge cases; the vendor point is noted in the vendored code.
- **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).
## Consequences
- The crate owns its protocol code in-tree (the vendor), so the
wasm gate, BIND, and the seam fixes 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
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
level, not the code level (`docs/research/iroh-socks5-eval.md`).
## References
- OQ-SK-04 (resolved: inline vendor), OQ-SK-03 (the `run_udp_proxy_
custom` caveat)
- `docs/research/iroh-socks5-eval.md` (the base comparison),
POC #1 + POC #4 (`poc-connect-wasm-findings.md`)
- Sibling precedent: alktunnels ADR-002 (one-ALPN option A)
@@ -0,0 +1,97 @@
# ADR-003: The Handle Swap — one Channel per Association, Frames In-Band
## Status
Accepted (2026-09-20, Phase 1; baseline settled 2026-09-13, POC #2
validated end to end 2026-09-14)
## Context
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
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 —
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
handle (channel instead of UDP socket) preserves every RFC property.
POC #2 validated the whole shape end to end, both egress variants,
including the front door.
## Decision
The in-band (channels-native) UDP ASSOCIATE shape is:
- **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
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
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 —
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
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
shape). Only a front door with a real socket reports a real
address.
- **Producer egress is the `EgressFactory` seam** (ADR-004, the
dial-callback shape): the protocol layer sees neither egress
variant. The two assembly-layer variants (local socket; composed
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
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
frame writer adds the single length prefix.
- **The consumer side mirrors the framing-once invariant, and its
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
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
consumer-side surface this ADR owns.
## Consequences
- The no-bind requirement holds for all three commands: CONNECT and
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
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 —
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 —
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
design prior art)
@@ -0,0 +1,87 @@
# 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))
## Context
The SOCKS5 protocol must be substrate-agnostic (AGENTS.md convention
7): the far side of a session may be a kernel socket, an alktunnels
channel, or any other byte/datagram source. The question is the
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
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
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;
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
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
select!-single-shot).
## Decision
- **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
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)
- **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
values.
- **The trait re-evaluation threshold is the same as alktunnels
ADR-004's**: revisit if three real implementations exist as trait
impls in practice. Evidence so far: local-socket and composed
providers are callbacks; the no-trait hunch is evidence-backed at
all three seams.
## Consequences
- The protocol layer never names a substrate type. Substrate-specific
providers live in feature-gated backend modules and are wired at
the assembly layer (see [producer.md](../producer.md) §Wiring and
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
on alkcall only (see [overview.md](../overview.md) §Dependencies;
the composed-provider helpers that reference alktunnels types live
behind the composition feature, ADR-009).
- The seams' contracts (readiness, ownership, refusal mapping) are
load-bearing API surface, pinned by the POC findings and
re-pinned by the crate's test suite.
## References
- 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)
- alktunnels ADR-004 (the no-trait precedent), alknet ADR-078 /
alkcall ADR-050 (the two-pump contract the handles feed)
@@ -0,0 +1,86 @@
# 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)
## Context
Two auth mechanisms are real: alkcall's identity seam (the per-call
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
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
not flow to channel-0 dispatch automatically) is a producer-harness
requirement worth surfacing in the spec, not a protocol-layer
concern.
## Decision
- **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
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
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
never speaks RFC in-band; the crate's doors speak RFC.)
- **Consumer front door (component 2's optional shape): RFC 1929
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
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
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
(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
translation); the cred-bearing door is the same bridge plus the
greeting half. Door variant selection is an assembly-layer
choice.
## Consequences
- 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
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.
## References
- 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)
- alkcall ledger CF-005/CF-006 (the identity seam); alkcall ADR-049
(the establisher sees the per-call opener identity)
@@ -0,0 +1,80 @@
# 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 —
POC #1 (`socks5:open`), POC #5 (`socks5:bind` at command dispatch))
## Context
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
(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.
## Decision
- **The open gate is the boundary.** The open op's
`AccessControl.required_scopes` is `["socks5:open"]`
(`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`) —
following alktty's `TTY_OPEN_SCOPE` / alktunnels'
`tunnel:open`/`tunnel:direct` shape. Stable once published.
- **The command gate: BIND requires `socks5:bind` at command
dispatch.** The open op carries the session-level gate; the
command-class distinction happens in-stream, at command read,
from the per-call opener identity (CF-005/CF-006): CONNECT and
UDP ASSOCIATE require only `socks5:open`; TCP BIND requires
`socks5:bind`. An identity without it gets the RFC-faithful
`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
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
resource governs everything reachable through it, plus whatever
the dial callback enforces.
- **Per-target ACL for the composed egress comes free**: each
destination rides its own alktunnels direct-op channel governed
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
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
socks5-specific relay work.
## Consequences
- Two grants, clean separation: `socks5:open` (egress) and
`socks5:bind` (ingress listeners) — never implied by each other
(the alktunnels ADR-006 Amendment 1 posture: a capability on its
own scope).
- BIND's security boundary is enforced even though BIND rides the
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
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.
## References
- OQ-SK-05 (target policy), OQ-SK-08 (the BIND security note)
- POC #1 (the `socks5:open` gate; the identity seam), POC #5
(the `socks5:bind` command gate), POC #2 (composed per-target ACL)
- alktunnels ADR-006 + Amendment 1 (the sibling posture),
alknet ADR-024 (registry layering), alknet ADR-050 (ownership),
alkcall ADR-042 / ADR-051 (hub relay)
@@ -0,0 +1,91 @@
# ADR-007: BIND in the Base Crate — the Phase Model Extended
## Status
Accepted (2026-09-20, Phase 1; POC #5 validated end to end
2026-09-15)
## Context
fast-socks5 parses `Socks5Command::TCPBind` but every handler
refuses it; no bind machinery exists. RFC 1928 BIND is the
accept-side command (the `-R` far-side listener shape): the client
asks for a listener on the producer's network; the producer replies
#1 with the bound address, waits for exactly one inbound accept, and
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
accept variants, the scope gate, the failure mapping, the vanilla
front door.
Support is purely additive: the same ALPN, same op, same params, no
framing change; RFC-refusal of BIND remains valid for old consumers
(a producer that doesn't implement BIND just keeps refusing it).
Nothing here is a one-way door.
## Decision
- **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/
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
stage is framed).
- **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
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
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,
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) —
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-
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).
- **Failure mapping is RFC-faithful end to end**: factory failure
(pre-reply#1) → reply#1 with the mapped reply code; accept-stage
failure (post-reply#1) → reply#2 with the mapped code, no data
plane. No phantom listeners, no phantom data planes.
## Consequences
- 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
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.
- Old consumers see no change (the refusal was always the default);
the support is additive and gated by `socks5:bind` (ADR-006).
## References
- OQ-SK-08 (the full reasoning + the graduation's shape
supersession), OQ-SK-01 (the accept seam's shape), ADR-004
(the accept seam), ADR-006 (the command gate)
- POC #5 `poc-bind-findings.md` (the validation record; the
superseded-in-shape ask ledger)
- alktunnels ADR-007/ADR-008 (the direct op; bind-first establisher
+ forwarded op), alkcall ADR-049 amendment 3 (the reply
projection), alktunnels graduation research
(`alktunnels/docs/research/tunnels-graduation.md`)
@@ -0,0 +1,78 @@
# 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
alktunnels 0.2.0 ADR-007)
## Context
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).
alktunnels 0.2.0 removed Option A's blocker (the direct op
`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 +
browsing.
## Decision
- **The base crate ships both provider kinds, both feature-gated:**
- **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
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.
- **Composed providers** (behind the composition feature): the
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).
Hub relaying is per-target terminate-and-re-produce
(alkcall ADR-051).
- **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
placement are in [overview.md](../overview.md) §Feature gates.
- **No default in the abstract**: registration always takes the
callbacks explicitly; there is no implicit "default dialer."
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
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 —
ADR-006); the local variant's target policy is the dial
callback itself.
## Consequences
- The "no ports" story has two halves, both covered: the
consumer-facing half (the handle swap, ADR-003) and the
producing-side half (local egress behind `local`; composed egress
behind the composition feature; the protocol layer sees neither).
- The 256-channel default cap bounds composed fan-out; the
documented proxy-workload recommendation (ADR-010) covers the
deployment guidance.
- The composed egress's open cost is measured and acceptable for
the target workloads (DNS + browsing); a high-fanout deployment
that dislikes per-destination opens uses the local variant or
raises the cap (config, not wire).
## References
- OQ-SK-01 (the A/B choice), OQ-SK-03 variant 2 (the composed
egress), OQ-SK-05 (per-target ACL), OQ-SK-09 (the cap)
- POC #2 `poc-udp-associate-findings.md` (both variants; the open
cost), POC #5 `poc-bind-findings.md` (the composed accept)
- alktunnels ADR-007 (the direct op), alkcall ADR-051 (the hub
relay), ADR-004 (the seams), ADR-006 (the ACL posture)
@@ -0,0 +1,81 @@
# ADR-009: Feature-Gate Layout — `net` (the Vendor's Gate), `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)
## Context
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
client-side; phase-0 OQ-SK-04's analysis). Three groups of
platform-specific or sibling-dependent code need gating:
1. **The vendored fast-socks5 subset's kernel-net touches** — POC #4
measured the gate at ~120 cfg lines across 5 files, keyed on one
feature (`net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`,
default-on for native; wasm users build with
`default-features = false`).
2. **The crate's own local backends** (dial/listen/egress providers
touching real sockets — ADR-008's local variants).
3. **The composed providers** (the alktunnels-composed egress/accept
helpers — ADR-008's composed variants), which need the
alktunnels dependency.
One design constraint from OQ-SK-04's resolution: the vendored
subset's `net` gate and the crate's own backend gate are
*mechanically the same boundary* (tokio::net + socket2), but their
*ownership* differs — the vendor's gate ships with the vendored
code (v1.0.0-faithful + carried changes), the crate's local backends
are first-party code.
## Decision
- **The default crate is protocol-only and wasm-clean.** Module
placement per [overview.md](../overview.md): protocol, wire,
producer, consumer, vendor modules at the core; backend modules
feature-gated, never imported by the protocol layer.
- **`net` (default-on)**: the vendored subset's kernel-net gate
(POC #4's shape, carried into the vendor). Turning it off yields
the wasm-clean functional protocol subset (a full RFC 1928
conversation passes — POC #4's verification).
- **`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
producer's local backend). Implies `net`. 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) —
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
`features = ["full"]`** (AGENTS.md convention 4); `net` and
`process`-shaped additions ride the features above.
## Consequences
- 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
structural guard for the invariant.
- The vendor's carried changes (BIND, the `run_udp_proxy_custom`
bind seam, `new_reply` publication) live inside the `net`-gated
region where they touch kernel sockets and in the ungated
`T`-generic region where they don't (the BIND typestate driving
is `T`-generic; only the local-socket accept variant is
`net`-gated).
## References
- OQ-SK-04 (resolved: inline vendor — see ADR-011), OQ-SK-01/03/08
(the providers the features gate)
- POC #4 `poc-connect-wasm-findings.md` (the measured gate),
alktty's `local` feature (the posture precedent), alktunnels
ADR-004 + overview.md (the same three-way split)
@@ -0,0 +1,69 @@
# ADR-010: Channel Capacity — Proxy-Workload Recommendation, Not a Wire Change
## Status
Accepted (2026-09-20, Phase 1; the analysis is OQ-SK-09's,
2026-09-14)
## Context
The 256-channel default cap (per-connection alkcall ADR-040;
per-identity ADR-041) is tight for a proxy workload: every proxied
flow *is* a channel (a browser or tun2proxy fan-out opens one
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
plus documentation, never wire.
Considered and rejected: in-channel port multiplexing (a
`[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 vendored `transfer` drop in
unchanged, imposes chunk boundaries on a stream RFC 1928 treats as
unstructured, and violates the one-channel-per-SOCKS5-session
convention (AGENTS.md #10).
## Decision
- **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/
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
docs-level tuning note, not an architecture constant.
- **Defense-in-depth posture stays layered**: ACL → per-connection
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);
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
ALPN's framing.
## Consequences
- 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
a framing change.
## References
- OQ-SK-09 (the analysis), OQ-SK-03 (the demux-vs-phase trade the
rejection leans on), OQ-SK-05 (the virtual-subnet idea that
survives, recorded as OQ-SK2-07 in the tracker)
- alkcall ADR-040 (per-connection cap), ADR-041 (per-identity cap),
ADR-034 (the channels wire format)
@@ -0,0 +1,81 @@
# ADR-011: the Inline Vendor — fast-socks5's `T`-Generic Subset, In-Tree
## Status
Accepted (2026-09-20, Phase 1; OQ-SK-04's resolution — the ownership
premise corrected the same day)
## Context
fast-socks5 is **third-party upstream** (v1.0.0, MIT;
`/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
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
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
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
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
without leaning on the adapter story. The change set is exactly the
shape POC #4 measured as minimal.
## Decision
- **Vendor the `T`-generic subset into this crate** (the codecs, the
typestate machinery, the client `use_stream` path — the parts
already generic over `T: AsyncRead + AsyncWrite + Unpin`), gated
behind the default-on `net` feature (ADR-009; POC #4's gate
shape).
- **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.
- **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
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
a dependency.
## Consequences
- The crate owns its protocol code in-tree: no upstream release
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 vendored subset (~4k LoC upstream;
the vendored subset is the smaller `T`-generic region + codecs).
The vendor's diff vs upstream is deliberate and bounded: the four
carried changes + the license/provenance notes.
- Differential testing against `/workspace/fast-socks5` guards the
RFC edge cases the vendored code must keep passing.
## References
- 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
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)
@@ -0,0 +1,85 @@
# ADR-012: Target Policy and the Virtual-Subnet Translation Layer
## Status
Accepted (2026-09-20, Phase 1; the dialer-refuses shape is
OQ-SK-05's evidence-backed resolution — see ADR-006 — and the
2026-09-14 virtual-subnet proposal is resolved as assembly-layer
policy)
## Context
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) —
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"
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
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).
## Decision
- **No allowlist hook in v1** (ADR-006's resolution, restated as the
target-policy half): the dial callback is the policy point;
per-target refusal is an RFC reply code. The declarative-allowlist
variant remains an additive future extension if a deployment
needs declarative policy; the evidence-backed default is
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`
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 stays off — it is a kernel-net concern, gated with `net`.
- **The virtual-subnet translation layer is assembly-layer policy,
not a crate feature.** The crate ships no translation-table
helper in v1: the dial callback is the seam, and a deployment
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
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 crate's obligation is only that the seams support it, which
ADR-004's shape already does.
## Consequences
- The security boundary stays exactly two mechanisms: the open
gate (`socks5:open`/`socks5:bind`) and the dial/accept callbacks.
No third policy layer.
- The virtual-subnet endgame is unblocked without crate work: the
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
change.
- DNS posture is uniform: producer-side resolution everywhere
(local egress resolves; composed egress hands the name to the
direct op which resolves at the tunnel producer).
## References
- 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
virtual-subnet idea that survives is recorded in the tracker)
- 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
virtual-network prior art + the endgame composition partner)
+221
View File
@@ -0,0 +1,221 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft — the OQ-SK ledger
OQ-SK-01..09 promoted; new questions continue as OQ-SK2-06..)
---
# 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
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?
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-01)
- **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,
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.
- **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)
- **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
association; `{addr, data}` frames in-band (`[len: u16 BE]` over
the vendored address encoding); association lifetime = channel
lifetime; RFC codec at front doors only; sentinel BND.ADDR
in-band. The datagram framing (the one-way door) is now decided
with empirical ground under it (open cost measured ~0.8 ms; LRU +
dedup pinned by test).
- **Cross-references**: OQ-SK-01, ADR-003, ADR-008
### 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)
- **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
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.
- **Cross-references**: OQ-SK-01, ADR-004, ADR-006, ADR-007
## Theme 2: Crate architecture
### 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)
- **Priority**: medium
- **Resolution**: 2026-09-20 (Phase 1; the posture settled
2026-09-13). "Both," split by the door: producer = identity seam
(in-band NoAuth no-op); front door = RFC 1929 + static
credential→identity config mapping. The door's credential→
identity mapping shape (the Phase 0 residue): static config in v1;
a pluggable lookup remains an additive extension if a deployment
needs dynamic resolution.
- **Cross-references**: ADR-005, ADR-006
### 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)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 0's final resolution, promoted).
The inline vendor: the `T`-generic subset in-tree behind the
default-on `net` feature; the four carried changes (net gate,
BIND, the udp-bind seam, `new_reply` publication); v1.0.0 noted;
differential testing against `/workspace/fast-socks5`.
- **Cross-references**: ADR-002, ADR-009, ADR-011
### OQ-SK-07: POC register
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-07)
- **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,
#4, #5 passed; #3 dropped (transferred to alknet with OQ-SK-06).
Findings live in `docs/research/` (the findings files are the
artifacts); numbering is historical (do not renumber). Full
register: phase-0 §OQ-SK-07.
- **Cross-references**: OQ-SK-06
### 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)
- **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
multiplexed ALPN).
- **Cross-references**: OQ-SK2-08, ADR-010
## Theme 3: Policy and composition
### 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)
(the gate posture), [ADR-012](decisions/012-target-policy.md)
(target policy + the virtual-subnet layer)
- **Priority**: high
- **Resolution**: 2026-09-20 (Phase 1). No allowlist hook in v1 (the
dialer-refuses shape); domain targets pass through unresolved
(egress-owned resolution); the virtual-subnet translation layer
is assembly-layer policy over the dial callback (no crate feature
in v1; a helper may be added additively if a deployment
materializes).
- **Cross-references**: OQ-SK2-07, ADR-006, ADR-008, ADR-012
### 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
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.
- **Cross-references**: ADR-002 (the base), phase-0 §Scope
### OQ-SK2-06: The sandboxed-wasm-consumer adapter story
- **Origin**: [overview.md](overview.md) (the wasm posture's product
input; phase-0 OQ-SK-04's retrospective)
- **Status**: deferred(scope)
- **Door type**: two-way
- **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
adapter embedding the consumer session). Tracker task id:
`architecture/oq-sk2-06-wasm-adapter-story`
(`tasks/architecture/oq-sk2-06-wasm-adapter-story.md`).
- **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
made and does not depend on this.
- **Cross-references**: ADR-009, ADR-011
### OQ-SK2-07: Virtual-subnet translation-layer helper
- **Origin**: [phase-0.md](../research/phase-0.md) (OQ-SK-05's
2026-09-14 proposal; ADR-012's resolution)
- **Status**: deferred(scope)
- **Door type**: two-way
- **Priority**: low
- **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.
- **Blocked on**: a concrete deployment wanting identity-scoped
virtual subnets (the tun2proxy virtual-network prior art to check
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 —
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).
- **Cross-references**: ADR-004, ADR-006, ADR-012, OQ-SK2-08
## Theme 4: Documentation tuning
### OQ-SK2-08: The recommended proxy-workload cap figure
- **Origin**: [overview.md](overview.md) §Open Questions; ADR-010's
docs-level residue
- **Status**: open
- **Priority**: low
- **Resolution**: ADR-010 pinned the posture (config, not wire;
documented recommendation with memory arithmetic). The concrete
recommended figure (e.g. "raise per-connection to 4096, per-
identity to 1024" or similar) is a docs tuning note to pin during
implementation when real workload numbers exist.
- **Cross-references**: OQ-SK-09, ADR-010
## Deferred / Blocked (index)
| OQ | Status | Blocked on | Tracker task |
|----|--------|------------|--------------|
| 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` |
## References
- Phase 0 ledger: `docs/research/phase-0.md` §Open Questions
(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)
+240
View File
@@ -0,0 +1,240 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft — the OQ-SK ledger promoted
into ADRs 001-012 and open-questions.md)
---
# alksocks — Overview
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**, 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) —
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
the sibling documents ([wire.md](wire.md), [producer.md](producer.md),
[consumer.md](consumer.md), [vendor.md](vendor.md)).
## What
`alksocks` registers the `alk/socks5` ALPN's open op on alkcall
channels and provides the typed consumer sessions plus the optional
front doors:
- **The producer half** ([producer.md](producer.md)): the
`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
open handler runs the RFC 1928 state machine in-stream over the
channel's `BiStream` — the vendored fast-socks5 typestate
([vendor.md](vendor.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
for CONNECT and BIND's post-reply#2 stage.
- **The consumer half** ([consumer.md](consumer.md)): a typed
channels-native session that opens `channels/socks5/sub` and speaks
RFC 1928 inside them (`Socks5Stream::use_stream`-shaped;
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).
- **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
resource).
The defining posture (the crate's reason to exist as a separate
crate): **the protocol never binds**. Kernel binds exist only as the
two explicit, optional, feature-gated assembly shapes above; the
binding decision belongs to the caller.
## Why
- The `-D` conclusion, realized as a first-class protocol crate:
alktunnels' Phase 0 settled that `-D` composes at the assembly
layer ("tunnel a socks5 connection"); this crate makes the SOCKS5
*service* itself a produced, ACL-scoped resource, so both
channels-native consumers and vanilla SOCKS5 clients get the same
service in-band or through the front door.
- The vpn-like endgame composition: user tunnels a produced SOCKS5
resource to a local port (the front door), points tun2proxy
(`--proxy socks5://user:pass@host:port` natively) at it, and the
whole host's traffic rides the alk* egress. This crate is the
SOCKS5 leg; alktunnels carries the tunnel; tun2proxy closes the
loop.
- The substrate-agnostic protocol layer: the vendored typestate is
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
inversion-point precedent).
## The Resource Model in Brief
A SOCKS5 channel's life (full detail in [wire.md](wire.md) and
[producer.md](producer.md)):
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
identity and does session-level policy; it never sees a target.
Failure is a typed `channel:open_failed` (alkcall ADR-049); no
phantom channel.
2. **RFC conversation (the control plane, in-stream).** The RFC 1928
handshake (method negotiation → command → reply) runs in-stream
over the `BiStream`, driven by the vendored typestate. Command
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
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
(the accept seam) → reply#2 → pass-through (ADR-007).
4. **Teardown.** Per-command: CONNECT/BIND end on channel EOF (the
channels sentinel); ASSOCIATE's association lifetime = channel
lifetime; BIND's quiet wait is `select!`-single-shot with the
client-gone teardown riding the accept future's drop. Ownership
rides the handle structurally (ADR-004).
Producer and consumer are roles, not sides: both ends of a channels
connection can be both simultaneously (alkcall ADR-022/ADR-037).
RFC 1928's own client/server roles keep their RFC names *inside* the
protocol layer; crate-level vocabulary is producer/consumer.
## Dependencies
```
alksocks (default — wasm-clean)
├── alkcall 0.8.0 (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)
├── 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)
alksocks (net feature, default-on) — non-wasm
└── adds: tokio/net, socket2, tokio-stream (the vendored subset's
kernel-net gate; ADR-009)
alksocks (local feature) — non-wasm by design
└── adds: the first-party local backends + front doors (implies net)
alksocks (composed feature)
└── adds: alktunnels 0.2.0 (the direct op egress; the bind-first
listen accept; ADR-008's composed providers) — its 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 vendored fast-socks5 subset rides
the `net` gate (ADR-011); substrate I/O is feature-gated
(ADR-009). `fast-socks5` is NOT a Cargo dependency — it is vendored
in-tree (ADR-011).
## Feature Gates
| Feature | Contents | Wasm |
|---------|----------|------|
| *(default)* | the vendored `T`-generic subset (codecs, typestate), wire codec, open-op spec, the three seams' traits/types, producer + consumer session types — protocol only | yes |
| `net` *(default-on)* | the vendored subset's kernel-net pieces (`tokio/net`, `socket2`): the local-socket default handlers, DNS resolve helper, `Socks5Datagram` kernel socket path | no (wasm builds use `default-features = false`) |
| `local` | the crate's first-party local backends: TCP dial, UDP egress socket, BIND `TcpListener` accept, the front doors (consumer + producer local shapes) — implies `net` | no (build error on wasm, the alktty posture) |
| `composed` | the composed providers over alktunnels 0.2.0: 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
vendor/ — the vendored fast-socks5 T-generic subset (ADR-011;
vendor point v1.0.0 noted in-tree): codecs, typestate,
client use_stream path, carried changes (BIND reply
writer, new_reply publication, the udp-bind seam)
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
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
establisher, the command-dispatch pump handler
(typestate driving + the three seams), teardown
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
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
constructors (dial/egress/accept)
composed/ — feature `composed`: the alktunnels-composed
providers (direct-op egress, bind-first accept)
error.rs — SocksError + the typed open-error surface
(alkcall ADR-049 §4)
```
Public API surface is `lib.rs` re-exports (AGENTS.md convention 15).
Backend modules are feature-gated and never imported from the
protocol/adapter/client modules.
## Design Decisions
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 |
| [002](decisions/002-protocol-base.md) | Protocol base | fast-socks5's explicit typestate API (vendored); 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 |
| [005](decisions/005-auth-split-by-door.md) | Auth: "both," split by the door | producer = identity seam; front door = RFC 1929 + credential→identity mapping (static config) |
| [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 | `net` (default-on, the vendor's gate), `local`, `composed`; 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 inline vendor | the `T`-generic subset vendored in-tree; the four carried changes; v1.0.0 noted |
| [012](decisions/012-target-policy.md) | Target policy | dialer-refuses; domain targets pass through unresolved; virtual subnets = assembly policy |
## Open Questions
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)
- **OQ-SK2-08**: recommended proxy-workload cap figure (open — a docs
tuning note; ADR-010 pins the posture)
## References
- Phase 0: `docs/research/phase-0.md` (the OQ ledger OQ-SK-01..09 +
the convergence), POC findings (`poc-connect-wasm-findings.md`,
`poc-udp-associate-findings.md`, `poc-bind-findings.md`),
`iroh-socks5-eval.md`
- Upstream ADRs: alkcall ADR-037 (channel ops), ADR-039 (params is
ALPN-specific), ADR-047 (openable ALPNs are operations), ADR-049
(establishment + reply projection), ADR-050 (`pump_bidi`), ADR-040/
041 (channel caps), ADR-051 (hub relay), ledger CF-005/CF-006
(identity seam); alktunnels ADR-003 (length codec), ADR-007/008
(direct op; bind-first + forwarded), ADR-006 (ACL posture)
- Siblings: alktty (`/workspace/@alkdev/alktty`), alktunnels
(`/workspace/@alkdev/alktunnels`); reference checkout
`/workspace/fast-socks5` (v1.0.0, MIT, third-party)
- RFC 1928, RFC 1929; tun2proxy (`/workspace/tun2proxy`)
+180
View File
@@ -0,0 +1,180 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
---
# 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)
and serves RFC 1928 inside each opened channel. The layering is the
POC #1/#2/#5-validated shape: **establisher = session gate** (the
target is unknown at open time), **open handler = the RFC state
machine + command dispatch** (the vendored typestate driving, ADR-002),
**I/O planes = the three seams** (dial/egress/accept, ADR-004).
## The Open Op and the Session-Gate Establisher
Registration uses `ChannelCore::register_openable_with_establisher`
(alkcall ADR-049) with:
- 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);
- 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
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) —
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
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
behavior, but the wiring requirement is implicit.
## The Open Handler: RFC Conversation + Command Dispatch
The open handler runs the vendored typestate over the channel's
`BiStream` (`T`-generic — the POC #1 fit):
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:
- **CONNECT** → the command gate (`socks5:open` already checked
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.
- **TCP BIND** → **the command gate**: require `socks5:bind`
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,
`/workspace/@alkdev/alkcall/docs/reviews/
007-establishment-follow-ups-review.md`; early return =
teardown-at-birth).
- ASSOCIATE: the `select!`-driven datagram stage; the
`EgressFactory` is called once per association; the stage owns
the egress; stage exit drops it. Framing applied exactly once
by the wire layer; the readiness contract (`Pending` when
nothing is ready) is the seam's API invariant.
- BIND: reply#1 → the quiet wait (`select!`-single-shot; the
`BindAcceptFactory` future owns the listener) → reply#2 →
`pump_bidi` against the accepted halves.
## The Three Seams (ADR-004)
| Seam | Called | Returns | Refusal shape |
|------|--------|---------|---------------|
| `DialFn(target)` | per CONNECT, at command read | `TargetHandle {read, write}` (boxed halves, `+ Sync`) | RFC reply code |
| `EgressFactory()` → egress | once per ASSOCIATE; stage owns it | per-association egress (`next_reply` readiness contract) | reply#1 mapped code (pre-reply failure) |
| `BindAcceptFactory` | once per BIND session; quiet wait selects its future | accepted halves + peer | reply#1 (factory) / reply#2 (accept-stage) mapped codes |
Contracts (POC-pinned, load-bearing):
- **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).
- **The egress speaks inner bytes** (`addr + data`); the wire layer
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
implementations as trait impls (the alktunnels ADR-004 posture).
## Scope Gating (ADR-006)
- `SOCKS5_OPEN_SCOPE = "socks5:open"` — the open op's
`required_scopes`; egress capability (CONNECT + ASSOCIATE).
- `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
registration-time choice.
## Producer-Side Local Backend (feature `local`, optional)
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 pipes each accepted TCP connection raw onto a channels
`BiStream` (`copy_bidirectional`, zero protocol translation — POC
#1's curl front door validated the pipe shape). 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. The door
surface (NoAuth raw pipe vs RFC 1929 cred-bearing, ADR-005) is the
consumer door's code shape (see [consumer.md](consumer.md)).
The producer-side local *backend* for the I/O planes (the
local-socket providers — TCP dial, UDP egress, BIND listener) is
ADR-008's variant set, also feature `local`.
## 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
.build();
producer.register(&mut channel_core)?; // register_openable_with_establisher
```
Missing callbacks = those commands refuse RFC-faithfully
(`CommandNotSupported`-class), never a panic. Substrate providers
are feature-gated constructors over the same seams (ADR-008/ADR-009):
`local::tcp_dial()`, `local::udp_egress()`, `local::bind_accept()`,
`composed::direct_egress(...)`, `composed::bindfirst_accept(...)`.
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-alpn-and-open-op.md) | ALPN + op + params | `channels/socks5/sub`, trivial params, session-gate establisher |
| [002](decisions/002-protocol-base.md) | Protocol base | the vendored typestate drives the conversation |
| [004](decisions/004-dial-callback-shape.md) | The seams | DialFn / EgressFactory / BindAcceptFactory; ownership rides the handle |
| [005](decisions/005-auth-split-by-door.md) | Auth | producer = identity seam; in-band NoAuth no-op |
| [006](decisions/006-access-control.md) | ACL | `socks5:open` + `socks5:bind` command gate; dialer-refuses |
| [007](decisions/007-bind-in-base.md) | BIND | the phase model; the accept variants |
| [008](decisions/008-provider-defaults.md) | Providers | local + composed, feature-gated |
| [011](decisions/011-inline-vendor.md) | The vendor | the typestate subset in-tree |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
None currently blocking this document: the seams, gates, and BIND
shape are decided (ADR-004/006/007); OQ-SK2-07 (virtual-subnet
translation) rides the dial callback without touching this surface.
## References
- POC #1/#2/#5 findings (the three seams' empirical records)
- Upstream: alkcall ADR-047/049 (ops/establishment), ADR-050
(`pump_bidi`), ADR-042/051 (hub relay), CF-005/CF-006 (identity
seam); alktunnels ADR-004 (no-trait), ADR-007/008 (direct op;
bind-first + forwarded)
- [wire.md](wire.md) (the normative wire),
[consumer.md](consumer.md) (the other half)
+105
View File
@@ -0,0 +1,105 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft)
---
# alksocks — The Inline Vendor (fast-socks5 subset)
The crate owns its RFC 1928 protocol code in-tree: the **inline
vendor** of fast-socks5's `T`-generic subset ([ADR-011](decisions/
011-inline-vendor.md); OQ-SK-04's resolution). This document is the
vendor's scope, the carried changes, and the maintenance posture.
The protocol base decision is ADR-002; the feature gating is
ADR-009.
## What is Vendored
From fast-socks5 **v1.0.0** (MIT, third-party upstream —
`/workspace/fast-socks5` is the read-only reference checkout; the
vendor point is noted in the vendored code):
| Piece | Why it rides | Gating |
|-------|--------------|--------|
| The server typestate (`Socks5ServerProtocol<T, states::*>`, negotiate/auth/command-read/reply) | the producer's conversation driver (ADR-002) | `T`-generic — ungated |
| The auth traits (`AuthMethod<T>`/`AuthMethodSuccessState<T>`) | the negotiation machinery (the crate uses NoAuth in-band; the traits stay for fidelity) | ungated |
| `transfer` (the generic two-pump) | the interception-point shape (the crate's data plane is `pump_bidi`, but `transfer` is the typestate's own seam) | ungated |
| The codecs: `new_udp_header`/`parse_udp_request`, `TargetAddr`/`to_be_bytes`/`read_address`, `new_reply` (published — carried change #4), `ReplyError`/`SocksError`/`SocksServerError` | the RFC wire vocabulary everywhere (front doors, associate frames, reply#2 reader) | ungated |
| 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 (upstream's `Socks5Datagram` kernel path is the net-gated row) | ungated |
| Kernel-net pieces (the local-socket default handlers, `Socks5Datagram`'s kernel socket path, the DNS resolve helper, the legacy `Socks5Server`/`Incoming`) | upstream surface kept for fidelity where `net` is on | **`net`-gated** |
Not vendored: the deprecated `Socks5Server`/`Socks5Socket`/`Incoming`
bind-based API as a *default path* (it exists upstream and stays
behind `net`; the crate never routes through it). The vendored subset
is functionally complete without `net` (POC #4: a full RFC 1928
CONNECT conversation passes `--no-default-features`).
## The Four Carried Changes (ADR-011)
The vendor's diff vs upstream v1.0.0 is deliberate and bounded:
1. **The `net` feature gate** — ~120 `#[cfg]` insertions across 5
files, exactly POC #4's measured shape
(`poc-connect-wasm-findings.md` §The gating change: server.rs
~8 insertions; client.rs 87 diff lines (whitespace-inflated);
util/stream.rs, util/target_addr.rs, lib.rs ≤3; Cargo.toml's
`net = ["tokio/net", "dep:socket2", "dep:tokio-stream"]`).
2. **BIND** — absent upstream (every handler refuses
`Socks5Command::TCPBind`). The crate drives the typestate itself
for the BIND shape (ADR-007); the vendor carries the shape
in-tree (`reply_success`'s `sock_addr` as the reply#1 carrier;
the reply#2 writer using the published `new_reply`).
3. **The `run_udp_proxy_custom` bind seam** — the seam is narrower
than it looks (`udp_bind_random_port` unconditionally; the reply
port derives from that kernel socket). In-tree fix: make the bind
optional / accept a caller-supplied socket. (The channels path
drives the typestate directly regardless; the fix serves the
`net`-gated path's embedders.)
4. **`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.
Plus the provenance notes: the vendor point (v1.0.0) and the license
headers (MIT; preserve per-file headers or a bundled notice —
ADR-011's license obligation).
## Maintenance Posture
- **The vendor is first-class code, not a patch stack**: the carried
changes live in-tree and co-evolve with the wrapper; the vendored
subset's RFC fidelity is the crate's own test suite's job.
- **Differential testing**: `/workspace/fast-socks5` stays the
reference for RFC edge cases (reply-code mapping, domain
addressing, fragmentation). The crate's test suite covers the
edge cases the vendored code must keep passing; when in doubt,
diff behavior against the reference checkout.
- **Upstream posture**: fast-socks5 is third-party; nothing waits on
it. Offering the `net` gate upstream as a courtesy PR is optional
(POC #4: small enough to offer as-is); a future upstream release
may be re-based against deliberately, never absorbed silently
(the carried changes would need re-verification).
- **No second codec, ever**: the vendored codecs are the only RFC
wire vocabulary in the crate (ADR-011's rationale; the alknet
anti-prior-art record is in phase-0 §Anti-prior-art).
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [002](decisions/002-protocol-base.md) | Protocol base | the explicit typestate API only |
| [009](decisions/009-feature-gates.md) | Feature gates | `net` default-on; wasm builds use `default-features = false` |
| [011](decisions/011-inline-vendor.md) | The inline vendor | the subset in-tree; the four carried changes; v1.0.0 noted |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
None currently blocking this document.
## References
- 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`
finding), POC #2 (the typestate-driving shape for ASSOCIATE)
- `/workspace/fast-socks5` (v1.0.0, MIT — the reference checkout);
`docs/research/iroh-socks5-eval.md` (why not the alternative base)
+245
View File
@@ -0,0 +1,245 @@
---
status: draft
last_updated: 2026-09-20 (Phase 1 initial draft — the POC-validated wire
shapes promoted into the normative spec)
---
# 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
**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
model (ADR-003/ADR-007), the RFC conversation itself plus raw
pass-through, with exactly one framed stage (ASSOCIATE datagrams).
Design decisions live in the ADRs ([001](decisions/001-alpn-and-open-op.md),
[003](decisions/003-udp-handle-swap.md), [007](decisions/007-bind-in-base.md),
[011](decisions/011-inline-vendor.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
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`,
never in the call response stream. ALPN marker `alk/socks5` (ADR-001).
### Input (params)
```json
{}
```
- 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).
- 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
session gate) succeeded, the open handler is running.
- Failure: a typed `channel:open_failed` call error with
`details: { reason, message }` — `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
phantom channel. Session-level refusal is always channels-level;
*target-level* refusal happens later, in-stream, as an RFC reply
code (ADR-004).
- The scope gate: `required_scopes = ["socks5:open"]`
(`SOCKS5_OPEN_SCOPE`, ADR-006). Caller identity resolves in the
alkcall CF-005 precedence order (payload `auth_token` >
`ServingConfig.identity` > transport identity); the establisher
and open handler receive the per-call opener identity (CF-006).
No extra reply fields in v1 (the session gate has nothing to
project; `with_reply_field` stays available additively).
## The Phase Model
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
synchronizes the switch (POC #2 confirmed post-reply garbage is
refused structurally; the demux-vs-phase trade is resolved by
ADR-003/ADR-007):
| Command | Phase 1 (control) | Phase 2 (data) | Framing in phase 2 |
|---------|-------------------|----------------|--------------------|
| CONNECT | negotiate → command → reply | raw pass-through | none (0 B) |
| BIND | negotiate → command → reply#1 | quiet wait (no bytes allowed) → accept → reply#2, then pass-through | none after reply#2 |
| UDP ASSOCIATE | negotiate → command → reply (sentinel BND.ADDR) | datagram stage | `[len: u16 BE][addr + data]` per datagram |
- **Post-reply control bytes are protocol garbage.** CONNECT: the
stream is a pure byte pipe; any non-data interpretation is a
consumer violation. ASSOCIATE: 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`, POC
#2). BIND: any byte in the quiet wait ends the session with no
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")
holds: no KEEPALIVE, no error frames, no flow-expiry frames.
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
channel the relay handle, ADR-003).
## The Data Plane
### CONNECT: raw pass-through
Post-reply, the handler runs `alkcall::channels::pump_bidi(channel_
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
conversation *is* the stream (POC #1: echo round-trips clean; the
front door adds no framing).
The dial seam (`DialFn`, ADR-004) is invoked at the command-read
interception point with the parsed target. Per-target refusal:
`reply_error` with the mapped RFC code (POC #1: dial refusal →
`ConnectionRefused` 0x05); no phantom data plane.
### 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;
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 +
port; the RFC UDP header minus RSV/FRAG). One frame per datagram,
both directions. `len = 0` 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`). **The length covers
`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
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
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`)
exists only at vanilla front-door boundaries** (ADR-003; POC #2
test 8: RFC datagram in, `{addr, data}` frames in-band, RFC
reply-header'd datagrams out, zero payload translation).
- **Association lifetime = channel lifetime.** Channel EOF (the
channels-level sentinel) ends the association; no extra control
vocabulary (POC #2 test 4: a fresh associate succeeds immediately
after a half-close teardown).
- **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).
**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
class POC #2 caught is an API-design invariant).
- **Producer egress** is the `EgressFactory` seam (ADR-004/ADR-008):
local-socket (one `UdpSocket` per association, per-datagram
`send_to`/`recv_from`, no flow table; replies attributed by the
sender's address per the RFC's per-datagram source semantics) or
composed (a lazy destination→direct-op-tunnel table, LRU-evicted;
open cost ~0.8 ms measured, per-target ACL for free).
### BIND: the phase model extended (ADR-007)
- Phase 1 ends at **reply#1 carrying `BND.ADDR`** (the accept
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
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
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)
between the channel and the accepted halves.
- Failure mapping is RFC-faithful: factory failure (pre-reply#1) →
reply#1 with the mapped code; accept-stage failure (post-reply#1)
→ reply#2 with the mapped code, no data plane (POC #5 test 6).
### Sentinels and lifecycle (all commands)
- **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.
- **No mid-stream control frames** in any phase (the phase model's
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).
## Normative byte diagrams
CONNECT (post-reply, one direction's view inside the channel
`BiStream`, after the channels layer's 8-byte header per chunk):
```
<payload bytes> ... <EOF sentinel: channels-level length=0 chunk>
```
UDP ASSOCIATE (the datagram stage, one direction's view):
```
[len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ...
len == 0: an empty datagram (legal payload, NOT EOF)
EOF: the channels-level sentinel, outside the codec
```
BIND (the phase sequence, one channel's view):
```
<negotiation> <BIND cmd> |
<- reply#1 (BND.ADDR) | <quiet: any byte = garbage> | (accept)
<- reply#2 (peer addr) | <pass-through bytes> ... <EOF sentinel>
```
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
None currently open against this document — the datagram framing
(the one-way door) is decided (ADR-003); OQ-SK2-07 (the
virtual-subnet translation layer) rides the dial callback and does
not touch this surface.
## References
- ADR-001 (op/params), ADR-003 (the handle swap + frame),
ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND),
ADR-011 (the vendored codecs)
- 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),
`poc-bind-findings.md` (the BIND phases, quiet-wait semantics,
failure mapping)
- Upstream: alkcall ADR-035 (channels pure multiplexing), ADR-037
(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)
@@ -0,0 +1,50 @@
---
id: architecture/oq-sk2-06-wasm-adapter-story
name: Track — OQ-SK2-06 unblock condition (sandboxed-wasm-consumer adapter)
status: pending
depends_on: []
scope: single
risk: trivial
impact: component
level: research
---
## Description
External-trigger tracker for OQ-SK2-06 (`docs/architecture/
open-questions.md`): the sandboxed-wasm-consumer adapter story. This
task is NOT actionable work — it tracks whether the external
unblocking condition has arrived. Tagged `[external-trigger,
deferred-oq]` per `docs/sdd_process.md` §Deferred OQs.
**Unblocking condition**: a concrete sandboxed-adapter use case
exists — a TS/Python (or other) adapter embedding the alksocks
consumer session in a wasm runtime.
## Work
When the condition arrives:
1. Mark this task `completed` (the condition arrived).
2. Transition OQ-SK2-06 from `deferred(scope)` to `open` (or
directly to `resolved` if no architecture decision remains).
3. Decide: how much wasm surface to exercise in the default crate
(bindings ergonomics, which session types to expose, whether any
surface is missing).
## Verification
N/A (tracking task — trivial risk, no code).
## Out of scope
Any wasm-binding implementation; any protocol change (the
wasm-clean default-crate invariant, ADR-009, holds regardless).
## Notes
> Agent fills this during implementation.
## Summary
> Agent fills this on completion.
@@ -0,0 +1,53 @@
---
id: architecture/oq-sk2-07-virtual-subnet-helper
name: Track — OQ-SK2-07 unblock condition (virtual-subnet deployment)
status: pending
depends_on: []
scope: single
risk: trivial
impact: component
level: research
---
## Description
External-trigger tracker for OQ-SK2-07 (`docs/architecture/
open-questions.md`): the virtual-subnet translation-layer helper.
This task is NOT actionable work — it tracks whether the external
unblocking condition has arrived. Tagged `[external-trigger,
deferred-oq]` per `docs/sdd_process.md` §Deferred OQs.
**Unblocking condition**: a concrete deployment wants identity-scoped
virtual subnets (proxied resources IP-addressable per identity) —
the shape ADR-012 resolved as assembly-layer policy over the dial
callback.
## Work
When the condition arrives:
1. Mark this task `completed` (the condition arrived).
2. Transition OQ-SK2-07 from `deferred(scope)` to `open` (or
directly to `resolved` if assembly-layer policy still suffices).
3. Decide: does a translation-table helper graduate into the crate
(subnet sizing, per-identity tables, hub exposure)? Check
tun2proxy's virtual-network/virtual-DNS support as prior art
(`/workspace/tun2proxy`).
## Verification
N/A (tracking task — trivial risk, no code).
## Out of scope
Any crate-level implementation of the translation layer before the
condition arrives (ADR-012: the dial callback is the seam; the
crate's obligation is only that the seams support it).
## Notes
> Agent fills this during implementation.
## Summary
> Agent fills this on completion.