From 0fcd5bc3225a6da8a6f202577ca75d5152508bb8 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Sun, 19 Jul 2026 11:17:57 +0000 Subject: [PATCH] =?UTF-8?q?docs(adr):=20094=20=E2=80=94=20per-identity=20c?= =?UTF-8?q?hannel=20cap=20as=20DoS=20defense?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/architecture/README.md | 41 ++- docs/architecture/crates/channels/README.md | 16 +- .../crates/channels/channel-operations.md | 151 +++++++- .../crates/channels/channels-adapter.md | 62 +++- docs/architecture/crates/hub/README.md | 116 ++++++- ...76-backpressure-channel-limits-id-reuse.md | 130 +++++-- .../decisions/094-per-identity-channel-cap.md | 324 ++++++++++++++++++ 7 files changed, 789 insertions(+), 51 deletions(-) create mode 100644 docs/architecture/decisions/094-per-identity-channel-cap.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 09f7959..10fad7e 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/crates/channels/README.md b/docs/architecture/crates/channels/README.md index 56cfc4a..2b1bd34 100644 --- a/docs/architecture/crates/channels/README.md +++ b/docs/architecture/crates/channels/README.md @@ -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 diff --git a/docs/architecture/crates/channels/channel-operations.md b/docs/architecture/crates/channels/channel-operations.md index d8fabe1..16eb99a 100644 --- a/docs/architecture/crates/channels/channel-operations.md +++ b/docs/architecture/crates/channels/channel-operations.md @@ -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` + 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` 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 \ No newline at end of file diff --git a/docs/architecture/crates/channels/channels-adapter.md b/docs/architecture/crates/channels/channels-adapter.md index 221b483..08d5599 100644 --- a/docs/architecture/crates/channels/channels-adapter.md +++ b/docs/architecture/crates/channels/channels-adapter.md @@ -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` 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 diff --git a/docs/architecture/crates/hub/README.md b/docs/architecture/crates/hub/README.md index 0f3417b..1d94cda 100644 --- a/docs/architecture/crates/hub/README.md +++ b/docs/architecture/crates/hub/README.md @@ -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>, dispatcher: Dispatcher, identity_provider: Arc, + /// 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, } ``` @@ -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> { &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 { + &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) + -> 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 diff --git a/docs/architecture/decisions/076-backpressure-channel-limits-id-reuse.md b/docs/architecture/decisions/076-backpressure-channel-limits-id-reuse.md index 88ed0b0..a0c3ca1 100644 --- a/docs/architecture/decisions/076-backpressure-channel-limits-id-reuse.md +++ b/docs/architecture/decisions/076-backpressure-channel-limits-id-reuse.md @@ -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/ diff --git a/docs/architecture/decisions/094-per-identity-channel-cap.md b/docs/architecture/decisions/094-per-identity-channel-cap.md new file mode 100644 index 0000000..2dcaa1c --- /dev/null +++ b/docs/architecture/decisions/094-per-identity-channel-cap.md @@ -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` + cap), constructed **once per accepting + peer** and shared (via `Arc`) across every channels connection that + peer accepts. For a hub, that's one `Arc` + 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` 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` 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` 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) \ No newline at end of file