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:
1 parent
2e5a7c9cee
commit
196289f5ad
6 files changed
+658
-26
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user