Files
glm-5.3-flash 0ceecf582b docs: remove em dashes from repo prose and forbid them going forward
- Rewrote the remaining em dashes outside docs/reviews/ (preserved as
  historical record, matching the alktty precedent): tests/ (313),
  fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in
  fences), docs/research + sdd_process + plans (525 plus 25 in
  fences), docs/architecture/overview.md fence labels (13), README,
  CHANGELOG, AGENTS.md, fuzz/README.md (176), and the
  .opencode/agents prompts (110)
- Rewrote fenced content too: ASCII tree and module-map label
  separators, Rust snippet comments, and template placeholders; wire
  data, JSON/BAST examples, and transcripts survive verbatim
- Rewrote 6 assert/expect message literals in tests/ and 2 format
  strings in fuzz/shared/src; no test data changed
- Added Project Convention 18: no em dashes in prose (doc comments,
  markdown including docs/ and tasks/, agent prompts, commit
  messages); fence content rule, en-dash clause rule, and the
  no-spaced-hyphen prohibition follow alktty's convention 15

Follows 6b20924 (src doc comments) and cd0afe0 (docs/architecture).

Verification: cargo test (141 pass); cargo clippy --all-targets -D
warnings; cargo fmt --check; cargo doc --no-deps; wasm check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
the repo returns matches only in docs/reviews/
2026-11-03 14:12:46 +00:00

194 lines
11 KiB
Markdown

---
status: draft
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op; ADR-007/008 as amended: the correlation token)
---
# alktunnels: Overview
Arbitrary bidirectional tunnels over alkcall channels: TCP, UDP, unix
sockets, and other stream or datagram substrates, all 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](wire.md),
[producer.md](producer.md), [consumer.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
via `Establishment::new(plan)`; the pump handler awaits
`alkcall::channels::pump_bidi` over 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
`channels/tunnel/forwarded` (served by the listen-opener side;
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_channel`
on the forward path; the reverse path's call + adopt equivalent),
splits the channel `BiStream`, presents the substrate-shaped data
plane (raw halves or datagram codec), and owns teardown
(close/join/Drop). `TunnelListener` is 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, and that is a
> different capability, its own op and its own scope (`tunnel:direct`), never
> implied by `tunnel:open`. Rich per-datagram addressing (SOCKS5 ATYP,
> udpgw-style remotes) still enters only through the `-D`/dynamic
> composition path, i.e. 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](wire.md)):
1. **Open (the establishment phase).** The consumer calls
`channels/tunnel/sub` with `params = {resource, substrate}`
(ADR-001), or `channels/tunnel/direct` with
`params = {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 typed
`channel: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"` (the resolved
address) and `"listen"` (the correlation token) in the reply
(ADR-008 §2/Amendment 1).
2. **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).
3. **Teardown.** The serving side is wrapper-managed (R-02); the
adopting side's `TunnelSession` owns 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.10.1 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, the ServingConfig identity seam (CF-005/006),
│ establisher reply projection + flavor-form discovery
│ derivation + ChannelRelay/HubLegTemplate (review 008/ADR-051))
├── 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,
though it is 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, i.e. 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 {listen, substrate, peer} (ADR-008 as amended);
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/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-open-params-layout.md) | Open-op params: resource + substrate | `{resource, substrate}`, the producer's stable name (not an address); wire-stable from the first consumer |
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN | Substrate in params selects the framing; no ALPN split, no API bifurcation |
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP; F-2 mandate); `len=0` is a legal empty datagram |
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop; the W3 gap closes structurally |
| [006](decisions/006-access-control-posture.md) | 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 with the `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
| [007](decisions/007-direct-open-op.md) | 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](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity); this is the `forwarded-tcpip` shape; amended so that a producer-minted correlation token supersedes the channel-ID key |
## Open Questions
Open questions are tracked in [open-questions.md](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](README.md) §References
- Sibling: alktty (`/workspace/@alkdev/alktty/docs/architecture/`)