docs(adr): 094 — per-identity channel cap as DoS defense
ADR-076 framed the per-connection max_channels=256 cap as the DoS defense, but a peer can open an unbounded number of transport connections, so a per-connection cap bounds a connection's reassembly-buffer cost, not a peer's total channels. The only coherent unit for a channel DoS defense is the identity. ADR-094 records the corrected design: a ChannelLifecyclePolicy trait in channels-call (where the identity is already on OperationContext), consulted by the channel/open handler (after AccessControl::check, before allocation) and the channel/close handler (after the drain completes). Default is PerIdentityChannelPolicy::new(256) — 256 per PeerId across all the peer's connections, shared via Arc across every channels connection a peer accepts. The cap is a peer concern (not hub-specific), symmetric (both sides enforce), and lives in channels-call because the channels layer is auth-blind by design (ADR-075) — that is what makes it WASM-compatible, transport-agnostic, and ALPN-blind. For the hub-relay path (ADR-079), the spoke sees the hub as the direct caller (ADR-032 — forwarded_for is metadata, not authority, for the cap as for AccessControl::check), so the spoke caps the hub, not the browser. A spoke serving a high-fan-out hub sets the hub peer's cap higher via with_per_identity_caps — the spoke's own policy, not the hub's. Recursive channels do not bypass the cap (the same policy can be wired into the inner ChannelOperations). ADR-076 is amended: the per-connection max_channels is reframed as a per-connection memory bound (still returns channel:too_many_channels when hit), the "DoS defense summary" table is removed, and the "per-connection, not per-peer" line (the channels layer confessing a hole and hoping the layer above would fill it) is corrected. Spec docs updated to reference ADR-094: channel-operations.md gains a "Per-identity channel cap" section (trait, default, enforcement point, relay consequence, recursion); channels-adapter.md adds the policy check as step 3 of the channel/open handler and the decrement in channel/close; hub README adds the channel_policy field on Hub, the with_channel_policy builder, a dedicated subsection, and the inbound-peer-vs-hub-as-caller distinction; channels/README.md adds ADR-094 to the Applicable ADRs table and a 9th Key Design Principle; docs/architecture/README.md adds a Current State note and the ADR table row.
This commit is contained in:
1 parent
762d9c7bd2
commit
0fcd5bc322
7 files changed
+789
-51
No files matched your search
@@ -1,12 +1,48 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-07-18
|
||||
last_updated: 2026-07-19
|
||||
---
|
||||
|
||||
# Alknet Architecture
|
||||
|
||||
## Current State
|
||||
|
||||
**Per-identity channel cap added (ADR-094, 2026-07-19).** The
|
||||
channels-layer `max_channels = 256` cap (ADR-076) was framed as the
|
||||
per-connection DoS defense. On review this is not a DoS defense at
|
||||
all — a peer can open an unbounded number of transport connections,
|
||||
so a per-connection cap bounds a connection's reassembly-buffer cost,
|
||||
not a peer's total channels. The only coherent unit for a channel
|
||||
DoS defense is the identity. [ADR-094](decisions/094-per-identity-channel-cap.md)
|
||||
records the corrected design: a `ChannelLifecyclePolicy` trait in
|
||||
`channels-call` (where the identity is already on `OperationContext`),
|
||||
consulted by the `channel/open` handler (after `AccessControl::check`,
|
||||
before allocation) and the `channel/close` handler (after the drain
|
||||
completes). Default: `PerIdentityChannelPolicy::new(256)` — 256 per
|
||||
`PeerId` across all the peer's connections (no "NoOp default + wire it
|
||||
later"). The policy `Arc` is shared across every channels connection
|
||||
a peer accepts, which is what makes the cap per-identity, not
|
||||
per-connection. ADR-076 is amended — the per-connection `max_channels`
|
||||
is reframed as a memory bound, the "DoS defense summary" table is
|
||||
removed, and the "per-connection, not per-peer — a peer can open more
|
||||
channels on a second connection" line (the channels layer confessing
|
||||
a hole and hoping the layer above would fill it) is corrected. The
|
||||
cap lives in `channels-call`, not `channels-core`, because the
|
||||
channels layer is auth-blind by design (ADR-075 — that is what makes
|
||||
it WASM-compatible, transport-agnostic, and ALPN-blind). The cap is a
|
||||
peer concern, not a hub-specific concern — any accepting peer (worker
|
||||
or hub) enforces it, the same way it enforces `AccessControl::check`.
|
||||
For the hub-relay path (ADR-079), the spoke sees the hub as the direct
|
||||
caller (ADR-032 — `forwarded_for` is metadata, not authority, for the
|
||||
cap as for `AccessControl::check`), so the spoke caps the hub, not the
|
||||
browser; a spoke serving a high-fan-out hub sets the hub peer's cap
|
||||
higher via `with_per_identity_caps`. The "assembly layer" hedging
|
||||
pattern (putting the hard question off on a fictional later that
|
||||
turns out to be exactly the same problem) is actively avoided — the
|
||||
default is secure out of the box, and per-peer-role overrides are
|
||||
explicit opt-ins. See [ADR-094](decisions/094-per-identity-channel-cap.md)
|
||||
and the amended [ADR-076](decisions/076-backpressure-channel-limits-id-reuse.md).
|
||||
|
||||
**Client-dial SOCKS5 proxy seam added (ADR-090, 2026-07-16).**
|
||||
`AlknetClient` (ADR-089) gains an optional SOCKS5 proxy
|
||||
(`with_socks5_proxy`) so a native client can hide its real IP from the
|
||||
@@ -340,7 +376,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | Accepted (amended by ADR-093 — `stream_types` field removed from `channel/open`; `stream_type` field removed from `channel/control`) |
|
||||
| [074](decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Accepted (amended by ADR-093 — `into_sub_streams()` removed; `accept_bi` yields `BiStream`) |
|
||||
| [075](decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Accepted (amended by ADR-093 — 8-byte headers, one reassembly buffer per channel) |
|
||||
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`) |
|
||||
| [076](decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Accepted (amended by ADR-093 — per-`channel_id`, not per-`(channel_id, stream_type)`; amended by ADR-094 — per-connection `max_channels` reframed as a memory bound, not a DoS defense; per-identity DoS defense lives in `channels-call` via `ChannelLifecyclePolicy`) |
|
||||
| [077](decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | Accepted (reversed by ADR-093 — TTY always uses its 5-byte format, carried transparently) |
|
||||
| [078](decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | Accepted |
|
||||
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
|
||||
@@ -358,6 +394,7 @@ adapter location map is now consistent: all HTTP-backed adapters
|
||||
| [091](decisions/091-connectioncredentials-decouple-dial-from-call.md) | `ConnectionCredentials` — Decouple Dial Credentials from Call Protocol | Accepted (amends ADR-089 §3/§5 and ADR-087 input framing; dial takes `ConnectionCredentials` not `CallCredentials`; all three dial signatures unified; `dial_iroh`'s `node_id` derived from `remote_identity`; `auth_token` is a per-request payload field; `CallCredentials` removed per Am. 2026-07-17) |
|
||||
| [092](decisions/092-bistream-as-the-handler-leaf.md) | `BiStream` as the Handler Leaf — Unify the Split-Pair `accept_bi` | Accepted (amends ADR-070's `accept_bi` return type; amends ADR-065's `from_stream`/`from_bidi` constructors; amends ADR-074's `ChannelBidiStreamSource::accept_bi` return type; `Connection::from_stream` removed; `from_bidi` is the only public stream constructor) |
|
||||
| [093](decisions/093-channels-pure-channel-multiplexing.md) | alknet-channels — Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | Accepted (amends ADR-071 — 8-byte header; ADR-074 — `into_sub_streams` removed; reverses ADR-077 — TTY always uses its 5-byte format; amends the channels-facing clauses of ADR-072/073/075/076/080/081) |
|
||||
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap as DoS Defense | Accepted (amends ADR-076 — per-connection `max_channels` reframed as a memory bound; 256 per `PeerId` enforced via `ChannelLifecyclePolicy` in `channels-call`; symmetric; spoke caps hub as direct caller) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations on the Call Protocol | `channel/open`/`close`/`control`/`resources/subscribe`; `direction` semantics; subscribe not poll |
|
||||
| [074](../../decisions/074-channelconnection-bidistreamsource.md) | ChannelConnection — BidiStreamSource over Chunk Reassembly | Per-channel `BidiStreamSource` impl; `accept_bi` yields `BiStream` (amended by ADR-093 — `into_sub_streams` removed) |
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | Substrate-agnostic demux loop; REQ-CH-01..04 contracts |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel cap, monotonic IDs with wrap |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Channel Limits, and ID Reuse | Bounded-buffer (1 MiB default), 256-channel per-connection memory bound, monotonic IDs with wrap (DoS defense reframed by ADR-094) |
|
||||
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound; symmetric (both sides enforce); spoke caps hub (direct caller), not browser (forwarded_for is metadata) |
|
||||
| [077](../../decisions/077-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | TTY's two modes (direct vs channels); TTY always uses its 5-byte format, carried transparently in the channels payload |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Shutdown-on-Completion Pattern | The two-pump deadlock contract; handler-level, not channels-layer |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | The hub translates channel 0, byte-forwards data channels with ID rewrite |
|
||||
@@ -120,6 +121,19 @@ handler owns its sub-stream multiplexing on the `BiStream` it receives.
|
||||
channels are byte-forwarded with `channel_id` rewrite (a 4-byte rewrite
|
||||
within the 8-byte header). This preserves the auth model. See ADR-079.
|
||||
|
||||
9. **The channel cap is per-identity, not per-connection.** A channel
|
||||
slot is a resource; the cap on how many an identity may hold open is
|
||||
a quota check, parallel to `OwnershipProvider::owns` (ADR-050) for
|
||||
spawned resources. The cap lives in `channels-call` (the channels
|
||||
layer is auth-blind by ADR-075 — no identity, no scopes), consulted
|
||||
by the `channel/open` and `channel/close` handlers after
|
||||
`AccessControl::check`. The default is `PerIdentityChannelPolicy::
|
||||
new(256)` — 256 per `PeerId` across all the peer's connections. The
|
||||
per-connection `max_channels` (ADR-076) is a memory bound, not a
|
||||
DoS defense. The cap is symmetric (both sides enforce); the spoke
|
||||
caps the hub as direct caller, not the browser as `forwarded_for`
|
||||
(metadata, not authority — ADR-032). See ADR-094.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` — Phase 0 research
|
||||
|
||||
@@ -235,6 +235,150 @@ The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
|
||||
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
|
||||
layer inherited the auth model by being a call-protocol operation.
|
||||
|
||||
## Per-identity channel cap (ADR-094)
|
||||
|
||||
A channel slot is a resource. The cap on how many channels an identity
|
||||
may hold open is a quota check on that resource — parallel to
|
||||
`OwnershipProvider::owns` (ADR-050) for spawned resources. Same
|
||||
primitive, different resource. The cap is a **peer concern**, not a
|
||||
hub-specific concern: any accepting peer (worker or hub) enforces the
|
||||
cap on its inbound channels, just as it enforces `AccessControl::check`
|
||||
on `channel/open`. The cap is also **symmetric** — both sides of a
|
||||
channels connection enforce their cap on the other's channels.
|
||||
|
||||
### Why the cap is not in the channels layer
|
||||
|
||||
`ChannelManager` (ADR-075) is auth-blind by design — no auth state, no
|
||||
identity, no scopes. That decision is load-bearing (it is what makes
|
||||
the channels layer WASM-compatible, transport-agnostic, and
|
||||
ALPN-blind). So the per-identity cap lives in `channels-call`, where
|
||||
the identity is already on `OperationContext` (the same place
|
||||
`AccessControl::check` runs). The channels layer (`channels-core`) is
|
||||
unchanged. See ADR-094 §"Why the channels layer cannot hold the cap".
|
||||
|
||||
The channels-layer per-connection `max_channels = 256` (ADR-076) is
|
||||
a **per-connection memory bound** (limits one connection's
|
||||
reassembly-buffer cost), not a DoS defense. A peer can open an
|
||||
unbounded number of transport connections, so a per-connection cap is
|
||||
not a per-peer DoS defense. The per-identity DoS defense is the cap
|
||||
documented here; see ADR-094 for the corrected DoS-defense framing.
|
||||
|
||||
### The `ChannelLifecyclePolicy` trait
|
||||
|
||||
```rust
|
||||
/// Per-identity channel lifecycle policy. Consulted by the
|
||||
/// `channel/open` handler (after `AccessControl::check`, before
|
||||
/// allocation) and the `channel/close` handler (after deallocation).
|
||||
/// Both handlers have the identity via `OperationContext`.
|
||||
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
|
||||
/// Before channel allocation. Deny with `channel:too_many_channels`
|
||||
/// (ADR-073) when the identity is over its cap. The identity is
|
||||
/// the direct caller (the peer that opened this channels
|
||||
/// connection); `forwarded_for` is metadata and is NOT consulted
|
||||
/// (ADR-032).
|
||||
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
|
||||
|
||||
/// After channel deallocation. Decrement the per-identity count.
|
||||
/// Called by the `channel/close` handler after the drain completes
|
||||
/// (ADR-076 §channel-id-reuse).
|
||||
fn on_close(&self, identity: &Identity);
|
||||
}
|
||||
```
|
||||
|
||||
### Default: `PerIdentityChannelPolicy::new(256)`
|
||||
|
||||
The default constructor enforces 256 per identity out of the box — no
|
||||
"NoOp default + wire it later." A channels-accepting peer that
|
||||
constructs `ChannelOperations::new(manager)` with no policy argument
|
||||
gets `PerIdentityChannelPolicy::new(256)`. The default is secure;
|
||||
opt-outs are explicit:
|
||||
|
||||
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
|
||||
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
|
||||
peer** and shared (via `Arc`) across every channels connection that
|
||||
peer accepts. The sharing is what makes the cap per-identity, not
|
||||
per-connection.
|
||||
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` —
|
||||
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
|
||||
default cap for specific peers. Used by a spoke that serves a
|
||||
high-fan-out hub (the hub peer's cap is set higher than a worker
|
||||
peer's cap — see "Relay consequence" below).
|
||||
- `NoCap` — no cap. Explicit opt-out for tests, POCs, and trusted
|
||||
single-peer deployments. Not the default.
|
||||
|
||||
The policy is constructed once and passed to `ChannelOperations` at
|
||||
registration time:
|
||||
|
||||
```rust
|
||||
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
|
||||
let channel_ops = ChannelOperations::new(manager, policy);
|
||||
channel_ops.register_on(&mut call_registry)?;
|
||||
```
|
||||
|
||||
### Enforcement point: between `AccessControl::check` and allocation
|
||||
|
||||
The `channel/open` handler (above) gains the policy check after ACL
|
||||
and before `next_id.fetch_add`:
|
||||
|
||||
1. ACL is already checked by `OperationRegistry::invoke` (the existing
|
||||
`AccessControl::check` path — unchanged).
|
||||
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
|
||||
`channel:too_many_channels` if over cap.
|
||||
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)`
|
||||
(DP-1: server-assigned — unchanged).
|
||||
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
|
||||
the `ChannelState` (unchanged).
|
||||
5. Return the `channel_id`.
|
||||
|
||||
The `channel/close` handler gains the decrement after the drain
|
||||
completes (the same point ADR-076 marks the `channel_id` as eligible
|
||||
for reuse):
|
||||
|
||||
1. Drain the reassembly buffer for `channel_id` (existing — ADR-076
|
||||
§channel-id-reuse).
|
||||
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
|
||||
per-identity count.
|
||||
3. Return `{ "closed": true }` (unchanged).
|
||||
|
||||
### Relay consequence: the spoke caps the hub, not the browser
|
||||
|
||||
When the hub relays a browser's channel to a spoke (ADR-079), the
|
||||
spoke sees the hub as the direct caller. `forwarded_for` carries the
|
||||
browser's identity as metadata (ADR-032 — `forwarded_for` is not
|
||||
authority; `AccessControl::check` never reads it). The channel cap
|
||||
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
|
||||
consulted with the **hub's** identity, not the browser's. The spoke
|
||||
asks "does the hub have access to open another channel?" and the
|
||||
hub's quota on the spoke reflects the aggregate of all relayed
|
||||
channels. The hub's per-browser caps are the hub's own concern
|
||||
(enforced on the browser leg by the hub's own policy), not the
|
||||
spoke's.
|
||||
|
||||
This is correct and consistent — the spoke authorizes the hub for
|
||||
container access the same way it authorizes any peer, and the hub's
|
||||
browser-relay ACL is the hub's own layer. The channel cap follows the
|
||||
same pattern as any other resource ACL.
|
||||
|
||||
**Deployment consequence:** a spoke that serves a hub relaying for
|
||||
many browsers must set the hub peer's cap higher than a worker peer's
|
||||
cap, or the spoke denies legitimate relayed channels when the hub's
|
||||
aggregate count exceeds a worker-sized cap. This is a per-peer-role
|
||||
policy, set by the spoke via `with_per_identity_caps`. The
|
||||
architecture provides the mechanism; the deployment sets the numbers.
|
||||
This is not a flaw — it is the same shape as any per-peer ACL (a
|
||||
spoke may authorize one peer for 1000 containers and another for 10;
|
||||
the channel cap is the same kind of per-peer policy).
|
||||
|
||||
### Recursive channels do not bypass the cap
|
||||
|
||||
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
|
||||
new `ChannelsAdapter` with a new `ChannelManager`. If the same
|
||||
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
|
||||
the inner channels are counted against the same identity. Recursion
|
||||
is not a bypass; the 13-byte-per-chunk overhead is the documented
|
||||
cost (ADR-093), and the cap behavior is unchanged. Recursive channels
|
||||
are an edge case for edge cases and not specced further.
|
||||
|
||||
## Hub relay contract (ADR-079 — summary)
|
||||
|
||||
The hub **translates**, not transparently forwards:
|
||||
@@ -267,14 +411,17 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
| [073](../../decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
|
||||
| [072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing |
|
||||
| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens |
|
||||
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The ownership store the spoke queries |
|
||||
| [032](../../decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
|
||||
| [050](../../decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check |
|
||||
|
||||
## References
|
||||
|
||||
- ADR-073: channel lifecycle operations (the decision)
|
||||
- ADR-094: per-identity channel cap (the cap, the trait, the relay
|
||||
consequence)
|
||||
- ADR-079: hub relay (the translate contract)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
|
||||
Negotiation, §ACL and Security Model
|
||||
@@ -78,7 +78,14 @@ pub struct ChannelManager {
|
||||
// call-protocol-blind.
|
||||
next_id: AtomicU32, // monotonic; wraps at u32::MAX
|
||||
buffer_cap: usize, // default 1 MiB (ADR-076)
|
||||
max_channels: usize, // default 256 (ADR-076)
|
||||
max_channels: usize, // default 256 (ADR-076) — per-connection
|
||||
// memory bound, NOT a DoS defense. The
|
||||
// per-identity DoS defense is the
|
||||
// ChannelLifecyclePolicy consulted by the
|
||||
// channel/open handler in channels-call
|
||||
// (ADR-094). The auth-blindness that forces
|
||||
// the cap out of this struct is ADR-075's
|
||||
// "no auth state" rule.
|
||||
}
|
||||
|
||||
struct ChannelState {
|
||||
@@ -136,27 +143,55 @@ dependencies.
|
||||
|
||||
The `channel/open` (and `channel/close`, `channel/control`,
|
||||
`channel/resources/subscribe`) operations are registered on the call
|
||||
protocol's `OperationRegistry` at assembly time:
|
||||
protocol's `OperationRegistry` at registration time. The
|
||||
`ChannelOperations` constructor takes a `ChannelLifecyclePolicy`
|
||||
(ADR-094) — the default is `PerIdentityChannelPolicy::new(256)` (a
|
||||
real per-identity cap, not NoOp):
|
||||
|
||||
```rust
|
||||
let channel_ops = ChannelOperations::new(manager.clone());
|
||||
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
|
||||
let channel_ops = ChannelOperations::new(manager.clone(), policy);
|
||||
channel_ops.register_on(&mut call_registry)?;
|
||||
```
|
||||
|
||||
The same `Arc<PerIdentityChannelPolicy>` is shared across every
|
||||
channels connection this peer accepts — that is what makes the cap
|
||||
per-identity, not per-connection. A hub constructs one policy and
|
||||
shares it across all worker and browser legs; a worker accepting
|
||||
direct channels constructs one policy and shares it across whatever
|
||||
connections it accepts. See ADR-094 for the policy trait and the
|
||||
default/opt-out variants.
|
||||
|
||||
The `channel/open` handler (ADR-073):
|
||||
1. ACL is already checked by `OperationRegistry::invoke` before this handler
|
||||
runs.
|
||||
2. Looks up the ALPN in `HandlerRegistry` → `channel:unknown_alpn` if
|
||||
missing.
|
||||
3. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned).
|
||||
4. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
|
||||
3. **Per-identity cap check (ADR-094):**
|
||||
`policy.check_open(&op_ctx.identity)?` — deny with
|
||||
`channel:too_many_channels` if the identity is over its cap. The
|
||||
identity is the direct caller (the peer on this channels
|
||||
connection); `forwarded_for` is metadata and is NOT consulted
|
||||
(ADR-032). For the hub-relay path, the spoke sees the hub as the
|
||||
direct caller — the hub's quota on the spoke reflects the aggregate
|
||||
of all relayed channels (ADR-094 §5).
|
||||
4. Allocates the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned). The per-connection `max_channels` (ADR-076) is
|
||||
checked here too — the per-connection memory bound; if hit, the same
|
||||
`channel:too_many_channels` error is returned (which cap fired first
|
||||
is an implementation detail — ADR-094 §4).
|
||||
5. Constructs the `ChannelBidiStreamSource` (ADR-074, as amended by
|
||||
ADR-093) — one reassembly buffer, yielding a `BiStream`.
|
||||
5. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
|
||||
6. Spawns the handler task — `tokio::spawn(handler.handle(conn, &auth))`.
|
||||
Identical to what `TtyAdapter::handle` does today, but on a
|
||||
channels-backed `Connection`.
|
||||
6. Records the `ChannelState`.
|
||||
7. Returns the `channel_id`.
|
||||
7. Records the `ChannelState`.
|
||||
8. Returns the `channel_id`.
|
||||
|
||||
The `channel/close` handler (ADR-073) gains a symmetric
|
||||
`policy.on_close(&op_ctx.identity)` call after the drain completes
|
||||
(the same point ADR-076 marks the `channel_id` as eligible for reuse)
|
||||
— decrementing the per-identity count.
|
||||
|
||||
## Demux invariants (REQ-CH-02, 04)
|
||||
|
||||
@@ -258,7 +293,8 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
|-----|----------|---------|
|
||||
| [075](../../decisions/075-channelsadapter-and-channelmanager.md) | ChannelsAdapter and ChannelManager | The split; the contracts |
|
||||
| [093](../../decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | The umbrella decision: 8-byte header, no `stream_type`, one reassembly buffer per channel |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel cap, monotonic IDs |
|
||||
| [076](../../decisions/076-backpressure-channel-limits-id-reuse.md) | Backpressure, Limits, ID Reuse | Bounded-buffer, 256-channel per-connection memory bound, monotonic IDs (DoS defense reframed by ADR-094) |
|
||||
| [094](../../decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
|
||||
| [078](../../decisions/078-two-pump-shutdown-on-completion.md) | Two-Pump Pattern | Shutdown-on-completion contract |
|
||||
| [079](../../decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
|
||||
|
||||
@@ -271,7 +307,11 @@ All design decisions are documented as ADRs in [decisions/](../../decisions/).
|
||||
- ADR-073: channel lifecycle operations (the ops registered on `call_ops`)
|
||||
- ADR-074: ChannelBidiStreamSource (what the manager constructs per
|
||||
channel, as amended by ADR-093)
|
||||
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels`)
|
||||
- ADR-076: backpressure and limits (`buffer_cap`, `max_channels` — the
|
||||
per-connection memory bound)
|
||||
- ADR-094: per-identity channel cap (the `ChannelLifecyclePolicy`
|
||||
consulted by the `channel/open` handler; the relay consequence for
|
||||
hub-relayed channels)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §Issues Surfaced #4-#7
|
||||
(REQ-CH-01..04, the two-pump deadlock)
|
||||
- `docs/research/stream-unification/findings.md` — the research that
|
||||
|
||||
@@ -117,7 +117,21 @@ welded to a dial. See "Transport" below.
|
||||
Lets a browser reach a spoke's channels through the hub without the
|
||||
hub parsing any protocol-specific framing.
|
||||
|
||||
6. **Worker registration** (in scope of the hub) — the HTTP endpoint
|
||||
6. **Per-identity channel cap** — the hub constructs one
|
||||
`ChannelLifecyclePolicy` (ADR-094) and shares it across every
|
||||
channels connection it accepts. This is the cap the hub enforces on
|
||||
its **inbound** peers (workers and browsers connecting to the hub).
|
||||
The cap is per-identity, not per-connection — a peer with N
|
||||
transport connections to the hub is bounded by the cap once, not
|
||||
N times. The default is 256 per `PeerId`; per-peer-role overrides
|
||||
(e.g., a lower cap for browser peers) are set via
|
||||
`with_channel_policy`. The hub-as-caller case (hub dialing a
|
||||
downstream spoke) is the **spoke's** policy — the spoke constructs
|
||||
its own policy with a high cap for the hub peer (ADR-094 §5). The
|
||||
cap is symmetric — both sides of a channels connection enforce
|
||||
their cap. See "Per-identity channel cap" below.
|
||||
|
||||
7. **Worker registration** (in scope of the hub) — the HTTP endpoint
|
||||
that lets a freshly-provisioned worker enroll its key with a
|
||||
one-time registration token. The registration flow is what makes
|
||||
worker provisioning over TCP+TLS a hard requirement, not an
|
||||
@@ -181,7 +195,8 @@ The hub's `CallClient`-direct dial path is replaced by
|
||||
### Hub struct
|
||||
|
||||
The `Hub` owns the aggregated `PeerCompositeEnv`, the
|
||||
`OperationRegistry`, and the `Dispatcher`:
|
||||
`OperationRegistry`, the `Dispatcher`, and the per-identity channel
|
||||
cap policy:
|
||||
|
||||
```rust
|
||||
pub struct Hub {
|
||||
@@ -189,6 +204,15 @@ pub struct Hub {
|
||||
aggregated_env: Arc<RwLock<PeerCompositeEnv>>,
|
||||
dispatcher: Dispatcher,
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
/// The per-identity channel cap policy (ADR-094). Shared across
|
||||
/// every channels connection the hub accepts — that is what makes
|
||||
/// the cap per-identity, not per-connection. Constructed once at
|
||||
/// Hub::new and passed to ChannelOperations::new for each
|
||||
/// connection. The hub's browser-leg caps and worker-leg caps are
|
||||
/// enforced by the same policy (the cap is symmetric — both
|
||||
/// sides of a channels connection enforce their cap on the other's
|
||||
/// channels).
|
||||
channel_policy: Arc<dyn ChannelLifecyclePolicy>,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -211,15 +235,43 @@ impl Hub {
|
||||
aggregated_env,
|
||||
dispatcher,
|
||||
identity_provider,
|
||||
channel_policy: Arc::new(PerIdentityChannelPolicy::new(256)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared aggregated PeerCompositeEnv. The assembly layer wires
|
||||
/// this into CallAdapter::with_aggregated_env so every call's
|
||||
/// The shared aggregated PeerCompositeEnv. The deployment binary
|
||||
/// wires this into CallAdapter::with_aggregated_env so every call's
|
||||
/// compose_root_env sees all connected workers.
|
||||
pub fn aggregated_env(&self) -> &Arc<RwLock<PeerCompositeEnv>> {
|
||||
&self.aggregated_env
|
||||
}
|
||||
|
||||
/// The shared per-identity channel cap policy (ADR-094). Wired into
|
||||
/// `ChannelOperations::new` for every channels connection the hub
|
||||
/// accepts — this is the cap the hub enforces on its **inbound**
|
||||
/// peers (workers and browsers connecting to the hub). The policy
|
||||
/// `Arc` is shared across all the hub's accepted connections, which
|
||||
/// is what makes the cap per-identity (a peer with N transport
|
||||
/// connections to the hub is bounded by the cap once, not N times).
|
||||
/// The hub-as-caller case (hub dialing a downstream spoke) is
|
||||
/// governed by the **spoke's** policy, not this one — the spoke
|
||||
/// constructs its own `ChannelLifecyclePolicy` with a high cap for
|
||||
/// the hub peer (ADR-094 §5). See "Per-identity channel cap" below.
|
||||
pub fn channel_policy(&self) -> &Arc<dyn ChannelLifecyclePolicy> {
|
||||
&self.channel_policy
|
||||
}
|
||||
|
||||
/// Override the default per-identity channel cap policy. Builder
|
||||
/// method for the deployment binary to set per-peer-role caps on
|
||||
/// the hub's inbound peers (e.g., a worker peer gets 256, a
|
||||
/// browser peer gets a lower cap). The hub-as-caller case on a
|
||||
/// downstream spoke is the spoke's own policy, not set here.
|
||||
pub fn with_channel_policy(mut self, policy: Arc<dyn ChannelLifecyclePolicy>)
|
||||
-> Self
|
||||
{
|
||||
self.channel_policy = policy;
|
||||
self
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -410,12 +462,16 @@ via a builder method. The `ChannelsAdapter::handle` flow becomes:
|
||||
aggregated env.
|
||||
|
||||
The assembly layer constructs the callback and passes it to
|
||||
`ChannelsAdapter`:
|
||||
`ChannelsAdapter`, wiring the hub's per-identity channel cap policy
|
||||
(ADR-094) into `ChannelOperations::new` so every channels connection
|
||||
the hub accepts shares the same policy (the cap is per-identity, not
|
||||
per-connection, because the policy `Arc` is shared):
|
||||
|
||||
```rust
|
||||
let callback = WorkerConnectedCallback::new(Arc::clone(&hub), FromCallConfig::new());
|
||||
let channels_adapter = ChannelsAdapter::new(Arc::clone(®istry), /* ... */)
|
||||
.with_worker_connected_callback(callback);
|
||||
.with_worker_connected_callback(callback)
|
||||
.with_channel_policy(hub.channel_policy().clone());
|
||||
// Register channels_adapter on alknet/channels in the HandlerRegistry.
|
||||
// The endpoint dispatches alknet/channels connections to it — whether
|
||||
// they arrived over quinn, iroh, or TCP+TLS (all owned by the endpoint).
|
||||
@@ -594,6 +650,46 @@ handlers (`alknet/tty`, `alknet/ssh`, `alknet/tunnel`) — it runs
|
||||
translation). The full relay contract is in ADR-079; the relay
|
||||
implementation lives in `alknet-hub`.
|
||||
|
||||
### Per-identity channel cap (ADR-094)
|
||||
|
||||
A channel slot is a resource. The cap on how many channels a peer may
|
||||
hold open against the hub is a quota check on that resource — parallel
|
||||
to `OwnershipProvider::owns` (ADR-050) for spawned resources. The hub
|
||||
constructs one `ChannelLifecyclePolicy` and shares it across every
|
||||
channels connection it accepts (the policy `Arc` is shared, so the
|
||||
cap is per-identity, not per-connection). This is the cap the hub
|
||||
enforces on its **inbound** peers — workers and browsers connecting
|
||||
to the hub. The default is `PerIdentityChannelPolicy::new(256)` — 256
|
||||
per `PeerId` across all the peer's connections to the hub. The cap is
|
||||
symmetric — both sides of a channels connection enforce their cap on
|
||||
the other's channels.
|
||||
|
||||
The cap lives in `channels-call`, not `channels-core`, because the
|
||||
channels layer is auth-blind by design (ADR-075 — that is what makes
|
||||
it WASM-compatible, transport-agnostic, and ALPN-blind). The identity
|
||||
is on `OperationContext`; the `channel/open` handler consults the
|
||||
policy after `AccessControl::check` and before allocation; the
|
||||
`channel/close` handler decrements after the drain completes.
|
||||
|
||||
**Relay consequence (ADR-094 §5):** when the hub relays a browser's
|
||||
channel to a spoke, the spoke sees the hub as the direct caller
|
||||
(ADR-032 — `forwarded_for` is metadata, not authority, for the cap as
|
||||
for `AccessControl::check`). The spoke's cap applies to the hub, not
|
||||
the browser. A spoke that serves a hub relaying for many browsers
|
||||
must set the hub peer's cap higher than a worker peer's cap on the
|
||||
**spoke's own** `ChannelLifecyclePolicy`, or the spoke denies
|
||||
legitimate relayed channels when the hub's aggregate count exceeds a
|
||||
worker-sized cap. This is a per-peer-role policy on the spoke, not on
|
||||
the hub — the hub's `channel_policy` governs the hub's inbound peers,
|
||||
not the hub-as-caller case. The hub enforces per-browser caps on the
|
||||
browser leg (the hub's own policy); the spoke enforces per-hub caps
|
||||
on the spoke leg (the spoke's own policy). Same shape as any per-peer
|
||||
ACL.
|
||||
|
||||
See [ADR-094](../../decisions/094-per-identity-channel-cap.md) for
|
||||
the full decision, the trait, the default/opt-out variants, and the
|
||||
recursive-channels edge case.
|
||||
|
||||
### Service discovery
|
||||
|
||||
The hub registers the built-in service discovery operations
|
||||
@@ -798,6 +894,7 @@ into `CallAdapter::with_aggregated_env`.
|
||||
| Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type |
|
||||
| `TlsClientConfig` for outbound dials | [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `alknet-tls` provides client-side TLS config; hub-as-client is a first-class use case; not blocked on the dial-seam extraction (OQ-55) |
|
||||
| `AlknetClient` native dial seam | [ADR-089](../../decisions/089-alknetclient-native-dial-seam.md) | New crate `alknet-client`; the hub's outbound worker dials use `AlknetClient` (via the `supervise_worker` closure or the `connect_quic_worker` convenience); resolves OQ-55 |
|
||||
| Per-identity channel cap | [ADR-094](../../decisions/094-per-identity-channel-cap.md) | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; the hub's policy governs its inbound peers and is shared across all their connections; the hub-as-caller case on a downstream spoke is the spoke's own policy with a high cap for the hub peer (ADR-094 §5) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -857,16 +954,23 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
`resolve_from_fingerprint` (the identity paths over transports)
|
||||
- ADR-029: Peer-Graph Routing Model
|
||||
- ADR-034: Three Peer Roles (hub = role-3, bearer-token identity)
|
||||
- ADR-050: Dynamic Resource Ownership (the parallel for the channel cap —
|
||||
a channel slot is a resource, the cap is a quota check)
|
||||
- ADR-065: `Connection::from_stream`/`from_bidi` (TCP+TLS path)
|
||||
- ADR-067: Aggregated Peer-Environment Wiring
|
||||
- ADR-068: PeerCompositeEnv::peer_operations Override
|
||||
- ADR-069: from_call Is a Manual Free Function
|
||||
- ADR-075: ChannelsAdapter and ChannelManager (the auth-blindness that
|
||||
forces the per-identity cap into `channels-call`, not `channels-core`)
|
||||
- ADR-079: Hub Relay — Translate, Not Transparently Forward
|
||||
- ADR-080: ChannelClient (transport-agnostic `from_connection`)
|
||||
- ADR-082: alknet-tls extraction (`TlsServerConfig` — shared across quinn + TCP+TLS)
|
||||
- ADR-083: Endpoint as multi-transport accept-loop runner (`with_tcp_tls` — TCP+TLS owned by the endpoint; the hub composes transports and handlers)
|
||||
- ADR-086: Endpoint types and entry points (web/native/iroh; entry-point vs. endpoint; split ALPN lists per endpoint type)
|
||||
- ADR-087: `TlsClientConfig` not blocked on dial seam (client-side TLS config; hub-as-client requirement)
|
||||
- ADR-094: Per-Identity Channel Cap (the `ChannelLifecyclePolicy` the hub
|
||||
constructs and shares across all its channels connections; the relay
|
||||
consequence for hub-as-caller on downstream spokes)
|
||||
- alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) —
|
||||
the first hub consumer, the concrete use case that informed this
|
||||
crate
|
||||
|
||||
@@ -5,7 +5,54 @@
|
||||
Accepted (amended 2026-07-18 by ADR-093 — backpressure is per-`channel_id`,
|
||||
not per-`(channel_id, stream_type)`; the channels layer has one reassembly
|
||||
buffer per channel, yielding a `BiStream` — see "Amendment (ADR-093,
|
||||
2026-07-18)" below)
|
||||
2026-07-18)" below; **amended 2026-07-19 by ADR-094 — the per-connection
|
||||
`max_channels = 256` is reframed as a per-connection memory bound, not a
|
||||
DoS defense; the per-identity DoS defense lives in `channels-call` via
|
||||
`ChannelLifecyclePolicy` — see "Amendment (ADR-094, 2026-07-19)" below**)
|
||||
|
||||
## Amendment (ADR-094, 2026-07-19)
|
||||
|
||||
The per-connection `max_channels = 256` cap is **reframed as a
|
||||
per-connection memory bound**, not a DoS defense. A single peer can
|
||||
open an unbounded number of transport connections, so a per-connection
|
||||
cap is not a per-peer DoS defense — it is a bound on one connection's
|
||||
reassembly-buffer cost. The per-identity DoS defense (256 per
|
||||
`PeerId`, enforced in `channels-call` via `ChannelLifecyclePolicy`)
|
||||
is documented in [ADR-094](094-per-identity-channel-cap.md).
|
||||
|
||||
What changes in this ADR:
|
||||
|
||||
1. **§"Maximum channels per connection: 256 default"** — the cap stays
|
||||
at 256, but its role is reframed. It is a per-connection memory
|
||||
bound (limits one connection's reassembly-buffer cost regardless of
|
||||
policy), not the DoS defense against an authenticated peer. The
|
||||
per-identity DoS defense is the `ChannelLifecyclePolicy`
|
||||
consultation in the `channel/open` handler (ADR-094).
|
||||
2. **§"DoS defense summary"** — the table is **removed**. It framed
|
||||
the per-connection cap as the DoS defense, which it is not. ADR-094
|
||||
§2 contains the corrected per-identity DoS defense summary.
|
||||
3. **The "per-connection, not per-peer — a peer can open more channels
|
||||
on a second connection" line** — this was the channels layer
|
||||
confessing a hole and hoping the layer above it would fill it. The
|
||||
line is **corrected** to state that the per-connection cap is a
|
||||
memory bound, and that the per-identity cap is the DoS defense
|
||||
(ADR-094). A peer that opens a second connection gets a second
|
||||
per-connection memory bound; it does **not** get a second
|
||||
per-identity quota — the `ChannelLifecyclePolicy` is shared across
|
||||
connections.
|
||||
|
||||
What stays:
|
||||
|
||||
- The 256 default and the `max_channels` field on `ChannelManager`
|
||||
(still returns `channel:too_many_channels` when hit — the
|
||||
per-identity policy returns the same error code, so an over-cap
|
||||
peer sees the same error either way).
|
||||
- The bounded-buffer backpressure decision (DP-5) — unchanged.
|
||||
- The channel-ID reuse decision (monotonic `next_id` with
|
||||
wrap-around) — unchanged.
|
||||
- The drain-before-reuse invariant — unchanged, and the
|
||||
`channel/close` handler now also calls
|
||||
`ChannelLifecyclePolicy::on_close` at this point (ADR-094 §3).
|
||||
|
||||
## Amendment (ADR-093, 2026-07-18)
|
||||
|
||||
@@ -88,36 +135,47 @@ default `max_channels` of 256, the `u32` space is effectively unlimited
|
||||
which time old channels are long drained. **The "reuse" in OQ-CH-04 is
|
||||
satisfied by the wrap-around, not by a free-list.**
|
||||
|
||||
### Maximum channels per connection: 256 default (OQ-CH-05/06)
|
||||
### Maximum channels per connection: 256 default (OQ-CH-05/06 — memory bound)
|
||||
|
||||
The `channel_id` is `u32` — the wire format supports ~4 billion channels.
|
||||
The practical limit is memory (reassembly buffers per channel) and the
|
||||
transport's flow control.
|
||||
|
||||
**Default per-connection channel limit: 256** (`max_channels` field on
|
||||
`ChannelManager`, configurable). This is the DoS defense (OQ-CH-06): an
|
||||
authenticated peer that opens many channels and never reads from them is
|
||||
bounded by `max_channels × buffer_cap` = 256 × 1 MiB = 256 MiB worst case.
|
||||
Bounded buffers (DP-5) limit the damage per channel; the connection cap
|
||||
limits the number of channels. Defense in depth.
|
||||
`ChannelManager`, configurable). This is a **per-connection memory
|
||||
bound**: it limits one connection's reassembly-buffer cost (256 × 1 MiB
|
||||
= 256 MiB worst case per connection) regardless of policy. It composes
|
||||
with the per-identity DoS defense (ADR-094) but is not itself a DoS
|
||||
defense — a peer can open an unbounded number of transport connections,
|
||||
so a per-connection cap cannot bound a peer's total channels. The
|
||||
per-identity DoS defense (256 per `PeerId`, enforced in `channels-call`
|
||||
via `ChannelLifecyclePolicy`) is documented in
|
||||
[ADR-094](094-per-identity-channel-cap.md).
|
||||
|
||||
Exceeding the limit returns `channel:too_many_channels` (ADR-073 error
|
||||
codes). The limit is per-connection, not per-peer — a peer can open more
|
||||
channels on a second connection.
|
||||
Exceeding the per-connection limit returns `channel:too_many_channels`
|
||||
(ADR-073 error codes) — the same error code the per-identity policy
|
||||
returns when the per-identity cap is hit. An over-cap peer sees the
|
||||
same error either way; which cap fired first is an implementation
|
||||
detail. The limit is per-connection as a memory bound; the per-identity
|
||||
cap (ADR-094) is what bounds a peer's total channels across all its
|
||||
connections.
|
||||
|
||||
### DoS defense summary (OQ-CH-06)
|
||||
|
||||
| Layer | Mechanism | Default |
|
||||
|-------|-----------|---------|
|
||||
| Per-channel | Bounded reassembly buffer (stop reading when full) | 1 MiB per `(channel_id, stream_type)` |
|
||||
| Per-connection | Channel count cap | 256 channels |
|
||||
| Per-peer | Auth (`AccessControl::check` on `channel/open`) | Assembly-layer policy |
|
||||
The DoS defense against an authenticated peer opening many channels is
|
||||
the **per-identity cap** enforced in `channels-call` via
|
||||
`ChannelLifecyclePolicy` — documented in
|
||||
[ADR-094](094-per-identity-channel-cap.md). A per-connection cap
|
||||
cannot be the DoS defense because a peer can open an unbounded number
|
||||
of transport connections; the unit that must be bounded is the
|
||||
identity, not the connection.
|
||||
|
||||
An authenticated peer that opens 256 channels and never reads from them
|
||||
consumes at most 256 MiB of reassembly buffers — bounded, not unbounded.
|
||||
The assembly layer's `AccessControl` policy can further restrict
|
||||
`channel/open` (e.g., `required_scopes: ["channel:open:alknet/tty"]`) to
|
||||
limit who can open channels at all.
|
||||
The per-connection `max_channels = 256` (this ADR) is a **memory
|
||||
bound** that limits one connection's reassembly-buffer cost. It
|
||||
composes with the per-identity cap as defense-in-depth (the
|
||||
`NoCap` policy path still has the per-connection memory bound), but
|
||||
it is not the security boundary. See ADR-094 §2 for the corrected
|
||||
DoS defense summary.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -125,18 +183,21 @@ limit who can open channels at all.
|
||||
- Bounded-buffer backpressure is validated by the POC (1 MiB test, no
|
||||
deadlock, no cross-channel blocking). The decision is made, not hedged.
|
||||
- The 256-channel default cap with 1 MiB buffers gives a bounded 256 MiB
|
||||
worst-case memory per connection — a clear DoS ceiling, not an open-ended
|
||||
one.
|
||||
worst-case memory per connection — a clear per-connection memory
|
||||
ceiling, not an open-ended one. The per-identity DoS ceiling (256 per
|
||||
`PeerId` across all the peer's connections) is documented in ADR-094.
|
||||
- Monotonic `next_id` with wrap-around avoids free-list drain-tracking
|
||||
complexity while still satisfying ID reuse (on wrap, after ~16.7M
|
||||
channels).
|
||||
|
||||
**Negative:**
|
||||
- The 256-channel default may be too low for a hub with many concurrent
|
||||
browser sessions each opening multiple channels. The cap is configurable
|
||||
per `ChannelManager`; the hub assembly layer may set it higher for
|
||||
deployments with many concurrent sessions. This is a deployment-time
|
||||
decision, not an architecture decision.
|
||||
- The 256-channel per-connection cap may be too low for a hub with many
|
||||
concurrent browser sessions each opening multiple channels. The cap
|
||||
is configurable per `ChannelManager`; the hub deployment may set it
|
||||
higher for deployments with many concurrent sessions. This is a
|
||||
deployment-time decision, not an architecture decision. (The
|
||||
per-identity cap in ADR-094 is the DoS-relevant bound; the
|
||||
per-connection cap is a memory backstop.)
|
||||
- Bounded-buffer backpressure does not eliminate head-of-line blocking — it
|
||||
bounds the memory cost. A slow consumer still stalls its own channel's
|
||||
demux reads. For the intended use cases (TTY, SSH, tunnels) this is
|
||||
@@ -157,8 +218,19 @@ doesn't change the wire format, so even that reversal is feasible.
|
||||
by ADR-093)
|
||||
- ADR-093: channels pure channel multiplexing (amends this ADR —
|
||||
per-channel reassembly buffer, not per-`(channel_id, stream_type)`)
|
||||
- ADR-073: channel lifecycle operations (`channel:too_many_channels` error)
|
||||
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id` fields)
|
||||
- ADR-094: per-identity channel cap as DoS defense (amends this ADR —
|
||||
the per-connection `max_channels = 256` is reframed as a per-connection
|
||||
memory bound, not a DoS defense; the per-identity DoS defense lives in
|
||||
`channels-call` via `ChannelLifecyclePolicy`)
|
||||
- ADR-073: channel lifecycle operations (`channel:too_many_channels`
|
||||
error; the `channel/open` and `channel/close` handlers that gain the
|
||||
`ChannelLifecyclePolicy` consultation)
|
||||
- ADR-075: ChannelManager (`buffer_cap`, `max_channels`, `next_id`
|
||||
fields; the auth-blindness that forces the per-identity cap into
|
||||
`channels-call`, not `channels-core`)
|
||||
- ADR-032: forwarded-for identity (why the spoke caps the hub, not the
|
||||
browser — `forwarded_for` is metadata, not authority, for the cap as
|
||||
for `AccessControl::check`)
|
||||
- `docs/research/alknet-channels/poc-summary.md` §POC Target 1 (backpressure
|
||||
validation), §POC Target 3 (1 MiB tunnel test)
|
||||
- `docs/research/alknet-channels/phase-0-findings.md` §DP-5, §OQ-CH-03/04/
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
# ADR-094: Per-Identity Channel Cap as DoS Defense
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (amends ADR-076's DoS-defense framing — the per-connection
|
||||
`max_channels = 256` is reframed as a per-connection memory bound, not a
|
||||
DoS defense)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-076 set the channels-layer channel limit at 256 **per connection**
|
||||
and framed that cap as the DoS defense against an authenticated peer
|
||||
opening many channels and never reading from them ("DoS defense
|
||||
summary" table, "Per-connection channel count cap → 256 channels"). On
|
||||
review, the per-connection cap is not a DoS defense at all. A single
|
||||
peer can open an unbounded number of transport connections, and across
|
||||
those connections, across substrates, the peer gets 256 × N × (substrate
|
||||
multiplier) channels:
|
||||
|
||||
| Substrate a peer can use | Channels per connection |
|
||||
|--------------------------|--------------------------|
|
||||
| In-line (TCP+TLS, WebTransport, SSH `direct-tcpip`) | 256 (one stream, header-demuxed) |
|
||||
| Native (QUIC substreams) | 256 (per-connection demux; each substream carries one channel) |
|
||||
| Multi-connection (N transport connections) | 256 × N |
|
||||
|
||||
A peer that opens 10 transport connections to the same accepting peer
|
||||
gets 2,560 channels. A peer that opens 100 gets 25,600. There is no
|
||||
bound on the number of transport connections a peer can open. The
|
||||
"per-connection, not per-peer — a peer can open more channels on a
|
||||
second connection" line in ADR-076 was, in retrospect, the channels
|
||||
layer confessing a hole and hoping the layer above it would fill it.
|
||||
That is not a DoS defense; it is a per-connection memory bound
|
||||
(reassembly-buffer cost per connection) labeled as a DoS defense.
|
||||
|
||||
The only coherent unit for a channel DoS defense is the **identity**.
|
||||
The peer, not the connection, is what an authenticated-DoS defense
|
||||
must bound. This is the same primitive as any other resource ACL:
|
||||
`OwnershipProvider` (ADR-050) checks "does identity X own resource
|
||||
Y?"; the channel cap checks "has identity X exceeded their channel
|
||||
quota?" Same shape, different resource.
|
||||
|
||||
### Why the channels layer cannot hold the cap
|
||||
|
||||
`ChannelManager` (ADR-075) is auth-blind by design: "No auth state.
|
||||
Auth lives in the `OperationContext` that the call protocol passes to
|
||||
`channel/open`." That decision is load-bearing — it is what makes the
|
||||
channels layer WASM-compatible, transport-agnostic, and ALPN-blind
|
||||
(ADR-075, ADR-093). Putting per-identity tracking in the channels
|
||||
layer would reverse ADR-075.
|
||||
|
||||
So the per-identity cap lives **one layer up**, in `channels-call`,
|
||||
where the identity is already on `OperationContext` (the same place
|
||||
`AccessControl::check` runs). The `channel/open` and `channel/close`
|
||||
handlers (ADR-073) are in `channels-call` already; they gain a policy
|
||||
consultation. The channels layer (`channels-core`) is unchanged —
|
||||
still auth-blind, still WASM-clean.
|
||||
|
||||
### This is not a hub-specific concern
|
||||
|
||||
The cap is a **channels-accepting-peer concern**. A worker accepting a
|
||||
direct channels connection from a peer needs the cap just as much as a
|
||||
hub does. The call protocol does not need a hub to enforce "does this
|
||||
peer have access to this resource?" (ADR-073: `AccessControl::check` on
|
||||
`channel/open`), and neither should channels. Framing the cap as
|
||||
hub-specific would be the "assembly layer" hedging pattern — putting
|
||||
the hard question off on a fictional "later" that, when it arrives,
|
||||
turns out to be exactly the same problem. The cap is a peer concern;
|
||||
the hub is one peer that happens to aggregate others.
|
||||
|
||||
The cap is also **symmetric**, like the call protocol. Peer A accepts
|
||||
a channels connection from Peer B; A enforces its cap on B's channels;
|
||||
B enforces its cap on A's channels. Both sides have the cap, both
|
||||
sides check it, same as `AccessControl::check` on any operation.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. A `ChannelLifecyclePolicy` trait in `channels-call`
|
||||
|
||||
```rust
|
||||
/// Per-identity channel lifecycle policy. Consulted by the
|
||||
/// `channel/open` handler (after `AccessControl::check`, before
|
||||
/// allocation) and the `channel/close` handler (after deallocation).
|
||||
/// Both handlers have the identity via `OperationContext`.
|
||||
///
|
||||
/// A channel slot is a resource; the cap is a quota check on that
|
||||
/// resource — parallel to `OwnershipProvider::owns` (ADR-050) for
|
||||
/// spawned resources. Same primitive, different resource.
|
||||
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
|
||||
/// Before channel allocation. Deny with `channel:too_many_channels`
|
||||
/// (ADR-073) when the identity is over its cap. The identity is
|
||||
/// the direct caller (the peer that opened this channels
|
||||
/// connection); `forwarded_for` is metadata and is NOT consulted
|
||||
/// (ADR-032).
|
||||
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
|
||||
|
||||
/// After channel deallocation. Decrement the per-identity count.
|
||||
/// Called by the `channel/close` handler after the drain completes
|
||||
/// (ADR-076 §channel-id-reuse).
|
||||
fn on_close(&self, identity: &Identity);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Default: `PerIdentityChannelPolicy::new(256)`
|
||||
|
||||
The default constructor enforces 256 per identity out of the box — no
|
||||
hedging, no "NoOp default + wire it in the assembly layer." A channels
|
||||
accepting peer that constructs `ChannelOperations::new(manager)` with
|
||||
no policy argument gets `PerIdentityChannelPolicy::new(256)`. The
|
||||
default is secure; opt-outs are explicit:
|
||||
|
||||
- `PerIdentityChannelPolicy::new(cap)` — shared per-identity state
|
||||
(`HashMap<PeerId, usize>` + cap), constructed **once per accepting
|
||||
peer** and shared (via `Arc`) across every channels connection that
|
||||
peer accepts. For a hub, that's one `Arc<PerIdentityChannelPolicy>`
|
||||
on the `Hub`, shared across all worker and browser legs. For a
|
||||
worker accepting direct channels, that's one `Arc` on the worker's
|
||||
own state, shared across whatever connections it accepts. For tests
|
||||
and POCs, the default constructor.
|
||||
- `PerIdentityChannelPolicy::with_per_identity_caps(mapping)` — a
|
||||
per-peer-role variant: `HashMap<PeerId, usize>` overrides the
|
||||
default cap for specific peers. Used by a spoke that serves a
|
||||
high-fan-out hub (the hub peer's cap is set higher than a worker
|
||||
peer's cap — see "Relay consequence" below).
|
||||
- `NoCap` — no cap (for tests, POCs, and trusted single-peer
|
||||
deployments). Explicit opt-out, not the default.
|
||||
|
||||
The policy is constructed once and passed to `ChannelOperations` at
|
||||
registration time:
|
||||
|
||||
```rust
|
||||
let policy = Arc::new(PerIdentityChannelPolicy::new(256));
|
||||
let channel_ops = ChannelOperations::new(manager, policy);
|
||||
channel_ops.register_on(&mut call_registry)?;
|
||||
```
|
||||
|
||||
The same `Arc<PerIdentityChannelPolicy>` is shared across every
|
||||
channels connection that peer accepts — that is what makes the cap
|
||||
per-identity, not per-connection.
|
||||
|
||||
### 3. Enforcement point: between `AccessControl::check` and allocation
|
||||
|
||||
The `channel/open` handler (ADR-073) gains the policy check after
|
||||
ACL and before `next_id.fetch_add`:
|
||||
|
||||
1. ACL is already checked by `OperationRegistry::invoke` (the existing
|
||||
`AccessControl::check` path — unchanged).
|
||||
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
|
||||
`channel:too_many_channels` if over cap.
|
||||
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)` (DP-1:
|
||||
server-assigned — unchanged).
|
||||
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
|
||||
the `ChannelState` (unchanged).
|
||||
5. Return the `channel_id`.
|
||||
|
||||
The `channel/close` handler (ADR-073) gains the decrement after the
|
||||
drain completes (the same point ADR-076 marks the `channel_id` as
|
||||
eligible for reuse):
|
||||
|
||||
1. Drain the reassembly buffer for `channel_id` (existing — ADR-076
|
||||
§channel-id-reuse).
|
||||
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
|
||||
per-identity count.
|
||||
3. Return `{ "closed": true }` (unchanged).
|
||||
|
||||
### 4. `ChannelManager.max_channels = 256` stays as a per-connection memory bound
|
||||
|
||||
The per-connection cap (ADR-076) stays, but is reframed. It is no
|
||||
longer the DoS defense — it is a per-connection **memory bound** that
|
||||
limits one connection's reassembly-buffer cost regardless of policy.
|
||||
It composes with the per-identity cap but is not the security
|
||||
boundary. It still returns `channel:too_many_channels` when hit; the
|
||||
per-identity policy returns the same error when the per-identity cap
|
||||
is hit. An over-cap peer sees the same error either way; which cap
|
||||
fired first is an implementation detail.
|
||||
|
||||
Keeping the per-connection bound as a backstop covers deployments that
|
||||
use `NoCap` (tests, trusted single-peer) and bounds the damage if a
|
||||
custom policy is buggy. Removing it would leave the channels layer
|
||||
unbounded in the no-policy case. The cost of keeping it is zero (the
|
||||
cap is already implemented in the POC); the cost of removing it is a
|
||||
real hole in the `NoCap` path.
|
||||
|
||||
### 5. Relay consequence: the spoke caps the hub, not the browser
|
||||
|
||||
When the hub relays a browser's channel to a spoke (ADR-079), the
|
||||
spoke sees the hub as the direct caller. `forwarded_for` carries the
|
||||
browser's identity as metadata (ADR-032 — `forwarded_for` is not
|
||||
authority; `AccessControl::check` never reads it). The channel cap
|
||||
follows the same shape: the spoke's `ChannelLifecyclePolicy` is
|
||||
consulted with the **hub's** identity, not the browser's. The spoke
|
||||
asks "does the hub have access to open another channel?" and the
|
||||
hub's quota on the spoke reflects the aggregate of all relayed
|
||||
channels. The hub's per-browser caps are the hub's own concern
|
||||
(enforced on the browser leg by the hub's own policy), not the
|
||||
spoke's.
|
||||
|
||||
This is correct and consistent — the spoke authorizes the hub for
|
||||
container access the same way it authorizes any peer, and the hub's
|
||||
browser-relay ACL is the hub's own layer. The channel cap follows the
|
||||
same pattern as any other resource ACL.
|
||||
|
||||
**Deployment consequence:** a spoke that serves a hub relaying for
|
||||
many browsers must set the hub peer's cap higher than a worker peer's
|
||||
cap, or the spoke denies legitimate relayed channels when the hub's
|
||||
aggregate count exceeds a worker-sized cap. This is a per-peer-role
|
||||
policy, set by the spoke via `with_per_identity_caps`. The
|
||||
architecture provides the mechanism (`PerIdentityChannelPolicy::with_per_identity_caps`);
|
||||
the deployment sets the numbers. This is not a flaw — it is the same
|
||||
shape as any per-peer ACL (a spoke may authorize one peer for 1000
|
||||
containers and another for 10; the channel cap is the same kind of
|
||||
per-peer policy).
|
||||
|
||||
### 6. Recursive channels do not bypass the cap
|
||||
|
||||
A recursive `alknet/channels`-inside-`alknet/channels` channel runs a
|
||||
new `ChannelsAdapter` with a new `ChannelManager`. If the same
|
||||
`ChannelLifecyclePolicy` is wired into the inner `ChannelOperations`,
|
||||
the inner channels are counted against the same identity. If a
|
||||
different policy is wired, the inner channels are counted against
|
||||
that policy's identity (which may be a different identity, if the
|
||||
inner channels connection is authenticated separately). Either way,
|
||||
the cap applies; recursion is not a bypass. The 13-byte-per-chunk
|
||||
overhead of recursion is the documented cost (ADR-093); the cap
|
||||
behavior is unchanged. Recursive channels are an edge case for edge
|
||||
cases and not specced further.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- A real per-identity DoS defense. A peer with N transport connections
|
||||
to the same accepting peer is bounded by 256 (or the configured
|
||||
per-identity cap), not 256 × N × (substrate multiplier). The cap
|
||||
composes correctly across substrates because the unit is the
|
||||
identity, not the connection.
|
||||
- The cap is symmetric, like the call protocol. Both sides of a
|
||||
channels connection enforce their cap; the cap is a peer concern,
|
||||
not a hub-specific concern.
|
||||
- The cap lives in `channels-call`, where the identity is already on
|
||||
`OperationContext`. The channels layer (`channels-core`) is
|
||||
unchanged — still auth-blind, still WASM-clean, still
|
||||
transport-agnostic. ADR-075's auth-blindness is preserved.
|
||||
- The default is secure. `PerIdentityChannelPolicy::new(256)` is the
|
||||
out-of-the-box behavior; opt-outs (`NoCap`) are explicit. A
|
||||
deployment that forgets to wire a policy still gets a per-identity
|
||||
cap.
|
||||
- The cap is the same primitive as any other resource ACL
|
||||
(`OwnershipProvider` for spawned resources, `AccessControl::check`
|
||||
for operations). The mental model is uniform: a channel slot is a
|
||||
resource, the cap is a quota check on that resource.
|
||||
|
||||
**Negative:**
|
||||
- One new trait (`ChannelLifecyclePolicy`) and one new constructor
|
||||
argument on `ChannelOperations`. The `channel/open` and
|
||||
`channel/close` handlers gain a policy call. Small implementation
|
||||
cost; the policy is a single trait method per direction.
|
||||
- Per-identity state is shared across connections
|
||||
(`HashMap<PeerId, usize>` on the policy, guarded by a `Mutex`). The
|
||||
state is touched on `channel/open` and `channel/close` only — not
|
||||
on every chunk. The contention is per-identity, not per-chunk;
|
||||
acceptable for the intended use cases.
|
||||
- A spoke serving a high-fan-out hub must set the hub peer's cap
|
||||
higher than the default, or legitimate relayed channels are denied.
|
||||
This is a deployment-time policy decision, surfaced explicitly by
|
||||
`with_per_identity_caps`. Not a flaw; the same shape as any
|
||||
per-peer ACL.
|
||||
- The cap is per direct-caller identity (ADR-032), not per
|
||||
`forwarded_for` originator. A hub relaying for 100 browsers
|
||||
consumes one channel slot per relayed channel against the hub's
|
||||
quota on the spoke, not 100 slots against 100 browser quotas. A
|
||||
spoke that wants per-browser capping would need to read
|
||||
`forwarded_for` for authority, which ADR-032 explicitly forbids.
|
||||
This is the correct trade-off: capping against `forwarded_for`
|
||||
would reverse ADR-032's "forwarded_for is metadata, not authority"
|
||||
and is a much bigger change. The hub enforces per-browser caps on
|
||||
the browser leg; the spoke enforces per-hub caps on the spoke leg.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The `ChannelLifecyclePolicy` trait surface
|
||||
(`check_open(&Identity) -> Result<(), ChannelError>` and
|
||||
`on_close(&Identity)`) is a one-way-door API commitment — the
|
||||
`channels-call` `channel/open` and `channel/close` handlers depend on
|
||||
it, and consumers (`Hub`, worker crates) construct implementations.
|
||||
Removing the trait or changing the signatures after deployments exist
|
||||
is a breaking change.
|
||||
|
||||
The **default cap value (256)** is a two-way-door implementation
|
||||
detail within the one-way trait surface — changing the default is
|
||||
additive (a new constructor or a default-override), not a wire-format
|
||||
change.
|
||||
|
||||
The **reframing of ADR-076's per-connection cap** (from DoS defense
|
||||
to memory bound) is two-way — it's a documentation change, not a
|
||||
behavior change. The per-connection cap still exists, still returns
|
||||
`channel:too_many_channels`, and still bounds one connection's
|
||||
reassembly-buffer cost.
|
||||
|
||||
## References
|
||||
|
||||
- **ADR-076**: Backpressure, Channel Limits, and ID Reuse (amended by
|
||||
this ADR — the per-connection `max_channels = 256` is reframed as a
|
||||
per-connection memory bound, not a DoS defense; the "DoS defense
|
||||
summary" table is removed; the "per-connection, not per-peer" line
|
||||
is corrected)
|
||||
- **ADR-075**: ChannelsAdapter and ChannelManager (the auth-blindness
|
||||
this ADR preserves — the cap lives in `channels-call`, not
|
||||
`channels-core`)
|
||||
- **ADR-073**: Channel Lifecycle Operations (the `channel/open` and
|
||||
`channel/close` handlers that gain the policy check; the
|
||||
`channel:too_many_channels` error code)
|
||||
- **ADR-093**: channels Pure Channel Multiplexing (the umbrella
|
||||
decision; the channels layer has no `stream_type` concept, and no
|
||||
identity concept either — both are above it)
|
||||
- **ADR-032**: Forwarded-For Identity (Metadata, Not Authority) (why
|
||||
the spoke caps the hub, not the browser — `forwarded_for` is
|
||||
metadata; the direct caller's identity is the authority for the cap
|
||||
just as it is for `AccessControl::check`)
|
||||
- **ADR-079**: Hub Relay — Translate, Not Transparently Forward (the
|
||||
relay path where the spoke sees the hub as the direct caller)
|
||||
- **ADR-050**: Dynamic Resource Ownership for Runtime-Spawned
|
||||
Resources (the parallel — a channel slot is a resource, the cap is
|
||||
a quota check, same primitive as `OwnershipProvider::owns`)
|
||||
- **ADR-030**: PeerEntry and Identity.id Decoupling (`PeerId` =
|
||||
`Identity.id` — the stable key the per-identity cap counts against)
|
||||
Reference in new issue
Block a user