Files
alktunnels/docs/research/phase-0-findings.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

40 KiB
Raw Blame History

status, last_updated
status last_updated
draft 2026-09-05

alktunnels — Phase 0 Research Findings

This document captures Phase 0 (Exploration) findings and open design questions for the alktunnels crate. The objective of Phase 0 per docs/sdd_process.md is: "Capture vision and guiding principles; research options; validate approaches; converge on a recommended approach." It is the input to Phase 1 (Architecture), where the Architect will produce docs/architecture/ specs, ADRs, and open questions.

Drafted 2026-09-05, emerging from the initial setup discussion. The crate is the sibling of alktty (alk/tty — terminal sessions) on the alkcall substrate: where alktty multiplexes one service with a fixed five-stream channel structure, alktunnels generalizes the tunnel handler shape to arbitrary bidirectional tunnels in the ssh -L / ssh -D sense — TCP, UDP, unix sockets, and other stream or datagram substrates.

The 2026-09-05 first revision adds the tun2proxy UDP gateway prior art (OQ-TN-01, OQ-TN-02, OQ-TN-07, OQ-TN-10) and the transport story clarification (TCP vs QUIC at the channels layer, invisible to this crate — §What is already settled). This prior art was discussed with the POC agent around the alknet-channels POC but never documented; it is captured here so it survives into the spec.

What is already settled

