Skip to main content

gunnar_ssh/
lib.rs

1//! gunnar's **SSH server**: the half of git-over-SSH that does not exist
2//! anywhere else in the constellation (the reuse rule).
3//!
4//! The client half is `nornir-git`, published and proven against codeberg and
5//! GitHub. This crate is the other end of the same wire: a [`russh`] server
6//! that authenticates a *key*, parses exactly two commands, resolves a
7//! repository inside a sandbox, checks a grant, and hands the channel's byte
8//! stream to the protocol state machines.
9//!
10//! ```text
11//!   TCP :2222
12//!     └── russh server            ── server::GunnarSsh
13//!          ├── auth_publickey     ── access::fingerprint_of_key
14//!          │                         → gunnar_core::AuthTable   (control::Control)
15//!          ├── exec_request       ── command::parse → command::unquote
16//!          │                         → gunnar_core::resolve_repo_path
17//!          │                         → gunnar_core::RepoCatalog → grant check
18//!          └── channel stream     ── seam::GitTransport
19//! ```
20//!
21//! One deliberate asymmetry in that resolve: a `git-receive-pack` for a
22//! repository that does not exist, from a principal the control plane already
23//! lets create repositories there, creates it — the **first-repository door**
24//! that makes an installed `--no-grpc` appliance administrable with nothing
25//! but the admin key and stock git. See [`Control::create_for_push`] for the
26//! whole argument; for every other key the not-found refusal is unchanged.
27//!
28//! # What this crate deliberately does NOT do
29//!
30//! * **No shell. Ever.** `shell_request`, `subsystem_request`, `pty_request`
31//!   and `x11_request` are all answered with a channel failure (see
32//!   [`server`]). The only thing that can be executed is one of the verbs in
33//!   [`command::SERVICES`], and there is no `exec` path to a process at all:
34//!   the "command" selects an in-process state machine.
35//! * **No environment, bar one allowlisted name.** `env_request` accepts
36//!   `GIT_PROTOCOL` and refuses every other variable
37//!   ([`server::ENV_ALLOWLIST`]). It is how a client asks for protocol v2, and
38//!   it is the only thing a client can set that the server remembers. Nothing
39//!   is exported to a process — there is no process — and it is attached to
40//!   [`ServiceRequest`] *after* the grant check, so it can never reach an
41//!   authorisation decision.
42//! * **No filesystem.** [`gunnar_core::resolve_repo_path`] parses the exec
43//!   argument into a validated namespace and name, never into a `Path`. There
44//!   is no path traversal because there is no path. That is the strongest
45//!   available form of the ACL sandbox, and a fix by construction rather than a
46//!   blocklist (one implementation).
47//! * **No `ssh-keygen`.** The host key is minted in-process and persisted
48//!   through [`gunnar_store::SecretStore`]. See
49//!   [`gunnar_core::host_key::ensure_host_key`].
50//! * **No OS users.** The SSH *username* is decorative. `git@host:ns/repo.git`
51//!   works and `git` means nothing; identity comes from the offered key's
52//!   SHA-256 fingerprint (the ACL model).
53//! * **No unbounded concurrency, and no invisible one.** [`admission`] caps how
54//!   many git transfers exist at once, server-wide and per principal, and
55//!   *refuses* over the limit with a message the client can read and a counter
56//!   an operator can see ([`AdmissionStats`]). Before it, the ceiling was
57//!   `tokio`'s default of 512 blocking threads, a number nobody chose, and past
58//!   it transfers queued in silence.
59//! * **No unattributable line.** Every connection opens a `ssh.connection`
60//!   span carrying a [`RequestId`], the peer, and — as they become known — the
61//!   principal and the repository ([`server`]). A transfer runs in a
62//!   `git.transfer` span beneath it, and `gunnar-cli`'s transport and
63//!   `gunnar-policy`'s gate emit *underneath that*, so a whole clone is
64//!   greppable by `conn=<id>` and no call site repeats a field the span already
65//!   carries (one implementation). [`ServerMetrics`] holds the counters; it is one
66//!   block of atomics, shared with the transport, and no lock is taken to
67//!   record one.
68//! * **No second copy of the security model.** See below.
69//!
70//! # ⚠ ONE sandbox, ONE fingerprint
71//!
72//! This crate and `gunnar-core` were written concurrently and **each grew its
73//! own** path sandbox, key fingerprint, host-key loader and auth lookup. That
74//! is the twinning one implementation forbids, and it was worse than it looks: the two
75//! sandboxes **disagreed**. `gunnar_ssh::path` enforced exactly two path
76//! components; `gunnar_core::sandbox` permits nested namespaces. Two security
77//! checks that disagree are worse than either alone, because the strict one
78//! gives false confidence about the permissive one.
79//!
80//! This crate now depends on `gunnar-core` and its copies are **deleted**:
81//!
82//! | was here | is now |
83//! |---|---|
84//! | `path::RepoPath` | [`gunnar_core::resolve_repo_path`] → [`gunnar_core::RepoRef`] |
85//! | `access::KeyFingerprint` | [`gunnar_core::fingerprint_of_blob`] → [`gunnar_core::Fingerprint`] |
86//! | `hostkey::load_or_create_host_key` | [`gunnar_core::host_key::ensure_host_key`] |
87//! | `seam::AuthDirectory`, `seam::RepoResolver` | [`gunnar_core::AuthTable`], [`gunnar_core::RepoCatalog`], behind [`control::Control`] |
88//! | `access::{Access, Principal, PrincipalKind}` | re-exports of `gunnar_core::{Level, Principal, PrincipalKind}` |
89//!
90//! **The permissive shape is the surviving one**: nested namespaces
91//! are legal (`team/sub/repo.git` → namespace `team/sub`), and the escalation
92//! that would otherwise carry is closed by grants **not** inheriting: `team`
93//! does not imply `team/sub`. That rule is what makes the merge safe, and it is
94//! pinned by `gunnar-core`'s `tests/acl.rs`.
95//!
96//! **What stayed here:** [`command::unquote`]. git sends one argv element with
97//! its quotes inside it and gunnar has no shell to strip them, but that is a
98//! transport fact; the sandbox is handed a bare string and does not know SSH
99//! exists (the one-sandbox rule).
100//!
101//! # ⚠ SEAMS: what is stubbed, and how to wire it
102//!
103//! One is left. `gunnar-wire` (the protocol state machines of the git wire protocol) is still
104//! being written, so [`seam::GitTransport`] describes exactly what an SSH front
105//! end needs from it and nothing more. It is a **seam, not a model**, and must
106//! not become a second source of truth for anything `gunnar-core` owns (one implementation).
107//! [`seam::BannerTransport`] is a test double, not a backend.
108//!
109//! # ⚠ A dependency fact that contradicts the spec's dependency table
110//!
111//! The dependency budget lists `russh` **and** `ssh-key` as server dependencies, and the
112//! workspace pins `ssh-key = "0.6"`. **russh 0.61.2 pins
113//! `ssh-key = "=0.7.0-rc.10"`**, an exact, semver-incompatible requirement.
114//! `russh::server::Handler::auth_publickey` takes `&ssh_key::PublicKey` from
115//! *that* copy, so the workspace's `ssh-key 0.6` types can never be handed to
116//! russh. This crate therefore names no `ssh-key` dependency of its own and
117//! uses [`russh::keys::ssh_key`] throughout.
118//!
119//! Depending on `gunnar-core` does **not** change that: both copies are in the
120//! graph, as they always were, and nothing here ever names an `ssh_key 0.6`
121//! type. The fingerprint crosses the boundary as **bytes**:
122//! [`access::fingerprint_of_key`] takes russh's key, calls `to_bytes()`, and
123//! hands the wire blob to [`gunnar_core::fingerprint_of_blob`]. The workspace was
124//! deliberately not bumped to a release candidate to paper over the split.
125//!
126//! **Since the RustCrypto bump (2026-10-03)** the workspace DOES pin the release
127//! candidate russh 0.63 rides (`ssh-key = "=0.7.0-rc.11"`, an exact pin until a
128//! stable 0.7 ships), so there is one copy and one `rand_core` (0.10); the
129//! twenty-line rand_core bridge this crate carried (`rng`) is gone and keys are
130//! minted from [`gunnar_core::os_rng`]. The fingerprint still crosses as bytes,
131//! so the next russh that moves its pin splits nothing.
132//!
133//! # ⚠ russh REQUIRES a C crypto backend, so the dependency budget cannot hold as written
134//!
135//! The dependency budget forbids "any C toolchain requirement" anywhere in the build graph.
136//! **russh 0.61.2 cannot satisfy that.** `russh/src/lib.rs:90` is a
137//! `compile_error!` reading *"`russh` requires enabling either the `ring` or
138//! `aws-lc-rs` feature as a crypto backend"*, and both of those are C/assembly
139//! libraries with a build script. With russh's default features the graph
140//! carries `aws-lc-rs` → `aws-lc-sys`, which builds C with `cmake`.
141//!
142//! Measured 2026-08-03 on this crate's `-e normal` tree: **196 distinct crates**,
143//! of which `aws-lc-rs` and `aws-lc-sys` are the C ones. There is no rayon, no
144//! openssl, no `git2`.
145//!
146//! This cannot be fixed from inside `gunnar-ssh`, because a member crate may not
147//! turn off a workspace dependency's default features. The choice is the
148//! workspace's, and it is a real choice with no clean option:
149//!
150//! * `russh = { version = "0.61", default-features = false, features = ["ring", "flate2"] }`
151//!   swaps `aws-lc-rs` for `ring`, which is smaller but still C and assembly,
152//! * keep `aws-lc-rs` and amend the dependency budget to say "no *unvendored* C", since both
153//!   backends vendor their sources and need no system library,
154//! * drop russh, which would mean writing an SSH transport, and `nornir-git`
155//!   uses russh on the client side already (the reuse rule), so it would have to go too.
156//!
157//! Recorded here rather than decided here.
158
159#![warn(missing_docs)]
160#![warn(clippy::all)]
161#![doc(
162    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
163    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
164)]
165
166pub mod access;
167pub mod admission;
168pub mod channel;
169pub mod command;
170pub mod config;
171pub mod control;
172pub mod governor;
173pub mod headroom;
174pub mod pipeline;
175pub mod plan;
176pub mod seam;
177pub mod server;
178pub mod space;
179
180pub use access::{fingerprint_of_key, Access, Fingerprint, Principal, PrincipalKind};
181pub use admission::{Admission, AdmissionGuard, AdmissionStats, Pause, Refusal, GROWING_MESSAGE};
182pub use command::{parse, CommandError, GitCommand, GitService, ServiceSpec, SERVICES};
183pub use config::{SshConfig, ANONYMOUS_SSH_USER};
184pub use control::{AnonymousPolicy, Control};
185/// The shared admission trait the gate asks (`admit-share`) and
186/// `Usage.AdmitPlan` reads — re-exported so the server names one version of it.
187pub use edda_admit;
188pub use governor::{Codel, Governor, LimitBand, MemoryLedger, OpClass};
189pub use headroom::{DiskFloor, DiskHeadroomGate, Headroom, AT_FLOOR};
190pub use plan::{PackCeiling, PlanAction, PlanGate, PlanVerdict};
191pub use seam::{Delivery, DuplexStream, GitTransport, ServiceRequest, TransportError};
192pub use server::{
193    env_refusal_is_routine, BoundServer, Dispatch, DispatchError, GunnarSsh, ENV_ALLOWLIST,
194    ENV_GIT_PROTOCOL, ENV_ROUTINE, EXIT_BUSY, EXIT_DENIED, EXIT_TRANSPORT_FAILED, STARTING_MESSAGE,
195};
196pub use server::{
197    parse_recipient_keys_verb, parse_repo_verb, parse_tunnel_address_verb, recipient_keys_answer,
198    user_key_answer, RepoAction, RepoAsk, RepoDoor, TunnelAddressDoor, FIRE_DRILL_VERB,
199    LFS_AUTHENTICATE_MESSAGE, LFS_AUTHENTICATE_VERB, RECIPIENT_KEYS_VERB, REPO_VERB,
200    TUNNEL_ADDRESS_VERB, WHOAMI_VERB,
201};
202pub use space::{SpaceHold, SpaceLedger, SpaceProbe, FULL_NOW, PUSH_COPIES};
203
204// The one sandbox, the one host key. Re-exported so a caller does not
205// have to know which crate they came from, but there is no second definition
206// here; these ARE `gunnar-core`'s.
207pub use gunnar_core::observe::{MetricsSnapshot, RequestId, ServerMetrics};
208pub use gunnar_core::{self, PathReject};
209pub use gunnar_core::{host_key::ensure_host_key, resolve_repo_path, HostKey, RepoRef};
210
211// Re-exported so a downstream crate can name the key types russh hands over
212// without having to work out for itself which `ssh-key` russh actually links
213// (see the note above; it is not the workspace one).
214pub use russh;
215pub use russh::keys::ssh_key;