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
//! gunnar's **SSH server**: the half of git-over-SSH that does not exist
//! anywhere else in the constellation (the reuse rule).
//!
//! The client half is `nornir-git`, published and proven against codeberg and
//! GitHub. This crate is the other end of the same wire: a [`russh`] server
//! that authenticates a *key*, parses exactly two commands, resolves a
//! repository inside a sandbox, checks a grant, and hands the channel's byte
//! stream to the protocol state machines.
//!
//! ```text
//!   TCP :2222
//!     └── russh server            ── server::GunnarSsh
//!          ├── auth_publickey     ── access::fingerprint_of_key
//!          │                         → gunnar_core::AuthTable   (control::Control)
//!          ├── exec_request       ── command::parse → command::unquote
//!          │                         → gunnar_core::resolve_repo_path
//!          │                         → gunnar_core::RepoCatalog → grant check
//!          └── channel stream     ── seam::GitTransport
//! ```
//!
//! One deliberate asymmetry in that resolve: a `git-receive-pack` for a
//! repository that does not exist, from a principal the control plane already
//! lets create repositories there, creates it — the **first-repository door**
//! that makes an installed `--no-grpc` appliance administrable with nothing
//! but the admin key and stock git. See [`Control::create_for_push`] for the
//! whole argument; for every other key the not-found refusal is unchanged.
//!
//! # What this crate deliberately does NOT do
//!
//! * **No shell. Ever.** `shell_request`, `subsystem_request`, `pty_request`
//!   and `x11_request` are all answered with a channel failure (see
//!   [`server`]). The only thing that can be executed is one of the verbs in
//!   [`command::SERVICES`], and there is no `exec` path to a process at all:
//!   the "command" selects an in-process state machine.
//! * **No environment, bar one allowlisted name.** `env_request` accepts
//!   `GIT_PROTOCOL` and refuses every other variable
//!   ([`server::ENV_ALLOWLIST`]). It is how a client asks for protocol v2, and
//!   it is the only thing a client can set that the server remembers. Nothing
//!   is exported to a process — there is no process — and it is attached to
//!   [`ServiceRequest`] *after* the grant check, so it can never reach an
//!   authorisation decision.
//! * **No filesystem.** [`gunnar_core::resolve_repo_path`] parses the exec
//!   argument into a validated namespace and name, never into a `Path`. There
//!   is no path traversal because there is no path. That is the strongest
//!   available form of the ACL sandbox, and a fix by construction rather than a
//!   blocklist (one implementation).
//! * **No `ssh-keygen`.** The host key is minted in-process and persisted
//!   through [`gunnar_store::SecretStore`]. See
//!   [`gunnar_core::host_key::ensure_host_key`].
//! * **No OS users.** The SSH *username* is decorative. `git@host:ns/repo.git`
//!   works and `git` means nothing; identity comes from the offered key's
//!   SHA-256 fingerprint (the ACL model).
//! * **No unbounded concurrency, and no invisible one.** [`admission`] caps how
//!   many git transfers exist at once, server-wide and per principal, and
//!   *refuses* over the limit with a message the client can read and a counter
//!   an operator can see ([`AdmissionStats`]). Before it, the ceiling was
//!   `tokio`'s default of 512 blocking threads, a number nobody chose, and past
//!   it transfers queued in silence.
//! * **No unattributable line.** Every connection opens a `ssh.connection`
//!   span carrying a [`RequestId`], the peer, and — as they become known — the
//!   principal and the repository ([`server`]). A transfer runs in a
//!   `git.transfer` span beneath it, and `gunnar-cli`'s transport and
//!   `gunnar-policy`'s gate emit *underneath that*, so a whole clone is
//!   greppable by `conn=<id>` and no call site repeats a field the span already
//!   carries (one implementation). [`ServerMetrics`] holds the counters; it is one
//!   block of atomics, shared with the transport, and no lock is taken to
//!   record one.
//! * **No second copy of the security model.** See below.
//!
//! # ⚠ ONE sandbox, ONE fingerprint
//!
//! This crate and `gunnar-core` were written concurrently and **each grew its
//! own** path sandbox, key fingerprint, host-key loader and auth lookup. That
//! is the twinning one implementation forbids, and it was worse than it looks: the two
//! sandboxes **disagreed**. `gunnar_ssh::path` enforced exactly two path
//! components; `gunnar_core::sandbox` permits nested namespaces. Two security
//! checks that disagree are worse than either alone, because the strict one
//! gives false confidence about the permissive one.
//!
//! This crate now depends on `gunnar-core` and its copies are **deleted**:
//!
//! | was here | is now |
//! |---|---|
//! | `path::RepoPath` | [`gunnar_core::resolve_repo_path`] → [`gunnar_core::RepoRef`] |
//! | `access::KeyFingerprint` | [`gunnar_core::fingerprint_of_blob`] → [`gunnar_core::Fingerprint`] |
//! | `hostkey::load_or_create_host_key` | [`gunnar_core::host_key::ensure_host_key`] |
//! | `seam::AuthDirectory`, `seam::RepoResolver` | [`gunnar_core::AuthTable`], [`gunnar_core::RepoCatalog`], behind [`control::Control`] |
//! | `access::{Access, Principal, PrincipalKind}` | re-exports of `gunnar_core::{Level, Principal, PrincipalKind}` |
//!
//! **The permissive shape is the surviving one**: nested namespaces
//! are legal (`team/sub/repo.git` → namespace `team/sub`), and the escalation
//! that would otherwise carry is closed by grants **not** inheriting: `team`
//! does not imply `team/sub`. That rule is what makes the merge safe, and it is
//! pinned by `gunnar-core`'s `tests/acl.rs`.
//!
//! **What stayed here:** [`command::unquote`]. git sends one argv element with
//! its quotes inside it and gunnar has no shell to strip them, but that is a
//! transport fact; the sandbox is handed a bare string and does not know SSH
//! exists (the one-sandbox rule).
//!
//! # ⚠ SEAMS: what is stubbed, and how to wire it
//!
//! One is left. `gunnar-wire` (the protocol state machines of the git wire protocol) is still
//! being written, so [`seam::GitTransport`] describes exactly what an SSH front
//! end needs from it and nothing more. It is a **seam, not a model**, and must
//! not become a second source of truth for anything `gunnar-core` owns (one implementation).
//! [`seam::BannerTransport`] is a test double, not a backend.
//!
//! # ⚠ A dependency fact that contradicts the spec's dependency table
//!
//! The dependency budget lists `russh` **and** `ssh-key` as server dependencies, and the
//! workspace pins `ssh-key = "0.6"`. **russh 0.61.2 pins
//! `ssh-key = "=0.7.0-rc.10"`**, an exact, semver-incompatible requirement.
//! `russh::server::Handler::auth_publickey` takes `&ssh_key::PublicKey` from
//! *that* copy, so the workspace's `ssh-key 0.6` types can never be handed to
//! russh. This crate therefore names no `ssh-key` dependency of its own and
//! uses [`russh::keys::ssh_key`] throughout.
//!
//! Depending on `gunnar-core` does **not** change that: both copies are in the
//! graph, as they always were, and nothing here ever names an `ssh_key 0.6`
//! type. The fingerprint crosses the boundary as **bytes**:
//! [`access::fingerprint_of_key`] takes russh's key, calls `to_bytes()`, and
//! hands the wire blob to [`gunnar_core::fingerprint_of_blob`]. The workspace was
//! deliberately not bumped to a release candidate to paper over the split.
//!
//! **Since the RustCrypto bump (2026-10-03)** the workspace DOES pin the release
//! candidate russh 0.63 rides (`ssh-key = "=0.7.0-rc.11"`, an exact pin until a
//! stable 0.7 ships), so there is one copy and one `rand_core` (0.10); the
//! twenty-line rand_core bridge this crate carried (`rng`) is gone and keys are
//! minted from [`gunnar_core::os_rng`]. The fingerprint still crosses as bytes,
//! so the next russh that moves its pin splits nothing.
//!
//! # ⚠ russh REQUIRES a C crypto backend, so the dependency budget cannot hold as written
//!
//! The dependency budget forbids "any C toolchain requirement" anywhere in the build graph.
//! **russh 0.61.2 cannot satisfy that.** `russh/src/lib.rs:90` is a
//! `compile_error!` reading *"`russh` requires enabling either the `ring` or
//! `aws-lc-rs` feature as a crypto backend"*, and both of those are C/assembly
//! libraries with a build script. With russh's default features the graph
//! carries `aws-lc-rs` → `aws-lc-sys`, which builds C with `cmake`.
//!
//! Measured 2026-08-03 on this crate's `-e normal` tree: **196 distinct crates**,
//! of which `aws-lc-rs` and `aws-lc-sys` are the C ones. There is no rayon, no
//! openssl, no `git2`.
//!
//! This cannot be fixed from inside `gunnar-ssh`, because a member crate may not
//! turn off a workspace dependency's default features. The choice is the
//! workspace's, and it is a real choice with no clean option:
//!
//! * `russh = { version = "0.61", default-features = false, features = ["ring", "flate2"] }`
//!   swaps `aws-lc-rs` for `ring`, which is smaller but still C and assembly,
//! * keep `aws-lc-rs` and amend the dependency budget to say "no *unvendored* C", since both
//!   backends vendor their sources and need no system library,
//! * drop russh, which would mean writing an SSH transport, and `nornir-git`
//!   uses russh on the client side already (the reuse rule), so it would have to go too.
//!
//! Recorded here rather than decided here.

