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:
@@ -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": <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.
|
||||
Reference in New Issue
Block a user