diff --git a/docs/research/phase-0-findings.md b/docs/research/phase-0-findings.md index 2d15e14..e5351de 100644 --- a/docs/research/phase-0-findings.md +++ b/docs/research/phase-0-findings.md @@ -307,6 +307,16 @@ and discovery residue). Direction of travel: field layout (Phase 1 spec, ADR before first consumer — wire-stable once published). +**Survey input 2026-09-06** (`ssh-socks5-survey.md`): SSH's +`direct-tcpip` payload reduces to "target + informational originator" — +the originator pair has no analogue here (ACL rides the channels +open-op machinery), supporting the two-field reframe. OpenSSH's +`direct-streamlocal` extension is the extensibility template: new +substrate = same open-op shape, degenerate address slots, new type +string → new `substrate` value, not a format change. SOCKS5 ATYP is +not needed in base params (dynamic-path addressing only). Minimal +shape: `{ "resource": , "substrate": "tcp" | "udp" | }`. + ### OQ-TN-02: Datagram substrates (UDP) — boundary preservation Does a UDP tunnel preserve datagram boundaries end-to-end, or does the @@ -316,11 +326,14 @@ re-chunked arbitrarily)? - Channels is a chunk stream with bounded buffers; the zero-length chunk is the EOF sentinel — datagram boundaries are *not* preserved by the substrate (alknet ADR-071/093; the POC only exercised TCP). -- SSH's `-D` UDP associate tunnels UDP as a stream with per-datagram - framing re-added by the tunnel protocol (e.g. SOCKS5 UDP over TCP). - Note (2026-09-05): russh does not support UDP channels at all — no - `direct-udp`-style prior art exists there; the tun2proxy gateway - (§Prior art) is the strongest framing precedent. +- **Corrected 2026-09-06** (`ssh-socks5-survey.md`): an earlier line + here claimed "SSH's `-D` UDP associate tunnels UDP as a stream" — a + category error. SSH has *no UDP forwarding at all* (RFC 4254 defines + only TCP/X11/session channels; russh is grep-confirmed UDP-free; + only SSH3 — a different HTTP/3 protocol — has `direct-udp`). SSH + `-D` carries only the SOCKS5 *TCP* control connection. The real + UDP-over-stream prior art is tun2proxy udpgw (§Prior art) and + SOCKS5's own UDP relay. - iroh and quinn-proxy-poc have native datagram transports; tun2proxy has a full UDP-over-TCP model worth reading. - Boundary preservation is a wire-format decision (per-datagram length @@ -331,20 +344,30 @@ re-chunked arbitrarily)? "association" carries many remote endpoints — does one tunnel channel carry one endpoint or many, and how are per-endpoint replies routed? -**Status:** open — survey mostly resolved by tun2proxy prior art -(§Prior art): per-datagram length framing over the stream is -production-proven (`LEN | FLAGS | CONN_ID | [addr] | DATA`), one -stream carries many UDP flows via a protocol-level `CONN_ID`, and -flow lifecycle (idle timeout + keepalive) is packet-level. Remaining: -whether alktunnels fixes the UDP endpoint at open (per-channel, TCP- -like) or carries per-datagram addresses (udpgw-like), and whether a -u16 conn-id vocabulary is right for channels (vs the channel ID -itself doing the demux and one channel per UDP flow). Note (2026-09-05, -hub model §Prior art): if UDP resources are produced like any other -resource, endpoint-at-open aligns naturally with resource naming -(OQ-TN-01); per-datagram addressing matches the `-D`/dynamic-target -composition path instead. A targeted POC (OQ-TN-10 #1) is likely still -+EV for the chosen shape. +**Status: resolved as a split by path, 2026-09-06** (survey +`ssh-socks5-survey.md` + tun2proxy prior art): + +- **Base open-op UDP resources: endpoint-at-open.** The resource + identifies the endpoint; one channel = one UDP flow (or one pinned + association). Aligns with resource naming (OQ-TN-01); per-datagram + addressing would reintroduce the "general addressing in params" the + reframe removed. Boundary preservation inside the channel stays + per-datagram length framing (tun2proxy-proven) — endpoint-at-open is + about addressing, not about dropping the LEN prefix. +- **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside + the tunnel payload** (SOCKS5 UDP header or udpgw format), composed + at the assembly layer, never in base params. +- **The channel ID replaces udpgw's CONN_ID** — a conn-id inside the + channel would be a second demux layer (AGENTS.md convention 10). + One channel per UDP flow; no protocol-level flow key. +- **KEEPALIVE drops** — udpgw's heartbeats exist for NAT-traversed + long-lived TCP; channels transport liveness is the alkcall layer's + concern. Flow-level idle expiry remains producer-side bookkeeping. +- A UDP gateway resource (multi-endpoint, udpgw-shaped) remains + possible *inside* a channel as self-describing framing — invisible + to base params, keeping OQ-TN-07 option A viable. A targeted POC + (OQ-TN-10 #1) remains +EV for the channels-layer fit + (chunk-size vs datagram-size, MTU vs bounded buffers, idle expiry). ### OQ-TN-03: Direction semantics (`-L` / `-R` / dynamic) @@ -486,14 +509,17 @@ consumer — ALPN strings are wire-stable once published. consumer-side API bifurcation (two session types vs one with a substrate enum). -**Status:** open — needs the OQ-TN-02 outcome first (if datagrams need -different framing, option B gets stronger). Note from the tun2proxy -prior art (§Prior art): udpgw runs its packet framing over a plain TCP -stream — one framing covers both the stream and datagram cases there. -If alktunnels follows the same shape (datagram framing self-describing -inside the channel), option A (single `alk/tunnel` ALPN) stays viable -even with UDP support; option B remains cleaner if the datagram -channel needs structurally different framing from the first chunk on. +**Status: option A strengthened, 2026-09-06** (survey +`ssh-socks5-survey.md`): SSH uses one channel mechanism for all +forwarding types (the type string is per-open metadata, not a separate +transport); SOCKS5 runs CONNECT and UDP ASSOCIATE over one control +connection with a CMD discriminator; udpgw proves datagram framing +self-describes over a stream. With OQ-TN-02 resolved as endpoint-at- +open for base UDP resources, the substrate discriminator in `params` +tells the handler which framing to expect — exactly option A's shape. +Option B (`alk/tunnel-dgram`) remains defensible only if Phase 1 wants +structurally different framing from byte zero with no params-dependent +dispatch; prior art gives no reason to prefer that. ### OQ-TN-08: Access control and ownership scope @@ -578,15 +604,29 @@ POC issue #6). What's missing is the error/level above bytes: (standard two-pump behavior) — is that always desired, or does the consumer need a "close both" control? -**Status: direction set 2026-09-05** — the original half-answer is -accepted: a self-contained control frame (alktty ADR-006 shape) +**Status: direction set 2026-09-05; vocabulary informed 2026-09-06** +(survey `ssh-socks5-survey.md` §Comparison). The original half-answer +is accepted: a self-contained control frame (alktty ADR-006 shape) carrying an establishment result/error, sent before any data chunk; dial errors are tunnel-closing (the whole channel dies), whereas -byte-level EOFs stay per-direction. Beyond that, the frame vocabulary -should follow the alktty stream-splitting pattern (§Prior art: the -alktty stream-splitting pattern) rather than invent a parallel -mechanism. Concrete frame set lands as a Phase 1 ADR (wire-stable -before the first consumer). +byte-level EOFs stay per-direction. The alktty-style JSON frame is the +strictly richest of the four prior-art vocabularies (SSH code+desc, +SOCKS5 codes-only, udpgw 1 opaque bit — the counterexample to avoid). + +Survey inputs for the Phase 1 frame ADR: +- Content: establishment ack + failure frame with a small reason-code + set mirroring SSH's four (policy-denied / dial-failed / + unknown-resource-or-substrate / resource-shortage — mapping 1:1 onto + what an open handler can produce: ACL denial, dial failure, + unregistered resource/substrate, channels limits) + a detail string + (SOCKS5's network/host/refused granularity lives in `detail`, not + more codes — codes are wire-stable, detail strings are not). +- Failure ⇒ channel closes before data flows: all four prior arts + agree; there is no "fail the open but keep the channel" prior art, + and none is needed (consumer just opens a new channel). +- The frame does NOT need: window adjustments (channels owns + backpressure), keepalives (channels owns liveness), fragmentation + (not the tunnel's job), per-datagram error replies. ### OQ-TN-10: POC scope for what remains unvalidated @@ -603,6 +643,9 @@ Phase 0 may need (in rough priority order, per the SDD process's endpoint-addressing shape. Derisks OQ-TN-02 (and OQ-TN-07's option B). 2. **Reverse-flow POC** — `-R`-style: the accept side listens, the far side carries. Derisks OQ-TN-03's advertisement/lifecycle shape. + Template available: SSH's `tcpip-forward` global-request + registration → per-accept `forwarded-tcpip` opens → cancel + (`ssh-socks5-survey.md` §RFC 4254 §7.1). 3. **Unix socket + stdio bridge POC** — cheap; validates "substrate agnostic" beyond IP substrates. 4. **Two-pump helper extraction spike** — OQ-TN-06, only after 1–3. @@ -648,12 +691,18 @@ Candidate reading for the research specialist (to be expanded): - alktty: `NegotiateRequest` shape (self-contained negotiation precedent), `TtyBackend` inversion point, `TTY_OPEN_SCOPE` access gate. +- `ssh-socks5-survey.md` (this directory, 2026-09-06): SSH + channel-open/open-failure/forwarding model, SOCKS5 ATYP/CONNECT/ + UDP ASSOCIATE, error-vocabulary comparison, endpoint-at-open vs + per-datagram analysis, anti-prior-art list (what NOT to carry over). + Feeds OQ-TN-01/02/07/09 statuses above. ## Convergence checklist (what Phase 0 must produce) -- [ ] Survey notes: SSH/SOCKS5 addressing + UDP framing - (OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway done (§Prior art); - SSH/SOCKS5 survey delegated to research specialist +- [x] Survey notes: SSH/SOCKS5 addressing + UDP framing + (OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway (§Prior art) + + `ssh-socks5-survey.md` (SSH/SOCKS5, error vocabularies, + endpoint-vs-per-datagram, anti-prior-art list) - [x] Reframe landed (OQ-TN-01) — params = self-contained JSON open-op object identifying a produced resource + substrate discriminator (`tcp`/`udp`/extensible); producer owns the diff --git a/docs/research/ssh-socks5-survey.md b/docs/research/ssh-socks5-survey.md new file mode 100644 index 0000000..e535fc1 --- /dev/null +++ b/docs/research/ssh-socks5-survey.md @@ -0,0 +1,602 @@ +--- +status: draft +last_updated: 2026-09-06 +--- + +# SSH & SOCKS5 Tunnel Survey (Phase 0) + +This document completes the last open survey item on the Phase 0 +convergence checklist: SSH and SOCKS5 addressing/framing prior art, +scoped to what alktunnels actually needs. It complements the tun2proxy +UDP-gateway prior art already in `phase-0-findings.md` (§Prior art: +tun2proxy UDP gateway) and answers the residue questions on OQ-TN-01, +OQ-TN-02, OQ-TN-07, and OQ-TN-09. + +Sources: RFC 4254 §4–7 (SSH Connection Protocol), RFC 1928 (SOCKS5), +russh (`/workspace/russh` — Rust SSH implementation), tun2proxy's +SOCKS5 client (`/workspace/tun2proxy/src/socks.rs`, +`src/proxy_handler.rs`, `src/lib.rs`), alktty's negotiation/control +frames (`/workspace/@alkdev/alktty/src/negotiation.rs`, `src/wire.rs`, +`src/control.rs`), and the alknet-channels POC tunnel handler +(`/workspace/alknet-channels-poc/src/tunnel_handler.rs`). + +## SSH forwarding model + +### RFC 4254 §5.1 — the channel-open envelope + +Every SSH channel open (RFC 4254 §5.1) is: + +``` +byte SSH_MSG_CHANNEL_OPEN (90) +string channel type (US-ASCII name) +uint32 sender channel (opener's local id) +uint32 initial window size +uint32 maximum packet size +.... channel-type-specific data +``` + +The channel-type-specific data is the SSH analogue of alktunnels' +`params`: a type-tagged payload appended to a generic open envelope. +SSH's "channel type" string plays the role of the ALPN/substrate +discriminator; the trailing bytes play the role of the resource +identification. + +### RFC 4254 §7.2 — `direct-tcpip` and `forwarded-tcpip` + +Both TCP forwarding channel types share one payload shape +(`TcpChannelInfo` in russh, `russh/src/parsing.rs:116-153`): + +``` +string host to connect (direct-tcpip) | address that was connected (forwarded-tcpip) +uint32 port to connect (direct-tcpip) | port that was connected (forwarded-tcpip) +string originator IP address +uint32 originator port +``` + +- `direct-tcpip` (RFC 4254 §7.2, second block): sent by the side that + accepted a local TCP connection; 'host to connect' may be a domain + name or numeric IP; the recipient dials it. This is the `-L`/`-D` + data path. +- `forwarded-tcpip` (§7.2, first block): sent by the side that + accepted a connection on a remotely-requested forwarded port; + 'address that was connected' is the forwarded listen address, and + the recipient is the side that requested the forward. This is the + `-R` data path. +- **The originator pair is informational.** RFC 4254 §7.2: "the + 'originator IP address' is the numeric IP address of the machine + from where the connection request originates" — it is for the + recipient's logging/policy, not routing. russh carries it verbatim + (`channel_open_direct_tcpip(host, port, originator_address, + originator_port)`, `russh/src/client/mod.rs:718-741`), and the + example passes the real accepted peer address + (`russh/examples/client_open_direct_tcpip.rs:135-143`). It is the + one piece of SSH addressing that has no alktunnels analogue in the + base params — it is metadata, not routing. + +OpenSSH extension for unix sockets (present in russh, +`russh/src/client/session.rs:90-100`): +`direct-streamlocal@openssh.com` carries only `socket_path` plus two +reserved fields (`""` string, `0u32`) — the same 4-slot shape with the +address slots emptied. This is prior art for "a new substrate = same +open-op shape, degenerate address fields," which supports OQ-TN-01's +substrate-discriminator reframe. + +### RFC 4254 §7.1 — the `-R` registration (`tcpip-forward`) + +Remote forwarding is registered with a *global request*, not a channel: + +``` +byte SSH_MSG_GLOBAL_REQUEST (80) +string "tcpip-forward" +boolean want reply +string address to bind ("" | "0.0.0.0" | "::" | "localhost" | "127.0.0.1" | ...) +uint32 port number to bind +``` + +Reply is `SSH_MSG_REQUEST_SUCCESS` (81) — carrying `uint32 port that +was bound` when the client passed port 0 (server allocates) — or +`SSH_MSG_REQUEST_FAILURE` (82). Cancellation is the +`cancel-tcpip-forward` global request (§7.1). After registration, each +accepted connection arrives as a `forwarded-tcpip` channel open from +the listening side (russh: `server/session.rs:1090-1104`, +`server/session.rs:1186-1233` for the global request; the RFC notes +channel opens may keep arriving until the cancel reply lands). + +This is the only SSH mechanism with no alktunnels counterpart yet: a +"please listen on your side, then open channels to me per accept" +advertisement. It maps onto OQ-TN-03's resolved model as an +assembly-layer concern, but the *registration/lifecycle* shape +(register → per-accept channel-open → cancel) is the prior art for the +reverse-flow POC (OQ-TN-10 #2). + +### Open-failure path (RFC 4254 §5.1) + +``` +byte SSH_MSG_CHANNEL_OPEN_FAILURE (92) +uint32 recipient channel (the opener's channel id) +uint32 reason code +string description (UTF-8) +string language tag +``` + +Reason codes (RFC 4254 §5.1; russh `lib_inner.rs:405-423`): + +| code | symbolic name | meaning | +|---|---|---| +| 1 | `SSH_OPEN_ADMINISTRATIVELY_PROHIBITED` | policy/ACL rejection | +| 2 | `SSH_OPEN_CONNECT_FAILED` | target dial failed | +| 3 | `SSH_OPEN_UNKNOWN_CHANNEL_TYPE` | unsupported channel type | +| 4 | `SSH_OPEN_RESOURCE_SHORTAGE` | limits exhausted | +| 0xFE000000–0xFFFFFFFF | private use | implementation-specific | + +russh's handling confirms the mechanics end to end: the server +dispatches per channel type and answers `fail(reason, message)` on +rejection (`server/encrypted.rs:1244-1250` for unknown types, +`server/encrypted.rs:1254-1278` `finalize_channel_open` answering +`SSH_OPEN_ADMINISTRATIVELY_PROHIBITED` with `"Rejected"` when the +handler says no); the client parses the failure, removes the channel, +and surfaces `ChannelMsg::OpenFailure(reason)` plus the +description/language strings to the handler +(`client/encrypted.rs:414-434`). Key structural property: **the open +failure is a first-class reply to the open, carrying a reason code plus +a human-readable description, and the channel never exists on the +opener's side afterward.** + +### Which side opens what (`-L` / `-R` / `-D` at protocol level) + +- **`-L` (local forward):** the client accepts on a local port, and for + each accept opens a `direct-tcpip` channel naming the target; the + server dials. Consumer opens, producer handles — the POC's shape. +- **`-R` (remote forward):** the client sends `tcpip-forward` (global + request) to register the listen; the *server* then opens + `forwarded-tcpip` channels per accepted connection toward the client, + which dials the real target. Producer-side accept, consumer-side + dial — the roles are the same pair, entry point on the other machine + (matches the phase-0 hub model resolution of OQ-TN-03). +- **`-D` (dynamic/SOCKS):** the client runs a SOCKS server locally; for + each SOCKS CONNECT it parses the target out of the SOCKS request and + opens a `direct-tcpip` channel with that target + (`stackoverflow.com/questions/78045232`; confirmed by OpenSSH's + sshd(8)/ssh(1) "dynamic forward" description). At the SSH protocol + level `-D` is *indistinguishable from `-L`*: one `direct-tcpip` + channel per application connection, target chosen per-channel by the + SOCKS layer in the client. There is no "dynamic" channel type on the + wire. This is direct protocol-level confirmation of the phase-0 + resolution that `-D` = "just tunnel a socks5 connection" (OQ-TN-08): + SSH itself implements `-D` exactly as "SOCKS5 server in front of + per-connection `direct-tcpip` opens." + +**SSH has no UDP forwarding.** RFC 4254 defines only TCP/X11/session +channels; there is no `direct-udp`/`forwarded-udp` anywhere in the RFC +or in OpenSSH. russh confirms: zero occurrences of "udp" in +`russh/src/` (grep clean, checked 2026-09-06). The only "SSH with UDP" +prior art is SSH3 (draft-michel-remote-terminal-http3), a different +protocol over HTTP/3, not SSH-the-RFC. **Correction to the phase-0 +doc:** OQ-TN-02's line "SSH's `-D` UDP associate tunnels UDP as a +stream with per-datagram framing re-added by the tunnel protocol (e.g. +SOCKS5 UDP over TCP)" is a category error — SSH `-D` carries the SOCKS5 +*TCP* control connection only; SOCKS5 UDP ASSOCIATE datagrams never +traverse an SSH channel in any standard SSH implementation. The real +UDP-over-stream prior art is tun2proxy's udpgw (already analyzed) and +SOCKS5's own UDP relay (below). + +## SOCKS5 model + +### ATYP addressing (RFC 1928 §5) + +Every address field (DST.ADDR in requests, BND.ADDR in replies) is: + +``` +ATYP (1 byte): + 0x01 IPv4 → 4 octets + 0x03 DOMAINNAME → 1 length octet + name (no NUL), max 255 + 0x04 IPv6 → 16 octets ++ port (2 bytes, network order) +``` + +This is the encoding udpgw reuses verbatim per data packet +(`udpgw.rs:68-76`), and the encoding the phase-0 doc already flagged as +concrete prior art for scheme-tagged addressing. + +### CONNECT flow (RFC 1928 §3–6) + +1. TCP connect to the SOCKS port (1080). +2. Method negotiation: `VER | NMETHODS | METHODS` → `VER | METHOD` + (`0x00` no-auth, `0x02` user/pass, `0xFF` no acceptable methods). +3. Optional method sub-negotiation (user/pass per RFC 1929). +4. Request: `VER | CMD | RSV(0x00) | ATYP | DST.ADDR | DST.PORT` with + CMD `0x01` CONNECT / `0x02` BIND / `0x03` UDP ASSOCIATE. +5. Reply: `VER | REP | RSV | ATYP | BND.ADDR | BND.PORT`. + +Reply codes (RFC 1928 §6): + +| REP | meaning | +|---|---| +| 0x00 | succeeded | +| 0x01 | general SOCKS server failure | +| 0x02 | connection not allowed by ruleset | +| 0x03 | network unreachable | +| 0x04 | host unreachable | +| 0x05 | connection refused | +| 0x06 | TTL expired | +| 0x07 | command not supported | +| 0x08 | address type not supported | + +On failure the server MUST close the TCP connection within 10 seconds +of sending the reply (§6 "Reply Processing") — the same +"error-then-close" shape SSH open-failure has, and the same shape +OQ-TN-09's accepted direction implies for tunnel establishment (control +frame, then channel death). + +tun2proxy's client side implements exactly this state machine: +`SocksState::{ClientHello, ServerHello, SendAuthData, +ReceiveAuthResponse, SendRequest, ReceiveResponse, Established}` +(`socks.rs:11-20`), with the reply parsed in +`receive_connection_status` (`socks.rs:226-248`) and non-`Succeeded` +replies aborting the session. The `ProxyHandler` trait +(`proxy_handler.rs:9-22`) is the handler-inversion point — same shape +as alktty's `TtyBackend`/this crate's OQ-TN-05 question, with +`get_udp_associate()` as the UDP-specific handle. + +### UDP ASSOCIATE flow (RFC 1928 §7) + +1. The client sends UDP ASSOCIATE **over the TCP control connection**, + with DST.ADDR/DST.PORT = the client's expected datagram source (all + zeros if unknown — the common case; tun2proxy sends + `Address::unspecified()`, `socks.rs:214-215`). +2. The reply's BND.ADDR/BND.PORT is **the relay endpoint the client + must send UDP datagrams to** — this is how the client learns where + to send (`socks.rs:241-244` stores it as `udp_associate`). +3. Each datagram to/from that endpoint carries the UDP request header: + +``` ++----+------+------+----------+----------+----------+ +|RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA | ++----+------+------+----------+----------+----------+ +| 2 | 1 | 1 | Variable | 2 | Variable | +``` + + RSV is `0x0000`; FRAG is the fragmentation sequence number (0 = + standalone, 1–127 = position, high bit = end-of-sequence); + fragmentation is optional and implementations that don't support it + MUST drop datagrams with FRAG ≠ 0 (§7). +4. The relay server pins the association to the client's source IP and + drops datagrams from any other source (§7). **The association + terminates when the TCP control connection terminates** (§6) — the + TCP session is the UDP flow's lifecycle anchor. +5. Replies are relayed silently; there is no per-datagram + acknowledgment or error channel. Errors are signaled only by + dropping, or by the control connection's reply codes. + +tun2proxy's non-udpgw path implements this verbatim: +`handle_udp_associate_session` (`lib.rs:625-724`) wraps each datagram +in a `UdpHeader` (the RSV/FRAG/ATYP/DST.ADDR/DST.PORT header) toward +the relay and strips it on the way back. + +### What udpgw dropped vs kept from RFC 1928 + +udpgw's packet format (`udpgw.rs:55-88`) is `LEN(u16 BE) | FLAGS(u8) | +CONN_ID(u16) | [SOCKS5 address] | DATA` — "referenced from SOCKS5 +packet format, with additional flags and connection ID fields" +(`udpgw.rs:57`): + +- **Kept:** the ATYP/DST.ADDR/DST.PORT address encoding per datagram + (§5's scheme-tagged addressing, unchanged). +- **Kept (implicitly):** per-datagram boundary framing — but udpgw + re-derives it with an explicit LEN prefix, because it rides a TCP + stream, not a datagram socket. SOCKS5 UDP never needed a length + prefix because UDP itself preserves boundaries; udpgw's LEN is the + price of moving the relay onto TCP. +- **Dropped: RSV** — pointless over a stream (it exists only to + word-align the UDP header). +- **Dropped: FRAG** — fragmentation over a reliable in-order stream is + unnecessary; udpgw caps datagram size with an MTU check instead + (`parse_udp_response` rejects `data.len() > udp_mtu`, + `udpgw.rs:527`). +- **Dropped: the TCP-control-connection lifecycle model** — SOCKS5 + binds each UDP association to a TCP session and learns the relay + endpoint from a reply; udpgw multiplexes all flows over pooled + gateway connections with a client-allocated `CONN_ID(u16)` + (`udpgw.rs:66`) and replaces "reply carries the relay endpoint" with + "the gateway connection is the endpoint." +- **Added: FLAGS** — `KEEPALIVE(0x01)` and `ERR(0x20)` as + address-less, data-less control packets (`udpgw.rs:21-26`), giving + the stream a minimal frame vocabulary: DATA / KEEPALIVE / ERR. +- **Dropped: reply codes** — the ERR flag carries *no* reason + information (`build_error_packet` is just header + conn_id, + `udpgw.rs:151-153`). SOCKS5's 9 reply codes and SSH's 4 reason codes + + description both collapse to a single opaque bit. This is the + weakest error vocabulary of the three prior arts. + +## Comparison: error/establishment vocabularies + +| | SSH open-failure (RFC 4254 §5.1) | SOCKS5 reply (RFC 1928 §6) | udpgw flags (udpgw.rs:21-26) | alktty ctrl frames (control.rs:58-93) | +|---|---|---|---|---| +| establishment ack | `CHANNEL_OPEN_CONFIRMATION` (91) | REP `0x00` + BND.ADDR/BND.PORT | (none — data just flows) | negotiation frame reply (negotiation.rs) | +| failure signal | `CHANNEL_OPEN_FAILURE` (92) | REP `0x01`–`0x08` | `ERR` flag packet | length-prefixed JSON error frame (error_response_bytes, negotiation.rs:263-277) | +| failure granularity | 4 IANA codes + private range | 8 codes | 1 opaque bit | free-form JSON `{"error": ...}` | +| human-readable detail | `description` string + language tag | none (codes only) | none | JSON fields | +| channel survives failure | no — channel removed client-side (client/encrypted.rs:421-427) | TCP connection closed ≤10s | flow-level (conn_id), stream survives | stream never established | +| in-band vs op-level | in-channel reply message | control-connection reply | in-stream flag packet | first frame on the stream | + +Observations for the alktunnels control-frame ADR (OQ-TN-09): + +- SSH is the only prior art that puts a *reason code + description* in + the failure reply; SOCKS5 has codes but no detail; udpgw has neither. + The alktty-shaped JSON control frame can carry both (a machine code + field and a detail string) at zero structural cost — it is the + strictly richest of the four, and it is already the accepted + direction. +- All four agree on the lifecycle consequence: **establishment failure + kills the channel/connection before data flows.** None of them + supports "fail the open but keep the channel for a retry" — there is + no prior art for a retry-without-reopen path, and none is needed + (the consumer just opens a new channel). +- SSH's reason-code taxonomy (policy / dial-failed / unknown-type / + resource-shortage) is a useful minimal set to mirror in the control + frame's error field: it covers exactly the four failure classes an + open handler can produce (ACL denied, dial failed, unknown + substrate/resource, channels limits). SOCKS5's set adds + network-unreachable vs host-unreachable vs connection-refused + granularity inside "dial failed" — worth considering as sub-codes or + just leaving to the detail string. + +## UDP: endpoint-at-open vs per-datagram + +What each prior art does: + +| | endpoint model | where the address lives | flow multiplexing | +|---|---|---|---| +| SSH (TCP only) | fixed at open | `direct-tcpip`/`forwarded-tcpip` payload | none needed — one channel = one TCP connection | +| SOCKS5 UDP ASSOCIATE | relay endpoint fixed at open (BND.ADDR/BND.PORT); **destination per-datagram** | DST.ADDR/DST.PORT in each UDP header | one association per control connection; source-IP pinning | +| udpgw | destination per-datagram | SOCKS5 address in each DATA packet | `CONN_ID(u16)` client-allocated, many flows per gateway stream | +| alktunnels base open-op (OQ-TN-01 reframe) | resource fixed at open | `params` (resource id + substrate) | the channel ID itself | + +Implications for a channels-based tunnel: + +1. **The two prior arts disagree because they solve different + problems.** SOCKS5/udpgw are *proxies*: the client picks arbitrary + destinations per datagram, so per-datagram addressing is intrinsic. + alktunnels' base open-op is a *resource access*: the consumer names + a produced resource, and the producer owns the backing endpoint. + Under the hub model (phase-0 §Prior art), endpoint-at-open is the + natural fit for the base op — the resource *is* the endpoint + binding, and per-datagram addressing would reintroduce exactly the + "general addressing in params" that the OQ-TN-01 reframe removed. +2. **Per-datagram addressing belongs to the `-D`/dynamic composition + path, not the base op.** If a UDP SOCKS5-style service is produced, + the consumer tunnels to it (base op, substrate `udp` or even `tcp` + for the control leg) and the SOCKS5 UDP header rides *inside* the + tunnel payload — exactly as tun2proxy's socks path does over its + proxy connection (`lib.rs:672-687`). The base protocol never sees + per-datagram addresses. +3. **If a base-op UDP resource ever needs multiple remote endpoints + (a "UDP gateway" resource), the udpgw shape is the template:** + length-prefixed datagrams, SOCKS5-style per-datagram address, a + flow key, and DATA/KEEPALIVE/ERR flags — all *inside* the channel, + self-describing, invisible to the open-op params. That keeps + OQ-TN-07's option A (single `alk/tunnel` ALPN) viable: udpgw proves + one framing can carry the datagram case over a stream substrate + without the ALPN knowing. +4. **The channel ID replaces CONN_ID.** udpgw needs CONN_ID because it + multiplexes over 1–5 pooled TCP streams (a throughput choice, + `UDPGW_MAX_CONNECTIONS = 5`, udpgw.rs:15); channels already + multiplexes per channel ID with a 256-channel cap. One channel per + UDP flow (or per association) needs no protocol-level flow key — + the OQ-TN-02 residue question "is a u16 conn-id vocabulary right" + resolves to *no*: the channel ID does the demux, and a conn-id + inside the channel would be a second demux layer (AGENTS.md + convention 10: don't build a second demux/mux). +5. **KEEPALIVE likely drops too.** udpgw's 30s heartbeats exist + because its gateway streams are long-lived TCP connections that NAT + middleboxes silently drop; on channels, transport liveness is the + alkcall layer's concern (phase-0 §Prior art already noted this). + Flow-level idle expiry (the `udp_timeout` concept) remains + producer-side bookkeeping, not a wire frame. + +## What NOT to carry over + +Explicit anti-prior-art — mechanisms the prior arts have that alkcall +channels already owns, or that don't survive the substrate change: + +1. **SSH window updates (`SSH_MSG_CHANNEL_WINDOW_ADJUST`, RFC 4254 + §5.2).** SSH multiplexes channels over one stream with sender-side + window accounting; alkcall channels already provides bounded + per-channel buffers and backpressure (alkcall ADR-040/041). A + tunnel-level window mechanism would be a second backpressure layer + (AGENTS.md convention 10). russh's whole `WindowSizeRef` machinery + (`channels/mod.rs:116-139`) is the thing we *don't* build. +2. **SSH's `maximum packet size` negotiation.** Channels owns chunk + sizing; the tunnel codec inherits it. (udpgw's MTU cap is the + datagram-flavored version — also producer-side, not wire.) +3. **SSH's sender/recipient dual channel numbering.** Channels + allocates monotonic IDs with a single namespace (alkcall + ADR-034/040); the open op's channel ID is already agreed before the + handler runs. No ID translation in the tunnel protocol. +4. **SOCKS5's FRAG byte.** Fragmentation over a reliable in-order + channel is redundant — udpgw already dropped it for exactly that + reason. If a datagram is too big for the channel's buffer, the + producer-side MTU cap rejects it; reassembly is not the tunnel's + job. +5. **SOCKS5's method negotiation / auth sub-negotiation.** alkcall's + `AuthContext`/`AccessControl` at the connection and open-op layers + owns authentication (AGENTS.md convention 13); SOCKS5 auth exists + only because SOCKS rides raw TCP. The `-D` composition inherits + alkcall's auth for free. +6. **SOCKS5's BND.ADDR/BND.PORT reply semantics.** The "reply tells + you where to send datagrams" pattern exists because SOCKS5's UDP + relay is a *separate socket* from the control connection. On + channels there is no second endpoint — the channel *is* the + endpoint. An establishment-ack control frame needs no address + payload. +7. **udpgw's KEEPALIVE flag and connection pooling.** Both are + transport-layer concerns (NAT keepalive, TCP stream count) that + channels absorbs (see §UDP above). +8. **SSH's `tcpip-forward` port-0 "server allocates a port" reply.** + The hub model produces named resources, not dynamically-allocated + listen ports; if a reverse-flow advertisement ever needs an + allocation reply it is an assembly-layer concern, and the ops + listing (OQ-TN-08) is how the allocated name propagates. + +## Answers for the OQ ledger + +### OQ-TN-01 residue (params field set; open-failure error path) + +**Minimal field set.** The SSH/SOCKS5 prior art supports the reframe's +two-field shape: + +- SSH's `direct-tcpip` payload is exactly "target + (informational + originator)" — and the informational half is the only part with no + alktunnels analogue, because ACL/identity is carried by the channels + open-op machinery, not by params. The routing-relevant payload + reduces to *one address-ish field + one port* — which under the + resource model collapses further into *one resource identifier*. +- OpenSSH's `direct-streamlocal` shows the extensibility pattern: same + open-op shape, degenerate address slots, new type string. For + alktunnels this maps to: `params` = `{ "resource": , + "substrate": "tcp" | "udp" | }` with any + substrate-specific detail owned by the producer's registry, not the + wire. A new substrate is a new `substrate` value, not a format + change — the same additive property SSH gets from channel-type + strings and SOCKS5 gets from ATYP values. +- SOCKS5's ATYP (v4/domain/v6) is *not* needed in the base params: it + is per-datagram/per-request addressing for the dynamic path, which + rides inside the tunnel payload (see §UDP). If the resource + identifier is ever an address rather than a name, ATYP's + scheme-tagged encoding is the fallback shape — but the resource-name + direction makes that unnecessary. + +**Open-failure error path.** The prior art converges on: *a structured +failure reply to the open, with a reason code, followed by +connection/channel teardown; the channel never carries data after a +failed open.* Concretely for alktunnels (feeding the OQ-TN-09 frame +ADR): + +- Establishment result rides the accepted control-frame direction + (phase-0 OQ-TN-09): a single JSON frame before any data chunk, with + at minimum `{"ok": true}` or `{"error": , "detail": }`. +- The reason-code vocabulary should cover SSH's four (policy-denied / + dial-failed / unknown-resource-or-substrate / resource-shortage) — + they map 1:1 onto what an open handler can actually produce: + `AccessControl` denial, target dial failure, unregistered + resource/substrate, channels limits. SOCKS5's finer + network/host/refused granularity can live in `detail` rather than + more codes (v1 minimalism; codes are wire-stable, detail strings are + not). +- udpgw's opaque ERR bit is the counterexample to avoid: an error the + consumer cannot distinguish "ACL denied" from "target down" makes + client UX and retry policy impossible. + +### OQ-TN-02 fork (endpoint-at-open vs per-datagram) + +Resolved as a *split by path*, not a single choice (§UDP above): + +- **Base open-op UDP resources: endpoint-at-open.** The resource + identifies the endpoint; one channel = one UDP flow (or one pinned + association). This aligns with resource naming, needs no in-band + addressing, and matches how the hub model produces any resource. + Boundary preservation inside the channel is still per-datagram + length framing (tun2proxy-proven) — endpoint-at-open is about + *addressing*, not about dropping the LEN prefix. +- **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside + the tunnel payload**, using the SOCKS5 UDP header or udpgw's + ADDR-bearing packet format — composed at the assembly layer, never + in base params. +- The channel ID replaces udpgw's CONN_ID (no second demux); KEEPALIVE + drops; the ERR flag's job is done better by the control frame. + +### OQ-TN-07 (ALPN strategy) + +The udpgw precedent (one framing over a plain stream, datagram +self-describing) already favored option A; the SSH/SOCKS5 survey +strengthens it: + +- SSH uses *one* channel mechanism for all forwarding types; the type + string is per-open metadata, not a separate transport. SOCKS5 + likewise runs CONNECT and UDP ASSOCIATE over the same control + connection with a CMD discriminator. +- With endpoint-at-open for base UDP resources (OQ-TN-02 answer), the + datagram framing is confined to UDP-substrate channels and is + self-describing from the first chunk (a length prefix is + unambiguous against raw pass-through only if TCP channels are also + framed — see the codec decision on the convergence checklist; if + TCP is raw pass-through, the substrate discriminator in `params` + tells the handler which framing to expect, which is exactly option + A's "substrate is a params field"). +- Option B (`alk/tunnel-dgram`) remains defensible only if Phase 1 + wants structurally different framing from byte zero with no + params-dependent dispatch. Prior art gives no reason to prefer that. + +### OQ-TN-09 (establishment/error frame vocabulary) + +Inputs from this survey (§Comparison table above): + +- Shape: alktty-style JSON control frame (already the accepted + direction) — it is the richest vocabulary of the four prior arts + (code + detail) and needs no new framing layer. +- Content: an establishment ack (SSH's CONFIRMATION / SOCKS5's + success reply analogue) and a failure frame with a small reason-code + set (SSH's four) + detail string. Failure ⇒ channel closes before + data (all four prior arts agree). +- What the frame does *not* need: window adjustments (channels owns + backpressure), keepalives (channels owns liveness), fragmentation + (not the tunnel's job), per-datagram error replies (SOCKS5 relays + silently; udpgw's ERR is flow-level at most — dial errors are + channel-closing per the accepted direction). + +## References + +- RFC 4254, *The Secure Shell (SSH) Connection Protocol* — §4 (global + requests), §5.1 (channel open / open-failure reason codes), §5.2 + (window adjust), §7.1 (`tcpip-forward`), §7.2 (`direct-tcpip`, + `forwarded-tcpip`). + https://www.rfc-editor.org/rfc/rfc4254.txt +- RFC 1928, *SOCKS Protocol Version 5* — §3 (method negotiation), §4 + (request format), §5 (ATYP addressing), §6 (replies, UDP ASSOCIATE + lifecycle), §7 (UDP relay header, FRAG). + https://www.rfc-editor.org/rfc/rfc1928.txt +- russh — `/workspace/russh`: + - `russh/src/parsing.rs:8-153` — `OpenChannelMessage` parse, + `TcpChannelInfo` (direct-tcpip/forwarded-tcpip payload), + `StreamLocalChannelInfo`, confirm/fail encoders. + - `russh/src/lib_inner.rs:402-423` — `ChannelOpenFailure` reason + enum. + - `russh/src/client/mod.rs:675-760` — public `channel_open_*` API; + `client/session.rs:74-100` — wire encoding of direct-tcpip / + direct-streamlocal payloads. + - `russh/src/server/encrypted.rs:1150-1278` — server-side open + dispatch, `finalize_channel_open` failure path; + `server/session.rs:1055-1115, 1186-1233` — server-initiated opens + and `tcpip-forward` global request. + - `russh/src/client/encrypted.rs:414-434` — client-side + open-failure handling. + - `russh/examples/client_open_direct_tcpip.rs:129-184` — the -L + pump shape (two select arms, EOF handling). + - Confirmed: no UDP anywhere in `russh/src/` (grep, 2026-09-06). +- tun2proxy — `/workspace/tun2proxy`: + - `src/udpgw.rs:55-88` — packet format doc comment; `:21-26` flags; + `:147-153` keepalive/error packet builders; `:527` MTU cap. + (Analyzed in phase-0-findings.md §Prior art.) + - `src/socks.rs:11-20` — SOCKS5 client state machine; `:213-224` + request encoding (UDP ASSOCIATE sends unspecified address); + `:226-248` reply parsing + `udp_associate` capture. + - `src/proxy_handler.rs:9-32` — `ProxyHandler`/`ProxyHandlerManager` + inversion point. + - `src/lib.rs:625-724` — `handle_udp_associate_session`: SOCKS5 UDP + header added/stripped per datagram over the relay endpoint. +- alktty — `/workspace/@alkdev/alktty`: + - `src/negotiation.rs:64-120` — `NegotiateRequest` (self-contained + JSON open-op precedent); `:127-277` — length-prefixed framing + + `error_response_bytes` (the error-frame shape). + - `src/wire.rs:1-57` — 5-byte chunk codec, stream types, zero-length + sentinels. + - `src/control.rs:58-108` — `ControlMessage` JSON control frames. +- alknet-channels POC — `/workspace/alknet-channels-poc`: + - `src/tunnel_handler.rs:24-59` — the two-pump tunnel handler (no + establishment ack — the gap OQ-TN-09 closes). +- OpenSSH `-D` protocol-level behavior (no local source; web): + - stackoverflow.com/questions/78045232 and + /questions/51347627 — `-D` = local SOCKS server feeding + per-connection `direct-tcpip` opens; no distinct channel type. + - SSH3 (draft-michel-remote-terminal-http3) — the only "direct-udp" + prior art found; a different protocol (HTTP/3), not RFC SSH. \ No newline at end of file