gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
//! Listener configuration (SSH and gRPC are separate listeners).

use std::net::SocketAddr;
use std::time::Duration;

/// How the SSH listener behaves.
///
/// The defaults are the appliance's: bind everywhere on 2222, publickey only,
/// and three separate ways for a connection that is going nowhere to be closed.
#[derive(Debug, Clone)]
pub struct SshConfig {
    /// Where to listen. Unlike the gRPC control plane — which defaults to
    /// loopback because it is unauthenticated in v1 — this
    /// listener authenticates every connection with a public key, so binding it
    /// publicly is the intended configuration.
    pub bind: SocketAddr,

    /// The `SSH-2.0-…` identification string.
    pub server_id: String,

    /// Close a connection that has sent nothing for this long. Covers the
    /// client that completes a handshake and then holds the socket open.
    pub inactivity_timeout: Option<Duration>,

    /// How long an authenticated session may sit without asking for a git
    /// command before it is disconnected.
    ///
    /// This is the leak the inactivity timeout does *not* catch: a client that
    /// authenticates, opens a session channel, and then answers keepalives
    /// forever is never idle. Sixty seconds is far longer than any real client
    /// takes between authenticating and sending its exec request.
    pub exec_grace: Option<Duration>,

    /// Constant-time rejection delay, so a wrong key cannot be distinguished
    /// from a slow lookup.
    pub auth_rejection_time: Duration,

    /// How many authentication attempts one connection may make.
    pub max_auth_attempts: usize,

    /// Send a keepalive if nothing has arrived for this long.
    pub keepalive_interval: Option<Duration>,

    /// How many git transfers may be in flight across the whole server at once.
    /// `None` turns the limit off.
    ///
    /// Before this existed the ceiling was `tokio`'s default of 512 blocking
    /// threads, one per transfer, and going past it queued **silently**. This
    /// ceiling is the same kind of thing, except that somebody chose the number
    /// and both the client and the operator get told when it bites
    /// ([`crate::AdmissionStats`]).
    ///
    /// The default is deliberately **below** the blocking-thread pool it is
    /// protecting: see `gunnar_cli::server::serve::GIT_BLOCKING_THREADS`, whose
    /// own test asserts the relationship rather than leaving it to a comment.
    pub max_concurrent_transfers: Option<usize>,

    /// Whether an unauthenticated caller may read **public** repositories
    /// `true` by default.
    ///
    /// Turning it off closes the anonymous door at the listener, before any
    /// repository is resolved and before the ACL is consulted, so no
    /// `Visibility` setting can reopen it. That is the switch for an appliance
    /// that must be able to state "this box has never served a stranger"; it is
    /// **not** what protects private repositories, which are protected by the
    /// ACL and are unreachable anonymously either way.
    ///
    /// The default is `true` because [`Visibility::Private`] is the default for
    /// a repository: nothing is anonymously readable until someone deliberately
    /// marks it public. See
    /// [`SshConfig::without_anonymous_access`].
    ///
    /// [`Visibility::Private`]: gunnar_core::Visibility::Private
    pub allow_anonymous: bool,

    /// How many git transfers one principal is **guaranteed** at once, and its
    /// share of a contended server. `None` turns the limit off.
    ///
    /// The server-wide limit alone is exhaustible by a single client, which
    /// would then be denying service to everybody else; a fork-bomb of clones
    /// is exactly the stated threat. The limit is per **principal**, not
    /// per IP, because gunnar authenticates keys: a per-IP cap would throttle a
    /// whole office behind one NAT and miss a botnet entirely.
    ///
    /// It is a **floor, not a wall.** Until 2026-08-03 it bound
    /// unconditionally, and measured on a 32-core desktop that meant one
    /// principal with 64 clients got 32 served and 32 refused against 224 idle slots: the
    /// advertised 256 needed eight separate identities and no single-team
    /// appliance could ever reach it. Now the number is what a principal always
    /// gets and what it is held to once the server is contended; below that the
    /// server-wide limit is the only ceiling. [`crate::admission`] has the rule
    /// and the measurements.
    pub max_transfers_per_principal: Option<usize>,

