docs: SSH/SOCKS5 survey + fold findings into OQ ledger

Research specialist survey (docs/research/ssh-socks5-survey.md):
SSH forwarding model (direct-tcpip/forwarded-tcpip payloads, open-
failure reason codes, tcpip-forward registration lifecycle), SOCKS5
(ATYP, CONNECT, UDP ASSOCIATE), error-vocabulary comparison table,
endpoint-at-open vs per-datagram analysis, and an explicit anti-
prior-art list (SSH window updates, packet-size negotiation, dual
channel numbering, SOCKS5 FRAG/auth/BND semantics, udpgw
KEEPALIVE/pooling — all mechanisms channels already owns or the
substrate change obsoletes).

Key corrections to phase-0 findings:
- OQ-TN-02 category error fixed: SSH has NO UDP forwarding at all;
  the earlier '-D UDP associate over SSH' line was wrong (SSH -D
  carries only the SOCKS5 TCP control connection). Real UDP-over-
  stream prior art: tun2proxy udpgw + SOCKS5's own UDP relay.
- OpenSSH -D is protocol-level indistinguishable from -L (one
  direct-tcpip channel per SOCKS CONNECT) — direct confirmation of
  the -D = 'tunnel a socks5 connection' resolution.

OQ ledger updates:
- OQ-TN-01: minimal params shape supported ({resource, substrate});
  direct-streamlocal is the extensibility template; ATYP not needed
  in base params
- OQ-TN-02: RESOLVED as split by path — endpoint-at-open for base UDP
  resources; per-datagram addressing only inside the -D payload;
  channel ID replaces CONN_ID (no second demux); KEEPALIVE drops
- OQ-TN-07: option A (single alk/tunnel ALPN) strengthened
- OQ-TN-09: reason-code vocabulary from SSH's four codes + detail
  string; udpgw ERR bit flagged as the counterexample to avoid
- OQ-TN-10: reverse-flow POC gains the tcpip-forward template
- Checklist: SSH/SOCKS5 survey item checked off
This commit is contained in:
2026-09-06 09:20:08 +00:00
parent 648feb194d
commit 1a3eb8418d
2 changed files with 688 additions and 37 deletions
+86 -37
View File
@@ -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": <id>, "substrate": "tcp" | "udp" | <extensible> }`.
### 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 13.
@@ -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
+602
View File
@@ -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 §47 (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 |
| 0xFE0000000xFFFFFFFF | 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 §36)
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, 1127 = 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 15 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": <id>,
"substrate": "tcp" | "udp" | <extensible> }` 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": <code>, "detail": <string>}`.
- 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.