The alksocks graduation research (tunnels-graduation.md) is decided;
the ADRs are normative now:
- ADR-007: the direct open op — channels/tunnel/direct on the one
alk/tunnel ALPN, params {substrate, target} (self-describing JSON
target; ATYP bytes rejected as not schema-describable), scope
tunnel:direct (never implied by tunnel:open), dial-only.
- ADR-008: listen metadata — bind-first listen establisher (reply
gains additive 'bound' = BND.ADDR fidelity; the BIND deadlock +
10s-bound findings dissolve) + the forwarded op
channels/tunnel/forwarded (accept-as-open, params carry
{channel, substrate, peer} = reply#2 fidelity). Accept-first stays.
- ADR-001/002/006 amendments: params layout is per-op; flavor-form
op ids on the one ALPN; per-flavor scopes + the hub re-produce
policy sentence (the hub's ACL governs the final dial).
- wire/producer/consumer/overview/README updated for the new ops,
the three establisher shapes, TunnelListener, open_direct.
- OQ-TN-15/16 promoted + resolved; upstream alkcall asks filed at
alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md
(flavor-form discovery derivation; the establisher → reply
projection).
Verification: cargo test (22 passed), clippy -D warnings, fmt --check
(docs-only change; wasm check unaffected)
10 KiB
status: draft last_updated: 2026-09-16 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008)
alktunnels — Overview
Arbitrary bidirectional tunnels over alkcall channels: TCP, UDP, unix
sockets, and other stream or datagram substrates — in the ssh -L /
ssh -D / ssh -R sense. A producer/consumer protocol crate riding
alkcall channels the same way alktty does (alk/tty is the sibling
precedent). This document covers the crate's purpose, the resource
model in brief, dependencies, the ALPN, and the module map; component
details are in the sibling documents (wire.md,
producer.md, consumer.md).
What
alktunnels registers the alk/tunnel ALPN's open ops on alkcall
channels and provides the typed consumer sessions:
- The producer half (open ops registered via
ChannelCore::register_openable_with_establisher, alkcall ADR-047/049): the establisher validates params semantically, then dials (or binds/accepts for) the substrate, and returns the handle viaEstablishment::new(plan); the pump handler awaitsalkcall::channels::pump_bidiover the channel and the substrate halves (ADR-050, R-02's lifetime contract). Three ops on the one ALPN (ADR-002 Amendment 1):channels/tunnel/sub(the resource op),channels/tunnel/direct(dynamic-target egress, ADR-007), and — served by the listen-opener side —channels/tunnel/forwarded(accept-as-open, ADR-008). Three establisher shapes: dial, listen (accept-first), and bind-first listen. - The consumer half (
TunnelSession/TunnelListener, ADR-005/008): opens tunnel channels (ChannelClient::open_channelon the forward path; the reverse path's call + adopt equivalent), splits the channelBiStream, presents the substrate-shaped data plane (raw halves or datagram codec), and owns teardown (close/join/Drop).TunnelListeneris the listen-flow session (bound-address discovery + forwarded-connection receipt).
The guiding insight (the OQ-TN-01 reframe):
A tunnel is a resource, not an address. Base params identify a produced resource + substrate; the producer owns the backing. The one additive exception is the direct op (ADR-007): a consumer may ask the producer to dial an arbitrary target — a different capability, its own op and its own scope (
tunnel:direct), never implied bytunnel:open. Rich per-datagram addressing (SOCKS5 ATYP, udpgw-style remotes) still enters only through the-D/dynamic composition path — inside the tunnel payload, never in the wire.
Why
The crate's purpose is to be the tunnel library for downstream consumers, the same role alktty plays for terminal sessions:
- A hub that runs agent workspaces exposes workers' services (postgres, redis, gitea HTTP) as named resources; a coordinator opens tunnels to them over alkcall channels — the VPN/SSH-tunnel service pattern without SSH.
- A worker behind NAT dials the hub and serves its own open ops
(the reverse flow,
ssh -R); the hub opens tunnel channels toward it per local accept. Both POCs validated this end-to-end. - A
-D-style consumer tunnels a SOCKS5 connection; the socks5 server (assembly layer) does dynamic target selection inside the tunnel payload. - The protocol layer is substrate-agnostic by construction — the bookkeeping (target addressing, direction, lifecycle) never hardcodes a substrate (AGENTS.md convention 7). TCP, UDP, unix sockets, and in-process pipes ride the same open op, pump shape, and session type.
The Resource Model in Brief
A tunnel channel's life (full detail in wire.md):
- Open (the establishment phase). The consumer calls
channels/tunnel/subwithparams = {resource, substrate}(ADR-001) — orchannels/tunnel/directwithparams = {substrate, target}(ADR-007, dynamic egress). The producer's establisher — the open op's awaited phase (alkcall ADR-049) — resolves the resource (or parses the target), dials the substrate, and returns the handle in the plan. Failure is a typedchannel:open_failed(dial_failed/unknown_resource/resource_shortage/handler_error/timeout); the channel never exists on the opener's side afterward. A bind-first listen open completes at bind time and may carry"bound"in the reply (ADR-008 §2 — the resolved address). - Pump (the data plane). Two pumps, one per direction, via
pump_bidi(ADR-050): each shuts the opposite sink down on completion; half-close semantics fall out. Stream substrates ride raw pass-through (0 B tunnel overhead); UDP rides the mandatory[len: u16 BE]codec (ADR-003, F-2). - Teardown. The serving side is wrapper-managed (R-02); the
adopting side's
TunnelSessionowns teardown (ADR-005, W3). EOF sentinels are the channels layer's; the codec never collides with them.
Direction is not in the protocol: whoever can reach the target is the
producer; whoever wants the bytes is the consumer; connection
direction (who dialed the transport) is independent of tunnel
direction (OQ-TN-03's hub model). -L, -R, and -D are assembly
shapes over the same ops; the -R composed flow rides the bind-first
establisher + the forwarded op (ADR-008).
Dependencies
alktunnels (default — wasm-clean)
├── alkcall 0.7.0 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, ServingConfig identity seam — CF-005/006)
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait
└── (no backend deps — sockets/process live in feature-gated modules)
alktunnels (local feature) — non-wasm by design
└── adds: tokio/net (TCP dial, UDP connect, unix sockets — unix per OQ-TN-14)
Same posture as alktty: the default crate is protocol-only and wasm-clean (AGENTS.md convention 4); substrate I/O is feature-gated (ADR-004). The wire format carries no alkcall-internal types (alktty ADR-006's self-containment rule applies to the tunnel codec — trivial here, since the codec is a 2-byte length prefix).
Feature Gates
| Feature | Contents | Wasm |
|---|---|---|
| (default) | params, wire codec, open-op spec, establisher shapes, TunnelSession — protocol only |
yes |
local |
TCP/UDP/unix dial + listen helpers — halves-producing dial/listen functions (ADR-004). Unix ships (OQ-TN-14); stdio does NOT — a spawned process's stdio is alktty's pipe mode (exit codes, signals), not a tunnel | no |
Module Map
src/
lib.rs — re-exports; crate docs
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001);
TunnelTarget {substrate, target} + direct-op schema (ADR-007);
TunnelForwardedParams {channel, substrate, peer} (ADR-008);
the three scope constants
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
(scope-gated), establisher shapes (dial + listen + bind-first),
pump handler (pump_bidi / accept dispatcher),
register_tunnel_*_openable (ADR-004/007/008 shapes)
consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves /
take_halves / send_datagram / recv_datagram / pump_against /
close / join / Drop (ADR-005); TunnelListener: open / bound /
forwarded-receipt / close / Drop (ADR-008)
error.rs — TunnelError + the typed open-error surface (ADR-049 §4)
local/ — the `local` feature module: real socket halves functions
Public API surface is lib.rs re-exports (AGENTS.md convention 16).
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 001 | Open-op params: resource + substrate | {resource, substrate} — the producer's stable name, not an address; wire-stable from the first consumer |
| 002 | Single alk/tunnel ALPN |
Substrate in params selects the framing; no ALPN split, no API bifurcation |
| 003 | Data-plane codec | Raw pass-through (stream) / mandatory [len: u16 BE] (UDP — F-2 mandate); len=0 is a legal empty datagram |
| 004 | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
| 005 | Consumer session owns teardown | TunnelSession with close/join/Drop — the W3 gap closes structurally |
| 006 | Access-control posture | The open gate is the boundary: tunnel:open scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — tunnel:direct/tunnel:forwarded scopes + the hub re-produce policy sentence |
| 007 | The direct open op | channels/tunnel/direct: dynamic-target egress, {substrate, target} params, tunnel:direct scope, dial-only — the ssh direct-tcpip capability with structural ACL separation |
| 008 | Listen metadata + the forwarded op | Bind-first establisher (reply bound field = BND.ADDR fidelity), channels/tunnel/forwarded accept-as-open (params carry {channel, substrate, peer} = reply#2 fidelity) — the forwarded-tcpip shape |
Open Questions
Open questions are tracked in open-questions.md. Key questions affecting this document:
- OQ-TN-11: resource naming collision domain + lifecycle (partially resolved)
- OQ-TN-12: hub re-produce composition helper (deferred(scope) — the policy sentence is pinned: the hub's ACL governs the final dial)
- OQ-TN-13: UDP truncation semantics (resolved — fail-loud, ADR-003)
- OQ-TN-14: unix/stdio placement (resolved — unix in via
local, stdio out: alktty's pipe mode owns process stdio) - OQ-TN-15: the direct op (resolved — ADR-007)
- OQ-TN-16: listen metadata (resolved — ADR-008)
References
- Phase 0:
docs/research/(findings, both POC summaries, survey) - Upstream ADRs: see README.md §References
- Sibling: alktty (
/workspace/@alkdev/alktty/docs/architecture/)