docs(research): add alknet-channels phase-0 research findings
Generalizes TTY's chunk format into a universal channel multiplexer (alknet-channels) that serves as a transparent proxy between the call protocol (control plane) and data-plane protocols (TTY, SSH, tunnels). Key design: 9-byte chunk header (channel_id + stream_type + length), ChannelConnection implementing the existing Connection interface, and ACL inherited from the call protocol's OperationContext.
This commit is contained in:
1 file changed
+716
@@ -0,0 +1,716 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-10
|
||||
---
|
||||
|
||||
# alknet-channels — Phase 0 Research Findings
|
||||
|
||||
This document captures Phase 0 (Exploration) findings for the `alknet-channels`
|
||||
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/crates/channels/*.md` specs, ADRs,
|
||||
and open questions.
|
||||
|
||||
This document was drafted 2026-07-10, emerging from a discussion about the
|
||||
conceptual tangle between the call protocol, the TTY crate, and the coming
|
||||
docker and SSH crates. The core issue: most "call clients" will use more than
|
||||
just the call protocol, and not everything is well described by JSON. The TTY
|
||||
crate's chunk format already solves sub-stream multiplexing for terminal
|
||||
sessions — this document generalizes that pattern into a universal channel
|
||||
multiplexer that the call protocol can orchestrate.
|
||||
|
||||
## Vision Recap
|
||||
|
||||
`alknet-channels` is a **multiplexing proxy** — a `ProtocolHandler` on
|
||||
`alknet/channels` that decomposes a single bidirectional stream into multiple
|
||||
logical channels, each carrying a different protocol (ALPN). It is the
|
||||
generalization of two existing patterns:
|
||||
|
||||
1. **SSH's channel multiplexer** (RFC 4254): `ChannelId(u32)` with
|
||||
string-named types negotiated per channel, all traffic interleaved on one
|
||||
encrypted transport stream.
|
||||
2. **alknet-tty's chunk format**: `[stream_type: u8][length: u32 be][payload]`
|
||||
with a fixed set of four sub-streams (stdin/stdout/stderr/control).
|
||||
|
||||
The generalization: a chunk header of `(channel_id: u32, stream_type: u8,
|
||||
length: u32)` — 9 bytes — that multiplexes an arbitrary number of channels,
|
||||
each with up to 256 sub-stream types, over a single transport. Channel 0 is
|
||||
reserved for the call protocol (the control plane). All other channels are
|
||||
data channels opened via call operations on channel 0.
|
||||
|
||||
The guiding insight:
|
||||
|
||||
> **Streams are streams.** A TTY session, an SSH channel, a forwarded TCP
|
||||
> connection, a QUIC bidi stream — they're all just `AsyncRead + AsyncWrite`
|
||||
> handles. The differences are only in how they're *opened* (negotiation) and
|
||||
> what *multiplexing layer* carries them. Once you normalize to
|
||||
> `AsyncRead + AsyncWrite`, the architecture collapses into a simple shape:
|
||||
> the call protocol is the universal control plane, channels are the universal
|
||||
> data plane, and ACL governs everything.
|
||||
|
||||
## The Problem This Solves
|
||||
|
||||
### The conceptual tangle
|
||||
|
||||
Today, alknet's architecture has three different multiplexing models that
|
||||
don't compose well:
|
||||
|
||||
| Model | Where | Mechanism |
|
||||
|-------|-------|-----------|
|
||||
| Connection-level | ALPN router | One ALPN per QUIC connection |
|
||||
| Stream-level | QUIC native | Many bidi streams per connection |
|
||||
| Sub-stream-level | TTY chunk format | 4 logical channels within one bidi stream |
|
||||
|
||||
A docker client needs both JSON call operations (`docker/container/list`,
|
||||
`docker/container/create`) and raw byte streams (interactive exec, container
|
||||
attach). Today that means **two separate QUIC connections** with different
|
||||
ALPNs (`alknet/call` and `alknet/tty`). The call protocol has no way to say
|
||||
"for this operation, open a TTY stream." There's no cross-ALPN coordination.
|
||||
|
||||
### The deeper issue
|
||||
|
||||
The call protocol is positioned as the universal RPC layer, but it's
|
||||
fundamentally **JSON-only**. The `EventEnvelope` framing can't carry raw
|
||||
bytes. So anything non-JSON (TTY sessions, SSH channels, file transfers, git
|
||||
pack protocols) needs its own ALPN and its own connection. The call protocol
|
||||
can't even *reference* these other streams — there's no "stream token" or
|
||||
"channel open" mechanism.
|
||||
|
||||
### What channels provides
|
||||
|
||||
A single `alknet/channels` connection carries:
|
||||
- Channel 0: the call protocol (JSON `EventEnvelope` frames)
|
||||
- Channel N: a TTY session (raw bytes, stdin/stdout/stderr/control)
|
||||
- Channel M: an SSH connection (SSH binary protocol)
|
||||
- Channel K: a tunnel (raw bytes, bidirectional proxy)
|
||||
|
||||
All on one QUIC connection (or one TCP connection, or one WebTransport
|
||||
session). The call protocol on channel 0 orchestrates channel open/close and
|
||||
type-specific control. The data channels carry whatever bytes the target
|
||||
protocol needs.
|
||||
|
||||
## The Wire Format
|
||||
|
||||
### Chunk header
|
||||
|
||||
```
|
||||
[channel_id: u32 be][stream_type: u8][length: u32 be][payload bytes]
|
||||
```
|
||||
|
||||
9 bytes of header, compared to TTY's current 5 bytes (`[stream_type: u8]
|
||||
[length: u32 be]`). The `channel_id` field is the addition — it's what turns
|
||||
a fixed 4-channel multiplexer into an arbitrary N-channel multiplexer.
|
||||
|
||||
### Channel 0: the control plane
|
||||
|
||||
Channel 0 carries the call protocol. Its chunks use `stream_type` to
|
||||
disambiguate framing:
|
||||
|
||||
| stream_type | purpose |
|
||||
|-------------|---------|
|
||||
| 0 | `EventEnvelope` frame (JSON, length-prefixed — the call protocol wire format) |
|
||||
| 1-255 | reserved for future control-plane sub-streams |
|
||||
|
||||
Channel 0 is opened implicitly when the `alknet/channels` connection is
|
||||
established. No negotiation frame is needed — both sides know channel 0 is
|
||||
the call protocol.
|
||||
|
||||
### Data channels (1..N)
|
||||
|
||||
Data channels carry protocol-specific bytes. The `stream_type` byte
|
||||
decomposes the channel into sub-streams, following the TTY crate's proven
|
||||
model:
|
||||
|
||||
| stream_type | direction | purpose |
|
||||
|-------------|-----------|---------|
|
||||
| 0 | write half | data flowing in (stdin equivalent) |
|
||||
| 1 | read half | data flowing out (stdout equivalent) |
|
||||
| 2 | read half (optional) | error/diagnostic output (stderr equivalent) |
|
||||
| 3 | bidirectional | control messages (protocol-specific JSON) |
|
||||
| 4-255 | reserved | future sub-stream types |
|
||||
|
||||
Not all channels use all sub-streams. A TTY session uses 0-3. A raw tunnel
|
||||
might use only 0 and 1. An SSH connection uses only 0 and 1 (SSH multiplexes
|
||||
internally). The `stream_type` set is fixed per channel type, declared at
|
||||
open time.
|
||||
|
||||
### Channel lifecycle
|
||||
|
||||
1. **Open**: client sends a `channel/open` call operation on channel 0 with
|
||||
`{ alpn, params }`. Server validates ACL, allocates the handler, returns
|
||||
`{ channel_id, stream_types }`. The `stream_types` field declares which
|
||||
sub-streams are active for this channel (e.g., `[0, 1, 2, 3]` for TTY,
|
||||
`[0, 1]` for a tunnel).
|
||||
2. **Data**: both sides read/write chunks on the assigned `channel_id`. Each
|
||||
side reassembles chunks for a given `(channel_id, stream_type)` into a
|
||||
stream. The reassembled streams are presented to the handler as
|
||||
`AsyncRead + AsyncWrite` handles.
|
||||
3. **Control**: channel-specific control messages ride on `stream_type 3` as
|
||||
JSON. For TTY channels this is resize/signal/eof. For tunnel channels
|
||||
this might be keepalive or connection-close. The call protocol on channel
|
||||
0 can also send control operations scoped to a specific channel (e.g.,
|
||||
`tty/resize` with a `channel_id` parameter).
|
||||
4. **Close**: either side sends a `channel/close` call operation on channel
|
||||
0, or the channel's handler signals completion (e.g., TTY exit). The
|
||||
channel's chunks stop; the `channel_id` may be reused.
|
||||
|
||||
### Framing disambiguation
|
||||
|
||||
The TTY crate's framing disambiguation trick (ADR-052 §5) carries forward:
|
||||
error frames on channel 0 start with `0x00` (the high byte of a
|
||||
length-prefixed JSON frame under 16 MiB). Data chunks on channels 1+ start
|
||||
with a non-zero `channel_id` high byte (channel 0 is reserved for control).
|
||||
Within a data channel, `stream_type` 0 (stdin) from the server is invalid,
|
||||
so `0x00` as the first byte of a chunk from the server is unambiguous.
|
||||
|
||||
## The Channel Connection Abstraction
|
||||
|
||||
### Reassembling chunks into streams
|
||||
|
||||
On each end of a channels connection, chunks for a given `(channel_id,
|
||||
stream_type)` are reassembled into a byte stream. The reassembly is
|
||||
order-preserving within a `(channel_id, stream_type)` pair — chunks arrive
|
||||
in order because they ride on a single ordered transport (QUIC stream, TCP
|
||||
connection).
|
||||
|
||||
The reassembled streams are presented through the same `Connection`
|
||||
abstraction that alknet-core already defines (`crates/alknet-core/src/
|
||||
types.rs`):
|
||||
|
||||
```rust
|
||||
// A channels connection presents the same interface as a QUIC connection
|
||||
impl ChannelConnection {
|
||||
async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
|
||||
}
|
||||
```
|
||||
|
||||
Internally, `accept_bi` / `open_bi` map to channel open/close operations on
|
||||
channel 0, and the returned `SendStream` / `RecvStream` are backed by chunk
|
||||
reassembly on the assigned `channel_id`.
|
||||
|
||||
### ALPN routing inside channels
|
||||
|
||||
A `ChannelConnection` holds a reference to the same `HandlerRegistry` that
|
||||
the outer `AlknetEndpoint` uses. When a `channel/open` arrives with an ALPN
|
||||
string, the channels handler looks up the ALPN in the registry, validates
|
||||
ACL, and hands the reassembled streams to the handler. The handler doesn't
|
||||
know it's inside a channels connection — it sees a `Connection` yielding bidi
|
||||
streams, same as if it were a top-level QUIC connection.
|
||||
|
||||
This means every existing and future `ProtocolHandler` works inside channels
|
||||
without modification. The `TtyAdapter`, the future `SshAdapter`, a tunnel
|
||||
handler — they all just see a `Connection`. The channels layer is a
|
||||
transparent proxy.
|
||||
|
||||
### Recursive composition
|
||||
|
||||
A `ChannelConnection` is itself a `Connection`. A channels handler can open a
|
||||
sub-channels connection on a data channel. This is recursive composition:
|
||||
`alknet/channels` inside `alknet/channels`. It's powerful (arbitrary nesting
|
||||
of multiplexing layers) and potentially confusing (infinite recursion). The
|
||||
recommendation is to allow it but not encourage it — the primary use case is
|
||||
one level of multiplexing. Recursive composition is a natural consequence of
|
||||
the `Connection` abstraction, not a feature to design for.
|
||||
|
||||
## Relationship to Existing Crates
|
||||
|
||||
### alknet-tty
|
||||
|
||||
The TTY crate's chunk format becomes a special case of the channels format
|
||||
(single-channel, no `channel_id` header). Two paths forward:
|
||||
|
||||
**Path A (recommended): TTY becomes a channel consumer.** The `TtyAdapter`
|
||||
no longer owns its own chunk format or its own ALPN. It receives a
|
||||
`Connection` from the channels layer and drives sessions the same way it does
|
||||
today. The chunk format becomes the channels layer's concern, not TTY's. The
|
||||
`alknet/tty` ALPN remains for direct connections (no channels layer), but the
|
||||
primary path is through channels.
|
||||
|
||||
**Path B: TTY keeps its own ALPN and chunk format.** The channels layer and
|
||||
TTY coexist. TTY's chunk format is a fixed 4-channel subset of the channels
|
||||
format. A future migration could unify them. This is lower-risk but leaves
|
||||
two chunk formats in the codebase.
|
||||
|
||||
The `TtyBackend` trait and `TtyHandle` are unchanged. The `DockerTtyBackend`
|
||||
and `LocalTtyBackend` don't need to know about channels — they still
|
||||
implement `TtyBackend::allocate()` and return a `TtyHandle`. The channels
|
||||
layer handles the wire format; the TTY adapter handles the session
|
||||
lifecycle.
|
||||
|
||||
### alknet-call
|
||||
|
||||
The call protocol is unchanged. It remains JSON-only, `EventEnvelope`-based.
|
||||
What changes is that it gains a new class of operations: channel lifecycle
|
||||
operations.
|
||||
|
||||
New call operations:
|
||||
- `channel/open` — open a data channel with a given ALPN and params
|
||||
- `channel/close` — close a data channel
|
||||
- `channel/control` — send a control message on a channel's stream_type 3
|
||||
|
||||
Existing call operations are unchanged. The call protocol doesn't need to
|
||||
know about raw bytes — it just hands out channel tokens and dispatches
|
||||
control operations.
|
||||
|
||||
### alknet-ssh
|
||||
|
||||
The SSH crate's relationship to channels is twofold:
|
||||
|
||||
1. **SSH as a channel type**: an `alknet/ssh` channel carries the SSH binary
|
||||
protocol over stream_types 0 and 1. The channels layer hands the
|
||||
reassembled stream to `SshAdapter`, which feeds it to russh. Russh
|
||||
multiplexes SSH channels inside. This is SSH-over-channels — one level of
|
||||
multiplexing (channels) carrying another (SSH).
|
||||
|
||||
2. **SSH as a channels transport**: an SSH `direct-tcpip` channel could
|
||||
carry a channels connection. This is channels-over-SSH — the reverse
|
||||
composition. Useful for tunneling alknet through an SSH bastion.
|
||||
|
||||
The SSH crate doesn't need to know about channels. It implements
|
||||
`ProtocolHandler` for `alknet/ssh` and accepts a `Connection`. Whether that
|
||||
`Connection` is a top-level QUIC connection or a channels data channel is
|
||||
transparent to the handler.
|
||||
|
||||
### alknet-docker
|
||||
|
||||
The docker crate registers call operations on the call protocol
|
||||
(`docker/container/list`, `docker/container/create`, etc.) and a
|
||||
`DockerTtyBackend` for interactive sessions. With channels:
|
||||
|
||||
- Docker lifecycle operations are call operations on channel 0 (unchanged
|
||||
from ADR-058).
|
||||
- Interactive exec/attach opens a TTY channel via `channel/open` with ALPN
|
||||
`alknet/tty` and backend `docker`. The channels layer routes to the
|
||||
`TtyAdapter`, which dispatches to `DockerTtyBackend`.
|
||||
- No separate `alknet/tty` connection needed. One `alknet/channels`
|
||||
connection handles both JSON operations and raw TTY sessions.
|
||||
|
||||
## Transport Agnosticism
|
||||
|
||||
The channels wire format works over any ordered, reliable bidirectional byte
|
||||
stream:
|
||||
|
||||
| Transport | How |
|
||||
|-----------|-----|
|
||||
| QUIC bidi stream | `alknet/channels` ALPN on a QUIC connection; one bidi stream carries all channels |
|
||||
| TCP+TLS | `alknet/channels` ALPN on a TLS connection; the TCP stream carries all channels |
|
||||
| WebTransport | `alknet/channels` session proxied through the h3 handler (ADR-040) |
|
||||
| SSH channel | channels connection riding inside an SSH `direct-tcpip` channel |
|
||||
| Another channels connection | recursive composition (channel type `alknet/channels`) |
|
||||
|
||||
The same wire format, the same chunk reassembly, the same `Connection`
|
||||
abstraction. The transport is a parameter, not a design constraint.
|
||||
|
||||
## WASM Compatibility
|
||||
|
||||
The channels wire format is pure byte manipulation: read a 9-byte header,
|
||||
split the payload, route to the right reassembly buffer. No platform
|
||||
dependencies, no tokio-specific types in the core splitting/recombining
|
||||
logic. A WASM build can:
|
||||
|
||||
1. Read chunks from a WebTransport `BiStream`
|
||||
2. Reassemble `(channel_id, stream_type)` streams
|
||||
3. Present them as `AsyncRead + AsyncWrite` handles to WASM-compatible
|
||||
handlers
|
||||
|
||||
The handlers themselves may or may not be WASM-compatible (russh's client is;
|
||||
`portable_pty` is not). But the channels layer — the multiplexing proxy —
|
||||
is WASM-compatible by construction. A browser can run a channels client in
|
||||
WASM over WebTransport, opening TTY channels, SSH channels, and tunnel
|
||||
channels, with the call protocol on channel 0 orchestrating everything.
|
||||
|
||||
## ACL and Security Model
|
||||
|
||||
### Channel open is gated by the call protocol's ACL
|
||||
|
||||
Every `channel/open` operation goes through the call protocol's existing
|
||||
`OperationContext` (identity, scopes, capabilities, ownership). No channel
|
||||
opens without authorization. The ACL check happens before the handler is
|
||||
invoked.
|
||||
|
||||
### No open ports by default
|
||||
|
||||
A core design principle carried from the SSH research (DP-5: default-deny
|
||||
baseline): alknet endpoints never expose ports, sockets, or listeners unless
|
||||
explicitly configured. Tunnels are channels, not ports. A `tunnel/open`
|
||||
operation targets a resource (`container:abc123`, `service:postgres`), not a
|
||||
port number. The hub resolves the resource to a stream. No listener, no
|
||||
socket, no port — just a channel.
|
||||
|
||||
This is the "VPN-like without being a VPN" property: bidirectional stream
|
||||
proxying through channels, gated by ACL, with no network-level exposure.
|
||||
|
||||
### Resources, not addresses
|
||||
|
||||
Forwarding destinations and tunnel targets are expressed as resources in the
|
||||
alknet identity/ownership model, not as IP:port pairs. A docker container
|
||||
hosting a service is a resource (`container:abc123`). Access is granted
|
||||
through ownership or scopes, not through firewall rules. This avoids the
|
||||
global trend of VPN restrictions — there are no VPN ports to block because
|
||||
there are no VPN ports.
|
||||
|
||||
## Decision Points
|
||||
|
||||
### DP-1: Channel ID allocation — server-assigned vs client-assigned vs negotiated
|
||||
|
||||
*(Recommended: server-assigned, returned in the `channel/open` response)*
|
||||
|
||||
Three options:
|
||||
- **(a) Client-assigned**: client picks a `channel_id` in the open request.
|
||||
Risk of collision if two clients open channels simultaneously.
|
||||
- **(b) Server-assigned**: server returns the `channel_id` in the open
|
||||
response. No collision risk. Slight latency (one round-trip before data
|
||||
can flow).
|
||||
- **(c) Negotiated ranges**: client and server each own half the ID space
|
||||
(e.g., client uses odd IDs, server uses even). SSH uses this. More
|
||||
complex, but zero-round-trip channel open.
|
||||
|
||||
**Recommendation**: **(b) server-assigned** for simplicity. The round-trip
|
||||
for channel open is acceptable — it's the same round-trip the call protocol
|
||||
already makes for every operation. If zero-round-trip channel open becomes a
|
||||
performance requirement, option (c) can be added as an extension (two-way
|
||||
door).
|
||||
|
||||
### DP-2: Channel 0 framing — EventEnvelope on stream_type 0 vs raw call protocol
|
||||
|
||||
*(Recommended: EventEnvelope frames on stream_type 0)*
|
||||
|
||||
Channel 0 carries the call protocol. Two framing options:
|
||||
- **(a) EventEnvelope on stream_type 0**: each call protocol frame is a
|
||||
chunk with `channel_id=0, stream_type=0`. The payload is a
|
||||
length-prefixed JSON `EventEnvelope` (the existing call protocol wire
|
||||
format). This is a thin wrapper — the call protocol's `FrameFramedReader`
|
||||
/ `FrameFramedWriter` work unchanged, just over a chunk-demuxed stream.
|
||||
- **(b) Raw call protocol**: channel 0 is not chunked; the call protocol
|
||||
runs directly on the transport stream. This requires the channels layer to
|
||||
peek at the first byte to disambiguate call protocol frames from data
|
||||
channel chunks. More complex framing disambiguation.
|
||||
|
||||
**Recommendation**: **(a) EventEnvelope on stream_type 0**. It's a thin
|
||||
wrapper that keeps the call protocol unchanged and the framing unambiguous.
|
||||
The overhead is 9 bytes per call frame — negligible for JSON control
|
||||
messages.
|
||||
|
||||
### DP-3: TTY chunk format — absorb into channels vs keep separate
|
||||
|
||||
*(Recommended: absorb into channels, keep `alknet/tty` ALPN as a direct-connect shortcut)*
|
||||
|
||||
The TTY crate's chunk format (`[stream_type: u8][length: u32 be]`) is a
|
||||
subset of the channels format (it's a channels chunk with `channel_id`
|
||||
implicitly 0 and only stream_types 0-3). Two paths:
|
||||
|
||||
- **(a) Absorb**: TTY sessions use the channels format. The `TtyAdapter`
|
||||
becomes a channel consumer. The `alknet/tty` ALPN remains as a
|
||||
direct-connect shortcut (no channels layer needed for simple cases), but
|
||||
the primary path is through channels.
|
||||
- **(b) Keep separate**: TTY keeps its own chunk format and ALPN. Channels
|
||||
and TTY coexist as separate multiplexing strategies. Two chunk formats in
|
||||
the codebase.
|
||||
|
||||
**Recommendation**: **(a) absorb**. The TTY crate's chunk format was always
|
||||
described as "a deliberately impoverished version of SSH's channel
|
||||
multiplexer" (alknet-tty phase-0-findings.md). Channels is the
|
||||
non-impoverished version. The `alknet/tty` ALPN stays for direct connections
|
||||
(useful for simple deployments, browser terminals), but the chunk format
|
||||
unifies under channels.
|
||||
|
||||
### DP-4: Control messages — call protocol operations vs stream_type 3 chunks
|
||||
|
||||
*(Recommended: both, with clear division of responsibility)*
|
||||
|
||||
Channel-specific control can flow through two paths:
|
||||
- **(a) Call protocol operations**: `tty/resize`, `tty/signal`,
|
||||
`channel/close` — dispatched through the `OperationRegistry` on channel 0.
|
||||
Benefits from the call protocol's existing auth, routing, and
|
||||
observability.
|
||||
- **(b) stream_type 3 chunks**: inline control messages on the data
|
||||
channel. Lower latency (no channel 0 round-trip), but requires the data
|
||||
channel handler to parse and dispatch them.
|
||||
|
||||
**Recommendation**: **both**. Use call protocol operations for
|
||||
channel lifecycle (open, close) and infrequent control (resize, signal). Use
|
||||
stream_type 3 for control that must be ordered relative to data (EOF before
|
||||
exit). The TTY crate's exit-chunk-is-last invariant (ADR-055) is an example
|
||||
of ordering-sensitive control that belongs on the data channel. The division
|
||||
is: call protocol for orchestration, stream_type 3 for data-ordered control.
|
||||
|
||||
### DP-5: Stream reassembly — backpressure and flow control
|
||||
|
||||
*(Recommended: rely on the transport's flow control; add channel-level
|
||||
windowing only if needed)*
|
||||
|
||||
QUIC provides per-stream flow control. TCP provides connection-level flow
|
||||
control. The channels layer adds a third multiplexing level. If one data
|
||||
channel's consumer is slow, it could block all other channels on the same
|
||||
transport (head-of-line blocking).
|
||||
|
||||
Options:
|
||||
- **(a) No channel-level flow control**: rely on the transport's flow
|
||||
control. Simple, but susceptible to head-of-line blocking.
|
||||
- **(b) Channel-level windowing**: each channel has its own flow-control
|
||||
window (like SSH's `ChannelParams.window_size`). Prevents head-of-line
|
||||
blocking but adds complexity.
|
||||
- **(c) Bounded channels with backpressure**: each channel's reassembly
|
||||
buffer has a maximum size; when full, the sender stops reading chunks for
|
||||
that channel. Simpler than full windowing, prevents unbounded memory
|
||||
growth.
|
||||
|
||||
**Recommendation**: **(c) bounded channels** for v1. A configurable maximum
|
||||
buffer size per channel (default 1 MiB). When a channel's buffer is full, the
|
||||
channels layer stops reading chunks for that `channel_id` until the consumer
|
||||
drains it. This prevents memory exhaustion without the complexity of SSH's
|
||||
sliding-window protocol. If head-of-line blocking becomes a real problem
|
||||
(which it likely won't for the intended use cases — TTY sessions and tunnels
|
||||
are not high-throughput), full windowing can be added as an extension
|
||||
(two-way door).
|
||||
|
||||
### DP-6: Crate structure — new crate vs extension of alknet-tty
|
||||
|
||||
*(Recommended: new `alknet-channels` crate)*
|
||||
|
||||
The channels layer is a generalization of TTY's chunk format, but it's a
|
||||
different thing: a multiplexing proxy, not a terminal session handler. Two
|
||||
options:
|
||||
- **(a) New crate `alknet-channels`**: depends on `alknet-core` (for
|
||||
`ProtocolHandler`, `Connection`, `HandlerRegistry`). Defines the chunk
|
||||
format, the `ChannelConnection`, and the `ChannelsAdapter`. TTY becomes a
|
||||
consumer.
|
||||
- **(b) Extend `alknet-tty`**: add `channel_id` to the chunk format, add
|
||||
the `ChannelConnection` abstraction. TTY is the base; channels is the
|
||||
generalization. Simpler crate graph, but blurs the conceptual boundary.
|
||||
|
||||
**Recommendation**: **(a) new crate**. The channels layer is a distinct
|
||||
concept (multiplexing proxy) from TTY (terminal session handler). Keeping
|
||||
them separate follows the existing pattern (ADR-003: no-handler-depends-on-
|
||||
another-handler) and makes the dependency graph clear: `alknet-channels`
|
||||
depends on `alknet-core`; `alknet-tty` depends on `alknet-core` (unchanged);
|
||||
both register on the same `HandlerRegistry`.
|
||||
|
||||
## Straightforward Parts
|
||||
|
||||
### 1. The chunk format is a thin extension of a proven design
|
||||
|
||||
The TTY crate's chunk format (`[stream_type: u8][length: u32 be]`) is
|
||||
validated by two POCs (alknet-docker-poc, alknet-tty-poc) and implemented in
|
||||
production code (`crates/alknet-tty/src/wire.rs`). Adding a `channel_id: u32`
|
||||
prefix is a 4-byte extension to a proven format. The `ChunkReader`/
|
||||
`ChunkWriter` pattern, the framing disambiguation trick, and the
|
||||
bidirectional pump pattern all carry forward.
|
||||
|
||||
### 2. The `Connection` abstraction already supports this
|
||||
|
||||
`alknet-core`'s `Connection` type already wraps multiple backends (QUIC,
|
||||
iroh, raw stream) behind a uniform `accept_bi()` / `open_bi()` interface. A
|
||||
`ChannelConnection` is a new backend for the same interface. The
|
||||
`HandlerRegistry` and all `ProtocolHandler` implementations work unchanged.
|
||||
|
||||
### 3. The call protocol needs no wire-format changes
|
||||
|
||||
The call protocol stays JSON-only, `EventEnvelope`-based. The new
|
||||
`channel/open`, `channel/close`, and channel-scoped control operations are
|
||||
just new operation types in the `OperationRegistry`. No new framing, no new
|
||||
carriage types, no protocol version bump.
|
||||
|
||||
### 4. WASM compatibility is by construction
|
||||
|
||||
Chunk splitting and reassembly is pure byte manipulation. The channels layer
|
||||
has no platform dependencies. A WASM build can read chunks from a
|
||||
WebTransport stream, reassemble them, and present `AsyncRead + AsyncWrite`
|
||||
handles to WASM-compatible handlers. The browser case (xterm.js over
|
||||
WebTransport to a TTY channel) works without the browser implementing SSH.
|
||||
|
||||
### 5. ACL is inherited from the call protocol
|
||||
|
||||
Channel open goes through the call protocol's existing `OperationContext`.
|
||||
Identity, scopes, capabilities, and ownership checks apply to channel open
|
||||
exactly as they apply to any other call operation. No new auth machinery.
|
||||
|
||||
## Less Straightforward Parts
|
||||
|
||||
### The TTY control channel is currently not bidirectional
|
||||
|
||||
The TTY crate's `ControlMessage` enum has server→client variants (`Exit`)
|
||||
and client→server variants (`Resize`, `Signal`, `Eof`), but the adapter
|
||||
ignores `Exit` from the client. The control channel *could* be fully
|
||||
bidirectional — there's no wire-format reason it isn't. With channels, the
|
||||
call protocol on channel 0 handles bidirectional control, and stream_type 3
|
||||
on the data channel handles data-ordered control. The TTY crate's
|
||||
`ControlMessage` enum may need to be split or generalized.
|
||||
|
||||
### The TTY adapter's session lifecycle assumes it owns the stream
|
||||
|
||||
The `TtyAdapter::handle()` currently loops `accept_bi()`, spawning a
|
||||
`drive_session` task for each stream. Inside a channels connection, the
|
||||
adapter receives a `Connection` that yields bidi streams — but those streams
|
||||
are already channel-demuxed. The adapter doesn't need to know this. The
|
||||
question is whether the adapter's current "one session per bidi stream"
|
||||
model maps cleanly to "one session per channel." It should — a channel is
|
||||
just a bidi stream with extra metadata.
|
||||
|
||||
### The exit-chunk-is-last invariant (ADR-055) with channel close
|
||||
|
||||
The TTY crate guarantees the exit chunk is the last chunk before stream
|
||||
close. With channels, the exit chunk is the last chunk on that channel, and
|
||||
then a `channel/close` operation on channel 0 signals completion. The
|
||||
ordering between the exit chunk and the close operation must be preserved:
|
||||
the exit chunk must be written and flushed before the close operation is
|
||||
sent. This is an implementation constraint, not a design change.
|
||||
|
||||
## Open Questions to Carry into Phase 1
|
||||
|
||||
- **OQ-CH-01 (channel open round-trip)**: server-assigned channel IDs mean
|
||||
one round-trip before data can flow. Is this acceptable for all channel
|
||||
types? For TTY sessions the negotiation frame already requires a
|
||||
round-trip. For tunnels it adds latency to connection establishment. If
|
||||
zero-round-trip open is needed, negotiated ID ranges (DP-1 option c) can
|
||||
be added.
|
||||
|
||||
- **OQ-CH-02 (TTY migration path)**: if the TTY chunk format is absorbed
|
||||
into channels, what's the migration path for existing `alknet/tty`
|
||||
deployments? The `alknet/tty` ALPN stays as a direct-connect shortcut.
|
||||
Does the TTY adapter's wire format change, or does it keep its current
|
||||
chunk format for direct connections and use the channels format when
|
||||
inside a channels connection?
|
||||
|
||||
- **OQ-CH-03 (channel-level flow control)**: is bounded-buffer backpressure
|
||||
(DP-5 option c) sufficient, or will head-of-line blocking be a real
|
||||
problem? The intended use cases (TTY sessions, tunnels, SSH connections)
|
||||
are not high-throughput. If a use case emerges that saturates a channel
|
||||
(e.g., file transfer over a tunnel), channel-level windowing may be
|
||||
needed.
|
||||
|
||||
- **OQ-CH-04 (channel ID reuse)**: after a channel is closed, can its
|
||||
`channel_id` be reused? SSH allows reuse. The channels format should
|
||||
allow it too, but the reassembly buffers must be fully drained before
|
||||
reuse to prevent data from the old channel leaking into the new one.
|
||||
|
||||
- **OQ-CH-05 (maximum channels per connection)**: is there a limit? SSH
|
||||
uses `u32` channel IDs, effectively unlimited. The channels format should
|
||||
match. The practical limit is memory (reassembly buffers per channel) and
|
||||
the transport's flow control.
|
||||
|
||||
- **OQ-CH-06 (channel open denial-of-service)**: an authenticated peer
|
||||
could open many channels and never read from them, exhausting memory.
|
||||
Bounded per-channel buffers (DP-5) limit the damage. A per-connection
|
||||
channel limit (configurable, default e.g. 256) provides defense in depth.
|
||||
|
||||
- **OQ-CH-07 (POC scope)**: what should a Phase 0 POC validate? Minimum:
|
||||
chunk format round-trip with multiple channels, channel open/close via
|
||||
call protocol, TTY session inside a channel. Stretch: SSH connection
|
||||
inside a channel, tunnel inside a channel, recursive composition.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### Crate
|
||||
|
||||
`alknet-channels`, depends on `alknet-core` (for `ProtocolHandler`,
|
||||
`Connection`, `HandlerRegistry`, `SendStream`, `RecvStream`). Defines:
|
||||
|
||||
- The chunk format (`[channel_id: u32 be][stream_type: u8][length: u32 be]`)
|
||||
- `ChunkReader` / `ChunkWriter` (generalized from TTY's wire module)
|
||||
- `ChannelConnection` (implements the `Connection` interface over chunk
|
||||
reassembly)
|
||||
- `ChannelsAdapter` (`ProtocolHandler` for `alknet/channels`)
|
||||
- Channel open/close call operations
|
||||
|
||||
Does not depend on `alknet-tty`, `alknet-call`, `alknet-ssh`, or any other
|
||||
handler crate. The dependency direction is: handlers depend on
|
||||
`alknet-core`; `alknet-channels` depends on `alknet-core`; nothing depends
|
||||
on `alknet-channels` except the assembly layer that registers it on the
|
||||
`HandlerRegistry`.
|
||||
|
||||
### Build order
|
||||
|
||||
**Step 1: Wire format + mock channels.**
|
||||
- Implement the 9-byte chunk format with `channel_id`.
|
||||
- Implement `ChunkReader` / `ChunkWriter` (generalized from TTY's
|
||||
`wire.rs`).
|
||||
- Implement `ChannelConnection` with mock channels (in-memory pipes).
|
||||
- Validate chunk round-trip with multiple channels, concurrent reads/writes.
|
||||
- **Result**: a working chunk multiplexer with no real handlers.
|
||||
|
||||
**Step 2: Channel open/close via call protocol.**
|
||||
- Implement `channel/open` and `channel/close` call operations.
|
||||
- Integrate with `HandlerRegistry` — `channel/open` looks up the ALPN,
|
||||
validates ACL, invokes the handler.
|
||||
- Validate: open a channel, pump data, close the channel.
|
||||
- **Result**: call protocol orchestrating channel lifecycle.
|
||||
|
||||
**Step 3: TTY inside channels.**
|
||||
- Route `alknet/tty` channel opens to the `TtyAdapter`.
|
||||
- Validate: `channel/open` with ALPN `alknet/tty` → TTY session inside a
|
||||
channel.
|
||||
- **Result**: TTY sessions work through channels. The docker use case (call
|
||||
operations + TTY sessions on one connection) is validated.
|
||||
|
||||
**Step 4: Tunnel channels.**
|
||||
- Implement a simple tunnel handler: `channel/open` with ALPN
|
||||
`alknet/tunnel` and a target resource → bidirectional byte proxy.
|
||||
- Validate: tunnel a TCP connection through a channel.
|
||||
- **Result**: the "VPN-like without being a VPN" property is proven.
|
||||
|
||||
**Step 5: SSH inside channels (stretch).**
|
||||
- Route `alknet/ssh` channel opens to the `SshAdapter`.
|
||||
- Validate: SSH connection inside a channel.
|
||||
- **Result**: SSH-over-channels works. The full stack (call + TTY + SSH +
|
||||
tunnel on one connection) is validated.
|
||||
|
||||
### De-risk POC
|
||||
|
||||
A Phase 0 POC should validate the core claim: **one connection, multiple
|
||||
channel types, orchestrated by the call protocol.** Minimum scope:
|
||||
|
||||
1. Chunk format round-trip with 3+ concurrent channels
|
||||
2. Channel open/close via call protocol operations
|
||||
3. TTY session inside a channel (reuse the existing `alknet-tty-poc`
|
||||
test infrastructure)
|
||||
4. Bidirectional byte pumping on a data channel
|
||||
|
||||
Stretch goals:
|
||||
5. Two different channel types (TTY + tunnel) on the same connection
|
||||
6. WASM build of the chunk splitting/recombining logic
|
||||
|
||||
The POC can be built as an extension to the existing `alknet-tty-poc` or as
|
||||
a standalone POC in `/workspace/alknet-channels-poc/`.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — the TTY crate's chunk
|
||||
format, control channel, and backend trait. The seed of the channels
|
||||
generalization.
|
||||
- `docs/research/alknet-ssh/phase-0-findings.md` — SSH's channel
|
||||
multiplexer, the channel decomposition (Layers 1-7), the default-deny
|
||||
baseline.
|
||||
- `docs/research/alknet-docker/poc-summary.md` — the docker POC that
|
||||
validated the raw chunk format and the two-carriage model.
|
||||
- `crates/alknet-tty/src/wire.rs` — the current chunk format implementation
|
||||
(`ChunkReader`, `ChunkWriter`, `[stream_type: u8][length: u32 be]`).
|
||||
- `crates/alknet-tty/src/control.rs` — the `ControlMessage` enum (resize,
|
||||
signal, eof, exit).
|
||||
- `crates/alknet-tty/src/adapter.rs` — the `TtyAdapter` and `drive_session`
|
||||
bidirectional pump.
|
||||
- `crates/alknet-core/src/types.rs` — `ProtocolHandler`, `Connection`,
|
||||
`SendStream`, `RecvStream`, `HandlerRegistry`.
|
||||
- `crates/alknet-call/src/protocol/wire.rs` — `EventEnvelope`,
|
||||
`FrameFramedReader` / `FrameFramedWriter`.
|
||||
- `crates/alknet-call/src/protocol/dispatch.rs` — `Dispatcher::run_loop`,
|
||||
`handle_stream`, `pump_stream`.
|
||||
- `/workspace/russh/russh/src/channels/` — russh's channel implementation
|
||||
(`ChannelMsg`, `Channel`, `ChannelReadHalf`, `ChannelWriteHalf`,
|
||||
`WindowSizeRef`).
|
||||
- `docs/architecture/decisions/001-alpn-protocol-dispatch.md` — ALPN
|
||||
dispatch.
|
||||
- `docs/architecture/decisions/002-protocol-handler-trait.md` —
|
||||
ProtocolHandler.
|
||||
- `docs/architecture/decisions/003-crate-decomposition.md` —
|
||||
no-handler-depends-on-another-handler.
|
||||
- `docs/architecture/decisions/040-webtransport-alpn-stream-proxy.md` —
|
||||
WebTransport stream proxy (the browser path for channels).
|
||||
- `docs/architecture/decisions/052-tty-wire-format.md` — TTY wire format
|
||||
(ADR-052).
|
||||
- `docs/architecture/decisions/055-tty-exit-chunk-ordering.md` — exit-chunk-
|
||||
is-last invariant (ADR-055).
|
||||
- `docs/architecture/decisions/058-docker-call-alpn.md` — docker on
|
||||
`alknet/call` (ADR-058).
|
||||
Reference in new issue
Block a user