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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user