feat(handler): enforce the in-crate concurrent-channel cap at the open surface (w2-bounded-resources)

- new private channel_cap module: per-session active-channel count;
  admit (check+increment at accept sites) / has_room / saturating
  release, single-writer (inline packet-loop hooks), unit-tested
- handler wires the cap at every channel_open_* hook: the session
  accept path admits-and-counts at the accept point; the tunnel rows
  cap-gate before the seam stub (a counted-but-unopened stub would
  leak the cap); x11 never consumes a count; the count releases
  through the new channel_close hook
- dispatch::cap_exceeded_open_decline -> AdministrativelyProhibited
  (the assembly's policy ceiling; OpenSSH MaxSessions parity)
- bounded-resources contract rows: cap reached -> clean decline,
  close -> recovery open succeeds (wire-order deterministic),
  zero cap refuses without poisoning, exhausted cap answers the
  tunnel row with the cap reason; max_auth_attempts verified
  behaviorally (within-ceiling failures keep the advertisement, the
  past-ceiling attempt is the NoMoreAuthMethodsAvailable teardown);
  window/packet/buffer/inactivity knobs recorded as covered by the
  config-mapping tests
- max_exec_command_len stays carried, not enforced (wave-3 grammar)

Verification: cargo test (default), --features session / git /
tunnel / --all-features, clippy --all-targets (-D warnings, also
--all-features), fmt --check, cargo doc --no-deps - all green
This commit is contained in:
glm-5.3-flash committed 2026-10-28 11:40:29 +00:00
1 parent 2e5a7c9cee
commit 196289f5ad
6 files changed
+658 -26

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
//! The in-crate concurrent-channel cap (`w2-bounded-resources`) — the
//! bound itself, carried by the per-connection handler
//! ([`crate::handler`]) and enforced at every `channel_open_*` accept
//! path (`overview.md` §Bounded-resources policy: "concurrent channels
//! per session — ours (in-crate) + per-identity policy when bridged";
//! AGENTS.md convention 9 — SSH servers are internet-facing; unbounded
//! loops and unbounded buffers are bugs).
//!
//! ## Shape
//!
//! [`ChannelCap`] is a per-session count of active channels. Admission
//! ([`ChannelCap::admit`]) is the check-and-increment the accept paths
//! call: room under the cap admits (the count rises), otherwise the
//! open is refused — a clean decline, **never queued, never half-open**
//! (the fail-closed posture; the refusal reason is
//! [`crate::dispatch::cap_exceeded_open_decline`]). Release
//! ([`ChannelCap::release`]) fires from the `channel_close` hook, so
//! the cap recovers as channels close.
//!
//! ## Why no lock
//!
//! The count is **handler-owned, not shared**: russh at this pin runs
//! every handler hook inline in the session's packet loop with
//! exclusive `&mut self` access (ADR-006 decision 4 puts serving
//! paths on spawned tasks, but those report through the session
//! handle — their channel closes cycle back through the packet loop
//! as `CHANNEL_CLOSE`, firing the same `channel_close` hook). The
//! only writer is that single loop, so the plain field is sound and a
//! lock would only mislead about shared mutation that does not exist.
//! The per-identity policy layer (when the alkcall bridge carries it)
//! is additional and arrives with the bridge, not here.
//!
//! ## Soundness of the decrement invariant
//!
//! Every `channel_close` delivery corresponds to exactly one
//! previously-admitted open: russh only routes `CHANNEL_CLOSE` for
//! channels it considers established, and a declined open never
//! establishes one — so no refusal path can ever decrement for a
//! channel it never counted. A `CHANNEL_CLOSE` is also consumed at
//! most once (russh removes the channel before the hook), so one
//! admitted channel decrements exactly once. [`ChannelCap::release`]
//! additionally saturates, making a stray decrement impossible to
//! manufacture capacity with. Channels never closed before session
//! teardown stay counted until the handler dies with the session —
//! per-session state, gone with the connection.
/// The per-session active-channel count for the in-crate
/// concurrent-channel cap — see the module docs for the shape, the
/// single-writer reasoning, and the decrement invariant.
#[derive(Debug, Default)]
pub(crate) struct ChannelCap {
active: usize,
}
impl ChannelCap {
/// Whether an open has room under `cap` — the pure check, for the
/// paths whose decline is ordered *before* the accept decision
/// (this wave: the tunnel rows, whose seam stub declines without
/// ever opening; wave 3 moves the admission call to the
/// accept-after-fabric-open point).
pub(crate) fn has_room(&self, cap: usize) -> bool {
self.active < cap
}
/// The accept-path admission decision: room under `cap` increments
/// the count and admits; otherwise refuses (fail-closed — the
/// caller answers the open with a clean decline). `cap == 0`
/// refuses every open.
pub(crate) fn admit(&mut self, cap: usize) -> bool {
if self.has_room(cap) {
self.active += 1;
true
} else {
false
}
}
/// A counted channel closed — the `channel_close` hook's
/// decrement, recovery of the cap. Saturating: a count never goes
/// below zero, so no decrement path can manufacture capacity.
pub(crate) fn release(&mut self) {
self.active = self.active.saturating_sub(1);
}
}
#[cfg(test)]
mod tests {
use super::ChannelCap;
const CAP: usize = 2;
/// - *Contract stamp:* AGENTS.md convention 9 / `overview.md`
/// §Bounded-resources policy (the in-crate cap admits opens under
/// the configured ceiling and refuses beyond it).
#[test]
fn admits_up_to_the_cap_then_refuses() {
let mut cap = ChannelCap::default();
assert!(cap.admit(CAP));
assert!(cap.admit(CAP));
assert!(!cap.admit(CAP), "beyond the cap: refused");
assert!(!cap.has_room(CAP));
}
/// - *Contract stamp:* the task's cap mechanics (a close recovers
/// the cap — the count decrements on close).
#[test]
fn release_recovers_the_cap() {
let mut cap = ChannelCap::default();
assert!(cap.admit(CAP));
assert!(cap.admit(CAP));
assert!(!cap.admit(CAP));
cap.release();
assert!(cap.has_room(CAP));
assert!(cap.admit(CAP), "an open succeeds again after a close");
assert!(!cap.admit(CAP));
}
/// - *Contract stamp:* the fail-closed cap posture (`cap == 0` is
/// a valid, assembly-tunable refusal of every open — never a
/// bypass).
#[test]
fn zero_cap_refuses_every_open() {
let mut cap = ChannelCap::default();
assert!(!cap.admit(0));
cap.release();
assert!(
!cap.admit(0),
"release never manufactures capacity below zero"
);
}
/// Release saturates: a decrement for a channel never counted can
/// never manufacture capacity (the soundness half of the
/// decrement invariant this crate's own gates make unreachable).
#[test]
fn release_saturates_at_zero() {
let mut cap = ChannelCap::default();
cap.release();
cap.release();
assert_eq!(cap.active, 0);
assert!(cap.admit(CAP));
}
}
+12
View File
@@ -99,6 +99,18 @@ pub(crate) fn tunnel_seam_stub_decline() -> ChannelOpenFailure {
ChannelOpenFailure::ConnectFailed
}
/// The wire decline reason for an open beyond the **in-crate
/// concurrent-channel cap** (`overview.md` §Bounded-resources policy,
/// `w2-bounded-resources`): `administratively prohibited` — the cap is
/// the assembly's own configured policy ceiling, refused fail-closed
/// at the open (never queued, never half-open). OpenSSH's own
/// `MaxSessions` ceiling answers the same code, so vanilla clients
/// see the vanilla-shaped policy refusal (POC-1 §5: channel-scoped,
/// auth intact, no hangs).
pub(crate) fn cap_exceeded_open_decline() -> ChannelOpenFailure {
ChannelOpenFailure::AdministrativelyProhibited
}
#[cfg(test)]
mod tests {
use super::{ExecRoute, classify_exec};
+118 -20
View File
@@ -128,6 +128,24 @@
//! serving task (ADR-006 decision 4, §The spawn-and-return
//! structural constraint above); the wire shape is unchanged.
//!
//! ## Bounded resources at the handler surface (`w2-bounded-resources`)
//!
//! (AGENTS.md convention 9; `overview.md` §Bounded-resources policy —
//! the "concurrent channels per session — ours" row.) The in-crate
//! concurrent-channel cap ([`crate::channel_cap::ChannelCap`]) is
//! enforced at every `channel_open_*` hook: an accept path beyond
//! `Limits::max_concurrent_channels` is refused fail-closed at the
//! open ([`crate::dispatch::cap_exceeded_open_decline`] — never
//! queued, never half-open), and the count releases through the
//! `channel_close` hook so the cap recovers. The order of the gates:
//! the feature gate first (an unfeatured build declines everything
//! before the cap matters), the cap gate before any accept — and
//! before the tunnel seam stub, whose decline opens nothing and so
//! must not consume a count. The `max_exec_command_len` cap stays
//! reserved for wave 3's grammar (carried by [`crate::Limits`], not
//! enforced here); the per-identity policy layer arrives with the
//! alkcall bridge.
//!
//! ## Test surface
//!
//! The handler type is deliberately constructible outside this module
@@ -148,6 +166,7 @@ use russh::server::{
};
use russh::{Channel, ChannelId, Pty};
use crate::channel_cap::ChannelCap;
use crate::config::ServeConfig;
use crate::dispatch;
use crate::error::AdapterError;
@@ -158,11 +177,15 @@ use crate::serve;
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted
/// stream and drives it through `russh::server::run_stream`.
///
/// Carries the serve-config the auth bridge and dispatch consume, and
/// the resolved identity once a publickey auth has been accepted (see
/// Carries the serve-config the auth bridge and dispatch consume, the
/// in-crate concurrent-channel cap (`ChannelCap` — see the private
/// `channel_cap` module; enforced at every `channel_open_*` accept
/// path, bounded resources, AGENTS.md convention 9), and the resolved
/// identity once a publickey auth has been accepted (see
/// [`Self::resolved_identity`] — the wave-3 seam reads it).
pub struct SessionHandler {
serve: Arc<ServeConfig>,
channel_cap: ChannelCap,
resolved_identity: Option<Identity>,
}
@@ -174,6 +197,7 @@ impl SessionHandler {
pub fn new(serve: Arc<ServeConfig>) -> Self {
Self {
serve,
channel_cap: ChannelCap::default(),
resolved_identity: None,
}
}
@@ -186,6 +210,33 @@ impl SessionHandler {
self.resolved_identity.as_ref()
}
/// The accept-path admission under the in-crate concurrent-channel
/// cap (bounded resources, `w2-bounded-resources`): room under
/// `Limits::max_concurrent_channels` admits and counts the channel;
/// beyond the cap the open is refused fail-closed — never queued,
/// never half-open. Called exactly at accept sites: a decline path
/// (unfeatured, never-feature, the tunnel seam stub) never touches
/// the count, and the only writer is this connection's packet loop
/// ([`ChannelCap`]'s single-writer reasoning).
fn admit_channel_open(&mut self) -> bool {
self.channel_cap
.admit(self.serve.limits.max_concurrent_channels)
}
/// Whether the cap is already exhausted — the gate slot for the
/// rows whose decline is ordered *before* the accept decision.
/// This wave that is the tunnel rows, whose seam stub declines
/// without ever opening a channel (a counted admission it never
/// opens would leak against the cap — declined-first-alloc-never,
/// the ADR-006 dispatch rule, applies to the budget too). Wave 3's
/// accept-after-fabric-open flow replaces the stub and moves the
/// admission ([`Self::admit_channel_open`]) to the accept point.
fn channel_cap_exhausted(&self) -> bool {
!self
.channel_cap
.has_room(self.serve.limits.max_concurrent_channels)
}
/// The ADR-004 decision 1 bridge core, structured out of the
/// [`Handler::auth_publickey`] hook so the contract tests can drive
/// it directly: the hook's russh-owned argument shape
@@ -314,10 +365,36 @@ impl russh::server::Handler for SessionHandler {
Ok(Auth::reject())
}
/// A counted channel closed — the in-crate concurrent-channel
/// cap's decrement (`w2-bounded-resources`): every fired
/// `channel_close` corresponds to exactly one previously-admitted
/// open (russh only routes `CHANNEL_CLOSE` for established
/// channels — a declined open never established one — and consumes
/// the close at most once), so the count recovers through this
/// single release point, saturating in [`ChannelCap::release`] on
/// top. The spawned serving paths never touch the count directly:
/// their closes ride the cloned handle, the client's close reply
/// cycles back through the packet loop, and this hook fires — the
/// single-writer reasoning in [`ChannelCap`].
async fn channel_close(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
self.channel_cap.release();
Ok(())
}
/// The session-channel open gate (ADR-003 feature graph,
/// `session` ∨ `git`): unfeatured builds decline at the open —
/// never a half-open channel. A confirmed open accepts the
/// channel; the parse-only dispatch serves it through its
/// `session` ∨ `git`), then the in-crate concurrent-channel cap
/// ([`Self::admit_channel_open`] — bounded resources,
/// `w2-bounded-resources`): unfeatured builds decline at the open
/// — never a half-open channel — and a featured open beyond the
/// cap is refused fail-closed with a clean wire reason
/// ([`crate::dispatch::cap_exceeded_open_decline`]), never queued.
/// A confirmed open accepts the channel and counts it; the count
/// releases when the channel closes ([`Self::channel_close`]). The
/// parse-only dispatch serves the accepted channel through its
/// requests (nothing serves request-less — ADR-006 as amended).
async fn channel_open_session(
&mut self,
@@ -325,16 +402,22 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_session_channels() {
if !self.serves_session_channels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.admit_channel_open() {
reply.accept().await;
} else {
reply.reject(dispatch::unfeatured_open_decline()).await;
reply.reject(dispatch::cap_exceeded_open_decline()).await;
}
Ok(())
}
/// The x11 channel open: **always declines** — a never-feature
/// (ADR-003 §5), declined in every build, never feature-gated.
/// (ADR-003 §5), declined in every build, never feature-gated. The
/// cap never applies here: a count is consumed only at an accept
/// path, and this hook never has one.
async fn channel_open_x11(
&mut self,
_channel: Channel<Msg>,
@@ -352,7 +435,10 @@ impl russh::server::Handler for SessionHandler {
/// route-1 bridge is wave-3 material, and the stub's decline is
/// the explicit `ConnectFailed` reject shape ADR-006 decision 1
/// pins (accept only after the fabric open succeeds; the skeleton
/// never opens the fabric).
/// never opens the fabric). The in-crate cap gates before the stub
/// ([`Self::channel_cap_exhausted`]): a cap-exhausted build
/// answers the cap reason — the stub opens nothing, so admission
/// moves to wave 3's real accept point.
async fn channel_open_direct_tcpip(
&mut self,
_channel: Channel<Msg>,
@@ -363,16 +449,20 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_tunnels() {
tunnel_row_stub(reply).await;
} else {
if !self.serves_tunnels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.channel_cap_exhausted() {
reply.reject(dispatch::cap_exceeded_open_decline()).await;
return Ok(());
}
tunnel_row_stub(reply).await;
Ok(())
}
/// The `forwarded-tcpip` open gate (`tunnel` — the `-R` data
/// leg): the same gate/stub split as `direct-tcpip`. The
/// leg): the same gate/cap/stub split as `direct-tcpip`. The
/// harness's russh client cannot initiate this channel type (the
/// server opens it toward the client), so its wire rows are
/// wave-3 acceptor material; the gate itself is straight-line
@@ -387,16 +477,20 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_tunnels() {
tunnel_row_stub(reply).await;
} else {
if !self.serves_tunnels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.channel_cap_exhausted() {
reply.reject(dispatch::cap_exceeded_open_decline()).await;
return Ok(());
}
tunnel_row_stub(reply).await;
Ok(())
}
/// The `direct-streamlocal` open gate (`tunnel`): the same
/// gate/stub split as the tcp rows. The runtime-Unix gate (OQ-11)
/// gate/cap/stub split as the tcp rows. The runtime-Unix gate (OQ-11)
/// gates *serving* on Unix; this wave the stub declines
/// everywhere, so the runtime gate has nothing to ride yet.
async fn channel_open_direct_streamlocal(
@@ -406,11 +500,15 @@ impl russh::server::Handler for SessionHandler {
reply: ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
if self.serves_tunnels() {
tunnel_row_stub(reply).await;
} else {
if !self.serves_tunnels() {
reply.reject(dispatch::unfeatured_open_decline()).await;
return Ok(());
}
if self.channel_cap_exhausted() {
reply.reject(dispatch::cap_exceeded_open_decline()).await;
return Ok(());
}
tunnel_row_stub(reply).await;
Ok(())
}
+1
View File
@@ -25,6 +25,7 @@
//! this gate). See also `docs/research/` and family ADR-003.
mod adapter;
mod channel_cap;
mod config;
mod dispatch;
mod error;