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

30 KiB
Raw Blame History

status, last_updated
status last_updated
draft 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 | METHODSVER | 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: FLAGSKEEPALIVE(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 0x010x08 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-153OpenChannelMessage parse, TcpChannelInfo (direct-tcpip/forwarded-tcpip payload), StreamLocalChannelInfo, confirm/fail encoders.
    • russh/src/lib_inner.rs:402-423ChannelOpenFailure 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-32ProxyHandler/ProxyHandlerManager inversion point.
    • src/lib.rs:625-724handle_udp_associate_session: SOCKS5 UDP header added/stripped per datagram over the relay endpoint.
  • alktty — /workspace/@alkdev/alktty:
    • src/negotiation.rs:64-120NegotiateRequest (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-108ControlMessage 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.