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:
@@ -307,6 +307,16 @@ and discovery residue). Direction of travel:
|
|||||||
field layout (Phase 1 spec, ADR before first consumer — wire-stable
|
field layout (Phase 1 spec, ADR before first consumer — wire-stable
|
||||||
once published).
|
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
|
### OQ-TN-02: Datagram substrates (UDP) — boundary preservation
|
||||||
|
|
||||||
Does a UDP tunnel preserve datagram boundaries end-to-end, or does the
|
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
|
- Channels is a chunk stream with bounded buffers; the zero-length chunk
|
||||||
is the EOF sentinel — datagram boundaries are *not* preserved by the
|
is the EOF sentinel — datagram boundaries are *not* preserved by the
|
||||||
substrate (alknet ADR-071/093; the POC only exercised TCP).
|
substrate (alknet ADR-071/093; the POC only exercised TCP).
|
||||||
- SSH's `-D` UDP associate tunnels UDP as a stream with per-datagram
|
- **Corrected 2026-09-06** (`ssh-socks5-survey.md`): an earlier line
|
||||||
framing re-added by the tunnel protocol (e.g. SOCKS5 UDP over TCP).
|
here claimed "SSH's `-D` UDP associate tunnels UDP as a stream" — a
|
||||||
Note (2026-09-05): russh does not support UDP channels at all — no
|
category error. SSH has *no UDP forwarding at all* (RFC 4254 defines
|
||||||
`direct-udp`-style prior art exists there; the tun2proxy gateway
|
only TCP/X11/session channels; russh is grep-confirmed UDP-free;
|
||||||
(§Prior art) is the strongest framing precedent.
|
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
|
- iroh and quinn-proxy-poc have native datagram transports; tun2proxy has
|
||||||
a full UDP-over-TCP model worth reading.
|
a full UDP-over-TCP model worth reading.
|
||||||
- Boundary preservation is a wire-format decision (per-datagram length
|
- 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
|
"association" carries many remote endpoints — does one tunnel channel
|
||||||
carry one endpoint or many, and how are per-endpoint replies routed?
|
carry one endpoint or many, and how are per-endpoint replies routed?
|
||||||
|
|
||||||
**Status:** open — survey mostly resolved by tun2proxy prior art
|
**Status: resolved as a split by path, 2026-09-06** (survey
|
||||||
(§Prior art): per-datagram length framing over the stream is
|
`ssh-socks5-survey.md` + tun2proxy prior art):
|
||||||
production-proven (`LEN | FLAGS | CONN_ID | [addr] | DATA`), one
|
|
||||||
stream carries many UDP flows via a protocol-level `CONN_ID`, and
|
- **Base open-op UDP resources: endpoint-at-open.** The resource
|
||||||
flow lifecycle (idle timeout + keepalive) is packet-level. Remaining:
|
identifies the endpoint; one channel = one UDP flow (or one pinned
|
||||||
whether alktunnels fixes the UDP endpoint at open (per-channel, TCP-
|
association). Aligns with resource naming (OQ-TN-01); per-datagram
|
||||||
like) or carries per-datagram addresses (udpgw-like), and whether a
|
addressing would reintroduce the "general addressing in params" the
|
||||||
u16 conn-id vocabulary is right for channels (vs the channel ID
|
reframe removed. Boundary preservation inside the channel stays
|
||||||
itself doing the demux and one channel per UDP flow). Note (2026-09-05,
|
per-datagram length framing (tun2proxy-proven) — endpoint-at-open is
|
||||||
hub model §Prior art): if UDP resources are produced like any other
|
about addressing, not about dropping the LEN prefix.
|
||||||
resource, endpoint-at-open aligns naturally with resource naming
|
- **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside
|
||||||
(OQ-TN-01); per-datagram addressing matches the `-D`/dynamic-target
|
the tunnel payload** (SOCKS5 UDP header or udpgw format), composed
|
||||||
composition path instead. A targeted POC (OQ-TN-10 #1) is likely still
|
at the assembly layer, never in base params.
|
||||||
+EV for the chosen shape.
|
- **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)
|
### 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
|
consumer-side API bifurcation (two session types vs one with a
|
||||||
substrate enum).
|
substrate enum).
|
||||||
|
|
||||||
**Status:** open — needs the OQ-TN-02 outcome first (if datagrams need
|
**Status: option A strengthened, 2026-09-06** (survey
|
||||||
different framing, option B gets stronger). Note from the tun2proxy
|
`ssh-socks5-survey.md`): SSH uses one channel mechanism for all
|
||||||
prior art (§Prior art): udpgw runs its packet framing over a plain TCP
|
forwarding types (the type string is per-open metadata, not a separate
|
||||||
stream — one framing covers both the stream and datagram cases there.
|
transport); SOCKS5 runs CONNECT and UDP ASSOCIATE over one control
|
||||||
If alktunnels follows the same shape (datagram framing self-describing
|
connection with a CMD discriminator; udpgw proves datagram framing
|
||||||
inside the channel), option A (single `alk/tunnel` ALPN) stays viable
|
self-describes over a stream. With OQ-TN-02 resolved as endpoint-at-
|
||||||
even with UDP support; option B remains cleaner if the datagram
|
open for base UDP resources, the substrate discriminator in `params`
|
||||||
channel needs structurally different framing from the first chunk on.
|
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
|
### 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
|
(standard two-pump behavior) — is that always desired, or does the
|
||||||
consumer need a "close both" control?
|
consumer need a "close both" control?
|
||||||
|
|
||||||
**Status: direction set 2026-09-05** — the original half-answer is
|
**Status: direction set 2026-09-05; vocabulary informed 2026-09-06**
|
||||||
accepted: a self-contained control frame (alktty ADR-006 shape)
|
(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;
|
carrying an establishment result/error, sent before any data chunk;
|
||||||
dial errors are tunnel-closing (the whole channel dies), whereas
|
dial errors are tunnel-closing (the whole channel dies), whereas
|
||||||
byte-level EOFs stay per-direction. Beyond that, the frame vocabulary
|
byte-level EOFs stay per-direction. The alktty-style JSON frame is the
|
||||||
should follow the alktty stream-splitting pattern (§Prior art: the
|
strictly richest of the four prior-art vocabularies (SSH code+desc,
|
||||||
alktty stream-splitting pattern) rather than invent a parallel
|
SOCKS5 codes-only, udpgw 1 opaque bit — the counterexample to avoid).
|
||||||
mechanism. Concrete frame set lands as a Phase 1 ADR (wire-stable
|
|
||||||
before the first consumer).
|
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
|
### 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).
|
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
|
2. **Reverse-flow POC** — `-R`-style: the accept side listens, the far
|
||||||
side carries. Derisks OQ-TN-03's advertisement/lifecycle shape.
|
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
|
3. **Unix socket + stdio bridge POC** — cheap; validates "substrate
|
||||||
agnostic" beyond IP substrates.
|
agnostic" beyond IP substrates.
|
||||||
4. **Two-pump helper extraction spike** — OQ-TN-06, only after 1–3.
|
4. **Two-pump helper extraction spike** — OQ-TN-06, only after 1–3.
|
||||||
@@ -648,12 +691,18 @@ Candidate reading for the research specialist (to be expanded):
|
|||||||
- alktty: `NegotiateRequest` shape (self-contained negotiation
|
- alktty: `NegotiateRequest` shape (self-contained negotiation
|
||||||
precedent), `TtyBackend` inversion point, `TTY_OPEN_SCOPE` access
|
precedent), `TtyBackend` inversion point, `TTY_OPEN_SCOPE` access
|
||||||
gate.
|
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)
|
## Convergence checklist (what Phase 0 must produce)
|
||||||
|
|
||||||
- [ ] Survey notes: SSH/SOCKS5 addressing + UDP framing
|
- [x] Survey notes: SSH/SOCKS5 addressing + UDP framing
|
||||||
(OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway done (§Prior art);
|
(OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway (§Prior art) +
|
||||||
SSH/SOCKS5 survey delegated to research specialist
|
`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
|
- [x] Reframe landed (OQ-TN-01) — params = self-contained JSON open-op
|
||||||
object identifying a produced resource + substrate
|
object identifying a produced resource + substrate
|
||||||
discriminator (`tcp`/`udp`/extensible); producer owns the
|
discriminator (`tcp`/`udp`/extensible); producer owns the
|
||||||
|
|||||||
@@ -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