Files
alktunnels/docs/research/phase-0-findings.md
T
glm-5.3-flash 0397194fe6 docs: resolve OQ-TN-08 — far-side ownership, ops-listing discovery, -D as socks5 tunnel
Untangles the 'host owns the resource and proxies on top' posture for
tunnels:
- Protocol-crate levels assume the resource is owned by the other side
  of the connection; alkcall's existing op-level ACL applies as-is. No
  new policy layer, target allowlists, or tunnel-specific ownership
  machinery. Proxy/overlay is a downstream (assembly-layer) concern
  (hub workers expose tunnels; hub overlays them per-ACL).
- Discovery resolved: openable channels are operations (alkcall
  ADR-047), so the existing bidirectional ACL-filtered ops listing IS
  tunnel-resource discovery. A consumer learns a socks5/postgres/redis
  tunnel is available from the same listing it already uses for ops.
- -D simplifies to 'just tunnel a socks5 connection': socks5 server
  lives on the producing side; target selection happens in the socks5
  protocol, not tunnel params; the dynamic-target policy residue
  dissolves (socks5 resource ACL governs reachability).

Residue for Phase 1: per-resource metadata in the ops listing
(substrate type, name/description) — an alkcall ADR-047 interaction,
not a new mechanism. OQ-TN-01 discovery residue and the hub-model
residue list cross-linked accordingly.
2026-09-06 08:41:34 +00:00

