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
30 KiB
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 §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/-Ddata 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-Rdata 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 adirect-tcpipchannel naming the target; the server dials. Consumer opens, producer handles — the POC's shape.-R(remote forward): the client sendstcpip-forward(global request) to register the listen; the server then opensforwarded-tcpipchannels 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 adirect-tcpipchannel with that target (stackoverflow.com/questions/78045232; confirmed by OpenSSH's sshd(8)/ssh(1) "dynamic forward" description). At the SSH protocol level-Dis indistinguishable from-L: onedirect-tcpipchannel 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-Dexactly as "SOCKS5 server in front of per-connectiondirect-tcpipopens."
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)
- TCP connect to the SOCKS port (1080).
- Method negotiation:
VER | NMETHODS | METHODS→VER | METHOD(0x00no-auth,0x02user/pass,0xFFno acceptable methods). - Optional method sub-negotiation (user/pass per RFC 1929).
- Request:
VER | CMD | RSV(0x00) | ATYP | DST.ADDR | DST.PORTwith CMD0x01CONNECT /0x02BIND /0x03UDP ASSOCIATE. - 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)
- 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). - 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-244stores it asudp_associate). - 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_responserejectsdata.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)andERR(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_packetis 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:
- 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.
- 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, substrateudpor eventcpfor 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. - 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/tunnelALPN) viable: udpgw proves one framing can carry the datagram case over a stream substrate without the ALPN knowing. - 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). - 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_timeoutconcept) 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:
- 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 wholeWindowSizeRefmachinery (channels/mod.rs:116-139) is the thing we don't build. - SSH's
maximum packet sizenegotiation. Channels owns chunk sizing; the tunnel codec inherits it. (udpgw's MTU cap is the datagram-flavored version — also producer-side, not wire.) - 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.
- 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.
- SOCKS5's method negotiation / auth sub-negotiation. alkcall's
AuthContext/AccessControlat the connection and open-op layers owns authentication (AGENTS.md convention 13); SOCKS5 auth exists only because SOCKS rides raw TCP. The-Dcomposition inherits alkcall's auth for free. - 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.
- udpgw's KEEPALIVE flag and connection pooling. Both are transport-layer concerns (NAT keepalive, TCP stream count) that channels absorbs (see §UDP above).
- SSH's
tcpip-forwardport-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-tcpippayload 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-streamlocalshows 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 newsubstratevalue, 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:
AccessControldenial, target dial failure, unregistered resource/substrate, channels limits. SOCKS5's finer network/host/refused granularity can live indetailrather 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/
-DUDP (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
paramstells 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—OpenChannelMessageparse,TcpChannelInfo(direct-tcpip/forwarded-tcpip payload),StreamLocalChannelInfo, confirm/fail encoders.russh/src/lib_inner.rs:402-423—ChannelOpenFailurereason enum.russh/src/client/mod.rs:675-760— publicchannel_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_openfailure path;server/session.rs:1055-1115, 1186-1233— server-initiated opens andtcpip-forwardglobal 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-26flags;:147-153keepalive/error packet builders;:527MTU cap. (Analyzed in phase-0-findings.md §Prior art.)src/socks.rs:11-20— SOCKS5 client state machine;:213-224request encoding (UDP ASSOCIATE sends unspecified address);:226-248reply parsing +udp_associatecapture.src/proxy_handler.rs:9-32—ProxyHandler/ProxyHandlerManagerinversion 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—ControlMessageJSON 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
-Dprotocol-level behavior (no local source; web):- stackoverflow.com/questions/78045232 and
/questions/51347627 —
-D= local SOCKS server feeding per-connectiondirect-tcpipopens; 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.
- stackoverflow.com/questions/78045232 and
/questions/51347627 —