    /// How many git reads may be served **without a key** at once.
    /// `None` lets anonymous take an ordinary principal's max-min share.
    ///
    /// The companion to [`Self::allow_anonymous`], and the reason it needs one:
    /// that flag decides *whether* the door is open, and this decides *how wide*.
    /// Without it, the open-by-default anonymous door inherits the per-principal share —
    /// which is work-conserving on purpose, so on an idle appliance a stranger's
    /// ceiling is the entire server ([`crate::admission`] has the rule and why it
    /// is right for a keyed identity and wrong for this one).
    ///
    /// The default is [`DEFAULT_MAX_ANONYMOUS_TRANSFERS`] (5), which is under
    /// 2 % of the default server-wide 256 and one sixth of what a single keyed
    /// identity is *guaranteed*. That asymmetry is the point: an operator must
    /// never be made to wait behind traffic gunnar cannot name.
    ///
    /// [`DEFAULT_MAX_ANONYMOUS_TRANSFERS`]: crate::admission::DEFAULT_MAX_ANONYMOUS_TRANSFERS
    pub max_anonymous_transfers: Option<usize>,

    /// **The usage share's settings** (`admit-share`): the rolling-window
    /// byte budget a namespace moves at the ordinary per-principal share, and
    /// past which it is held to the anonymous ceiling. `limit: None`
    /// (`--admit-limit none`) is PassThrough — the gate decides exactly as a
    /// build without the feature. Defaults: 100 GB per 30 days.
    #[cfg(feature = "admit-share")]
    pub admit: edda_admit::Settings,

    /// Make [`Self::max_transfers_per_principal`] an **unconditional** ceiling
    /// again, contention or not.
    ///
    /// `false` by default, because the appliance case is one team behind a
    /// handful of keys and a wall there only throttles the owner. Set it on a
    /// host where an identity is a *tenant* and the cap is the thing being
    /// sold: there, letting a tenant use an idle server is giving away what was
    /// metered.
    pub per_principal_limit_is_hard: bool,

    /// The **memory budget** the server is allowed to hold for git operations
    /// in flight, in bytes (gate 2 of [`crate::governor`]).
    ///
    /// A reservation gunnar owns, never a measurement of the machine. By the
    /// OS-less law this crate may not read `/proc`, `sysinfo` or `getrusage`, so
    /// it cannot size itself to the box and does not pretend to: this is the
    /// number an **operator** sets, and the default
    /// ([`DEFAULT_MEMORY_BUDGET_BYTES`](crate::governor::DEFAULT_MEMORY_BUDGET_BYTES),
    /// 8 GiB) is the cgroup the bakeoff measurement was taken in, not a fit to
    /// this host. **Lower it on a small machine.**
    ///
    /// `None` only where [`Self::max_concurrent_transfers`] is also `None`: an
    /// unlimited gate is unlimited in both dimensions.
    pub memory_budget_bytes: Option<u64>,

    /// How long a transfer may **wait** for a slot before it is refused
    /// ([`crate::governor::DEFAULT_ADMISSION_WAIT`]).
    ///
    /// `Some(Duration::ZERO)` restores the pre-2026-08-30 gate exactly — refuse
    /// on the spot — and is the arm the before/after table in
    /// the published benchmark numbers were measured against. `None` takes
    /// the default.
    ///
    /// It is a *bound*, not a queue depth: nothing is enqueued, the waiting
    /// session is one parked future, and the wait is counted
    /// ([`crate::AdmissionStats::peak_wait_ms`]) and is the CoDel controller's
    /// input. See [`crate::governor::DEFAULT_ADMISSION_WAIT`] for why refusing
    /// a client that will reconnect costs more than parking it.
    pub admission_wait: Option<Duration>,
    /// How long an **authenticated** caller may be queued for a slot — and,
    /// after a restart, for the stores to open — before it is refused.
    /// `None`: keyed callers get [`SshConfig::admission_wait`] like everybody.
    /// See [`crate::governor::DEFAULT_KEYED_ADMISSION_WAIT`].
    pub keyed_admission_wait: Option<Duration>,
    /// How long a transfer whose session ended is given to end by itself
    /// before it is told to stop ([`crate::channel::SESSION_END_GRACE`]).
    pub session_end_grace: Duration,
    /// The same grace when others wait for the transfer's fan
    /// ([`crate::channel::CROWDED_SESSION_END_GRACE`]): a worker a hung-up
    /// client holds is one a queued client does not get (stinky row 19).
    pub session_end_grace_crowded: Duration,
}

