Files
alktunnels/docs/research/ssh-socks5-survey.md
T
glm-5.3-flash 1a3eb8418d 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
2026-09-06 09:20:08 +00:00

602 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.