Port the call + channels architecture documentation from the alknet mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045. Renumbering map (alknet -> alkcall): Core: 001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012 Call: 005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030 Shared: 003,009,013 -> 031-033 Channels: 071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045 3 superseded/reversed ADRs kept for historical trail: - ADR-013 (irpc foundation, superseded by ADR-014) - ADR-023 (peer-scoped filtering, superseded by ADR-024) - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only) Ported docs (11 spec files + README + open-questions): - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md - README.md (index with doc table, ADR table grouped by category, key principles) - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap) Cross-reference rewriting: - All ADR-NNN references rewritten single-pass (no chaining bug) - Markdown link paths fixed - Title lines aligned with filenames - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note The open-questions.md includes OQ-22 (new): the call protocol pub/sub gap — subscribe exists but pub does not, needed for channels channel/resources/subscribe fan-out. This is the next ADR to write (alkcall ADR-046).
324 lines
16 KiB
Markdown
324 lines
16 KiB
Markdown
# ADR-041: Per-Identity Channel Cap as DoS Defense
|
||
|
||
## Status
|
||
|
||
Accepted (amends ADR-040's DoS-defense framing — the per-connection
|
||
`max_channels = 256` is reframed as a per-connection memory bound, not a
|
||
DoS defense)
|
||
|
||
## Context
|
||
|
||
ADR-040 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-040 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-011) 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-039) 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-039, ADR-035). Putting per-identity tracking in the channels
|
||
layer would reverse ADR-039.
|
||
|
||
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-037) 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-037: `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-011) for
|
||
/// spawned resources. Same primitive, different resource.
|
||
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
|
||
/// Before channel allocation. Deny with `channel:too_many_channels`
|
||
/// (ADR-037) 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-026).
|
||
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-040 §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-037) 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-037) gains the decrement after the
|
||
drain completes (the same point ADR-040 marks the `channel_id` as
|
||
eligible for reuse):
|
||
|
||
1. Drain the reassembly buffer for `channel_id` (existing — ADR-040
|
||
§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-040) 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-042), the
|
||
spoke sees the hub as the direct caller. `forwarded_for` carries the
|
||
browser's identity as metadata (ADR-026 — `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-035); 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-039'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-026), 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-026 explicitly forbids.
|
||
This is the correct trade-off: capping against `forwarded_for`
|
||
would reverse ADR-026'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-040'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-040**: 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-039**: ChannelsAdapter and ChannelManager (the auth-blindness
|
||
this ADR preserves — the cap lives in `channels-call`, not
|
||
`channels-core`)
|
||
- **ADR-037**: Channel Lifecycle Operations (the `channel/open` and
|
||
`channel/close` handlers that gain the policy check; the
|
||
`channel:too_many_channels` error code)
|
||
- **ADR-035**: 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-026**: 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-042**: Hub Relay — Translate, Not Transparently Forward (the
|
||
relay path where the spoke sees the hub as the direct caller)
|
||
- **ADR-011**: 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-025**: PeerEntry and Identity.id Decoupling (`PeerId` =
|
||
`Identity.id` — the stable key the per-identity cap counts against) |