crates/gunnar-ssh/src/config.rs
//! 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 git transfer may move no bytes while it waits on the client**
/// before it is closed and its slot given back (GUN-SSH-SLOT-HANG,
/// [`crate::idle`]). `None` turns it off. Default [`DEFAULT_TRANSFER_IDLE`].
pub transfer_idle_timeout: 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;
/// **A git transfer moving no bytes for this long, while it waits on its client, is
/// closed** (GUN-SSH-SLOT-HANG): 180 s. Far above any pause a working client makes
/// between two pkt-lines (git-lfs's pool between two objects, a push between its
/// command list and its pack), far below the 35 minutes a stuck pool held slots for.
pub const DEFAULT_TRANSFER_IDLE: Duration = Duration::from_secs(180);
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),
transfer_idle_timeout: Some(DEFAULT_TRANSFER_IDLE),
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
);
}
}