alkcall 0.7.0 resolved every open downstream-consumer finding, including this crate's reverse-flow POC W1 (filed as ledger CF-005, with the CF-006 corollary): - CF-005 (a): ServingConfig.identity — the explicit override - CF-005 (b): transport Connection::identity() propagates to channel 0 — set_identity before dialing is the mTLS/QUIC path - CF-006: the establisher/pump handler receive the per-call opener identity (dispatch-resolved), not the install-time context - CF-007: ADR-016 code list completed (doc-only) - F-1 (POC finding): the ChannelPlan type doc now carries the Send + Sync payload constraint alktunnels updates: - Cargo.toml: alkcall 0.6.0 -> 0.7.0 (lockfile resolves 0.7.0) - AGENTS.md convention 11: pin note updated; the 0.7.0 identity surface marked load-bearing (precedence order + per-call opener) - AGENTS.md architecture context: CF-005/CF-006 row added; the 'What the POC does NOT settle' list replaced with the accurate settled state (Phase 0 complete; Phase 1 = the spec) - reverse-poc-summary: W1 marked RESOLVED with the remediation validated from the POC's topology (16 tests over 0.7.0: transport identity alone authorizes, ServingConfig.identity overrides, identity-less fails closed, CF-006 witness); F-1 marked documented upstream; what-the-POC-does-not-validate and verification blocks updated - phase-0-findings OQ-TN-10: reverse POC entry notes the 0.7.0 re-validation and W1 resolution The reverse POC (/workspace/alktunnels-reverse-poc, 8b35818) re- validated: the default harness now authorizes on transport identity alone — the 0.6.0-era auth_token workaround is dead code. Verification: cargo test, clippy --all-targets -D warnings, fmt --check, wasm32 check, doc --no-deps — all clean
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 (#3 unix/stdio, #4 helper spike) are spec-scope: #3 folds into the real crate's tests (pump agnosticism confirmed twice), #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