611 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 2026-09-05
---
# alktunnels — Phase 0 Research Findings
This document captures Phase 0 (Exploration) findings and open design
questions for the `alktunnels` crate. The objective of Phase 0 per
`docs/sdd_process.md` is: *"Capture vision and guiding principles; research
options; validate approaches; converge on a recommended approach."* It is the
input to Phase 1 (Architecture), where the Architect will produce
`docs/architecture/` specs, ADRs, and open questions.
Drafted 2026-09-05, emerging from the initial setup discussion. The crate is
the sibling of alktty (`alk/tty` — terminal sessions) on the alkcall
substrate: where alktty multiplexes one service with a fixed five-stream
channel structure, alktunnels generalizes the tunnel handler shape to
arbitrary bidirectional tunnels in the `ssh -L` / `ssh -D` sense — TCP, UDP,
unix sockets, and other stream or datagram substrates.
The 2026-09-05 first revision adds the tun2proxy UDP gateway prior art
(`OQ-TN-01`, `OQ-TN-02`, `OQ-TN-07`, `OQ-TN-10`) and the transport story
clarification (TCP vs QUIC at the channels layer, invisible to this crate —
§What is already settled). This prior art was discussed with the POC agent
around the alknet-channels POC but never documented; it is captured here so
it survives into the spec.
## What is already settled
The foundation is POC-validated and ADR-pinned; this crate is not starting
from zero. It inherits:
- **The demux→Connection→handler→mux path** — validated by the
alknet-channels POC (Target 3), now production alkcall channels. The
tunnel payload is raw bytes inside a channels data channel; channels
strips its 8-byte header transparently (alknet ADR-093 / alkcall
ADR-035).
- **The two-pump handler shape** — one pump per direction, each pump MUST
shut down the opposite sink on completion (`try_join!` alone deadlocks;
alknet ADR-078). POC-validated with a 1 MiB backpressure test. This
crate is the *second* two-pump consumer the ADR deferred helper
extraction for (the first was the POC's tunnel handler; SSH
`direct-tcpip` would be a later third).
- **The producer/consumer model** — producer registers openable channels
via `ChannelCore::register_openable` (authorization for free via
`AccessControl`); consumer opens tunnel channels via `ChannelClient`
(alkcall ADR-037, ADR-043). Connection direction is independent of
tunnel direction.
- **The backend inversion point pattern** — substrate-specific types
(`TcpStream`, `UdpSocket`, unix sockets) confined to feature-gated
backend modules, injected at the assembly layer, never imported from
the shared/producer/consumer modules (alktty `TtyBackend` precedent).
- **The wasm-clean default crate** — protocol-only code compiles to
`wasm32-unknown-unknown`; socket/platform I/O is feature-gated
(alktty precedent).
- **The relay story** — tunnels traverse alkcall hub relays
transparently via byte-for-byte data-channel forwarding with ID
rewrite (alkcall ADR-042). No tunnel-specific relay work.
- **The transport story** — the underlying channels transport is TCP or
QUIC (QUIC preferred) but that is the alkcall layer's concern, not
this crate's: the tunnel protocol sees a `BiStream` and is
transport-agnostic like the sibling crates. UDP tunneling (below)
rides the same chunk stream; the stream-vs-datagram question is
about what the tunnel protocol frames *inside* the channel, not
about the transport.
## Prior art: the hub-owns-the-connection model (`OQ-TN-01`, `OQ-TN-03`, `OQ-TN-04`, `OQ-TN-08`)
Discussed 2026-09-05. The standing stance across the alk* crates: **the
hub owns the connection and explicitly proxies to expose resources for
others.** A produced resource is not a socket — it is an ACL-scoped
*virtual resource* on the side that can reach the real target. Applied
to tunnels, this dissolves the SSH direction model:
- **Role follows the resource.** Whoever can reach the target is the
producer (registers openable tunnel channels via
`ChannelCore::register_openable`, dials/serves the target); whoever
wants the bytes is the consumer (opens channels via `ChannelClient`).
This holds for both SSH `-L` and `-R`: in both, the entry-point side
opens the channel (consumer) and the target side handles the open
(producer) — the only difference is which machine hosts the entry
point, which is assembly-layer wiring, not protocol.
- **The "exposed port" is a virtual port.** The far side may *think* it
exposed a port on the remote end; what actually exists is an
ACL-scoped resource in the connection/identity registry — the same
shape as operation registration, not a bind. Example: a self-hosted
gitea (HTTP in a docker container, really binding a port) is tunneled
to the hub; the hub proxies the tunnel to consumers whose ACL grants
access to the gitea HTTP service. A SOCKS5 service is the same shape,
further downstream.
- **The hub proxy is a producer wrapping a consumer.** The proxy is an
assembly-layer construct. On leg 1 (gitea side ↔ hub): gitea side is
producer, hub is consumer. On leg 2 (hub ↔ service consumer): the hub
re-produces the resource ACL-scoped — producer wrapping its leg-1
consumer role. Data path per hop is the same two-pump shape; the
consumer opens a channel naming the resource, the producer's open
handler dials the target. This means the hub *terminates and
re-produces* rather than relaying byte-for-byte — unlike alkcall
ADR-042's transparent data-channel relay — because ACL must apply
per hop. (ADR-042 relays remain valid for transports that don't
need per-hop ACL; the hub's tunnel proxy is the explicit-ACL path.)
- **`-D` (dynamic/SOCKS) composes.** A SOCKS5 server at some assembly
layer is just a consumer that opens channels with per-connection
dynamic targets, gated by the producer's target policy. Not a base
crate concern (same conclusion as the original half-answer, now with
the mechanism named).
- **No forced binding, everywhere (OQ-TN-04 resolved).** Binding is
always assembly-layer and optional, on either side. The protocol
never binds: a producer "produces a resource" (which may or may not
correspond to a local bind — a docker container's port, a unix
socket, an in-process service); a consumer "consumes it." The SSH
mental image of "expose a port" is an illusion the assembly layer
may create locally; the protocol only ever carries
produce/consume bookkeeping.
**Model, one sentence:** a producer produces a resource (a stream of
basically anything, if TCP and UDP are supported); a consumer consumes
it; direction, bind, and ACL scoping are assembly-layer concerns
wrapped around that pair.
**Residue (folded into other OQs):**
- How does a consumer *discover* produced resources — resolved
2026-09-05: the existing bidirectional ACL-filtered ops listing
(openable channels are operations, alkcall ADR-047). See OQ-TN-08.
- Dynamic-target policy for `-D`-style opens — dissolved 2026-09-05:
`-D` is "just tunnel a socks5 connection"; target selection lives in
the socks5 protocol at the producing side, governed by the same
op-level ACL. See OQ-TN-08.
- Whether the hub proxy needs anything from this crate (a
re-produce helper? typed open-handler composition?) or composes from
the public producer/consumer surface as-is → spec question for
Phase 1, tracked here as part of OQ-TN-05.
## Prior art: tun2proxy UDP gateway (`OQ-TN-01`, `OQ-TN-02`, `OQ-TN-07`, `OQ-TN-10`)
`/workspace/tun2proxy/src/udpgw.rs` implements a UDP gateway over a TCP
stream — structurally the same problem this crate faces for UDP tunnels
over channels data channels. Discussed with the alknet-channels POC
agent as the example of "UDP over a stream substrate," but never
documented. Key mechanics, all of which generalize:
- **Per-datagram length framing over the stream** — the packet format is
`LEN(u16 BE) | FLAGS(u8) | CONN_ID(u16) | [SOCKS5 address] | DATA`
(`udpgw.rs:82-88`). Boundary preservation is re-added by the protocol,
not by the substrate: exactly the "length-prefix each datagram inside
the channel" half-answer in OQ-TN-02, proven in production.
- **SOCKS5 address format travels per data 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`),
with `keepalive` (0x01) and `error` (0x20) flag packets as the only
non-data frame types (`udpgw.rs:21-26`). This is the "one channel =
one association, per-endpoint multiplexing inside" half-answer in
OQ-TN-02, with the refinement that the per-endpoint multiplexing key
(`CONN_ID`) is protocol-level, allocated by the client, u16.
- **Flow lifecycle is packet-level** — `udp_timeout` idle expiry,
`keepalive_time` heartbeats on idle connections
(`UDPGW_KEEPALIVE_TIME = 30s`, `udpgw.rs:16`), and an MTU cap
(`parse_udp_response` rejects `data.len() > udp_mtu`,
`udpgw.rs:527`). Also `UDPGW_MAX_CONNECTIONS = 5` pooled gateway
connections *above* the packet layer — a throughput choice, not a
protocol requirement; channels gives us N channels already.
- **Implications for alktunnels:**
- UDP boundary preservation over the chunk stream is validated
prior art, not speculation — raises confidence in the OQ-TN-02
half-answer considerably.
- The frame-type set (DATA/KEEPALIVE/ERR) is a useful minimal
vocabulary — it maps onto OQ-TN-09's establishment/error frame
question (tun2proxy uses flag packets, alktty uses typed control
chunks; both are self-contained frames inside the data stream).
- Per-datagram addressing (SOCKS5-style) vs per-channel addressing
(fixed target at open) is a real fork for the params design: TCP
tunnels fix the target at open; UDP associations may either fix
one endpoint at open or carry per-datagram addresses like udpgw.
This interaction is unresolved and feeds OQ-TN-01 + OQ-TN-07.
- NAT/keepalive concerns partially disappear on channels: the
underlying transport (QUIC/TCP) handles connection keepalive, and
channel liveness is the channels layer's concern. The tunnel
protocol likely needs only flow-level idle expiry, not
transport-level keepalive packets — TBD in the spec.
## Open Questions
These are the design questions Phase 0 must resolve (or explicitly defer)
before the architecture spec. They are numbered OQ-TN-01.. so they can be
referenced, tracked, and promoted into `docs/architecture/open-questions.md`
in Phase 1. Half-answers and hunches are marked as such — the point of this
document is to hold them without forcing premature decisions.
### OQ-TN-01: Target addressing format
What does the tunnel `params` on `channel/open` look like? alknet ADR-071
§ALPN table noted `alknet/tunnel` as `[0, 1]` data in/out only, but the
addressing scheme was never decided. It must cover at minimum:
- TCP dial (`host:port`)
- UDP (associate-style or endpoint-style — see OQ-TN-02)
- Unix domain sockets (path)
- Direction (who dials the target — see OQ-TN-03, resolved)
- Bind/listen vs dial semantics (see OQ-TN-04, mostly resolved)
Considerations:
- `params` is ALPN-specific JSON, interpreted by the open handler, not by
the channels layer (alknet ADR-075 / alkcall ADR-039). alktty's
precedent is the `NegotiateRequest` shape — a self-contained JSON
object carried in the open op.
- The addressing string is wire-stable once a consumer exists (one-way
door). It must be substrate-extensible without format changes (a new
substrate should be an additive `scheme` value, not a v2 format).
- Prior art to survey: SSH forwarding models (`direct-tcpip`,
`forwarded-tcpip`), SOCKS5 addressing (ATYP + addr + port), russh
(`/workspace/russh` — note: russh has no UDP channel type at all, so
there is no `direct-udp`-style prior art there; still useful for
channel-open framing and addressing-intel generally).
- ALPN convention check (2026-09-05): the ALPN prefix swap `alknet/`
`alk/` happened alkcall v0.1.1, before the first published consumer.
Everything in this crate's docs referencing `alknet/tunnel` means
`alk/tunnel` — a small thing, but docs/ADRs must not perpetuate the
old prefix or consumers will bake it in.
**Reframe 2026-09-05 — this is partly an XY problem.** The rich
"remote addressing" framing was chasing the wrong thing. Except in the
`-D`/dynamic case (which composes at the assembly layer), a tunnel is
either TCP or UDP, and `params` need only *identify a produced
resource* — not carry a general-purpose address. The producer owns
where the resource comes from (a local port, a docker container's
port, an in-process service, a unix socket — its problem, most likely
a local port). So `params` reduce to:
1. Which resource (a produced resource identifier — stable name or
target address, still to decide),
2. Substrate discriminator (`tcp` / `udp` / extensible) — so UDP is
structurally supported from day one even if the vast majority of
use is TCP.
Rich in-band addressing (SOCKS5 ATYP, per-datagram remote addresses)
enters only through the `-D`/dynamic-target composition path
(OQ-TN-03/OQ-TN-08 residue), not through the base open-op params.
**Status:** mostly resolved 2026-09-05 (pending the resource-naming
and discovery residue). Direction of travel:
- `params` = self-contained JSON object in the open op (alktty
`NegotiateRequest` precedent) — accepted path.
- `params` identify a produced resource + substrate discriminator;
producer owns the backing. No URL-style general addressing.
- Residue: stable resource name vs target address as the identifier
shape; discovery resolved 2026-09-05 — see OQ-TN-08 (the existing
ACL-filtered ops listing IS tunnel-resource discovery, since
openable channels are operations per alkcall ADR-047); exact JSON
field layout (Phase 1 spec, ADR before first consumer — wire-stable
once published).
### 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).
- SSH's `-D` UDP associate tunnels UDP as a stream with per-datagram
framing re-added by the tunnel protocol (e.g. SOCKS5 UDP over TCP).
Note (2026-09-05): russh does not support UDP channels at all — no
`direct-udp`-style prior art exists there; the tun2proxy gateway
(§Prior art) is the strongest framing precedent.
- 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:** open — survey mostly resolved by tun2proxy prior art
(§Prior art): per-datagram length framing over the stream is
production-proven (`LEN | FLAGS | CONN_ID | [addr] | DATA`), one
stream carries many UDP flows via a protocol-level `CONN_ID`, and
flow lifecycle (idle timeout + keepalive) is packet-level. Remaining:
whether alktunnels fixes the UDP endpoint at open (per-channel, TCP-
like) or carries per-datagram addresses (udpgw-like), and whether a
u16 conn-id vocabulary is right for channels (vs the channel ID
itself doing the demux and one channel per UDP flow). Note (2026-09-05,
hub model §Prior art): if UDP resources are produced like any other
resource, endpoint-at-open aligns naturally with resource naming
(OQ-TN-01); per-datagram addressing matches the `-D`/dynamic-target
composition path instead. A targeted POC (OQ-TN-10 #1) is likely still
+EV for the chosen shape.
### OQ-TN-03: Direction semantics (`-L` / `-R` / dynamic)
**Status: resolved 2026-09-05** by the hub-owns-the-connection model
(§Prior art: the hub-owns-the-connection model). There is no
protocol-level direction: role follows the resource. Whoever can reach
the target is the producer (registers openable channels); whoever wants
the bytes is the consumer (opens channels). SSH `-L` and `-R` are the
same producer/consumer pair with the entry point on different machines —
assembly-layer wiring, not protocol. The "exposed port" is a virtual,
ACL-scoped resource on the producing side; the hub proxy is a producer
wrapping a consumer. `-D`/SOCKS composes as a consumer opening channels
with per-connection dynamic targets, gated by the producer's target
policy (OQ-TN-08) — not a base-crate concern. Original question retained
below for context.
Considerations (original):
SSH has three forwarding flavors; the crate must model them without
"server/client" framing:
- `-L` (local forward): consumer dials a local port; producer dials the
target. Channels flows consumer→producer; target dial happens on the
producer side. This is the POC's shape.
- `-R` (remote forward): producer (or a third party) listens; the
*consumer's* side dials or accepts incoming connections and asks the
other side to carry them. Channels flows producer→consumer.
- `-D` (dynamic/SOCKS): one side runs a SOCKS5 server; the target is
chosen per-connection by the client. Addressing arrives per-channel,
not per-tunnel-registration.
Both sides can be producer and consumer simultaneously (alkcall ADR-022/037
direction semantics), so the model must not bake direction into the
connection. The open questions:
- Is direction a field in `params`, or two distinct open-handler shapes /
ALPNs?
- How does `-R` register availability (the side that will carry traffic
advertises listen targets)? Does it interact with `channel/open` at all,
or is it a call-level operation ("please open a tunnel channel to me
when a local accept happens")?
- Dynamic (-D) may not be a tunnel concern at all — it may compose as
"SOCKS5 server implemented over alktunnels dial primitives" in a
separate crate. Keep or cut for v1?
**Status:** open — needs architecture decision. Half-answer (hunch): `-L`
is the channel/open handler; `-R` needs a small advertisement/lifecycle
surface; `-D` composes on top and is out of scope for the base crate.
### OQ-TN-04: No forced local binding
A tunnel must not require the producer (or consumer) to bind a local port.
The POC's shape dialed a target from the handler; binding is optional and
belongs to the caller (assembly layer), not the protocol crate. The API
surface must support:
- Dial flows with no local bind (POC shape) — covered.
- Listen flows where the binding happens on one side only.
- Unbound/abstract flows (e.g. unix socketpair-style, stdio bridges,
in-process pipes) where neither side binds.
The protocol layer must express "carry bytes between this target and this
channel" without assuming either endpoint is a bound socket. Substrate
modules (behind feature flags) own actual `bind()` calls; the protocol
owns bookkeeping only.
**Status:** open — mostly resolved (2026-09-05, see §Prior art: the
hub-owns-the-connection model): binding is always assembly-layer and
optional, on either side; the protocol never binds. What remains is the
concrete API surface — who calls what to start a tunnel in each mode
(produce-with-dial, produce-without-dial/accept-style, consume). This is
now a spec-shape task, not a research question.
### OQ-TN-05: Backend inversion point — is there a `TunnelBackend` trait?
alktty has `TtyBackend` because backends (local PTY, docker, SSH) produce
handles and the adapter pumps them. For tunnels, the producer side's
substrate action is narrower — dial a target, or accept on a listener —
so the question:
- Is a `TunnelBackend`-style trait needed at all, or is the two-pump
handler + feature-gated substrate modules (dial/listen helpers) the
whole story, with the assembly layer wiring substrate streams directly?
- If a trait: what is the handle type? A tunnel "handle" is just an
`AsyncRead + AsyncWrite` stream (or a datagram endpoint) — much thinner
than `TtyHandle`'s stdin/stdout/stderr/exit-code quadruple. The trait
may collapse to "produce a boxed stream for this target" plus a
listener variant.
- Backpressure/limits come from channels (AGENTS.md convention 10); the
backend trait must not add a second layer of them.
- New sub-question from the hub model (2026-09-05, §Prior art): the
hub's tunnel proxy *re-produces* a resource it consumes (producer
wrapping a consumer). Does the proxy need a composition helper from
this crate, or does it assemble from the public producer/consumer
surface as-is? If a helper is warranted, it may share shape with the
substrate dial/listen trait — which would argue for the trait.
**Status:** open — needs a survey of what backends would actually
implement (local TCP? docker exec? ssh -w?) before deciding trait vs
no-trait. Half-answer (hunch): a thin trait (or just a fn alias) for
"obtain a bidirectional substrate stream for a target," possibly no
trait at all if the only meaningful backends are local sockets — decide
after surveying candidate backends.
### OQ-TN-06: The two-pump helper — extract now?
alknet ADR-078 deferred helper extraction until a second two-pump consumer
exists ("a genuine deferral... the contract is decided (shutdown-on-
completion), only the extraction is deferred"). This crate is that second
consumer (POC tunnel was the first; SSH `direct-tcpip` would be a third).
- Does the helper live here (as a pub utility other handler crates can
use), or upstream in alkcall (which already owns `core` types)?
- Shape: `pump_bidi(recv, send) -> (Future, Future)` returning both
pumps with the shutdown-on-completion wired in? Or a
`join_two_pumps(a, b)` combinator?
- alknet ADR-057 (two-pump helper extraction OQ) noted the helper from
one consumer would bake in a wrong shape; with two consumers the shapes
should be compared before extraction.
**Status:** open — decide when the first real tunnel handler is written;
not a blocker for the spec. Half-answer: the helper probably belongs
upstream (alkcall, near the channels-adapter handler-integration
conventions) but only if the two shapes genuinely converge.
### OQ-TN-07: ALPN strategy
This crate owns the `alk/tunnel`-family ALPN(s). alkcall ADR-004: one
ALPN per protocol; `alk/` prefix. If stream (TCP/unix) and datagram (UDP)
tunnels get distinct ALPNs, the split must be decided before the first
consumer — ALPN strings are wire-stable once published.
- Option A: single `alk/tunnel` ALPN; substrate is a `params` field
(and datagram framing, if any, is self-describing inside the channel).
- Option B: `alk/tunnel` (stream) + `alk/tunnel-dgram` (datagram), so
the wire framing differs per ALPN cleanly.
- Channels' `params` is ALPN-specific, and the open-handler registry
dispatches per ALPN — both options are cheap mechanically; the cost is
consumer-side API bifurcation (two session types vs one with a
substrate enum).
**Status:** open — needs the OQ-TN-02 outcome first (if datagrams need
different framing, option B gets stronger). Note from the tun2proxy
prior art (§Prior art): udpgw runs its packet framing over a plain TCP
stream — one framing covers both the stream and datagram cases there.
If alktunnels follows the same shape (datagram framing self-describing
inside the channel), option A (single `alk/tunnel` ALPN) stays viable
even with UDP support; option B remains cleaner if the datagram
channel needs structurally different framing from the first chunk on.
### OQ-TN-08: Access control and ownership scope
Tunnels reach local networks — the open gate is the security boundary.
Shape follows alktty: `TUNNEL_OPEN_SCOPE` scope-gate, and the channels
path gets `AccessControl` wiring for free via
`ChannelCore::register_openable`. Open sub-questions:
- Should ownership (`OwnershipProvider.owns(...)`) be consulted for
tunnel targets, and what is the resource identity of a tunnel target
(a `host:port`? a registered tunnel name?), given targets may be
arbitrary strings and wildcard targets (`0.0.0.0/0`-style egress) may
be intentionally allowed for some identities?
- Is there a target-allowlist concept (per-identity reachable target
sets), and does it live in `AccessControl` or in the open handler's
params validation?
**Status: mostly resolved 2026-09-05** — this OQ pointed at a general
conceptual tangle ("the host owns the resource and proxies on top of
that" — how does that manifest for tunnels?), and the resolution is
the same posture alktty already uses:
- **Assume the resource is owned by the other side of the connection.**
At these protocol-crate levels (alktunnels, like alktty), the
protocol works under the assumption that a produced resource belongs
to the far side — so the ACL story is exactly alkcall's existing
op-level ACL. No new policy layer, no target allowlists, no
tunnel-specific ownership machinery. The actual proxy/overlay
mechanism (hub workers connecting in and exposing tunnels, the hub
providing an overlay to those resources based on the other side's
ACL) is a *downstream* (assembly-layer) concern.
- **Discovery is the existing bidirectional ops listing.** alkcall
already has an underlying bidirectional discovery mechanism — each
side can obtain the list of ops available to it (ACL-filtered). Since
openable channels *are* operations (alkcall ADR-047), that listing
*is* tunnel-resource discovery: a consumer asks "what ops are
available to me" and produced tunnel resources appear there, scoped
by identity. This is exactly how a consumer learns a socks5 tunnel,
a postgres TCP tunnel, or a redis tunnel is an available resource.
Examples of the pattern: services typically served over a VPN or
SSH tunnels (postgres, redis, gitea HTTP) — workers connect to a
hub, expose those tunnels, and the hub proxies them per-ACL.
- **`-D` simplifies to "just tunnel a socks5 connection."** The socks5
server lives on the producing side; the consumer opens an ordinary
tunnel channel to that resource and speaks socks5 inside it. Target
selection happens in the socks5 protocol at the far side — *not* in
tunnel params — so the previously-tracked "dynamic-target policy
hook for `-D`-style opens" residue dissolves: whatever ACL governs
the socks5 resource governs everything reachable through it, plus
whatever policy the socks5 implementation itself applies downstream.
Residue for Phase 1 (spec-shape, not research): confirm the
ops-listing surface carries enough per-resource metadata (substrate
type, resource name/description) for a consumer UI to distinguish
produced tunnels — or whether `params`-describing metadata rides
alongside the operation spec. That is an alkcall ADR-047 interaction,
not a new mechanism.
### OQ-TN-09: Lifecycle, teardown, and error reporting
The two-pump shape gives byte-level teardown for free (EOF sentinels;
channels drops per-channel senders on transport EOF — alknet ADR-078,
POC issue #6). What's missing is the error/level above bytes:
- How does a failed target dial reach the consumer (e.g. "connection
refused to 10.0.0.5:80")? Is there a structured error frame in the
channel before close, a `channel/close` with reason, or call-level
error on the open op?
- Is there a "tunnel established/failed" ack before byte pumping starts
(alktty has the negotiation frame; the POC's tunnel handler had
nothing — it dialed and pumped)?
- Half-open semantics: one direction EOFs, the other keeps pumping
(standard two-pump behavior) — is that always desired, or does the
consumer need a "close both" control?
**Status:** open — needs a wire-format decision (ADR) if an error frame
is added. Half-answer (hunch): a self-contained control frame (alktty
ADR-006 shape) carrying an establishment result/error, sent before any
data chunk; dial errors are tunnel-closing (the whole channel dies),
whereas byte-level EOFs stay per-direction.
### OQ-TN-10: POC scope for what remains unvalidated
The alknet-channels POC validated TCP only. Candidate targeted POCs
Phase 0 may need (in rough priority order, per the SDD process's
"validate promising approaches"):
1. **UDP tunnel POC** — boundary-preserving length framing over a
channels channel, per-endpoint multiplexing inside one association,
backpressure behavior. Partially derisked by the tun2proxy prior
art (§Prior art) — the POC now mainly validates *channels-layer*
fit: chunk-size vs datagram-size interaction, MTU cap against the
channels bounded buffers, idle-expiry mapping, and the chosen
endpoint-addressing shape. Derisks OQ-TN-02 (and OQ-TN-07's option B).
2. **Reverse-flow POC**`-R`-style: the accept side listens, the far
side carries. Derisks OQ-TN-03's advertisement/lifecycle shape.
3. **Unix socket + stdio bridge POC** — cheap; validates "substrate
agnostic" beyond IP substrates.
4. **Two-pump helper extraction spike** — OQ-TN-06, only after 13.
POCs live in `.worktrees/research/<task-id>/` per the SDD process, or as
standalone crates (`/workspace/alknet-channels-poc` precedent).
**Status:** open — pick 1 (and probably 2) after the research pass;
3 is cheap enough to fold into whichever POC runs first.
## Survey / prior-art list
Candidate reading for the research specialist (to be expanded):
- SSH channel/forwarding model: RFC 4254 §7 (direct-tcpip /
forwarded-tcpip), OpenSSH `-L`/`-R`/`-D` semantics, russh's
`ChannelOpen` framing (russh is already in `/workspace/russh`;
confirmed 2026-09-05: no UDP channel support at all — no UDP prior
art there, but useful for open-op framing intel).
- SOCKS5 (RFC 1928): addressing (ATYP), UDP ASSOCIATE framing,
per-endpoint multiplexing — the closest standardized "arbitrary
tunnel + UDP" model. Relevant to the `-D` composition path
(OQ-TN-03 residue), not the base open-op params (OQ-TN-01 reframe).
- tun2proxy (`/workspace/tun2proxy`, `src/udpgw.rs`): UDP gateway over
TCP — per-datagram length framing, SOCKS5 per-datagram addressing,
CONN_ID flow multiplexing, keepalive/ERR flag packets, MTU cap,
idle expiry. Analyzed in §Prior art. Its `socks.rs` /
`proxy_handler.rs` are also relevant for the `-D` (dynamic/SOCKS)
composition question (OQ-TN-03).
- quinn-proxy-poc (`/workspace/quinn-proxy-poc`) and iroh
(`/workspace/iroh`): datagram-native transports; how they model
per-endpoint flows.
- alknet docs: ADR-071 §ALPN table (`alknet/tunnel` row), ADR-078,
`docs/architecture/crates/channels/channel-operations.md` (`params`
for `alknet/tunnel` is "the target resource"), and the hub-relay
interaction (ADR-042/079).
- alktty: `NegotiateRequest` shape (self-contained negotiation
precedent), `TtyBackend` inversion point, `TTY_OPEN_SCOPE` access
gate.
## Convergence checklist (what Phase 0 must produce)
- [ ] Survey notes: SSH/SOCKS5/tun2proxy addressing + UDP framing
(OQ-TN-01, OQ-TN-02) — tun2proxy UDP gateway done (§Prior art);
SSH/SOCKS5 addressing survey still open
- [x] 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)
- [x] Direction model resolved (OQ-TN-03) — hub-owns-the-connection
model: role follows the resource, no protocol-level direction;
`-D` composes at the assembly layer
- [x] 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)
- [x] Access control + discovery resolved (OQ-TN-08) — resource
ownership assumed on the far side; alkcall's existing ACL
applies as-is; ops listing (ADR-047) is tunnel-resource
discovery; `-D` = "tunnel a socks5 connection" (target selection
in the socks5 protocol, not params). Residue: per-resource
metadata in the ops listing (alkcall ADR-047 interaction,
Phase 1)
- [ ] Decision input: backend trait vs no-trait (OQ-TN-05), now
including the hub re-produce composition question
- [ ] Targeted POC(s) run + summary (OQ-TN-10) — UDP first, reverse
flow second
- [ ] Open questions promoted to Phase 1
`docs/architecture/open-questions.md` with statuses