POC at /workspace/alktunnels-reverse-poc (14 tests, clippy/fmt clean, repeat-run stable) over alkcall 0.6.0: - The review 007 non-finding trace confirmed by execution: the hub (accept side) opens tunnel channels toward a connect-side worker serving its own open op (from_connection_with_serving + ChannelOperations::register_on + post-hoc openable registration) - ADR-047 §5 exercised in the Pub-like orientation: the worker (connect side) allocates odd IDs; the hub adopts - R-01 plan flow under concurrency: two same-resource opens, distinct channels, distinct dialed handles — the forward POC's handoff race is structurally gone - OQ-TN-03's last open thread closed: no advertisement op needed — the listener is the initiator's own local resource; a far-side listener is a producer-side listen establisher over the same open op - W4 EOF sentinel propagation validated (half-close semantics); W3 adopter self-reaping pinned as a TunnelSession spec requirement; W2 post-hoc registration viable Findings: - F-1: ChannelPlan's Send+Sync bound constrains plan payloads (socket halves carry + Sync; spec should pin the constraint) - F-2: empty UDP datagrams collide with the EOF sentinel in raw pass-through (pinned by an executable test) — the length-framed codec is mandatory for UDP; raw pass-through stays stream-substrate-only. Second validation of the codec decision; OQ-TN-02 mandate strengthened W1 (connect-side serving path resolves caller identity only via payload auth_token) filed upstream as alkcall consumer-findings ledger CF-005 (alkcall 0d287a9). Verification: cargo test 14/14, clippy --all-targets -D warnings, fmt --check — all clean, repeat-run stable
903 lines
49 KiB
Markdown
903 lines
49 KiB
Markdown
---
|
||
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 — 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_session`
|
||
too — the convergence test the ADR asked for may be nearly free.
|
||
- **Wasm story intact** — the codec is pure byte manipulation; the
|
||
split/pump plumbing is tokio-async, no substrate types in the
|
||
protocol layer. Same as alktty.
|
||
|
||
**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=0` means *empty datagram* (legal in UDP; DNS uses it, e.g. TCP
|
||
length-prefix `0`), while EOF is the channels-level sentinel on the
|
||
`BiStream` (`length=0` in 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:
|
||
|
||
- `params` is ALPN-specific JSON, interpreted by the open handler, not by
|
||
the channels layer (alknet ADR-075 / alkcall ADR-039). alktty's
|
||
precedent is the `NegotiateRequest` shape — a self-contained JSON
|
||
object carried in the open op.
|
||
- The addressing string is wire-stable once a consumer exists (one-way
|
||
door). It must be substrate-extensible without format changes (a new
|
||
substrate should be an additive `scheme` value, not a v2 format).
|
||
- Prior art to survey: SSH forwarding models (`direct-tcpip`,
|
||
`forwarded-tcpip`), SOCKS5 addressing (ATYP + addr + port), russh
|
||
(`/workspace/russh` — note: russh has no UDP channel type at all, so
|
||
there is no `direct-udp`-style prior art there; still useful for
|
||
channel-open framing and addressing-intel generally).
|
||
- ALPN convention check (2026-09-05): the ALPN prefix swap `alknet/` →
|
||
`alk/` happened alkcall v0.1.1, before the first published consumer.
|
||
Everything in this crate's docs referencing `alknet/tunnel` means
|
||
`alk/tunnel` — a small thing, but docs/ADRs must not perpetuate the
|
||
old prefix or consumers will bake it in.
|
||
|
||
**Reframe 2026-09-05 — this is partly an XY problem.** The rich
|
||
"remote addressing" framing was chasing the wrong thing. Except in the
|
||
`-D`/dynamic case (which composes at the assembly layer), a tunnel is
|
||
either TCP or UDP, and `params` need only *identify a produced
|
||
resource* — not carry a general-purpose address. The producer owns
|
||
where the resource comes from (a local port, a docker container's
|
||
port, an in-process service, a unix socket — its problem, most likely
|
||
a local port). So `params` reduce to:
|
||
|
||
1. Which resource (a produced resource identifier — stable name or
|
||
target address, still to decide),
|
||
2. Substrate discriminator (`tcp` / `udp` / extensible) — so UDP is
|
||
structurally supported from day one even if the vast majority of
|
||
use is TCP.
|
||
|
||
Rich in-band addressing (SOCKS5 ATYP, per-datagram remote addresses)
|
||
enters only through the `-D`/dynamic-target composition path
|
||
(OQ-TN-03/OQ-TN-08 residue), not through the base open-op params.
|
||
|
||
**Status:** mostly resolved 2026-09-05 (pending the resource-naming
|
||
and discovery residue). Direction of travel:
|
||
- `params` = self-contained JSON object in the open op (alktty
|
||
`NegotiateRequest` precedent) — accepted path.
|
||
- `params` identify a produced resource + substrate discriminator;
|
||
producer owns the backing. No URL-style general addressing.
|
||
- Residue: stable resource name vs target address as the identifier
|
||
shape; discovery resolved 2026-09-05 — see OQ-TN-08 (the existing
|
||
ACL-filtered ops listing IS tunnel-resource discovery, since
|
||
openable channels are operations per alkcall ADR-047); exact JSON
|
||
field layout (Phase 1 spec, ADR before first consumer — wire-stable
|
||
once published).
|
||
|
||
**Survey input 2026-09-06** (`ssh-socks5-survey.md`): SSH's
|
||
`direct-tcpip` payload reduces to "target + informational originator" —
|
||
the originator pair has no analogue here (ACL rides the channels
|
||
open-op machinery), supporting the two-field reframe. OpenSSH's
|
||
`direct-streamlocal` extension is the extensibility template: new
|
||
substrate = same open-op shape, degenerate address slots, new type
|
||
string → new `substrate` value, not a format change. SOCKS5 ATYP is
|
||
not needed in base params (dynamic-path addressing only). Minimal
|
||
shape: `{ "resource": <id>, "substrate": "tcp" | "udp" | <extensible> }`.
|
||
|
||
### OQ-TN-02: Datagram substrates (UDP) — boundary preservation
|
||
|
||
Does a UDP tunnel preserve datagram boundaries end-to-end, or does the
|
||
tunnel present a byte-stream abstraction to the consumer (boundaries lost,
|
||
re-chunked arbitrarily)?
|
||
|
||
- Channels is a chunk stream with bounded buffers; the zero-length chunk
|
||
is the EOF sentinel — datagram boundaries are *not* preserved by the
|
||
substrate (alknet ADR-071/093; the POC only exercised TCP).
|
||
- **Corrected 2026-09-06** (`ssh-socks5-survey.md`): an earlier line
|
||
here claimed "SSH's `-D` UDP associate tunnels UDP as a stream" — a
|
||
category error. SSH has *no UDP forwarding at all* (RFC 4254 defines
|
||
only TCP/X11/session channels; russh is grep-confirmed UDP-free;
|
||
only SSH3 — a different HTTP/3 protocol — has `direct-udp`). SSH
|
||
`-D` carries only the SOCKS5 *TCP* control connection. The real
|
||
UDP-over-stream prior art is tun2proxy udpgw (§Prior art) and
|
||
SOCKS5's own UDP relay.
|
||
- iroh and quinn-proxy-poc have native datagram transports; tun2proxy has
|
||
a full UDP-over-TCP model worth reading.
|
||
- Boundary preservation is a wire-format decision (per-datagram length
|
||
framing inside the `BiStream`) and would need an ADR + possibly a BAST
|
||
document (AGENTS.md convention 12). Boundary loss is cheaper but
|
||
changes what protocols can ride the tunnel (DNS? QUIC? game traffic?).
|
||
- Datagrams also raise multiplexing questions TCP does not: one UDP
|
||
"association" carries many remote endpoints — does one tunnel channel
|
||
carry one endpoint or many, and how are per-endpoint replies routed?
|
||
|
||
**Status: resolved as a split by path, 2026-09-06** (survey
|
||
`ssh-socks5-survey.md` + tun2proxy prior art):
|
||
|
||
- **Base open-op UDP resources: endpoint-at-open.** The resource
|
||
identifies the endpoint; one channel = one UDP flow (or one pinned
|
||
association). Aligns with resource naming (OQ-TN-01); per-datagram
|
||
addressing would reintroduce the "general addressing in params" the
|
||
reframe removed. Boundary preservation inside the channel stays
|
||
per-datagram length framing (tun2proxy-proven) — endpoint-at-open is
|
||
about addressing, not about dropping the LEN prefix.
|
||
- **Dynamic/`-D` UDP (SOCKS5-style): per-datagram addressing inside
|
||
the tunnel payload** (SOCKS5 UDP header or udpgw format), composed
|
||
at the assembly layer, never in base params.
|
||
- **The channel ID replaces udpgw's CONN_ID** — a conn-id inside the
|
||
channel would be a second demux layer (AGENTS.md convention 10).
|
||
One channel per UDP flow; no protocol-level flow key.
|
||
- **KEEPALIVE drops** — udpgw's heartbeats exist for NAT-traversed
|
||
long-lived TCP; channels transport liveness is the alkcall layer's
|
||
concern. Flow-level idle expiry remains producer-side bookkeeping.
|
||
- A UDP gateway resource (multi-endpoint, udpgw-shaped) remains
|
||
possible *inside* a channel as self-describing framing — invisible
|
||
to base params, keeping OQ-TN-07 option A viable. A targeted POC
|
||
(OQ-TN-10 #1) remains +EV for the channels-layer fit
|
||
(chunk-size vs datagram-size, MTU vs bounded buffers, idle expiry).
|
||
|
||
**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 `-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:** 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 + 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: 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 `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.
|
||
**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/tunnel` ALPN; substrate is a `params` field
|
||
(and datagram framing, if any, is self-describing inside the channel).
|
||
- Option B: `alk/tunnel` (stream) + `alk/tunnel-dgram` (datagram), so
|
||
the wire framing differs per ALPN cleanly.
|
||
- Channels' `params` is ALPN-specific, and the open-handler registry
|
||
dispatches per ALPN — both options are cheap mechanically; the cost is
|
||
consumer-side API bifurcation (two session types vs one with a
|
||
substrate enum).
|
||
|
||
**Status: option A strengthened, 2026-09-06** (survey
|
||
`ssh-socks5-survey.md`): SSH uses one channel mechanism for all
|
||
forwarding types (the type string is per-open metadata, not a separate
|
||
transport); SOCKS5 runs CONNECT and UDP ASSOCIATE over one control
|
||
connection with a CMD discriminator; udpgw proves datagram framing
|
||
self-describes over a stream. With OQ-TN-02 resolved as endpoint-at-
|
||
open for base UDP resources, the substrate discriminator in `params`
|
||
tells the handler which framing to expect — exactly option A's shape.
|
||
Option B (`alk/tunnel-dgram`) remains defensible only if Phase 1 wants
|
||
structurally different framing from byte zero with no params-dependent
|
||
dispatch; prior art gives no reason to prefer that.
|
||
|
||
### OQ-TN-08: Access control and ownership scope
|
||
|
||
Tunnels reach local networks — the open gate is the security boundary.
|
||
Shape follows alktty: `TUNNEL_OPEN_SCOPE` scope-gate, and the channels
|
||
path gets `AccessControl` wiring for free via
|
||
`ChannelCore::register_openable`. Open sub-questions:
|
||
|
||
- Should ownership (`OwnershipProvider.owns(...)`) be consulted for
|
||
tunnel targets, and what is the resource identity of a tunnel target
|
||
(a `host:port`? a registered tunnel name?), given targets may be
|
||
arbitrary strings and wildcard targets (`0.0.0.0/0`-style egress) may
|
||
be intentionally allowed for some identities?
|
||
- Is there a target-allowlist concept (per-identity reachable target
|
||
sets), and does it live in `AccessControl` or in the open handler's
|
||
params validation?
|
||
|
||
**Status: mostly resolved 2026-09-05** — this OQ pointed at a general
|
||
conceptual tangle ("the host owns the resource and proxies on top of
|
||
that" — how does that manifest for tunnels?), and the resolution is
|
||
the same posture alktty already uses:
|
||
|
||
- **Assume the resource is owned by the other side of the connection.**
|
||
At these protocol-crate levels (alktunnels, like alktty), the
|
||
protocol works under the assumption that a produced resource belongs
|
||
to the far side — so the ACL story is exactly alkcall's existing
|
||
op-level ACL. No new policy layer, no target allowlists, no
|
||
tunnel-specific ownership machinery. The actual proxy/overlay
|
||
mechanism (hub workers connecting in and exposing tunnels, the hub
|
||
providing an overlay to those resources based on the other side's
|
||
ACL) is a *downstream* (assembly-layer) concern.
|
||
- **Discovery is the existing bidirectional ops listing.** alkcall
|
||
already has an underlying bidirectional discovery mechanism — each
|
||
side can obtain the list of ops available to it (ACL-filtered). Since
|
||
openable channels *are* operations (alkcall ADR-047), that listing
|
||
*is* tunnel-resource discovery: a consumer asks "what ops are
|
||
available to me" and produced tunnel resources appear there, scoped
|
||
by identity. This is exactly how a consumer learns a socks5 tunnel,
|
||
a postgres TCP tunnel, or a redis tunnel is an available resource.
|
||
Examples of the pattern: services typically served over a VPN or
|
||
SSH tunnels (postgres, redis, gitea HTTP) — workers connect to a
|
||
hub, expose those tunnels, and the hub proxies them per-ACL.
|
||
- **`-D` simplifies to "just tunnel a socks5 connection."** The socks5
|
||
server lives on the producing side; the consumer opens an ordinary
|
||
tunnel channel to that resource and speaks socks5 inside it. Target
|
||
selection happens in the socks5 protocol at the far side — *not* in
|
||
tunnel params — so the previously-tracked "dynamic-target policy
|
||
hook for `-D`-style opens" residue dissolves: whatever ACL governs
|
||
the socks5 resource governs everything reachable through it, plus
|
||
whatever policy the socks5 implementation itself applies downstream.
|
||
|
||
Residue for Phase 1 (spec-shape, not research): **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/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: 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 receives `channel:open_failed` with
|
||
`details: {reason, message}`, `reason ∈ dial_failed /
|
||
unknown_resource / resource_shortage / handler_error / timeout` —
|
||
the SSH contract consumer-visibly (a failed open never returns a
|
||
`channel_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-once `BiStream` belongs
|
||
exclusively to the pump handler. **Amendment 2 (alkcall 0.6.0,
|
||
review 007 R-01) filled the reserved `Establishment` field:**
|
||
`Establishment::new(plan)` carries the dialed handle to the pump
|
||
handler via its `plan` parameter (`ChannelPlan` — typed-opaque
|
||
`Arc<dyn Any + Send + Sync>`; downcast happens in this crate). The
|
||
POC's side-channel `HandleHandoff` is dead — no handoff map, no
|
||
same-resource race; the establisher just returns
|
||
`Ok(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"):
|
||
|
||
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).
|
||
|
||
**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).
|
||
2. **Reverse-flow POC** — `-R`-style: the accept side listens, the far
|
||
side carries. Derisks OQ-TN-03's advertisement/lifecycle shape.
|
||
Template available: SSH's `tcpip-forward` global-request
|
||
registration → per-accept `forwarded-tcpip` opens → cancel
|
||
(`ssh-socks5-survey.md` §RFC 4254 §7.1).
|
||
|
||
**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 be `Send + Sync`), 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 payload `auth_token`) filed for the
|
||
alkcall ledger.
|
||
3. **Unix socket + stdio bridge POC** — cheap; validates "substrate
|
||
agnostic" beyond IP substrates.
|
||
4. **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`/`-D` semantics, russh's
|
||
`ChannelOpen` framing (russh is already in `/workspace/russh`;
|
||
confirmed 2026-09-05: no UDP channel support at all — no UDP prior
|
||
art there, but useful for open-op framing intel).
|
||
- SOCKS5 (RFC 1928): addressing (ATYP), UDP ASSOCIATE framing,
|
||
per-endpoint multiplexing — the closest standardized "arbitrary
|
||
tunnel + UDP" model. Relevant to the `-D` composition path
|
||
(OQ-TN-03 residue), not the base open-op params (OQ-TN-01 reframe).
|
||
- tun2proxy (`/workspace/tun2proxy`, `src/udpgw.rs`): UDP gateway over
|
||
TCP — per-datagram length framing, SOCKS5 per-datagram addressing,
|
||
CONN_ID flow multiplexing, keepalive/ERR flag packets, MTU cap,
|
||
idle expiry. Analyzed in §Prior art. Its `socks.rs` /
|
||
`proxy_handler.rs` are also relevant for the `-D` (dynamic/SOCKS)
|
||
composition question (OQ-TN-03).
|
||
- quinn-proxy-poc (`/workspace/quinn-proxy-poc`) and iroh
|
||
(`/workspace/iroh`): datagram-native transports; how they model
|
||
per-endpoint flows.
|
||
- alknet docs: ADR-071 §ALPN table (`alknet/tunnel` row), ADR-078,
|
||
`docs/architecture/crates/channels/channel-operations.md` (`params`
|
||
for `alknet/tunnel` is "the target resource"), and the hub-relay
|
||
interaction (ADR-042/079).
|
||
- alktty: `NegotiateRequest` shape (self-contained negotiation
|
||
precedent), `TtyBackend` inversion point, `TTY_OPEN_SCOPE` access
|
||
gate.
|
||
- `ssh-socks5-survey.md` (this directory, 2026-09-06): SSH
|
||
channel-open/open-failure/forwarding model, SOCKS5 ATYP/CONNECT/
|
||
UDP ASSOCIATE, error-vocabulary comparison, endpoint-at-open vs
|
||
per-datagram analysis, anti-prior-art list (what NOT to carry over).
|
||
Feeds OQ-TN-01/02/07/09 statuses above.
|
||
|
||
## Convergence checklist (what Phase 0 must produce)
|
||
|
||
- [x] 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)
|
||
- [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). Per-op metadata residue
|
||
RESOLVED 2026-09-06: alkcall 0.5.0 review 006 E-02 lands
|
||
`OperationSpec.description` (round-trips through discovery);
|
||
the live resource-enumeration half (OQ-40) stays deferred and
|
||
is not needed for v1
|
||
- [x] 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
|
||
typed `channel:open_failed` call 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
|
||
- [x] 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 is `len=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
|
||
- [x] 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 `-R` shape validated
|
||
end-to-end over alkcall 0.6.0; findings F-1/F-2; W1 filed for
|
||
the upstream ledger). 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)
|
||
- [x] Two-pump helper convergence input gathered (OQ-TN-06) — the POC
|
||
gives both shapes (producer `pump_halves`, consumer
|
||
`take_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 as `channels::pump_bidi`
|
||
(ADR-050, review 007 R-03) — this crate consumes it; see
|
||
OQ-TN-06
|
||
- [x] OQ-TN-05 executable input (backend trait vs no-trait) — POC
|
||
confirms stream substrates need no trait: `pump_halves` is
|
||
generic over boxed halves (TCP `into_split`, UDP `UdpHalf`
|
||
adapter); "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.md` with statuses |