/// The port the appliance listens on by default. Port 22 usually belongs to
/// something else on a developer machine, and gunnar never asks for privileges
/// it does not need.
pub const DEFAULT_PORT: u16 = 2222;

/// The default server-wide concurrent-transfer ceiling.
///
/// **Chosen, not inherited.** Each in-flight transfer costs one blocking thread
/// (`SyncIoBridge`), one open repository handle, and roughly one core's worth of
/// work while it is packing. Measurement 3
/// shows a 32-thread box already at 63% efficiency, so admitting far more
/// concurrent transfers than the machine has cores buys queueing, not
/// throughput. 256 leaves room for a burst on a 32–64 core appliance while
/// staying well under the blocking pool that carries them.
pub const DEFAULT_MAX_CONCURRENT_TRANSFERS: usize = 256;

/// The default per-principal concurrent-transfer **floor and fair share**.
///
/// One eighth of the server-wide limit: the number any identity is guaranteed
/// however busy the box is, and the share it is held to once the box is
/// contended by eight or more of them.
///
/// It used to be a hard ceiling, and that made it the number a single-key
/// client actually got — measured on a 32-core desktop 2026-08-03, one
/// principal with 64 clients was served 32 and refused 32 while 224 slots sat idle. It is now a
/// floor; see [`crate::admission`] for the rule and
/// [`SshConfig::per_principal_limit_is_hard`] for pinning it back into a wall.
pub const DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL: usize = 32;

impl Default for SshConfig {
    fn default() -> Self {
        SshConfig {
            bind: SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT)),
            server_id: format!("SSH-2.0-gunnar_{}", env!("CARGO_PKG_VERSION")),
            inactivity_timeout: Some(Duration::from_secs(600)),
            exec_grace: Some(Duration::from_secs(60)),
            auth_rejection_time: Duration::from_secs(1),
            max_auth_attempts: 6,
            // 15 s, not 60 (2026-10-06): with russh's keepalive_max of 3 a
            // client whose link died is called dead in 60 s, not 4 minutes —
            // and its transfer, its fan worker and its admission slot are
            // released then (see `channel::SessionBound`).
            keepalive_interval: Some(Duration::from_secs(15)),
            allow_anonymous: true,
            max_concurrent_transfers: Some(DEFAULT_MAX_CONCURRENT_TRANSFERS),
            max_transfers_per_principal: Some(DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL),
            max_anonymous_transfers: Some(crate::admission::DEFAULT_MAX_ANONYMOUS_TRANSFERS),
            per_principal_limit_is_hard: false,
            #[cfg(feature = "admit-share")]
            admit: edda_admit::Settings::default(),
            memory_budget_bytes: Some(crate::governor::DEFAULT_MEMORY_BUDGET_BYTES),
            admission_wait: Some(crate::governor::DEFAULT_ADMISSION_WAIT),
            keyed_admission_wait: Some(crate::governor::DEFAULT_KEYED_ADMISSION_WAIT),
            session_end_grace: crate::channel::SESSION_END_GRACE,
            session_end_grace_crowded: crate::channel::CROWDED_SESSION_END_GRACE,
        }
    }
}

