feat(config): land the configuration surface (serve-config, dial-config, Limits)
- ServeConfig: OQ-08 shell_argv / exec_wrapper_argv as Option<Vec<String>> with unset-declines doc contracts (no crate default shell/wrapper); OQ-09 session_cwd/session_env (assembly-supplied, never wire-derived); injected Arc<dyn IdentityProvider> + Arc<dyn HostKeySource> handles; optional Arc<ChannelClient> route-1 handle (absent -> tunnel rows decline even under the tunnel feature); Limits. - DialConfig: address + injected Arc<dyn KeySource> and Arc<dyn HostKeyPolicy> handles + Limits. - Limits: the bounded-resources budget model both directions carry — in-crate caps (max_concurrent_channels 64, max_exec_command_len 8192) and the russh-knob mapping with documented postures (max_auth_attempts 10 per ADR-004 decision 6; window 2 MiB / packet 32 KiB / buffer 100; inactivity 600 s always-set shape; keepalive Some(15 s)/3 client posture). - Types only: no russh Config construction (wave 2), no dialing (wave 4); no construction-time validation — unset-declines lives in the type shape (error.rs no-config-error contract). Verified: cargo test (36), cargo test --all-features, clippy (all-targets, -D warnings, default + all-features), fmt --check.
This commit is contained in:
1 parent
4d529dbacf
commit
949d0bbbe4
3 files changed
+634
-9
No files matched your search
+550
@@ -0,0 +1,550 @@
|
||||
//! The pure configuration surface — the shapes every later wave's
|
||||
//! constructors take (serve-config, dial-config, [`Limits`]). Types
|
||||
//! only: russh `Config` construction is wave-2 material, dialing lands
|
||||
//! at wave 4; nothing here touches the wire or the vault.
|
||||
//!
|
||||
//! Two directions, one budget model (`overview.md` §Public surface):
|
||||
//!
|
||||
//! - [`ServeConfig`] — the server direction's assembly-supplied serving
|
||||
//! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] /
|
||||
//! [`ServeConfig::exec_wrapper_argv`]), the injected identity,
|
||||
//! host-key and tunnel handles, the OQ-09 `cwd`/`env` values, and the
|
||||
//! [`Limits`].
|
||||
//! - [`DialConfig`] — the client direction's dial configuration
|
||||
//! (`client.md` §The dial surface): address, key source, host-key
|
||||
//! policy, and the bounded-resource knobs (carried by [`Limits`]).
|
||||
//!
|
||||
//! Crate rules this module encodes:
|
||||
//!
|
||||
//! - **Unset-declines, no crate defaults** (OQ-08): the OQ-08 fields
|
||||
//! are `Option` and their unset semantics live in the type shape —
|
||||
//! an unset field makes its row decline with a clean error at
|
||||
//! request time (wave-2/3 consumption), never a fallback, never
|
||||
//! construction-time validation (`error.rs`'s no-config-error
|
||||
//! contract). No construction-time validation exists in this
|
||||
//! module.
|
||||
//! - **Injected handles, never crate-resolved identity or trust**:
|
||||
//! the [`IdentityProvider`], [`HostKeySource`] and
|
||||
//! [`HostKeyPolicy`] handles are `Arc<dyn ..>` the assembly
|
||||
//! supplies; the crate holds no identity model, no trust policy and
|
||||
//! no vault lifecycle of its own. A git-only tunnel build simply
|
||||
//! leaves [`ServeConfig::channel_client`] absent.
|
||||
//! - **Bounded resources everywhere** (convention 9): every session
|
||||
//! — both directions — carries a [`Limits`]; there is no
|
||||
//! unbounded-loop or unbounded-buffer configuration shape the
|
||||
//! assembly can build from these types.
|
||||
//!
|
||||
//! Knob-name parity with the russh 0.65 pin (ADR-002):
|
||||
//! `window_size`, `maximum_packet_size`, `channel_buffer_size`,
|
||||
//! `inactivity_timeout`, `keepalive_interval`, `keepalive_max`,
|
||||
//! `max_auth_attempts` — wave-2/wave-4 map these fields into the russh
|
||||
//! `Config` verbatim. russh's rekey `Limits` stay russh-enforced at
|
||||
//! its own RFC-4253 §9 defaults (wave-2); they are not carried here —
|
||||
//! this module's [`Limits`] is the crate's in-crate + mapped-knob
|
||||
//! budget model.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::core::auth::IdentityProvider;
|
||||
|
||||
use crate::host_key::HostKeyPolicy;
|
||||
use crate::key_source::{HostKeySource, KeySource};
|
||||
|
||||
/// The bounded-resources budget model (`overview.md`
|
||||
/// §Bounded-resources policy): the in-crate caps plus the russh-knob
|
||||
/// mapping with the documented default postures. Every session —
|
||||
/// server and client direction — carries one (`AGENTS.md` convention
|
||||
/// 9); there is no unbounded configuration shape.
|
||||
///
|
||||
/// Carried by value (`Copy`): a [`Limits`] is plain tuning data, and
|
||||
/// wave-2/4 copies it into the russh `Config` construction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// The crates' own cap on concurrent channels per session —
|
||||
/// opened channels beyond the cap are declined (fail-closed
|
||||
/// refuse, never queue). The per-identity policy layer (when the
|
||||
/// alkcall bridge carries it) is additional and comes with the
|
||||
/// bridge itself, not from this field. Assembly-tunable.
|
||||
///
|
||||
/// Default: `DEFAULT_MAX_CONCURRENT_CHANNELS`.
|
||||
pub max_concurrent_channels: usize,
|
||||
/// The parse-input cap for the wave-3 exec grammar: a wire exec
|
||||
/// command string longer than this declines before any parse or
|
||||
/// resource allocation (decline-first, alloc-never — the ADR-006
|
||||
/// dispatch contract). Assembly-tunable.
|
||||
///
|
||||
/// Default: `DEFAULT_MAX_EXEC_COMMAND_LEN`.
|
||||
pub max_exec_command_len: usize,
|
||||
/// Maximal number of allowed authentication attempts — mapped to
|
||||
/// russh's `max_auth_attempts` (server direction). Default `10`
|
||||
/// per ADR-004 decision 6: the OpenSSH counting `none`-probe
|
||||
/// consumes one attempt, so `10` leaves `IdentitiesOnly`-style
|
||||
/// clients at least nine real key attempts.
|
||||
pub max_auth_attempts: usize,
|
||||
/// The initial channel receive window — mapped to russh's
|
||||
/// `window_size` (per-channel memory bound).
|
||||
///
|
||||
/// Default: 2 MiB (russh's own default posture, kept).
|
||||
pub window_size: u32,
|
||||
/// The maximal size of a single SSH packet — mapped to russh's
|
||||
/// `maximum_packet_size` (per-channel record-size bound).
|
||||
///
|
||||
/// Default: 32 KiB (russh's own default posture, kept).
|
||||
pub maximum_packet_size: u32,
|
||||
/// Per-channel unprocessed-message buffer — mapped to russh's
|
||||
/// `channel_buffer_size` (the backpressure bound); messages past
|
||||
/// it propagate backpressure to the TCP stream, never an
|
||||
/// unbounded buffer.
|
||||
///
|
||||
/// Default: 100 (russh's own default posture, kept).
|
||||
pub channel_buffer_size: usize,
|
||||
/// The session wall clock — the connection is garbage-collected
|
||||
/// after this long with no activity. Mapped to russh's
|
||||
/// `Option<inactivity_timeout>` as always-set (`Some(value)`) in
|
||||
/// wave 2/4: the bounded-resources policy's "set
|
||||
/// (assembly-tunable)" posture — the crate never constructs a
|
||||
/// session with no wall clock. Assembly-tunable.
|
||||
///
|
||||
/// Default: `DEFAULT_INACTIVITY_TIMEOUT`.
|
||||
pub inactivity_timeout: Duration,
|
||||
/// Client-direction keepalive interval (cloud fleets): the
|
||||
/// dead-peer probe, mapped to russh's `keepalive_interval`.
|
||||
///
|
||||
/// Default: `Some(15 s)` — the reverse-runner-evidence posture
|
||||
/// (OpenSSH `ServerAliveInterval=15` parity): with russh's own
|
||||
/// default (`None`) a dead NAT'd connection sits unnoticed until
|
||||
/// an exec health check fails (`reverse-runner.md` lesson 3).
|
||||
/// `None` is available to the assembly but is not the posture.
|
||||
pub keepalive_interval: Option<Duration>,
|
||||
/// How many unanswered keepalives close the connection — mapped
|
||||
/// to russh's `keepalive_max`.
|
||||
///
|
||||
/// Default: `3` (OpenSSH `ServerAliveCountMax=3` parity, the
|
||||
/// reverse-runner-evidence posture).
|
||||
pub keepalive_max: usize,
|
||||
}
|
||||
|
||||
/// The crate default for [`Limits::max_concurrent_channels`]: a
|
||||
/// conservative per-session channel cap for the door shapes alkssh
|
||||
/// serves (the git door's few exec channels; tunnel legs are
|
||||
/// separate channels and fit well inside it).
|
||||
pub const DEFAULT_MAX_CONCURRENT_CHANNELS: usize = 64;
|
||||
|
||||
/// The crate default for [`Limits::max_exec_command_len`]: generous
|
||||
/// room for the git exec row's command strings (the
|
||||
/// `git-upload-pack '/repo'` grammar) plus arbitrary non-git
|
||||
/// `ssh host "cmd"` commands, while staying far below the wire's
|
||||
/// 32 KiB packet bound.
|
||||
pub const DEFAULT_MAX_EXEC_COMMAND_LEN: usize = 8192;
|
||||
|
||||
/// The crate default for [`Limits::inactivity_timeout`]: the 10-minute
|
||||
/// wall clock (russh's server default posture; an assembly that
|
||||
/// serves long-idle-but-alive sessions tunes it, together with the
|
||||
/// keepalive knobs on the client direction).
|
||||
pub const DEFAULT_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// The client-direction keepalive default posture
|
||||
/// (`Limits::keepalive_interval`): 15 seconds, per the
|
||||
/// reverse-runner evidence.
|
||||
pub const DEFAULT_KEEPALIVE_INTERVAL: Option<Duration> = Some(Duration::from_secs(15));
|
||||
|
||||
/// The crate default for [`Limits::keepalive_max`]: 3 unanswered
|
||||
/// probes.
|
||||
pub const DEFAULT_KEEPALIVE_MAX: usize = 3;
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrent_channels: DEFAULT_MAX_CONCURRENT_CHANNELS,
|
||||
max_exec_command_len: DEFAULT_MAX_EXEC_COMMAND_LEN,
|
||||
max_auth_attempts: 10,
|
||||
window_size: 2_097_152,
|
||||
maximum_packet_size: 32_768,
|
||||
channel_buffer_size: 100,
|
||||
inactivity_timeout: DEFAULT_INACTIVITY_TIMEOUT,
|
||||
keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL,
|
||||
keepalive_max: DEFAULT_KEEPALIVE_MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The server direction's assembly-supplied serving configuration
|
||||
/// (`overview.md` §Server stack): everything wave-2's serving loop
|
||||
/// takes at construction. Pure data + injected handles — no russh
|
||||
/// `Config` is built here (wave 2), no serving loop exists yet.
|
||||
///
|
||||
/// Construction carries no validation beyond the required handles:
|
||||
/// the unfeatured/unset capability surfaces are structural `Option`s
|
||||
/// that decline at request time (the fail-closed contract) — see
|
||||
/// [`ServeConfig::shell_argv`] and [`ServeConfig::channel_client`].
|
||||
pub struct ServeConfig {
|
||||
/// OQ-08 field 1: the shell argv, run verbatim for
|
||||
/// `shell_request` (`NegotiateRequest.cmd`). No crate default —
|
||||
/// **unset → `shell_request` declines with a clean error, never a
|
||||
/// fallback**: the crate is not an sshd and has no account model
|
||||
/// from which a shell could be derived, so any baked-in shell
|
||||
/// would be deployment policy smuggled into the crate. A
|
||||
/// `session` build with this unset serves neither shell nor exec
|
||||
/// requests (the git row is unaffected — parsing never needs a
|
||||
/// shell).
|
||||
///
|
||||
/// Set to `Some(vec)` to serve the shell row (feature-gated at
|
||||
/// wave 2: only under the `session` feature).
|
||||
pub shell_argv: Option<Vec<String>>,
|
||||
/// OQ-08 field 2: the exec wrapper argv (platform-neutral, e.g.
|
||||
/// `["/bin/sh", "-c"]`). A non-git `exec_request`'s wire command
|
||||
/// string is appended as the final argv element
|
||||
/// (`wrapper ++ [cmd]`) — vanilla `ssh host "cmd"` quoting
|
||||
/// semantics; command strings are never naively split. No crate
|
||||
/// default — **unset → the non-git exec row declines with a
|
||||
/// clean error, never a fallback**.
|
||||
///
|
||||
/// Set to `Some(vec)` to serve the pipe-exec row (feature-gated
|
||||
/// at wave 2: only under the `session` feature).
|
||||
pub exec_wrapper_argv: Option<Vec<String>>,
|
||||
/// The OQ-09 posture's assembly-supplied working directory for
|
||||
/// alktty `NegotiateRequest.cwd` — **never wire-derived**
|
||||
/// (`env_request` declines unconditionally; wire env is not
|
||||
/// family config). Unset → the backend-side default applies.
|
||||
pub session_cwd: Option<PathBuf>,
|
||||
/// The OQ-09 posture's assembly-supplied environment for
|
||||
/// `NegotiateRequest.env` — **never wire-derived**. Empty by
|
||||
/// default.
|
||||
pub session_env: BTreeMap<String, String>,
|
||||
/// The injected identity provider (alkcall's
|
||||
/// `IdentityProvider::resolve_from_fingerprint`) — the assembly
|
||||
/// supplies it; the crate never resolves identities itself and
|
||||
/// holds no identity model.
|
||||
pub identity_provider: Arc<dyn IdentityProvider>,
|
||||
/// The injected host-key source (the wave-1
|
||||
/// [`HostKeySource`] seam, decrypt-at-construction per ADR-005
|
||||
/// decision 6) — feeds wave-2's `Config::keys`; a load failure
|
||||
/// means no server is constructed, never a half-configured one.
|
||||
pub host_key_source: Arc<dyn HostKeySource>,
|
||||
/// The tunnel route-1 bridge's family-fabric entry: a held
|
||||
/// alkcall `ChannelClient` (`TunnelSession::open_direct` runs on
|
||||
/// it). **Absent → every tunnel row declines with a clean error
|
||||
/// even under the `tunnel` feature** (the feature alone configures
|
||||
/// nothing — the fabric handle is the row's other half). The
|
||||
/// present handle is still gated by the `tunnel` feature at wave
|
||||
/// 2: absent + unfeatured both decline; present + featured
|
||||
/// serves.
|
||||
pub channel_client: Option<Arc<ChannelClient>>,
|
||||
/// The bounded-resources budget this serving direction enforces
|
||||
/// (see [`Limits`]).
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
impl ServeConfig {
|
||||
/// Builds a serve-config from the two injected handles the
|
||||
/// serving direction can never default — the identity provider
|
||||
/// and the host-key source. The OQ-08 fields start unset (their
|
||||
/// rows decline), the tunnel handle starts absent (its rows
|
||||
/// decline), and `limits` starts at [`Limits::default()`] —
|
||||
/// override the `pub` fields from the assembly.
|
||||
pub fn new(
|
||||
identity_provider: Arc<dyn IdentityProvider>,
|
||||
host_key_source: Arc<dyn HostKeySource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
shell_argv: None,
|
||||
exec_wrapper_argv: None,
|
||||
session_cwd: None,
|
||||
session_env: BTreeMap::new(),
|
||||
identity_provider,
|
||||
host_key_source,
|
||||
channel_client: None,
|
||||
limits: Limits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the `shell_request` row is configurable at all in this
|
||||
/// configuration (`Some` under the `session` feature): `false`
|
||||
/// means the row declines — the unset-declines contract, checked
|
||||
/// at request time by the wave-2 dispatch, never here.
|
||||
pub fn shell_configured(&self) -> bool {
|
||||
self.shell_argv.is_some()
|
||||
}
|
||||
|
||||
/// Whether the non-git `exec_request` (pipe) row is configurable
|
||||
/// at all in this configuration: `false` means the row declines —
|
||||
/// the unset-declines contract, checked at request time by the
|
||||
/// wave-2 dispatch, never here.
|
||||
pub fn exec_wrapper_configured(&self) -> bool {
|
||||
self.exec_wrapper_argv.is_some()
|
||||
}
|
||||
|
||||
/// Whether the tunnel rows are configurable at all in this
|
||||
/// configuration (`channel_client` present; the `tunnel` feature
|
||||
/// gate applies at wave 2 on top): `false` means the rows decline.
|
||||
pub fn tunnel_configured(&self) -> bool {
|
||||
self.channel_client.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ServeConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ServeConfig")
|
||||
.field("shell_argv", &self.shell_argv)
|
||||
.field("exec_wrapper_argv", &self.exec_wrapper_argv)
|
||||
.field("session_cwd", &self.session_cwd)
|
||||
.field("session_env", &self.session_env)
|
||||
.field("identity_provider", &"<dyn IdentityProvider>")
|
||||
.field("host_key_source", &"<dyn HostKeySource>")
|
||||
.field("channel_client", &self.channel_client.as_ref().is_some())
|
||||
.field("limits", &self.limits)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The client direction's dial configuration (`client.md` §The dial
|
||||
/// surface): everything wave-4's dial loop takes at construction.
|
||||
/// Pure data + injected handles — no russh `Config` is built here
|
||||
/// (wave 2), no dial happens (wave 4).
|
||||
///
|
||||
/// Construction carries no validation: the key source's own
|
||||
/// load path is fail-closed ([`KeySource`] — locked/absent/decryption
|
||||
/// failures are all no-dial outcomes), and the host-key policy's
|
||||
/// default is fail-closed ([crate::host_key::RejectAll]).
|
||||
pub struct DialConfig {
|
||||
/// The dial target — `host:port` as russh's connect surface
|
||||
/// takes it (the reverse-runner shape: vanilla `sshd` on the
|
||||
/// fleet box).
|
||||
pub address: String,
|
||||
/// The key source the dial authenticates with (the wave-1
|
||||
/// [`KeySource`] seam) — never a static key, never env vars;
|
||||
/// decrypt-at-dial, fail-closed per ADR-005.
|
||||
pub key_source: Arc<dyn KeySource>,
|
||||
/// The injected host-key policy (OQ-05) — the trust decision
|
||||
/// surface the dial calls at russh's `check_server_key` hook.
|
||||
/// The crate is policy-blind; the built-in default policy
|
||||
/// ([crate::host_key::RejectAll]) connects to nothing, by design.
|
||||
pub host_key_policy: Arc<dyn HostKeyPolicy>,
|
||||
/// The bounded-resource budget this dial runs under — the
|
||||
/// keepalive interval/max, the wall clock and the window/packet
|
||||
/// knobs ride here (see [`Limits`]; the keepalive 15 s / 3
|
||||
/// default posture is the client direction's).
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
impl DialConfig {
|
||||
/// Builds a dial-config from the address and the two injected
|
||||
/// handles the dial direction can never default — the key source
|
||||
/// and the host-key policy. `limits` starts at
|
||||
/// [`Limits::default()`] (keepalive 15 s / 3 posture riding
|
||||
/// inside).
|
||||
pub fn new(
|
||||
address: impl Into<String>,
|
||||
key_source: Arc<dyn KeySource>,
|
||||
host_key_policy: Arc<dyn HostKeyPolicy>,
|
||||
) -> Self {
|
||||
Self {
|
||||
address: address.into(),
|
||||
key_source,
|
||||
host_key_policy,
|
||||
limits: Limits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DialConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DialConfig")
|
||||
.field("address", &self.address)
|
||||
.field("key_source", &"<dyn KeySource>")
|
||||
.field("host_key_policy", &"<dyn HostKeyPolicy>")
|
||||
.field("limits", &self.limits)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::core::auth::NoopIdentityProvider;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{DEFAULT_INACTIVITY_TIMEOUT, DialConfig, Limits, ServeConfig};
|
||||
use crate::error::KeySourceError;
|
||||
use crate::host_key::RejectAll;
|
||||
use crate::key_source::{HostKeySource, KeySource};
|
||||
use russh::keys::PrivateKey;
|
||||
|
||||
struct NoKeySource;
|
||||
|
||||
#[async_trait]
|
||||
impl KeySource for NoKeySource {
|
||||
async fn load(&self) -> Result<PrivateKey, KeySourceError> {
|
||||
Err(KeySourceError::Absent)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoHostKeysSource;
|
||||
|
||||
#[async_trait]
|
||||
impl HostKeySource for NoHostKeysSource {
|
||||
async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError> {
|
||||
Err(KeySourceError::Absent)
|
||||
}
|
||||
}
|
||||
|
||||
fn key_source() -> Arc<dyn KeySource> {
|
||||
Arc::new(NoKeySource)
|
||||
}
|
||||
|
||||
fn host_key_source() -> Arc<dyn HostKeySource> {
|
||||
Arc::new(NoHostKeysSource)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_default_carries_the_documented_postures() {
|
||||
let limits = Limits::default();
|
||||
|
||||
assert_eq!(limits.max_concurrent_channels, 64);
|
||||
assert_eq!(limits.max_exec_command_len, 8192);
|
||||
assert_eq!(limits.max_auth_attempts, 10);
|
||||
assert_eq!(limits.window_size, 2_097_152);
|
||||
assert_eq!(limits.maximum_packet_size, 32_768);
|
||||
assert_eq!(limits.channel_buffer_size, 100);
|
||||
assert_eq!(limits.inactivity_timeout, DEFAULT_INACTIVITY_TIMEOUT);
|
||||
assert_eq!(limits.inactivity_timeout.as_secs(), 600);
|
||||
assert_eq!(
|
||||
limits.keepalive_interval,
|
||||
Some(std::time::Duration::from_secs(15))
|
||||
);
|
||||
assert_eq!(limits.keepalive_max, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_are_copyable_and_tunable() {
|
||||
let limits = Limits::default();
|
||||
let copy = limits;
|
||||
assert_eq!(limits, copy);
|
||||
|
||||
let tuned = Limits {
|
||||
max_concurrent_channels: 8,
|
||||
max_exec_command_len: 1024,
|
||||
inactivity_timeout: std::time::Duration::from_secs(3600),
|
||||
keepalive_interval: None,
|
||||
..Limits::default()
|
||||
};
|
||||
assert_eq!(tuned.max_concurrent_channels, 8);
|
||||
assert_eq!(tuned.max_exec_command_len, 1024);
|
||||
assert_eq!(tuned.keepalive_interval, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_config_starts_unconfigured_everywhere() {
|
||||
let serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
|
||||
|
||||
assert!(serve.shell_argv.is_none());
|
||||
assert!(serve.exec_wrapper_argv.is_none());
|
||||
assert!(!serve.shell_configured());
|
||||
assert!(!serve.exec_wrapper_configured());
|
||||
assert!(!serve.tunnel_configured());
|
||||
assert!(serve.channel_client.is_none());
|
||||
assert!(serve.session_cwd.is_none());
|
||||
assert!(serve.session_env.is_empty());
|
||||
assert_eq!(serve.limits, Limits::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_config_option_semantics_flip_independently() {
|
||||
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
|
||||
|
||||
serve.shell_argv = Some(vec!["/bin/sh".into(), "-l".into()]);
|
||||
assert!(serve.shell_configured());
|
||||
assert!(!serve.exec_wrapper_configured());
|
||||
assert!(!serve.tunnel_configured());
|
||||
|
||||
serve.exec_wrapper_argv = None;
|
||||
serve.exec_wrapper_argv = Some(vec!["/bin/sh".into(), "-c".into()]);
|
||||
assert!(serve.shell_configured());
|
||||
assert!(serve.exec_wrapper_configured());
|
||||
|
||||
serve.shell_argv = None;
|
||||
assert!(!serve.shell_configured());
|
||||
assert!(serve.exec_wrapper_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_config_holds_the_injected_handles() {
|
||||
let provider: Arc<dyn alkcall::core::auth::IdentityProvider> =
|
||||
Arc::new(NoopIdentityProvider);
|
||||
let keys = host_key_source();
|
||||
|
||||
let serve = ServeConfig::new(Arc::clone(&provider), Arc::clone(&keys));
|
||||
|
||||
assert!(Arc::ptr_eq(&provider, &serve.identity_provider));
|
||||
assert!(Arc::ptr_eq(&keys, &serve.host_key_source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_config_env_and_cwd_are_assembly_supplied_not_wire_derived() {
|
||||
let mut serve = ServeConfig::new(Arc::new(NoopIdentityProvider), host_key_source());
|
||||
|
||||
serve.session_cwd = Some(std::path::PathBuf::from("/srv/git"));
|
||||
serve
|
||||
.session_env
|
||||
.insert("GIT_DIR".to_string(), "/srv/git".to_string());
|
||||
|
||||
assert_eq!(
|
||||
serve.session_cwd.as_deref(),
|
||||
Some(std::path::Path::new("/srv/git"))
|
||||
);
|
||||
assert_eq!(
|
||||
serve.session_env.get("GIT_DIR").map(String::as_str),
|
||||
Some("/srv/git")
|
||||
);
|
||||
assert_eq!(serve.session_env.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dial_config_defaults_carry_the_keepalive_posture() {
|
||||
let dial = DialConfig::new("runner.example:22", key_source(), Arc::new(RejectAll));
|
||||
|
||||
assert_eq!(dial.address, "runner.example:22");
|
||||
assert_eq!(dial.limits, Limits::default());
|
||||
assert_eq!(
|
||||
dial.limits.keepalive_interval,
|
||||
Some(std::time::Duration::from_secs(15))
|
||||
);
|
||||
assert_eq!(dial.limits.keepalive_max, 3);
|
||||
assert!(dial.limits.inactivity_timeout > std::time::Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dial_config_holds_the_injected_handles() {
|
||||
let keys = key_source();
|
||||
let policy: Arc<dyn crate::host_key::HostKeyPolicy> = Arc::new(RejectAll);
|
||||
|
||||
let dial = DialConfig::new("fleet.example:22", Arc::clone(&keys), Arc::clone(&policy));
|
||||
|
||||
assert!(Arc::ptr_eq(&keys, &dial.key_source));
|
||||
assert!(Arc::ptr_eq(&policy, &dial.host_key_policy));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dial_config_key_source_stays_fail_closed_through_the_config() {
|
||||
let dial = DialConfig::new("fleet.example:22", key_source(), Arc::new(RejectAll));
|
||||
|
||||
let err = dial.key_source.load().await.expect_err("no key source");
|
||||
assert!(matches!(err, KeySourceError::Absent));
|
||||
|
||||
let policy = dial.host_key_policy.as_ref();
|
||||
assert!(
|
||||
!policy
|
||||
.decide(&crate::host_key::PresentedHostKey {
|
||||
host: "fleet.example".into(),
|
||||
port: 22,
|
||||
fingerprint: "SHA256:anything".into(),
|
||||
})
|
||||
.await
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,13 @@
|
||||
//! the dependency surface and feature graph land with implementation
|
||||
//! start). See also `docs/research/` and family ADR-003.
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod fingerprint;
|
||||
mod host_key;
|
||||
mod key_source;
|
||||
|
||||
pub use config::{DialConfig, Limits, ServeConfig};
|
||||
pub use error::KeySourceError;
|
||||
pub use fingerprint::fingerprint;
|
||||
pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w1-config
|
||||
name: Wave 1 — the config surface (serve-config, dial-config, Limits)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy]
|
||||
scope: moderate
|
||||
risk: low
|
||||
@@ -48,19 +48,19 @@ validation.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Serve-config carries the OQ-08 fields as `Option` with
|
||||
- [x] Serve-config carries the OQ-08 fields as `Option` with
|
||||
unset-declines doc contracts; no crate default shell/wrapper
|
||||
- [ ] `IdentityProvider`, host-key source, and `ChannelClient` are
|
||||
- [x] `IdentityProvider`, host-key source, and `ChannelClient` are
|
||||
injected handles; the crate holds no identity/trust policy of
|
||||
its own
|
||||
- [ ] Dial-config carries key source + `HostKeyPolicy` + the
|
||||
- [x] Dial-config carries key source + `HostKeyPolicy` + the
|
||||
keepalive/inactivity/window knobs with the documented defaults
|
||||
- [ ] `Limits` carries the in-crate caps (channels, exec-parse) and
|
||||
- [x] `Limits` carries the in-crate caps (channels, exec-parse) and
|
||||
the russh knob defaults (`max_auth_attempts` 10, keepalive
|
||||
15 s / 3 client posture)
|
||||
- [ ] Unit tests cover default construction and the `Option`-field
|
||||
- [x] Unit tests cover default construction and the `Option`-field
|
||||
semantics
|
||||
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo fmt --check` pass
|
||||
|
||||
## References
|
||||
@@ -76,8 +76,81 @@ validation.
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
> Landed `src/config.rs` (`ServeConfig`, `DialConfig`, `Limits`,
|
||||
> four posture constants) + lib.rs re-exports. Design decisions
|
||||
> pinned against the docs and the alkcall/russh 0.65 source:
|
||||
>
|
||||
> - **`Limits` is the single budget model both directions carry**
|
||||
> (`Copy`, plain tuning data): the in-crate caps
|
||||
> (`max_concurrent_channels` 64, `max_exec_command_len` 8192 —
|
||||
> numbers chosen here, documented as assembly-tunable posture
|
||||
> constants) plus the russh-knob mapping (`max_auth_attempts` 10
|
||||
> per ADR-004 decision 6; `window_size` 2 MiB / `maximum_packet_size`
|
||||
> 32 KiB / `channel_buffer_size` 100 — russh's own defaults, kept;
|
||||
> `inactivity_timeout` 600 s carried as plain `Duration` so the
|
||||
> "set (assembly-tunable)" posture is structural — wave 2/4 map it
|
||||
> to russh's `Option` as always-`Some`;
|
||||
> `keepalive_interval`/`keepalive_max` Some(15 s)/3 riding inside as
|
||||
> the client-direction posture, satisfying the dial-config
|
||||
> criterion without a duplicate knob set). russh's rekey `Limits`
|
||||
> stay russh-enforced at their RFC-4253 defaults and are not
|
||||
> carried (documented; wave-2 material).
|
||||
> - **Dial-config carries the knobs via its `Limits` field** — the
|
||||
> criterion's "keepalive/inactivity/window knobs with the
|
||||
> documented defaults" read as the budget model riding the config;
|
||||
> the accepted criterion on `Limits` itself pins that placement,
|
||||
> so a second knob set on `DialConfig` would have duplicated state.
|
||||
> - **No `Default` for the two configs** — the required handles
|
||||
> (`Arc<dyn IdentityProvider>`,
|
||||
> `Arc<dyn HostKeySource>`, `Arc<dyn KeySource>`,
|
||||
> `Arc<dyn HostKeyPolicy>`) have no crate-defaultable shape
|
||||
> (identity resolution and trust are injection-only, per the
|
||||
> criterion); `ServeConfig::new`/`DialConfig::new` take them, and
|
||||
> every tunable is a `pub` field + `Limits::default()`.
|
||||
> - **The OQ-08 fields are plain `Option<Vec<String>>` `pub` fields
|
||||
> + doc contracts**; no construction-time validation (the
|
||||
> `error.rs` no-config-error contract). Two request-time check
|
||||
> helpers exist (`shell_configured`/`exec_wrapper_configured`,
|
||||
> plus `tunnel_configured`) for the wave-2 dispatch to call —
|
||||
> reading the `Option`, never validating.
|
||||
> - **`cwd`/`env` (OQ-09 posture) carried on `ServeConfig`** as
|
||||
> `Option<PathBuf>` / `BTreeMap<String, String>` (empty defaults;
|
||||
> never wire-derived — doc contract).
|
||||
> - **`channel_client` is not feature-gated** — alkcall is a base
|
||||
> dependency and a field presence is not a capability; the `tunnel`
|
||||
> feature gate applies at wave 2 on top (absent → decline even
|
||||
> under the feature; unfeatured → decline too). Doc contract
|
||||
> records both gates.
|
||||
> - Verified the alkcall 0.10 surfaces in-source (registry):
|
||||
> `core::auth::IdentityProvider` is a sync,
|
||||
> `Send + Sync + 'static` trait (dyn-compatible with no
|
||||
> async-trait help on alkssh's side — `ChannelClient` and the
|
||||
> `NoopIdentityProvider` test fixture ride
|
||||
> `alkcall::channels::client` / `alkcall::core::auth`), and the
|
||||
> russh 0.65.0 knob names (`window_size`, `maximum_packet_size`,
|
||||
> `channel_buffer_size`, `inactivity_timeout`,
|
||||
> `keepalive_interval`, `keepalive_max`, `max_auth_attempts`) match
|
||||
> this module's fields one-for-one.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
> Config surface landed: `src/config.rs` with `Limits` (in-crate
|
||||
> caps: `max_concurrent_channels` 64, `max_exec_command_len` 8192;
|
||||
> russh knobs: `max_auth_attempts` 10, window 2 MiB, packet 32 KiB,
|
||||
> buffer 100, inactivity 600 s, keepalive Some(15 s)/3 — every number
|
||||
> a named posture constant or documented ADR anchor), `ServeConfig`
|
||||
> (OQ-08 `shell_argv`/`exec_wrapper_argv` as unset-declines
|
||||
> `Option<Vec<String>>`, OQ-09 `session_cwd`/`session_env`,
|
||||
> injected `IdentityProvider` + `HostKeySource` + optional
|
||||
> `ChannelClient`, `limits`), and `DialConfig` (address,
|
||||
> injected `KeySource` + `HostKeyPolicy`, `limits` with the
|
||||
> keepalive/inactivity/window posture riding inside). No
|
||||
> construction-time validation, no crate defaults for the unset
|
||||
> rows, no `Default` for the configs (handles only). Tests: defaults
|
||||
> carry the documented postures; `Option` fields flip independently;
|
||||
> handles hold by `Arc::ptr_eq`; the dial's key source and policy
|
||||
> stay fail-closed through the config. Verified: `cargo test` (36),
|
||||
> `cargo test --all-features`, `cargo clippy --all-targets
|
||||
> -- -D warnings`, `cargo clippy --all-features --all-targets
|
||||
> -- -D warnings`, `cargo fmt --check` all pass; `taskgraph
|
||||
> validate` clean.
|
||||
Reference in new issue
Block a user