- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
269 lines
13 KiB
Markdown
269 lines
13 KiB
Markdown
---
|
|
status: draft
|
|
last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided:
|
|
teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1
|
|
initial draft 2026-09-20; the POC-validated wire shapes promoted
|
|
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), [013](decisions/013-targeted-fork.md),
|
|
[014](decisions/014-idle-timeout-posture.md)); this document is the normative
|
|
WHAT. RFC 1928's own wire rules (method negotiation, command encoding,
|
|
reply encoding, the RFC UDP datagram header) are specified by the RFC
|
|
itself; this document covers the channels-specific surface and the
|
|
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 }` where `reason ∈ dial_failed` /
|
|
`unknown_resource` / `resource_shortage` / `handler_error` /
|
|
`timeout` (alkcall ADR-049 §3). The registry's ACL gate runs
|
|
before the wrapper (`FORBIDDEN` for a scope-less identity;
|
|
POC #1). **A failed open never returns a `channel_id`**: no
|
|
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. No
|
|
idle/inactivity vocabulary either: every phase's lifetime is
|
|
teardown-governed (EOF / garbage / error sentinels), never
|
|
protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet
|
|
wait's ends the same way). A deployment enforces idle policy at
|
|
its edges, not in-band. Errors are EOF-shaped (channels-level) or
|
|
reply-code-shaped (in-stream, pre-data-plane).
|
|
- **One channel per SOCKS5 session** (no sub-demux, no flow key;
|
|
AGENTS.md convention 10; the ASSOCIATE handle swap makes the
|
|
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. **A zero-length payload is a legal empty datagram
|
|
(never EOF; the F-2 invariant, shaped on alktunnels ADR-003's
|
|
codec, re-implemented in first-party `src/wire.rs`); `len == 0`
|
|
(zero inner bytes) is unreachable from a compliant peer, since
|
|
`addr` is always ≥ 7 bytes, so the operative invariant lives at
|
|
payload level.** The length covers
|
|
`addr + data`, not data alone; the joint bound is the
|
|
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). No inactivity timer exists at the
|
|
protocol layer: every phase (the pre-command handshake, the BIND
|
|
quiet wait, the association) is teardown-governed, never
|
|
idle-expired (ADR-014); a deployment wanting an idle bound enforces
|
|
it outside the protocol layer (door accept paths, caller-side
|
|
wrappers).
|
|
- **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).
|
|
**Per-datagram `send` is awaited inline** (the stage interleaves
|
|
sends with reply drains); a slow egress applies backpressure to
|
|
the association, not to other channels; the batch-2
|
|
composed-egress author inherits this bound knowingly.
|
|
|
|
### 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 is `accept()`'s peer, byte-exact;
|
|
composed is the forwarded op's `peer` param since the bind-first
|
|
graduation, alktunnels `docs/research/tunnels-graduation.md`;
|
|
sentinel `0.0.0.0:0` wrapper-aware).
|
|
- **Phase 2 is raw pass-through** (`pump_bidi`, the CONNECT shape)
|
|
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][...] ...
|
|
zero-length data: an empty datagram (legal payload, NOT EOF;
|
|
the F-2 invariant at payload level)
|
|
len == 0 (no addr bytes): unreachable from a compliant peer
|
|
EOF: the channels-level sentinel, outside the codec
|
|
```
|
|
|
|
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); the phase lifetimes'
|
|
timeout posture is decided (ADR-014: teardown-governed, no
|
|
protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet
|
|
translation layer) rides the dial callback and does not touch this
|
|
surface.
|
|
|
|
## 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 forked codecs), ADR-014 (the teardown-governed
|
|
phase lifetimes; no protocol-layer idle timeout)
|
|
- POC findings: `poc-connect-wasm-findings.md` (CONNECT pass-through,
|
|
refusal mapping), `poc-udp-associate-findings.md` (the stage,
|
|
readiness contract, framing-once invariant, association lifetime),
|
|
`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) |