#![warn(missing_docs)]
#![warn(clippy::all)]
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

pub mod access;
pub mod admission;
pub mod channel;
pub mod command;
pub mod config;
pub mod control;
pub mod governor;
pub mod headroom;
pub mod pipeline;
pub mod plan;
pub mod seam;
pub mod server;
pub mod space;

pub use access::{fingerprint_of_key, Access, Fingerprint, Principal, PrincipalKind};
pub use admission::{Admission, AdmissionGuard, AdmissionStats, Pause, Refusal, GROWING_MESSAGE};
pub use command::{parse, CommandError, GitCommand, GitService, ServiceSpec, SERVICES};
pub use config::{SshConfig, ANONYMOUS_SSH_USER};
pub use control::{AnonymousPolicy, Control};
/// The shared admission trait the gate asks (`admit-share`) and
/// `Usage.AdmitPlan` reads — re-exported so the server names one version of it.
pub use edda_admit;
pub use governor::{Codel, Governor, LimitBand, MemoryLedger, OpClass};
pub use headroom::{DiskFloor, DiskHeadroomGate, Headroom, AT_FLOOR};
pub use plan::{PackCeiling, PlanAction, PlanGate, PlanVerdict};
pub use seam::{Delivery, DuplexStream, GitTransport, ServiceRequest, TransportError};
pub use server::{
    env_refusal_is_routine, BoundServer, Dispatch, DispatchError, GunnarSsh, ENV_ALLOWLIST,
    ENV_GIT_PROTOCOL, ENV_ROUTINE, EXIT_BUSY, EXIT_DENIED, EXIT_TRANSPORT_FAILED, STARTING_MESSAGE,
};
pub use server::{
    parse_recipient_keys_verb, parse_repo_verb, parse_tunnel_address_verb, recipient_keys_answer,
    user_key_answer, RepoAction, RepoAsk, RepoDoor, TunnelAddressDoor, FIRE_DRILL_VERB,
    LFS_AUTHENTICATE_MESSAGE, LFS_AUTHENTICATE_VERB, RECIPIENT_KEYS_VERB, REPO_VERB,
    TUNNEL_ADDRESS_VERB, WHOAMI_VERB,
};
pub use space::{SpaceHold, SpaceLedger, SpaceProbe, FULL_NOW, PUSH_COPIES};

// The one sandbox, the one host key. Re-exported so a caller does not
// have to know which crate they came from, but there is no second definition
// here; these ARE `gunnar-core`'s.
pub use gunnar_core::observe::{MetricsSnapshot, RequestId, ServerMetrics};
pub use gunnar_core::{self, PathReject};
pub use gunnar_core::{host_key::ensure_host_key, resolve_repo_path, HostKey, RepoRef};

// Re-exported so a downstream crate can name the key types russh hands over
// without having to work out for itself which `ssh-key` russh actually links
// (see the note above; it is not the workspace one).
pub use russh;
pub use russh::keys::ssh_key;