9.6 KiB
alktunnels
Arbitrary bidirectional tunnels over alkcall
channels: TCP, UDP, and unix-socket tunnels in the ssh -L / ssh -R
sense (and the -D/SOCKS flavor via composition), without SSH. A
producer/consumer protocol crate riding alkcall channels the same way
alktty does — no networking, no
transport dependencies; the transport (QUIC, websocket, stdio, ...)
is whatever alkcall runs over.
Downstream crates compose on top of it: wire your substrate's sockets
into the producer's dial/accept closures (the local feature ships
reference helpers), and open typed sessions from the consumer side.
Quick start
Producer — register produced resources and serve the open op
use std::sync::Arc;
use alkcall::channels::operations::ChannelCore;
use alkcall::core::auth::AuthContext;
use alkcall::registry::registration::OperationRegistry;
use alktunnels::params::Substrate;
use alktunnels::producer::{register_tunnel_openable, ResourceRegistry};
// The producer's registry: (resource, substrate) → backing. The
// backing string is whatever your dial closure understands — a
// `host:port`, a socket path, an in-process id. It never rides the
// wire; the consumer only ever names the resource.
let registry = ResourceRegistry::new();
registry.register("postgres-primary", Substrate::Tcp, "127.0.0.1:5432").await;
registry.register("dns", Substrate::Udp, "127.0.0.1:53").await;
// The substrate boundary is a function, not a trait (ADR-004):
// `DialFn` = (substrate, backing) → boxed halves. Behind the `local`
// feature, `alktunnels::local::local_dial()` covers tcp/udp/unix real
// sockets; swap in docker/ssh/in-process closures for anything else.
let dial = alktunnels::local::local_dial();
let op_registry = Arc::new(OperationRegistry::new());
let core = ChannelCore::new(manager, default_policy());
// Registers `channels/tunnel/sub` on the `alk/tunnel` ALPN: the
// registry's ACL (scope `tunnel:open`) gates the open op, the
// establisher dials the backing, and `pump_bidi` bridges the channel
// to the dialed halves. Wire `core` + `op_registry` into your
// channels serving stack (the alkcall recipe) — this call is the
// tunnel-specific step:
register_tunnel_openable(
&core,
®istry,
&op_registry,
AuthContext::anonymous(b"alk/tunnel"),
dial,
None,
)?;
Producer — listen shape (the -R far side accepts)
The same open op with a listen establisher: instead of dialing a target, the establisher pops an already-accepted local connection.
use alktunnels::producer::{register_tunnel_listen_openable, AcceptQueue, ResourceRegistry};
let queue = AcceptQueue::new();
// Your accept loop owns the listener and feeds the queue; the
// protocol crate never binds (OQ-TN-04). With the `local` feature:
// let listener = alktunnels::local::bind_tcp("127.0.0.1:8080").await?;
// tokio::spawn(listener.accept_loop(queue.clone()));
let accept: AcceptFn = /* pops from the queue (or your own source) */;
register_tunnel_listen_openable(&core, ®istry, &op_registry, auth, accept)?;
Consumer — open a tunnel channel (the -L path)
use alkcall::channels::client::ChannelClient;
use alktunnels::params::{Substrate, TunnelParams};
use alktunnels::TunnelSession;
let client: ChannelClient = /* your alk/channels connection */;
// One session = one channel = one tunnel. A failed open never yields
// a session (typed errors: `unknown_resource`, `dial_failed`,
// `resource_shortage`, `timeout`, `FORBIDDEN`, ...).
let mut session = TunnelSession::open(
&client,
TunnelParams { resource: "postgres-primary".into(), substrate: Substrate::Tcp },
).await?;
// Stream substrates (tcp/unix): raw pass-through — the halves ARE
// the tunnel, zero tunnel-level framing.
let (read, write) = session.stream_halves().expect("stream session");
// ... use like any tokio duplex half (or take_halves() for owned) ...
// Datagram substrate (udp): boundary-preserving, framed
// (`[len: u16 BE]`, ADR-003) — an empty datagram is legal and is
// NOT EOF.
let payload = session.recv_datagram().await?; // Some(Bytes) | None on EOF
// Teardown is the session's job (ADR-005): abort + reap, idempotent
// via Drop, or await both pumps with copy counts:
let (client_to_target, target_to_client, reaped) = session.join().await;
Consumer — reverse flow (the -R path)
The consumer initiates (it may hold the hub's call surface while the producer serves); the producer-allocated channel ID is adopted into a session, then pumped against a local acceptor.
use alkcall::protocol::connection::CallConnection;
use alktunnels::{open_reverse_channel, TunnelSession};
let hub_call: CallConnection = /* channel-0 call surface */;
let params = TunnelParams { resource: "socks-exit".into(), substrate: Substrate::Tcp };
let channel_id = open_reverse_channel(&hub_call, ¶ms, None).await?;
let session = TunnelSession::adopt(
&manager,
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
).await?;
// Hand each locally accepted connection to the session's pump; the
// session keeps teardown ownership (`close` aborts, `join` awaits
// and reaps, `Drop` aborts + reaps).
session = session.pump_against(accepted_local_stream).await;
Architecture
Two roles, symmetric per the alk convention (avoid "server"/"client": both sides of a connection can be either; role follows the resource):
| Role | Via alk/channels |
|---|---|
| Producer (owns the backing: local ports, sockets, in-process services) | register_tunnel_openable / register_tunnel_listen_openable — the open op (channels/tunnel/sub) with a dial establisher (target dialing) or a listen establisher (pops an accepted connection); the pump handler bridges the channel via alkcall::channels::pump_bidi |
| Consumer (wants the bytes) | TunnelSession — open (forward) / adopt (reverse), substrate-shaped data planes, teardown ownership |
One connection hosts many tunnels (one per channel); sessions are
independent. Both roles are callable from either connection side —
-L and -R are the same open op with the entry point on different
machines (OQ-TN-03; the reverse POC validated the worker-serves /
hub-initiates shape).
A tunnel is a resource, not an address
The open-op params are wire-stable (ADR-001) and deliberately address-free:
{ "resource": "postgres-primary", "substrate": "tcp" }
The producer's registry maps the resource to its backing; the consumer
never learns an address. Rich addressing (SOCKS5 ATYP, per-datagram
remotes) enters only through the -D/dynamic composition path,
speaking its protocol inside the tunnel payload — never in the wire.
Data plane
- Stream substrates (
tcp,unix): raw pass-through — the channel's read/write halves are the tunnel; zero tunnel-level framing, zero-length reads are EOF (ADR-003). - Datagram substrate (
udp): mandatory[len: u16 BE]per-datagram framing (boundary preservation;len = 0is a legal empty datagram, never EOF — the F-2 layering; >65535 fails at frame time). The machine-readable contract is the BAST document atdocs/architecture/bast.md. - Sentinels and backpressure are alkcall channels invariants (zero-length-chunk EOF, bounded buffers, the 256-channel cap) — this crate re-derives none of them.
The local feature's UdpHalf
composes the codec at the substrate boundary: the pump-facing side
speaks framed channel bytes, the target socket sees payload-only
datagrams. Truncation fails loud, never silent (OQ-TN-13).
WASM target
The default crate (no features) compiles to wasm32-unknown-unknown
— the protocol layer for a sandboxed TS/Python adapter. The local
feature (real sockets) needs a real OS and is non-wasm by design;
enabling it on wasm is a compile error.
Features
| Feature | Contents | wasm |
|---|---|---|
| (default) | params, wire codec, open-op spec, establishers (dial + listen), TunnelSession, registration helpers — protocol only |
yes |
local |
dial_tcp / bind_tcp / connect_udp (the framed UdpHalf) / dial_unix / local_dial, the TCP accept loop |
no |
Stdio is intentionally out of scope — a spawned process's stdio is alktty's pipe mode (exit codes + signals are tty semantics); remote command execution composes via alktty (OQ-TN-14).
Verification
cargo test # default crate (wasm-clean)
cargo test --features local # + real-socket suites
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo check --target wasm32-unknown-unknown # the wasm-clean guard
License
MIT OR Apache-2.0