impl SshConfig {
    /// A configuration bound to an ephemeral loopback port, for tests.
    ///
    /// The timeouts are shortened so a test that waits for one finishes in
    /// under a second. It is a constructor, not a mode: nothing here changes
    /// what is authenticated or what is dispatched.
    pub fn ephemeral_loopback() -> Self {
        SshConfig {
            bind: SocketAddr::from(([127, 0, 0, 1], 0)),
            inactivity_timeout: Some(Duration::from_secs(20)),
            exec_grace: Some(Duration::from_secs(10)),
            auth_rejection_time: Duration::from_millis(50),
            // A TIMEOUT, shortened like the three above it — not an admission
            // limit. `the_default_admission_limits_are_a_decision_and_are_ordered`
            // holds the line that this constructor may not relax what is
            // *admitted*; the bound on how long a refusal takes to arrive is a
            // wait, and a test that spent two seconds per refusal would measure
            // the clock rather than the gate.
            admission_wait: Some(Duration::from_millis(100)),
            // The same reasoning for the keyed bound: a test that wants it
            // sets it (`tests/keyed_queue.rs`).
            keyed_admission_wait: None,
            ..SshConfig::default()
        }
    }

    /// The same configuration with the anonymous read path closed.
    ///
    /// After this, a caller with no key cannot open a session at all, not even
    /// to a repository marked [`Visibility::Public`](gunnar_core::Visibility).
    pub fn without_anonymous_access(mut self) -> Self {
        self.allow_anonymous = false;
        self
    }
}

/// **The SSH username that asks for the anonymous path**, and the only thing
/// gunnar has ever read a username for.
///
/// Everywhere else the username is decorative (identity comes from the key,
/// the ACL model) and it stays decorative here, because this name selects an
/// *authentication method*, not an authorisation outcome. See
/// `gunnar_ssh::server`'s module docs for why the choice was forced and why it
/// cannot be a bypass:
///
/// * `ssh://anonymous@host/ns/repo.git` → the `none` method is accepted and the
///   session is [`Principal::anonymous`](gunnar_core::Principal::anonymous),
///   which holds nothing and can only read a public repository;
/// * every other username → `none` is refused exactly as it was before this
///   existed, and the client falls through to publickey.
///
/// A user who spells this name gets strictly *less* than one who does not, and
/// a user who does not spell it cannot be silently downgraded into it.
pub const ANONYMOUS_SSH_USER: &str = "anonymous";

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_default_admission_limits_are_a_decision_and_are_ordered() {
        let config = SshConfig::default();
        let server_wide = config
            .max_concurrent_transfers
            .expect("the appliance default must HAVE a limit; the defect was that it had none");
        let per_principal = config
            .max_transfers_per_principal
            .expect("the per-principal default must exist too");
        assert!(
            per_principal < server_wide,
            "a per-principal limit of {per_principal} against a server-wide {server_wide} means \
             one principal can take the whole server, which is the half of the defect the global \
             limit does not answer"
        );
    }

    #[test]
    fn the_test_constructor_does_not_quietly_change_the_limits() {
        // `ephemeral_loopback` shortens timeouts. If it also relaxed admission,
        // every integration test would be running a configuration no appliance
        // has, and the gate would be untested where it matters.
        let ephemeral = SshConfig::ephemeral_loopback();
        let default = SshConfig::default();
        assert_eq!(
            ephemeral.max_concurrent_transfers,
            default.max_concurrent_transfers
        );
        assert_eq!(
            ephemeral.max_transfers_per_principal,
            default.max_transfers_per_principal
        );
        assert_eq!(
            ephemeral.allow_anonymous, default.allow_anonymous,
            "the test constructor must not quietly open or close the anonymous path, or every \
             integration test would be exercising a configuration no appliance has"
        );
    }

    #[test]
    fn anonymous_read_is_on_by_default_and_can_be_switched_off() {
        // Both directions. A `without_anonymous_access` that did nothing would
        // be invisible without the first assertion, and a default of `false`
        // would make `Repo.visibility` mean nothing again without the second.
        assert!(
            SshConfig::default().allow_anonymous,
            "a public repository must be readable without a key by default"
        );
        assert!(
            !SshConfig::default()
                .without_anonymous_access()
                .allow_anonymous
        );
    }
}