The foundation is POC-validated and ADR-pinned; this crate is not starting from zero. It inherits:

  • The demux→Connection→handler→mux path — validated by the alknet-channels POC (Target 3), now production alkcall channels. The tunnel payload is raw bytes inside a channels data channel; channels strips its 8-byte header transparently (alknet ADR-093 / alkcall ADR-035).
  • The two-pump handler shape — one pump per direction, each pump MUST shut down the opposite sink on completion (try_join! alone deadlocks; alknet ADR-078). POC-validated with a 1 MiB backpressure test. This crate is the second two-pump consumer the ADR deferred helper extraction for (the first was the POC's tunnel handler; SSH direct-tcpip would be a later third).
  • The producer/consumer model — producer registers openable channels via ChannelCore::register_openable (authorization for free via AccessControl); consumer opens tunnel channels via ChannelClient (alkcall ADR-037, ADR-043). Connection direction is independent of tunnel direction.
  • The backend inversion point pattern — substrate-specific types (TcpStream, UdpSocket, unix sockets) confined to feature-gated backend modules, injected at the assembly layer, never imported from the shared/producer/consumer modules (alktty TtyBackend precedent).
  • The wasm-clean default crate — protocol-only code compiles to wasm32-unknown-unknown; socket/platform I/O is feature-gated (alktty precedent).
  • The relay story — tunnels traverse alkcall hub relays transparently via byte-for-byte data-channel forwarding with ID rewrite (alkcall ADR-042). No tunnel-specific relay work.
  • The transport story — the underlying channels transport is TCP or QUIC (QUIC preferred) but that is the alkcall layer's concern, not this crate's: the tunnel protocol sees a BiStream and is transport-agnostic like the sibling crates. UDP tunneling (below) rides the same chunk stream; the stream-vs-datagram question is about what the tunnel protocol frames inside the channel, not about the transport.

Prior art: the hub-owns-the-connection model (OQ-TN-01, OQ-TN-03, OQ-TN-04, OQ-TN-08)

Discussed 2026-09-05. The standing stance across the alk* crates: the hub owns the connection and explicitly proxies to expose resources for others. A produced resource is not a socket — it is an ACL-scoped virtual resource on the side that can reach the real target. Applied to tunnels, this dissolves the SSH direction model:

  • Role follows the resource. Whoever can reach the target is the producer (registers openable tunnel channels via ChannelCore::register_openable, dials/serves the target); whoever wants the bytes is the consumer (opens channels via ChannelClient). This holds for both SSH -L and -R: in both, the entry-point side opens the channel (consumer) and the target side handles the open (producer) — the only difference is which machine hosts the entry point, which is assembly-layer wiring, not protocol.
  • The "exposed port" is a virtual port. The far side may think it exposed a port on the remote end; what actually exists is an ACL-scoped resource in the connection/identity registry — the same shape as operation registration, not a bind. Example: a self-hosted gitea (HTTP in a docker container, really binding a port) is tunneled to the hub; the hub proxies the tunnel to consumers whose ACL grants access to the gitea HTTP service. A SOCKS5 service is the same shape, further downstream.
  • The hub proxy is a producer wrapping a consumer. The proxy is an assembly-layer construct. On leg 1 (gitea side ↔ hub): gitea side is producer, hub is consumer. On leg 2 (hub ↔ service consumer): the hub re-produces the resource ACL-scoped — producer wrapping its leg-1 consumer role. Data path per hop is the same two-pump shape; the consumer opens a channel naming the resource, the producer's open handler dials the target. This means the hub terminates and re-produces rather than relaying byte-for-byte — unlike alkcall ADR-042's transparent data-channel relay — because ACL must apply per hop. (ADR-042 relays remain valid for transports that don't need per-hop ACL; the hub's tunnel proxy is the explicit-ACL path.)
  • -D (dynamic/SOCKS) composes. A SOCKS5 server at some assembly layer is just a consumer that opens channels with per-connection dynamic targets, gated by the producer's target policy. Not a base crate concern (same conclusion as the original half-answer, now with the mechanism named).
  • No forced binding, everywhere (OQ-TN-04 resolved). Binding is always assembly-layer and optional, on either side. The protocol never binds: a producer "produces a resource" (which may or may not correspond to a local bind — a docker container's port, a unix socket, an in-process service); a consumer "consumes it." The SSH mental image of "expose a port" is an illusion the assembly layer may create locally; the protocol only ever carries produce/consume bookkeeping.

Model, one sentence: a producer produces a resource (a stream of basically anything, if TCP and UDP are supported); a consumer consumes it; direction, bind, and ACL scoping are assembly-layer concerns wrapped around that pair.

Residue (folded into other OQs):

  • How does a consumer discover produced resources — resolved 2026-09-05: the existing bidirectional ACL-filtered ops listing (openable channels are operations, alkcall ADR-047). See OQ-TN-08.
  • Dynamic-target policy for -D-style opens — dissolved 2026-09-05: -D is "just tunnel a socks5 connection"; target selection lives in the socks5 protocol at the producing side, governed by the same op-level ACL. See OQ-TN-08.
  • Whether the hub proxy needs anything from this crate (a re-produce helper? typed open-handler composition?) or composes from the public producer/consumer surface as-is → spec question for Phase 1, tracked here as part of OQ-TN-05.

Prior art: tun2proxy UDP gateway (OQ-TN-01, OQ-TN-02, OQ-TN-07, OQ-TN-10)

/workspace/tun2proxy/src/udpgw.rs implements a UDP gateway over a TCP stream — structurally the same problem this crate faces for UDP tunnels over channels data channels. Discussed with the alknet-channels POC agent as the example of "UDP over a stream substrate," but never documented. Key mechanics, all of which generalize:

  • Per-datagram length framing over the stream — the packet format is LEN(u16 BE) | FLAGS(u8) | CONN_ID(u16) | [SOCKS5 address] | DATA (udpgw.rs:82-88). Boundary preservation is re-added by the protocol, not by the substrate: exactly the "length-prefix each datagram inside the channel" half-answer in OQ-TN-02, proven in production.
  • SOCKS5 address format travels per data packetATYP (0x01 IPv4 / 0x03 domain / 0x04 IPv6) + variable address + port (udpgw.rs:68-76). This is concrete prior art for OQ-TN-01's addressing: a scheme-tagged addressing encoding with v4/v6/domain coverage already standardized. Note the asymmetry with TCP tunnels: for UDP, the remote endpoint is per-datagram, not per-channel.
  • One stream carries many UDP flowsCONN_ID(u16) multiplexes associations over a single gateway connection (udpgw.rs:66), with keepalive (0x01) and error (0x20) flag packets as the only non-data frame types (udpgw.rs:21-26). This is the "one channel = one association, per-endpoint multiplexing inside" half-answer in OQ-TN-02, with the refinement that the per-endpoint multiplexing key (CONN_ID) is protocol-level, allocated by the client, u16.
  • Flow lifecycle is packet-leveludp_timeout idle expiry, keepalive_time heartbeats on idle connections (UDPGW_KEEPALIVE_TIME = 30s, udpgw.rs:16), and an MTU cap (parse_udp_response rejects data.len() > udp_mtu, udpgw.rs:527). Also UDPGW_MAX_CONNECTIONS = 5 pooled gateway connections above the packet layer — a throughput choice, not a protocol requirement; channels gives us N channels already.
  • Implications for alktunnels:
    • UDP boundary preservation over the chunk stream is validated prior art, not speculation — raises confidence in the OQ-TN-02 half-answer considerably.
    • The frame-type set (DATA/KEEPALIVE/ERR) is a useful minimal vocabulary — it maps onto OQ-TN-09's establishment/error frame question (tun2proxy uses flag packets, alktty uses typed control chunks; both are self-contained frames inside the data stream).
    • Per-datagram addressing (SOCKS5-style) vs per-channel addressing (fixed target at open) is a real fork for the params design: TCP tunnels fix the target at open; UDP associations may either fix one endpoint at open or carry per-datagram addresses like udpgw. This interaction is unresolved and feeds OQ-TN-01 + OQ-TN-07.
    • NAT/keepalive concerns partially disappear on channels: the underlying transport (QUIC/TCP) handles connection keepalive, and channel liveness is the channels layer's concern. The tunnel protocol likely needs only flow-level idle expiry, not transport-level keepalive packets — TBD in the spec.

Prior art: the alktty stream-splitting pattern (OQ-TN-09, OQ-TN-05)

Discussed 2026-09-05. alktty tunnels io-like streams: the adapter splits the BiStream from accept_bi into read/write halves (tokio::io::split), wraps each half in a ChunkReader/ChunkWriter (wire.rs — the 5-byte [stream_type: u8][length: u32 be][payload] codec), and pumps chunks between the wire halves and the backend's TtyHandle halves. Strip packets on the way in, wrap packets on the way out; everything above the codec sees plain AsyncRead/AsyncWrite.

The proposal: that same pattern (or close) may be the tunnel pattern. A tunnel is then literally "tty without the five stream types" — split the channel's BiStream, wrap the halves in a codec, pump to/from substrate halves. What differs per concern is only the codec and the far-end handle:

alktty alktunnels (TCP) alktunnels (UDP)
wire codec 5-byte, 5 stream types raw pass-through or minimal framing length-prefixed datagrams (tun2proxy-style)
far end TtyHandle (stdin/stdout/stderr/ctrl) TcpStream split halves UdpSocket + flow table
pumps 3 (stdout/stderr + stdin, exit future coordinates) 2 (the ADR-078 shape) 2 + flow expiry

Implications:

  • OQ-TN-09 falls out naturally. alktty's STREAM_CTRL_IN/OUT (flag frames inside the same codec) is the precedent for the establishment/error frame: same codec, one extra stream type (TUNNEL_CTRL or similar), typed control messages — rather than a separate framing layer. alktty's zero-length-sentinel EOF semantics carry over unchanged.
  • OQ-TN-05 (backend trait) gets a concrete shape to judge. If tunnels are "split + wrap + pump," the far-end handle is just "(AsyncRead + AsyncWrite) halves" for stream substrates — which is either a very thin trait or no trait (fn returning boxed halves). The UDP case's flow table is the real differentiator to design around, not the stream case.
  • Possible convergence with OQ-TN-06 (two-pump helper). If the pump loop shape is identical to alktty's modulo the number of pumps, a shared helper extraction (this crate's second-consumer moment, alknet ADR-078) should be evaluated against alktty's pump_session too — the convergence test the ADR asked for may be nearly free.
  • Wasm story intact — the codec is pure byte manipulation; the split/pump plumbing is tokio-async, no substrate types in the protocol layer. Same as alktty.

Open: whether the tunnel codec is literally alktty's 5-byte format with different stream-type constants (reuse), a stripped 5-byte variant (1 stream type + ctrl), or raw pass-through with framing only for UDP (the OQ-TN-01 reframe suggests TCP needs nothing beyond the channels chunk stream). Phase 1 ADR, before the first consumer.

Open Questions

These are the design questions Phase 0 must resolve (or explicitly defer) before the architecture spec. They are numbered OQ-TN-01.. so they can be referenced, tracked, and promoted into docs/architecture/open-questions.md in Phase 1. Half-answers and hunches are marked as such — the point of this document is to hold them without forcing premature decisions.

OQ-TN-01: Target addressing format

What does the tunnel params on channel/open look like? alknet ADR-071 §ALPN table noted alknet/tunnel as [0, 1] data in/out only, but the addressing scheme was never decided. It must cover at minimum:

  • TCP dial (host:port)
  • UDP (associate-style or endpoint-style — see OQ-TN-02)
  • Unix domain sockets (path)
  • Direction (who dials the target — see OQ-TN-03, resolved)
  • Bind/listen vs dial semantics (see OQ-TN-04, mostly resolved)

Considerations:

  • params is ALPN-specific JSON, interpreted by the open handler, not by the channels layer (alknet ADR-075 / alkcall ADR-039). alktty's precedent is the NegotiateRequest shape — a self-contained JSON object carried in the open op.
  • The addressing string is wire-stable once a consumer exists (one-way door). It must be substrate-extensible without format changes (a new substrate should be an additive scheme value, not a v2 format).
  • Prior art to survey: SSH forwarding models (direct-tcpip, forwarded-tcpip), SOCKS5 addressing (ATYP + addr + port), russh (/workspace/russh — note: russh has no UDP channel type at all, so there is no direct-udp-style prior art there; still useful for channel-open framing and addressing-intel generally).
  • ALPN convention check (2026-09-05): the ALPN prefix swap alknet/alk/ happened alkcall v0.1.1, before the first published consumer. Everything in this crate's docs referencing alknet/tunnel means alk/tunnel — a small thing, but docs/ADRs must not perpetuate the old prefix or consumers will bake it in.

Reframe 2026-09-05 — this is partly an XY problem. The rich "remote addressing" framing was chasing the wrong thing. Except in the -D/dynamic case (which composes at the assembly layer), a tunnel is either TCP or UDP, and params need only identify a produced resource — not carry a general-purpose address. The producer owns where the resource comes from (a local port, a docker container's port, an in-process service, a unix socket — its problem, most likely a local port). So params reduce to:

  1. Which resource (a produced resource identifier — stable name or target address, still to decide),
  2. Substrate discriminator (tcp / udp / extensible) — so UDP is structurally supported from day one even if the vast majority of use is TCP.

Rich in-band addressing (SOCKS5 ATYP, per-datagram remote addresses) enters only through the -D/dynamic-target composition path (OQ-TN-03/OQ-TN-08 residue), not through the base open-op params.

Status: mostly resolved 2026-09-05 (pending the resource-naming and discovery residue). Direction of travel:

  • params = self-contained JSON object in the open op (alktty NegotiateRequest precedent) — accepted path.
  • params identify a produced resource + substrate discriminator; producer owns the backing. No URL-style general addressing.
  • Residue: stable resource name vs target address as the identifier shape; discovery resolved 2026-09-05 — see OQ-TN-08 (the existing ACL-filtered ops listing IS tunnel-resource discovery, since openable channels are operations per alkcall ADR-047); exact JSON field layout (Phase 1 spec, ADR before first consumer — wire-stable 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

Does a UDP tunnel preserve datagram boundaries end-to-end, or does the tunnel present a byte-stream abstraction to the consumer (boundaries lost, re-chunked arbitrarily)?

  • Channels is a chunk stream with bounded buffers; the zero-length chunk is the EOF sentinel — datagram boundaries are not preserved by the substrate (alknet ADR-071/093; the POC only exercised TCP).
  • Corrected 2026-09-06 (ssh-socks5-survey.md): an earlier line here claimed "SSH's -D UDP associate tunnels UDP as a stream" — a category error. SSH has no UDP forwarding at all (RFC 4254 defines only TCP/X11/session channels; russh is grep-confirmed UDP-free; 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 a full UDP-over-TCP model worth reading.
  • Boundary preservation is a wire-format decision (per-datagram length framing inside the BiStream) and would need an ADR + possibly a BAST document (AGENTS.md convention 12). Boundary loss is cheaper but changes what protocols can ride the tunnel (DNS? QUIC? game traffic?).
  • Datagrams also raise multiplexing questions TCP does not: one UDP "association" carries many remote endpoints — does one tunnel channel carry one endpoint or many, and how are per-endpoint replies routed?

Status: resolved as a split by path, 2026-09-06 (survey ssh-socks5-survey.md + tun2proxy prior art):

  • Base open-op UDP resources: endpoint-at-open. The resource identifies the endpoint; one channel = one UDP flow (or one pinned association). Aligns with resource naming (OQ-TN-01); per-datagram addressing would reintroduce the "general addressing in params" the reframe removed. Boundary preservation inside the channel stays 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 (SOCKS5 UDP header or udpgw format), composed at the assembly layer, never in base params.
  • 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)

Status: resolved 2026-09-05 by the hub-owns-the-connection model (§Prior art: the hub-owns-the-connection model). There is no protocol-level direction: role follows the resource. Whoever can reach the target is the producer (registers openable channels); whoever wants the bytes is the consumer (opens channels). SSH -L and -R are the same producer/consumer pair with the entry point on different machines — assembly-layer wiring, not protocol. The "exposed port" is a virtual, ACL-scoped resource on the producing side; the hub proxy is a producer wrapping a consumer. -D/SOCKS composes as a consumer opening channels with per-connection dynamic targets, gated by the producer's target policy (OQ-TN-08) — not a base-crate concern. Original question retained below for context.

Considerations (original):

SSH has three forwarding flavors; the crate must model them without "server/client" framing:

  • -L (local forward): consumer dials a local port; producer dials the target. Channels flows consumer→producer; target dial happens on the producer side. This is the POC's shape.
  • -R (remote forward): producer (or a third party) listens; the consumer's side dials or accepts incoming connections and asks the other side to carry them. Channels flows producer→consumer.
  • -D (dynamic/SOCKS): one side runs a SOCKS5 server; the target is chosen per-connection by the client. Addressing arrives per-channel, not per-tunnel-registration.

Both sides can be producer and consumer simultaneously (alkcall ADR-022/037 direction semantics), so the model must not bake direction into the connection. The open questions:

  • Is direction a field in params, or two distinct open-handler shapes / ALPNs?
  • How does -R register availability (the side that will carry traffic advertises listen targets)? Does it interact with channel/open at all, or is it a call-level operation ("please open a tunnel channel to me when a local accept happens")?
  • Dynamic (-D) may not be a tunnel concern at all — it may compose as "SOCKS5 server implemented over alktunnels dial primitives" in a separate crate. Keep or cut for v1?

Status: open — needs architecture decision. Half-answer (hunch): -L is the channel/open handler; -R needs a small advertisement/lifecycle surface; -D composes on top and is out of scope for the base crate.

OQ-TN-04: No forced local binding

A tunnel must not require the producer (or consumer) to bind a local port. The POC's shape dialed a target from the handler; binding is optional and belongs to the caller (assembly layer), not the protocol crate. The API surface must support:

  • Dial flows with no local bind (POC shape) — covered.
  • Listen flows where the binding happens on one side only.
  • Unbound/abstract flows (e.g. unix socketpair-style, stdio bridges, in-process pipes) where neither side binds.

The protocol layer must express "carry bytes between this target and this channel" without assuming either endpoint is a bound socket. Substrate modules (behind feature flags) own actual bind() calls; the protocol owns bookkeeping only.

Status: open — mostly resolved (2026-09-05, see §Prior art: the hub-owns-the-connection model): binding is always assembly-layer and optional, on either side; the protocol never binds. What remains is the concrete API surface — who calls what to start a tunnel in each mode (produce-with-dial, produce-without-dial/accept-style, consume). This is now a spec-shape task, not a research question.

OQ-TN-05: Backend inversion point — is there a TunnelBackend trait?

alktty has TtyBackend because backends (local PTY, docker, SSH) produce handles and the adapter pumps them. For tunnels, the producer side's substrate action is narrower — dial a target, or accept on a listener — so the question:

  • Is a TunnelBackend-style trait needed at all, or is the two-pump handler + feature-gated substrate modules (dial/listen helpers) the whole story, with the assembly layer wiring substrate streams directly?
  • If a trait: what is the handle type? A tunnel "handle" is just an AsyncRead + AsyncWrite stream (or a datagram endpoint) — much thinner than TtyHandle's stdin/stdout/stderr/exit-code quadruple. The trait may collapse to "produce a boxed stream for this target" plus a listener variant.
  • Backpressure/limits come from channels (AGENTS.md convention 10); the backend trait must not add a second layer of them.
  • New sub-question from the hub model (2026-09-05, §Prior art): the hub's tunnel proxy re-produces a resource it consumes (producer wrapping a consumer). Does the proxy need a composition helper from this crate, or does it assemble from the public producer/consumer surface as-is? If a helper is warranted, it may share shape with the substrate dial/listen trait — which would argue for the trait.

Status: open — needs a survey of what backends would actually implement (local TCP? docker exec? ssh -w?) before deciding trait vs no-trait. Half-answer (hunch): a thin trait (or just a fn alias) for "obtain a bidirectional substrate stream for a target," possibly no trait at all if the only meaningful backends are local sockets — decide after surveying candidate backends.

OQ-TN-06: The two-pump helper — extract now?

alknet ADR-078 deferred helper extraction until a second two-pump consumer exists ("a genuine deferral... the contract is decided (shutdown-on- completion), only the extraction is deferred"). This crate is that second consumer (POC tunnel was the first; SSH direct-tcpip would be a third).

  • Does the helper live here (as a pub utility other handler crates can use), or upstream in alkcall (which already owns core types)?
  • Shape: pump_bidi(recv, send) -> (Future, Future) returning both pumps with the shutdown-on-completion wired in? Or a join_two_pumps(a, b) combinator?
  • alknet ADR-057 (two-pump helper extraction OQ) noted the helper from one consumer would bake in a wrong shape; with two consumers the shapes should be compared before extraction.

Status: open — decide when the first real tunnel handler is written; not a blocker for the spec. Half-answer: the helper probably belongs upstream (alkcall, near the channels-adapter handler-integration conventions) but only if the two shapes genuinely converge.

OQ-TN-07: ALPN strategy

This crate owns the alk/tunnel-family ALPN(s). alkcall ADR-004: one ALPN per protocol; alk/ prefix. If stream (TCP/unix) and datagram (UDP) tunnels get distinct ALPNs, the split must be decided before the first consumer — ALPN strings are wire-stable once published.

  • Option A: single alk/tunnel ALPN; substrate is a params field (and datagram framing, if any, is self-describing inside the channel).
  • Option B: alk/tunnel (stream) + alk/tunnel-dgram (datagram), so the wire framing differs per ALPN cleanly.
  • Channels' params is ALPN-specific, and the open-handler registry dispatches per ALPN — both options are cheap mechanically; the cost is consumer-side API bifurcation (two session types vs one with a substrate enum).

Status: option A strengthened, 2026-09-06 (survey ssh-socks5-survey.md): SSH uses one channel mechanism for all forwarding types (the type string is per-open metadata, not a separate transport); SOCKS5 runs CONNECT and UDP ASSOCIATE over one control connection with a CMD discriminator; udpgw proves datagram framing self-describes over a stream. With OQ-TN-02 resolved as endpoint-at- open for base UDP resources, the substrate discriminator in params 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

Tunnels reach local networks — the open gate is the security boundary. Shape follows alktty: TUNNEL_OPEN_SCOPE scope-gate, and the channels path gets AccessControl wiring for free via ChannelCore::register_openable. Open sub-questions:

  • Should ownership (OwnershipProvider.owns(...)) be consulted for tunnel targets, and what is the resource identity of a tunnel target (a host:port? a registered tunnel name?), given targets may be arbitrary strings and wildcard targets (0.0.0.0/0-style egress) may be intentionally allowed for some identities?
  • Is there a target-allowlist concept (per-identity reachable target sets), and does it live in AccessControl or in the open handler's params validation?

Status: mostly resolved 2026-09-05 — this OQ pointed at a general conceptual tangle ("the host owns the resource and proxies on top of that" — how does that manifest for tunnels?), and the resolution is the same posture alktty already uses:

  • Assume the resource is owned by the other side of the connection. At these protocol-crate levels (alktunnels, like alktty), the protocol works under the assumption that a produced resource belongs to the far side — so the ACL story is exactly alkcall's existing op-level ACL. No new policy layer, no target allowlists, no tunnel-specific ownership machinery. The actual proxy/overlay mechanism (hub workers connecting in and exposing tunnels, the hub providing an overlay to those resources based on the other side's ACL) is a downstream (assembly-layer) concern.
  • Discovery is the existing bidirectional ops listing. alkcall already has an underlying bidirectional discovery mechanism — each side can obtain the list of ops available to it (ACL-filtered). Since openable channels are operations (alkcall ADR-047), that listing is tunnel-resource discovery: a consumer asks "what ops are available to me" and produced tunnel resources appear there, scoped by identity. This is exactly how a consumer learns a socks5 tunnel, a postgres TCP tunnel, or a redis tunnel is an available resource. Examples of the pattern: services typically served over a VPN or SSH tunnels (postgres, redis, gitea HTTP) — workers connect to a hub, expose those tunnels, and the hub proxies them per-ACL.
  • -D simplifies to "just tunnel a socks5 connection." The socks5 server lives on the producing side; the consumer opens an ordinary tunnel channel to that resource and speaks socks5 inside it. Target selection happens in the socks5 protocol at the far side — not in tunnel params — so the previously-tracked "dynamic-target policy hook for -D-style opens" residue dissolves: whatever ACL governs the socks5 resource governs everything reachable through it, plus whatever policy the socks5 implementation itself applies downstream.

Residue for Phase 1 (spec-shape, not research): confirm the ops-listing surface carries enough per-resource metadata (substrate type, resource name/description) for a consumer UI to distinguish produced tunnels — or whether params-describing metadata rides alongside the operation spec. That is an alkcall ADR-047 interaction, not a new mechanism.

Upstream posture (2026-09-05): we own the upstream. Both prior downstream crates (alktty, alkhttp — /workspace/@alkdev/alkhttp) required fixes/additions to alkcall, and the rule is to make those asks early: there are only three downstream dependents right now, and each issue resolved upstream makes the next dependent cheaper. So if the ops listing needs per-resource metadata (or anything else in the ADR-047 interaction), the expectation is a small upstream alkcall change in Phase 0/1 — not a workaround in this crate.

OQ-TN-09: Lifecycle, teardown, and error reporting

The two-pump shape gives byte-level teardown for free (EOF sentinels; channels drops per-channel senders on transport EOF — alknet ADR-078, POC issue #6). What's missing is the error/level above bytes:

  • How does a failed target dial reach the consumer (e.g. "connection refused to 10.0.0.5:80")? Is there a structured error frame in the channel before close, a channel/close with reason, or call-level error on the open op?
  • Is there a "tunnel established/failed" ack before byte pumping starts (alktty has the negotiation frame; the POC's tunnel handler had nothing — it dialed and pumped)?
  • Half-open semantics: one direction EOFs, the other keeps pumping (standard two-pump behavior) — is that always desired, or does the consumer need a "close both" control?

Status: direction set 2026-09-05; vocabulary informed 2026-09-06 (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; dial errors are tunnel-closing (the whole channel dies), whereas byte-level EOFs stay per-direction. The alktty-style JSON frame is the strictly richest of the four prior-art vocabularies (SSH code+desc, SOCKS5 codes-only, udpgw 1 opaque bit — the counterexample to avoid).

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

The alknet-channels POC validated TCP only. Candidate targeted POCs Phase 0 may need (in rough priority order, per the SDD process's "validate promising approaches"):

  1. UDP tunnel POC — boundary-preserving length framing over a channels channel, per-endpoint multiplexing inside one association, backpressure behavior. Partially derisked by the tun2proxy prior art (§Prior art) — the POC now mainly validates channels-layer fit: chunk-size vs datagram-size interaction, MTU cap against the channels bounded buffers, idle-expiry mapping, and the chosen 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 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 agnostic" beyond IP substrates.
  4. Two-pump helper extraction spike — OQ-TN-06, only after 13.

POC placement conventions (2026-09-05): a POC that needs code from this repo runs in a worktree/branch (.worktrees/research/<task-id>/ per the SDD process); a POC that is self-contained runs as a standalone crate in the global workspace (the /workspace/alknet-channels-poc precedent) with its findings written into docs/research/ here. Both are valid; pick per the POC's dependency footprint. Findings always land in docs/research/ regardless of where the code lives.

Status: open — pick 1 (and probably 2) after the research pass; 3 is cheap enough to fold into whichever POC runs first.

Survey / prior-art list

Candidate reading for the research specialist (to be expanded):

  • SSH channel/forwarding model: RFC 4254 §7 (direct-tcpip / forwarded-tcpip), OpenSSH -L/-R/-D semantics, russh's ChannelOpen framing (russh is already in /workspace/russh; confirmed 2026-09-05: no UDP channel support at all — no UDP prior art there, but useful for open-op framing intel).
  • SOCKS5 (RFC 1928): addressing (ATYP), UDP ASSOCIATE framing, per-endpoint multiplexing — the closest standardized "arbitrary tunnel + UDP" model. Relevant to the -D composition path (OQ-TN-03 residue), not the base open-op params (OQ-TN-01 reframe).
  • tun2proxy (/workspace/tun2proxy, src/udpgw.rs): UDP gateway over TCP — per-datagram length framing, SOCKS5 per-datagram addressing, CONN_ID flow multiplexing, keepalive/ERR flag packets, MTU cap, idle expiry. Analyzed in §Prior art. Its socks.rs / proxy_handler.rs are also relevant for the -D (dynamic/SOCKS) composition question (OQ-TN-03).
  • quinn-proxy-poc (/workspace/quinn-proxy-poc) and iroh (/workspace/iroh): datagram-native transports; how they model per-endpoint flows.
  • alknet docs: ADR-071 §ALPN table (alknet/tunnel row), ADR-078, docs/architecture/crates/channels/channel-operations.md (params for alknet/tunnel is "the target resource"), and the hub-relay interaction (ADR-042/079).
  • alktty: NegotiateRequest shape (self-contained negotiation precedent), TtyBackend inversion point, TTY_OPEN_SCOPE access 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)

  • Survey notes: SSH/SOCKS5 addressing + UDP framing (OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway (§Prior art) + ssh-socks5-survey.md (SSH/SOCKS5, error vocabularies, endpoint-vs-per-datagram, anti-prior-art list)
  • Reframe landed (OQ-TN-01) — params = self-contained JSON open-op object identifying a produced resource + substrate discriminator (tcp/udp/extensible); producer owns the backing; no URL-style general addressing. Residue: resource naming shape, exact JSON layout (Phase 1 ADR); discovery resolved (OQ-TN-08)
  • Direction model resolved (OQ-TN-03) — hub-owns-the-connection model: role follows the resource, no protocol-level direction; -D composes at the assembly layer
  • No-forced-binding requirement encoded (OQ-TN-04) — binding is always assembly-layer and optional; remaining work is the concrete produce/consume API surface sketch (spec task)
  • Access control + discovery resolved (OQ-TN-08) — resource ownership assumed on the far side; alkcall's existing ACL applies as-is; ops listing (ADR-047) is tunnel-resource discovery; -D = "tunnel a socks5 connection" (target selection in the socks5 protocol, not params). Residue: per-resource metadata in the ops listing — small upstream alkcall ask, early per the upstream posture (alktty/alkhttp precedent)
  • Lifecycle/error direction set (OQ-TN-09) — establishment/error control frame accepted in principle (dial errors are channel-closing); frame vocabulary follows the alktty stream-splitting pattern; concrete set = Phase 1 ADR
  • Codec decision (new, from the alktty pattern prior art) — reuse 5-byte format vs stripped variant vs raw pass-through + UDP framing; feeds OQ-TN-07 (ALPN) and the OQ-TN-05 trait shape
  • Decision input: backend trait vs no-trait (OQ-TN-05), now including the hub re-produce composition question
  • Targeted POC(s) run + summary (OQ-TN-10) — UDP first, reverse flow second
  • Open questions promoted to Phase 1 docs/architecture/open-questions.md with statuses