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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w2-bounded-resources
|
||||
name: Wave 2 — bounded resources (the in-crate channel cap at the handler surface)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w2-spawn-return]
|
||||
scope: narrow
|
||||
risk: low
|
||||
@@ -39,15 +39,15 @@ not from this task.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The concurrent-channel cap is enforced at every
|
||||
- [x] The concurrent-channel cap is enforced at every
|
||||
`channel_open_*` hook: beyond `Limits::max_concurrent_channels`
|
||||
→ clean decline; the count recovers on close
|
||||
- [ ] Harness tests: cap reached → next open declines; a close → an
|
||||
- [x] Harness tests: cap reached → next open declines; a close → an
|
||||
open succeeds again; the cap is `Limits`-tunable
|
||||
- [ ] The wired russh knobs verified behaviorally where cheap
|
||||
- [x] The wired russh knobs verified behaviorally where cheap
|
||||
(auth-attempt ceiling through the harness) or recorded as
|
||||
covered by the config-mapping tests
|
||||
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo fmt --check` pass
|
||||
|
||||
## References
|
||||
@@ -63,6 +63,95 @@ not from this task.
|
||||
|
||||
> To be filled by implementation agent
|
||||
|
||||
- **The count is handler-owned, not locked.** New private module
|
||||
`src/channel_cap.rs` (`ChannelCap`): the per-session active-channel
|
||||
count with `admit` (check+increment at accept sites) / `has_room`
|
||||
(pure check) / `release` (saturating decrement). The only writer is
|
||||
the connection's russh packet loop — every hook runs inline with
|
||||
exclusive `&mut self` (ADR-006 decision 4 puts serving paths on
|
||||
spawned tasks, but their closes cycle back through the packet loop
|
||||
as `CHANNEL_CLOSE`, firing the hook) — so the `parking_lot`
|
||||
convention's short-held *shared* state does not apply: no lock
|
||||
exists because no shared mutation does. Saturating `release` means
|
||||
a stray decrement cannot manufacture capacity.
|
||||
- **Gate ordering** (handler.rs): feature gate first, then the cap.
|
||||
Session-channel opens admit-and-count exactly at the accept point.
|
||||
The tunnel rows check the cap *before* the seam stub — the stub
|
||||
declines without opening a channel, so a counted admission it never
|
||||
opens would leak against the cap forever (decline-first-alloc-never
|
||||
applies to the budget, not just capability). Wave 3's
|
||||
accept-after-fabric-open flow replaces the stub and moves admission
|
||||
to the real accept point; the wire behavior this wave: a
|
||||
cap-exhausted tunnel row answers the cap reason instead of the
|
||||
seam's `ConnectFailed` (pinned by a row, not by convention). x11
|
||||
declines in every build and never consumes a count.
|
||||
- **Decline reason**: `dispatch::cap_exceeded_open_decline()` →
|
||||
`AdministrativelyProhibited` — the cap is the assembly's configured
|
||||
policy ceiling; OpenSSH's own `MaxSessions` answers the same code,
|
||||
so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5).
|
||||
- **Decrement invariant**: every fired `channel_close` corresponds to
|
||||
exactly one previously-admitted open — russh only routes
|
||||
`CHANNEL_CLOSE` for channels it considers established (a declined
|
||||
open never established one) and consumes the close at most once
|
||||
(the channel is removed from russh's map before the hook).
|
||||
- **Recovery observability (russh-pin fact, recorded in the test
|
||||
doc)**: a *client-initiated* channel close is not observable
|
||||
client-side at this pin — russh removes a locally-closed channel
|
||||
from its client map before the server's `CHANNEL_CLOSE` arrives
|
||||
back, so `Channel::wait()` never delivers the close (the harness's
|
||||
`await_close` observes only *server-initiated* closes, which is what
|
||||
the spawn-return/dispatch rows use). The recovery row therefore
|
||||
rests on wire ordering (deterministic): close and open ride the
|
||||
client loop's single FIFO event queue, are written in that order,
|
||||
and the server's packet loop processes them in order — the counted
|
||||
release precedes the recovery admission; had the release not
|
||||
happened, the recovery open declines and the row fails.
|
||||
- **`max_auth_attempts` verified behaviorally**: ceiling `2`
|
||||
(tunable) → two within-ceiling failures stay ordinary
|
||||
`AuthResult::Failure` riding the `{publickey}` advertisement; the
|
||||
attempt past the ceiling is answered with the server's
|
||||
`Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is
|
||||
russh's synthesized `Failure` with an *empty* remaining-methods set
|
||||
(no reply came; the advertisement is gone) — the session ends
|
||||
cleanly server-side (row: `tests/bounded_resources.rs`
|
||||
`auth_attempt_ceiling_disconnects_an_exceeding_client`).
|
||||
- **The size/window/clock knobs recorded as config-mapping-covered**:
|
||||
`window_size`, `maximum_packet_size`, `channel_buffer_size`,
|
||||
`max_auth_attempts` mapping and the always-set `inactivity_timeout`
|
||||
are pinned one-for-one by the adapter's config-mapping unit tests
|
||||
(`adapter.rs` `limits_knobs_map_one_for_one_with_inactivity_always_set`,
|
||||
`default_limits_map_to_the_documented_postures`); the client
|
||||
direction's keepalive/wall-clock postures are pinned by the harness
|
||||
(`client_config_maps_the_limits_postures`). Re-driving them through
|
||||
the wire buys no new information (russh reads the `Config` fields it
|
||||
owns); not cheaply behavioral, so recorded, per the task's split.
|
||||
- **`max_exec_command_len` stays carried, not enforced** (wave 3's
|
||||
grammar owns it) — unchanged from `src/config.rs`/`src/dispatch.rs`
|
||||
documentation; nothing here consumes it.
|
||||
- **Verification**: `cargo test` (default), and `--features session`
|
||||
/ `git` / `tunnel` / `--all-features` (the feature-gated cap rows
|
||||
run in the builds whose opens can be accepted), `cargo clippy
|
||||
--all-targets -- -D warnings` (+ `--all-features`),
|
||||
`cargo fmt --check`, `cargo doc --no-deps` — all green.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> To be filled on completion
|
||||
|
||||
Landed the in-crate concurrent-channel cap at the handler surface:
|
||||
new private `channel_cap` module (count + admit/release semantics,
|
||||
unit-tested), wired into `SessionHandler`'s every open hook — the
|
||||
session accept path admits-and-counts at the accept, the tunnel rows
|
||||
cap-gate before the seam stub, x11 is unaffected — and decremented
|
||||
through the new `channel_close` hook (single release point, single
|
||||
writer). Cap-exceeded opens decline fail-closed with
|
||||
`AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`,
|
||||
OpenSSH `MaxSessions` parity). Contract rows in
|
||||
`tests/bounded_resources.rs`: cap reached → clean decline; close →
|
||||
open succeeds again (wire-order deterministic); zero cap refuses
|
||||
everything without poisoning; exhausted cap answers the tunnel row
|
||||
with the cap reason; the `max_auth_attempts` ceiling verified
|
||||
behaviorally through the harness. The per-identity policy layer stays
|
||||
with the wave-3 bridge; the per-length exec-parse cap stays the
|
||||
grammar's. All five feature-build test runs, clippy (both) and fmt
|
||||
green.
|
||||
@@ -0,0 +1,288 @@
|
||||
//! The `w2-bounded-resources` contract rows — the bounded-resources
|
||||
//! policy enforced through the real wire via the loopback harness: the
|
||||
//! in-crate concurrent-channel cap at every `channel_open_*` accept
|
||||
//! path, its recovery on close, its `Limits` tunability, and the
|
||||
//! behavioral verification of the wired `max_auth_attempts` russh knob
|
||||
//! (the ceiling disconnects an exceeding client). The size/window/
|
||||
//! buffer knobs (`window_size`, `maximum_packet_size`,
|
||||
//! `channel_buffer_size`), the inactivity wall clock and the remaining
|
||||
//! auth-counting posture ride the config-mapping tests
|
||||
//! (`adapter.rs` `#[cfg(test)]` + the harness's own
|
||||
//! `client_config_maps_the_limits_postures`) — recorded, not
|
||||
//! re-proven on the wire here.
|
||||
//!
|
||||
//! The cap rows are feature-gated to the builds whose opens can be
|
||||
//! accepted (session channels need `session` or `git`; the
|
||||
//! tunnel-gate ordering row additionally needs `tunnel`), per the
|
||||
//! `dispatch_core.rs` feature-build convention.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::core::auth::IdentityProvider;
|
||||
use common::harness::{
|
||||
DUPLEX_CAPACITY, HarnessClientHandler, LoopbackSession, authenticate_publickey, client_config,
|
||||
drive, serve_config,
|
||||
};
|
||||
use common::{MemHostKeyPolicy, MemIdentityProvider, fixture, identity};
|
||||
use russh::{ChannelOpenFailure, Error, MethodSet};
|
||||
|
||||
use alkssh::{Limits, SshAdapter};
|
||||
|
||||
fn registered_provider(fingerprints: &[&str]) -> Arc<MemIdentityProvider> {
|
||||
let provider = Arc::new(MemIdentityProvider::new());
|
||||
for (index, registered) in fingerprints.iter().enumerate() {
|
||||
provider.register(registered, identity(&format!("user-{index}"), &["ssh"]));
|
||||
}
|
||||
provider
|
||||
}
|
||||
|
||||
/// Mounts an authenticated loopback session over the doubles with the
|
||||
/// row's [`Limits`] — the bounded-resources rows' driver.
|
||||
async fn authenticated_with(limits: Limits) -> LoopbackSession {
|
||||
let provider = registered_provider(&[fixture("alpha").fingerprint]);
|
||||
let adapter = SshAdapter::new(serve_config(
|
||||
provider as Arc<dyn IdentityProvider>,
|
||||
&[fixture("bravo")],
|
||||
limits,
|
||||
))
|
||||
.await
|
||||
.expect("adapter constructs");
|
||||
|
||||
let mut session = drive(
|
||||
adapter,
|
||||
Arc::new(MemHostKeyPolicy::with_default(true)),
|
||||
limits,
|
||||
)
|
||||
.await
|
||||
.expect("the loopback mounts");
|
||||
let result = authenticate_publickey(&mut session.client, "anyone", fixture("alpha")).await;
|
||||
assert!(
|
||||
result.success(),
|
||||
"the fixture identity authenticates before every bounded-resources row"
|
||||
);
|
||||
session
|
||||
}
|
||||
|
||||
/// Matches an open failure's SSH reason — the wire decline reason the
|
||||
/// cap-gate rows assert.
|
||||
fn open_failure(err: Error, expected: ChannelOpenFailure, context: &str) {
|
||||
assert!(
|
||||
matches!(
|
||||
&err,
|
||||
Error::ChannelOpenFailure(reason) if *reason == expected
|
||||
),
|
||||
"{context}: expected open decline with {expected:?}, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// - *Contract stamp:* `overview.md` §Bounded-resources policy (the
|
||||
/// auth-attempts row — the `max_auth_attempts` knob is behavioral,
|
||||
/// verified where cheap: the ceiling disconnects an exceeding
|
||||
/// client) + ADR-004 decision 6 (the mapping in `adapter.rs` makes
|
||||
/// the knob the counting ceiling; within the ceiling, failures stay
|
||||
/// ordinary `AuthResult::Failure` riding the publickey-only
|
||||
/// advertisement).
|
||||
///
|
||||
/// Drive: `max_auth_attempts: 2` (Limits-tunable, not the 10 default),
|
||||
/// three unregistered keys. The first two rejections stay ordinary
|
||||
/// failures with the advertisement intact; the third request past the
|
||||
/// ceiling is answered with the server's
|
||||
/// `Disconnect::NoMoreAuthMethodsAvailable` — client-side, the
|
||||
/// in-flight attempt sees russh's synthesized `Failure` with the
|
||||
/// **empty** remaining-methods set (no reply ever came; the
|
||||
/// advertisement is gone). The session ends cleanly server-side.
|
||||
#[tokio::test]
|
||||
async fn auth_attempt_ceiling_disconnects_an_exceeding_client() {
|
||||
use russh::MethodKind;
|
||||
use russh::keys::PrivateKeyWithHashAlg;
|
||||
|
||||
let provider = registered_provider(&[fixture("alpha").fingerprint]);
|
||||
let limits = Limits {
|
||||
max_auth_attempts: 2,
|
||||
..Limits::default()
|
||||
};
|
||||
let adapter = SshAdapter::new(serve_config(
|
||||
provider as Arc<dyn IdentityProvider>,
|
||||
&[fixture("bravo")],
|
||||
limits,
|
||||
))
|
||||
.await
|
||||
.expect("adapter constructs");
|
||||
|
||||
let (client_io, server_io) = tokio::io::duplex(DUPLEX_CAPACITY);
|
||||
let server_task = tokio::spawn(async move { adapter.handle(server_io).await });
|
||||
let mut client = russh::client::connect_stream(
|
||||
client_config(limits),
|
||||
client_io,
|
||||
HarnessClientHandler::new(Arc::new(MemHostKeyPolicy::with_default(true))),
|
||||
)
|
||||
.await
|
||||
.expect("the in-process pair connects");
|
||||
|
||||
let normal_advertisement = MethodSet::from(&[MethodKind::PublicKey][..]);
|
||||
let within = authenticate_publickey(&mut client, "anyone", fixture("delta")).await;
|
||||
assert!(
|
||||
!within.success(),
|
||||
"within the ceiling, a rejection stays an ordinary failure: {within:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_ads(&within),
|
||||
Some(normal_advertisement.clone()),
|
||||
"the within-ceiling failure still carries the advertisement"
|
||||
);
|
||||
let last_within = authenticate_publickey(&mut client, "anyone", fixture("charlie")).await;
|
||||
assert!(
|
||||
!last_within.success(),
|
||||
"the ceiling's last rejection still rides as an ordinary failure: {last_within:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_ads(&last_within),
|
||||
Some(normal_advertisement),
|
||||
"the advertisement survives every within-ceiling rejection"
|
||||
);
|
||||
|
||||
let exceeding = async {
|
||||
let key = Arc::new(fixture("bravo").private_key());
|
||||
client
|
||||
.authenticate_publickey("anyone", PrivateKeyWithHashAlg::new(key, None))
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
match &exceeding {
|
||||
Ok(result) => {
|
||||
assert!(
|
||||
!result.success(),
|
||||
"the attempt past the ceiling never re-answers auth: {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_ads(result),
|
||||
Some(MethodSet::empty()),
|
||||
"the past-ceiling answer is the torn-down shape: no advertisement rides"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
// The client loop already exited on the disconnect — the
|
||||
// equally-valid torn-down shape (the send side is gone).
|
||||
}
|
||||
}
|
||||
|
||||
drop(client);
|
||||
server_task
|
||||
.await
|
||||
.expect("server task ends")
|
||||
.expect("the ceiling disconnect ends the session cleanly");
|
||||
}
|
||||
|
||||
/// The remaining-methods set of an [`AuthResult`] — `None` on success
|
||||
/// (the within-ceiling vs past-ceiling observation shape).
|
||||
fn remaining_ads(result: &russh::client::AuthResult) -> Option<russh::MethodSet> {
|
||||
match result {
|
||||
russh::client::AuthResult::Failure {
|
||||
remaining_methods, ..
|
||||
} => Some(remaining_methods.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "session", feature = "git"))]
|
||||
#[tokio::test]
|
||||
async fn concurrent_channel_cap_declines_and_recovers_over_the_wire() {
|
||||
use common::harness::open_session_channel;
|
||||
|
||||
let session = authenticated_with(Limits {
|
||||
max_concurrent_channels: 2,
|
||||
..Limits::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let first = open_session_channel(&session.client)
|
||||
.await
|
||||
.expect("the first open admits under the cap");
|
||||
let _second = open_session_channel(&session.client)
|
||||
.await
|
||||
.expect("the second open admits under the cap");
|
||||
|
||||
let err = open_session_channel(&session.client)
|
||||
.await
|
||||
.expect_err("the third open is refused at the cap — never queued");
|
||||
open_failure(
|
||||
err,
|
||||
ChannelOpenFailure::AdministrativelyProhibited,
|
||||
"the cap-exceeded open",
|
||||
);
|
||||
|
||||
first.close().await.expect("the close rides the wire");
|
||||
|
||||
// The close reply is not observable client-side at this pin: russh
|
||||
// removes a locally-closed channel from its map before the
|
||||
// server's CHANNEL_CLOSE arrives back, so no message — and no
|
||||
// `None` — is ever delivered (the harness's `await_close` only
|
||||
// observes *server-initiated* closes). The recovery observation is
|
||||
// the open itself, and it is deterministic: close and open ride
|
||||
// the client loop's single FIFO event queue, they are written in
|
||||
// that order on the wire, and the server's packet loop processes
|
||||
// them in order — the counted close releases before the recovery
|
||||
// open is admitted. Had the release not happened, this open
|
||||
// declines and this expect fails.
|
||||
let _third = open_session_channel(&session.client)
|
||||
.await
|
||||
.expect("a close recovers the cap; the next open succeeds again");
|
||||
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "session", feature = "git"))]
|
||||
#[tokio::test]
|
||||
async fn zero_cap_refuses_every_open_and_poisons_nothing() {
|
||||
use common::harness::open_session_channel;
|
||||
|
||||
let session = authenticated_with(Limits {
|
||||
max_concurrent_channels: 0,
|
||||
..Limits::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
for attempt in 1..=2 {
|
||||
let err = open_session_channel(&session.client)
|
||||
.await
|
||||
.expect_err("a zero cap refuses the very first open — no half-open, no queue");
|
||||
open_failure(
|
||||
err,
|
||||
ChannelOpenFailure::AdministrativelyProhibited,
|
||||
&format!("the zero-cap open #{attempt}"),
|
||||
);
|
||||
}
|
||||
|
||||
session.unmount().await;
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tunnel", any(feature = "session", feature = "git")))]
|
||||
#[tokio::test]
|
||||
async fn an_exhausted_cap_answers_the_tunnel_row_with_the_cap_reason() {
|
||||
use common::harness::{open_direct_tcpip, open_session_channel};
|
||||
|
||||
let session = authenticated_with(Limits {
|
||||
max_concurrent_channels: 1,
|
||||
..Limits::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let _first = open_session_channel(&session.client)
|
||||
.await
|
||||
.expect("the first open admits — the cap is now exhausted");
|
||||
|
||||
let err = open_direct_tcpip(&session.client, "internal.example", 5432)
|
||||
.await
|
||||
.expect_err("the cap gates the tunnel row before the seam stub");
|
||||
open_failure(
|
||||
err,
|
||||
ChannelOpenFailure::AdministrativelyProhibited,
|
||||
"the cap-exhausted tunnel open",
|
||||
);
|
||||
|
||||
session.unmount().await;
|
||||
}
|
||||
Reference in new issue
Block a user