Split the last open substrate-placement question: - Unix: ships with the local feature v1 (dial_unix — same halves shape as TCP; the wire enum already carried unix per ADR-001; the params task's schema list includes all three values) - Stdio: OUT of scope — a spawned process's stdin/stdout/stderr IS alktty's pipe mode (LocalTtyBackend + tokio::process + Stdio::piped, alktty tty-local.md): three multiplexed logical streams + the exit-code control chunk (alktty ADR-004) + signal forwarding (REQ-TTY-02). A stdio bridge here would be alktty's runner mode with the terminal stripped out — a strictly worse duplicate that also drops the semantics that matter (a byte tunnel has neither exit codes nor signals). Remote command execution composes via alktty on the same channels substrate. Updated: open-questions.md OQ-TN-14 (resolved), overview.md feature gate + deps + OQ summary, producer.md OQ ref, OQ-TN-10 promotion (#3 split), phase-0-findings + both POC summaries' resolution notes, params task (schema enum includes unix), local-socket-halves task (unix ships, stdio does NOT — with the composition rationale), oq-tn-14-tracker task repurposed (boundary-maintenance: re-opens only if a consumer needs stdio-without-process-semantics — which would need its own ADR, or if the alktty/alktunnels boundary needs sharpening). Verified: taskgraph valid (12 tasks, no cycles)
49 KiB
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; SSHdirect-tcpipwould be a later third). - The producer/consumer model — producer registers openable channels
via
ChannelCore::register_openable(authorization for free viaAccessControl); consumer opens tunnel channels viaChannelClient(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 (alkttyTtyBackendprecedent). - 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
BiStreamand 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 viaChannelClient). This holds for both SSH-Land-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:-Dis "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 packet —
ATYP(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 flows —
CONN_ID(u16)multiplexes associations over a single gateway connection (udpgw.rs:66), withkeepalive(0x01) anderror(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-level —
udp_timeoutidle expiry,keepalive_timeheartbeats on idle connections (UDPGW_KEEPALIVE_TIME = 30s,udpgw.rs:16), and an MTU cap (parse_udp_responserejectsdata.len() > udp_mtu,udpgw.rs:527). AlsoUDPGW_MAX_CONNECTIONS = 5pooled 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 — historically mapped onto OQ-TN-09's establishment question, now superseded there by alkcall ADR-049 (establishment is the open op's reply, not an in-stream frame). KEEPALIVE and ERR both drop (channels owns liveness; establishment is call-level). The vocabulary survives only as anti-prior-art context.
- 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: partially superseded by alkcall ADR-049. The
establishment/error half is resolved at the call layer (the open op
replies
channel:open_failed; no in-stream establishment frame). What the ctrl-frame pattern still informs: whether a mid-stream control frame is ever needed (v1 likely not — see OQ-TN-09's resolved status), and the alktty zero-length-sentinel EOF semantics carry over unchanged either way. - 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_sessiontoo — 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.
Codec direction set 2026-09-06 (discussion): raw pass-through for
stream substrates, u16 length-prefix for UDP — no 5-byte header.
Phase 1 ADR, before the first consumer.
Rationale (structural, not stylistic): alktty's 5-byte
[stream_type:u8][len:u32] exists because five logical streams share
one BiStream — the type byte is a sub-demux key. A tunnel has
exactly one data stream per direction (the channel's own read/write
halves), so there is nothing to demux and no per-chunk type byte. TCP
needs no length prefix either: the channels layer already
length-prefixes every chunk (8-byte header), so the codec is pure
pass-through. Only UDP needs boundary re-framing (datagram boundaries
do not survive the chunk stream), and u16 suffices — UDP's max
payload is 65507 < 65535.
Per-chunk wire overhead (data chunks, both directions):
| channels header | tunnel codec | total | |
|---|---|---|---|
| alktty in channels | 8 B | 5 B (type + len) | 13 B |
| tunnel TCP | 8 B | 0 B | 8 B |
| tunnel UDP | 8 B | 2 B (len:u16) | 10 B |
Calibration: udpgw's packet header is 5 bytes + a per-datagram SOCKS5 address (its CONN_ID/FLAGS vocabulary is dropped per OQ-TN-02); a DNS-sized datagram (~100 B) pays ~3% for boundary framing.
Trade-offs accepted:
- No in-band control path for TCP channels, ever (any future
mid-stream signaling is a wire break). Post-ADR-049 this is clean —
establishment is call-level (
channel:open_failed), the survey found no mid-stream control need, and the escape hatch is protocol-level (a new ALPN is cheap; a wire change is not). The udpgw KEEPALIVE/ERR vocabulary is dropped per OQ-TN-02. - Sentinel collision does not exist across layers: the UDP codec's
len=0means empty datagram (legal in UDP; DNS uses it, e.g. TCP length-prefix0), while EOF is the channels-level sentinel on theBiStream(length=0in the channels 8-byte header). They live at different layers and do not interact. - UDP-specific framing lives only in the UDP path; a hypothetical future multi-endpoint UDP gateway resource would carry its own self-describing framing inside the datagram payloads (udpgw precedent, OQ-TN-02) — invisible to this base codec.
Remaining for the Phase 1 codec ADR: confirm len=0 empty-datagram
semantics (send allowed? receive maps to a zero-payload datagram),
and whether the UDP length prefix rides u16 BE (leaned) or a varint
(rejected for v1 simplicity — datagrams are MTU-bounded anyway).
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:
paramsis ALPN-specific JSON, interpreted by the open handler, not by the channels layer (alknet ADR-075 / alkcall ADR-039). alktty's precedent is theNegotiateRequestshape — 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
schemevalue, 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 nodirect-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 referencingalknet/tunnelmeansalk/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:
- Which resource (a produced resource identifier — stable name or target address, still to decide),
- 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 (alkttyNegotiateRequestprecedent) — accepted path.paramsidentify 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-DUDP 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 — hasdirect-udp). SSH-Dcarries 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/
-DUDP (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).
Mandate strengthened 2026-09-07 (reverse POC finding F-2): the
length-framed codec is not just boundary preservation — it is
mandatory for correctness on UDP. In the raw pass-through pump
shape, an empty datagram is a zero-byte read from the substrate
adapter — indistinguishable from EOF (tokio::io::copy treats Ok(0)
as end-of-stream and shuts the pump down). An empty datagram and the
zero-length EOF sentinel are the same wire shape at the pump level;
the [len: u16 BE] prefix makes an empty datagram two bytes,
unambiguous. UDP rides the codec, never raw pass-through; raw
pass-through stays stream-substrate-only (where a zero-byte read is
genuinely EOF). Validated from a second angle by the reverse-flow POC
(reverse-poc-summary.md §F-2, pinned by an executable test).
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
-Rregister availability (the side that will carry traffic advertises listen targets)? Does it interact withchannel/openat 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: resolved 2026-09-05 by the hub-owns-the-connection model;
the last open thread (the -R advertisement/lifecycle surface) closed
2026-09-07 by the reverse-flow POC (reverse-poc-summary.md): no
advertisement op is needed. The listener is the initiating side's
own local resource — it binds (assembly layer, OQ-TN-04) and opens
tunnel channels toward the serving side per accept; a far-side
listener ("expose a port on your end") is a producer-side listen
establisher over the same open op, not a new mechanism. The hunch
above is superseded: -L and -R are the same open op with the
entry point on different machines; -D composes on top and stays 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 + AsyncWritestream (or a datagram endpoint) — much thinner thanTtyHandle'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: resolved for stream substrates, 2026-09-06 (POC).
pump_halves is generic over (AsyncRead, AsyncWrite) halves — TCP
contributes into_split() halves, UDP contributes the UdpHalf
socket adapter, the pump never knows which. "Produce boxed halves for
a resource" is a function, not a trait; the TunnelBackend trait
question resolves to no trait for substrate access. The only
remaining thread is the hub re-produce composition question (above) —
a Phase 1 spec question, and now decidable against the validated
producer/consumer surface.
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
coretypes)? - Shape:
pump_bidi(recv, send) -> (Future, Future)returning both pumps with the shutdown-on-completion wired in? Or ajoin_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.
Convergence input 2026-09-06 (POC): the producer pump
(pump_halves) and the consumer pump (take_halves + copy) are the
same shape modulo channel side — the ADR-078 convergence test is
satisfied; extraction is decidable in Phase 1. Also surfaced by the
POC: the OpenHandler JoinHandle semantics (see poc-summary.md §
Issues Surfaced #1) are where such a helper would pin the teardown
contract.
RESOLVED 2026-09-07 — the helper landed upstream in alkcall 0.6.0
(review 007 R-03, ADR-050): alkcall::channels::pump_bidi(channel, peer_read, peer_write) -> (u64, u64). Shape as this OQ sketched it —
two pumps, shutdown-on-completion wired in, copy counts returned for
observability, errors EOF-shaped (no Err state — a mid-stream error
is an abrupt close, so an io::Result would be dead code). The POC
is cited in the ADR as convergence input alongside alktty's channels
session and the assembly-layer copies. alktunnels consumes it; the
POC's pump_halves is now prior art only. The JoinHandle lifetime
contract (R-02) is documented on the OpenHandler type: await the
helper inline inside the handler's task — early return is
teardown-at-birth.
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/tunnelALPN; substrate is aparamsfield (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'
paramsis 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 (ahost: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
AccessControlor 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.
-Dsimplifies 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): resolved 2026-09-06
by alkcall 0.5.0 (review 006 E-02): OperationSpec.description
(with_description) lands additively and round-trips through
services/schema / from_call / op/register, disclosed by
services/list and services/list-peers when set. The tunnel open op
carries its description; the live resource-enumeration half (OQ-40)
stays deferred and is not needed for v1.
Upstream posture (2026-09-05, updated 2026-09-06): we own the
upstream, and the rule is to make asks early. The E-01/E-02 sweep
(alkcall review 006, filed from this crate's Phase 0) is the working
proof: the establishment phase (ADR-049), the typed
ChannelOpenError, and OperationSpec.description all landed in
0.5.0 within a day of being filed — alktunnels was the consumer that
pulled them through. Future upstream asks follow the same path.
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/closewith 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: resolved 2026-09-06 by alkcall ADR-049 (alkcall 0.5.0, review 006 E-01 — the establishment gap filed from this crate's Phase 0 pass). The in-band control-frame hunch below is superseded: establishment failure is now the open op's reply, not a frame on the channel stream.
- The producer's tunnel open op registers via
ChannelCore::register_openable_with_establisher; the establisher (OpenEstablisher) runs as an awaited, bounded establishment phase — semantically validate params, dial the target — before the open op replies. On failure the just-allocated channel is torn down (allocation and teardown balance; ledger un-increment) and the consumer receiveschannel:open_failedwithdetails: {reason, message},reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout— the SSH contract consumer-visibly (a failed open never returns achannel_id). No phantom channel, no establishment frame needed on the data stream. - Implementation note (ADR-049 amendment): the establisher takes
(input, auth)only — the channel's yield-onceBiStreambelongs exclusively to the pump handler. Amendment 2 (alkcall 0.6.0, review 007 R-01) filled the reservedEstablishmentfield:Establishment::new(plan)carries the dialed handle to the pump handler via itsplanparameter (ChannelPlan— typed-opaqueArc<dyn Any + Send + Sync>; downcast happens in this crate). The POC's side-channelHandleHandoffis dead — no handoff map, no same-resource race; the establisher just returnsOk(Establishment::new(dialed)). - What remains for this crate's Phase 1 ADR (narrowed from the
original frame-vocabulary question):
- Half-open semantics: one direction EOFs, the other keeps pumping (standard two-pump behavior) — the only establishment/teardown question not answered upstream. Dial errors are fully establishment-phase now; byte-level EOFs stay per-direction (unchanged).
- Whether the tunnel ever needs a mid-stream control frame (post-establishment). Per ADR-049 §6's pinned posture, pump-phase failures are EOF-shaped by design; the SSH/SOCKS5 survey found no prior art for in-stream control after establishment beyond udpgw's KEEPALIVE (dropped — channels owns liveness). v1 likely needs none; if a UDP flow table ever needs in-band signaling, that is a substrate-framing decision, not a base-wire one.
Original question (retained for context): how does a failed target dial reach the consumer, is there an establishment ack before byte pumping, and what are the half-open semantics?
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"):
-
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).
Done 2026-09-06 — see
poc-summary.md. 17 tests pass; the producer/consumer shape validated end-to-end over alkcall 0.5.0 (establisher + two-pump + codec + typed establishment errors). -
Reverse-flow POC —
-R-style: the accept side listens, the far side carries. Derisks OQ-TN-03's advertisement/lifecycle shape. Template available: SSH'stcpip-forwardglobal-request registration → per-acceptforwarded-tcpipopens → cancel (ssh-socks5-survey.md§RFC 4254 §7.1).Done 2026-09-07 — see
reverse-poc-summary.md. 14 tests pass over alkcall 0.6.0; the review 007 non-finding trace confirmed by execution (from_connection_with_serving+register_on+ serving-side allocation). The advertisement thread of OQ-TN-03 closes: no advertisement op needed — the listener is the hub's own local resource; a far-side listener is a producer-side listen establisher over the same open op. Findings: F-1 (plan payloads must beSend + Sync— documented upstream in 0.7.0), F-2 (UDP raw pass-through cannot carry empty datagrams — the length-framed codec is mandatory, second validation of the codec decision). W1 (connect-side serving path resolves identity only via payloadauth_token) filed for the alkcall ledger — RESOLVED by alkcall 0.7.0 (CF-005/CF-006); the POC re-validated over 0.7.0 (16 tests): transport identity authorizes alone,ServingConfig. identityoverrides, identity-less fails closed, and the establisher sees the per-call opener identity. -
Unix socket + stdio bridge POC — cheap; validates "substrate agnostic" beyond IP substrates.
-
Two-pump helper extraction spike — OQ-TN-06, only after 1–3.
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/-Dsemantics, russh'sChannelOpenframing (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
-Dcomposition 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. Itssocks.rs/proxy_handler.rsare 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/tunnelrow), ADR-078,docs/architecture/crates/channels/channel-operations.md(paramsforalknet/tunnelis "the target resource"), and the hub-relay interaction (ADR-042/079). - alktty:
NegotiateRequestshape (self-contained negotiation precedent),TtyBackendinversion point,TTY_OPEN_SCOPEaccess 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;
-Dcomposes 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). Per-op metadata residue RESOLVED 2026-09-06: alkcall 0.5.0 review 006 E-02 landsOperationSpec.description(round-trips through discovery); the live resource-enumeration half (OQ-40) stays deferred and is not needed for v1 - Lifecycle/error resolved (OQ-TN-09) — superseded 2026-09-06 by
alkcall ADR-049 (0.5.0): establishment is the open op's awaited
phase (
register_openable_with_establisher); dial failure is a typedchannel:open_failedcall error (reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout), never a phantom channel. Residual for Phase 1: half-open semantics only; v1 needs no mid-stream control frame - Codec direction set (from the alktty pattern prior art) — raw
pass-through for stream substrates (0 B tunnel overhead),
[len:u16 BE]per datagram for UDP (2 B); no 5-byte header (a tunnel has one data stream per direction — no sub-demux key needed). Residual for the Phase 1 ADR: empty-datagram (len=0) semantics — strengthened 2026-09-07 (F-2): the codec is mandatory for UDP (empty datagram = zero-byte read = EOF collision in raw pass-through); the empty-datagram semantic under the codec islen=0= a legal empty datagram (forward POC validated). Feeds OQ-TN-07 (option A strengthened: substrate discriminator in params selects the framing) 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 POC DONE
(2026-09-06,
poc-summary.md); reverse-flow POC DONE (2026-09-07,reverse-poc-summary.md— the-Rshape validated end-to-end over alkcall 0.6.0, re-validated over 0.7.0 with the CF-005/CF-006 identity remediation; findings F-1/F-2; W1 filed upstream and RESOLVED). Remaining items resolved in Phase 1: #3 split — unix ships with thelocalfeature (OQ-TN-14); stdio is OUT (alktty's pipe mode owns process stdio — exit codes + signals are tty semantics); #4 resolved by alkcall 0.6.0 (ADR-050) - Two-pump helper convergence input gathered (OQ-TN-06) — the POC
gives both shapes (producer
pump_halves, consumertake_halves+ copy): same shape modulo channel side; the alknet ADR-078 convergence test is satisfied, extraction now decidable in Phase 1. RESOLVED 2026-09-07: the helper landed upstream in alkcall 0.6.0 aschannels::pump_bidi(ADR-050, review 007 R-03) — this crate consumes it; see OQ-TN-06 - OQ-TN-05 executable input (backend trait vs no-trait) — POC
confirms stream substrates need no trait:
pump_halvesis generic over boxed halves (TCPinto_split, UDPUdpHalfadapter); "produce halves for a resource" is a function. The hub re-produce composition question is the only remaining OQ-TN-05 thread - Open questions promoted to Phase 1
docs/architecture/open-questions.mdwith statuses