Skip to main content

Crate gunnar_ssh

Crate gunnar_ssh 

Source
Expand description

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.

  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 hereis now
path::RepoPathgunnar_core::resolve_repo_path → gunnar_core::RepoRef
access::KeyFingerprintgunnar_core::fingerprint_of_blob → gunnar_core::Fingerprint
hostkey::load_or_create_host_keygunnar_core::host_key::ensure_host_key
seam::AuthDirectory, seam::RepoResolvergunnar_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.

Re-exports§

pub use access::fingerprint_of_key;
pub use access::Access;
pub use access::Fingerprint;
pub use access::Principal;
pub use access::PrincipalKind;
pub use admission::Admission;
pub use admission::AdmissionGuard;
pub use admission::AdmissionStats;
pub use admission::Pause;
pub use admission::Refusal;
pub use admission::GROWING_MESSAGE;
pub use command::parse;
pub use command::CommandError;
pub use command::GitCommand;
pub use command::GitService;
pub use command::ServiceSpec;
pub use command::SERVICES;
pub use config::SshConfig;
pub use config::ANONYMOUS_SSH_USER;
pub use control::AnonymousPolicy;
pub use control::Control;
pub use governor::Codel;
pub use governor::Governor;
pub use governor::LimitBand;
pub use governor::MemoryLedger;
pub use governor::OpClass;
pub use headroom::DiskFloor;
pub use headroom::DiskHeadroomGate;
pub use headroom::Headroom;
pub use headroom::AT_FLOOR;
pub use plan::PackCeiling;
pub use plan::PlanAction;
pub use plan::PlanGate;
pub use plan::PlanVerdict;
pub use seam::Delivery;
pub use seam::DuplexStream;
pub use seam::GitTransport;
pub use seam::ServiceRequest;
pub use seam::TransportError;
pub use server::env_refusal_is_routine;
pub use server::BoundServer;
pub use server::Dispatch;
pub use server::DispatchError;
pub use server::GunnarSsh;
pub use server::ENV_ALLOWLIST;
pub use server::ENV_GIT_PROTOCOL;
pub use server::ENV_ROUTINE;
pub use server::EXIT_BUSY;
pub use server::EXIT_DENIED;
pub use server::EXIT_TRANSPORT_FAILED;
pub use server::STARTING_MESSAGE;
pub use server::parse_recipient_keys_verb;
pub use server::parse_repo_verb;
pub use server::parse_tunnel_address_verb;
pub use server::recipient_keys_answer;
pub use server::user_key_answer;
pub use server::RepoAction;
pub use server::RepoAsk;
pub use server::RepoDoor;
pub use server::TunnelAddressDoor;
pub use server::FIRE_DRILL_VERB;
pub use server::LFS_AUTHENTICATE_MESSAGE;
pub use server::LFS_AUTHENTICATE_VERB;
pub use server::RECIPIENT_KEYS_VERB;
pub use server::REPO_VERB;
pub use server::TUNNEL_ADDRESS_VERB;
pub use server::WHOAMI_VERB;
pub use space::SpaceHold;
pub use space::SpaceLedger;
pub use space::SpaceProbe;
pub use space::FULL_NOW;
pub use space::PUSH_COPIES;
pub use edda_admit;
pub use self;
pub use russh;
pub use russh::keys::ssh_key;

Modules§

access
Identity and grant levels as the SSH front end names them (the ACL model).
admission
Admission control for git transfers: an explicit, countable ceiling in place of the accidental one.
channel
An owned byte stream over a russh channel that does not close the channel when it is dropped.
command
The exec parser (the ACL model), the most security-critical code in this crate.
config
Listener configuration (SSH and gRPC are separate listeners).
control
The control plane this listener answers from: gunnar-core, directly (the ACL model).
governor
The two load-shedding gates: a CoDel delay governor (time) and a memory reservation ledger (space). Both refuse at admission, before a single byte of the operation’s work has been allocated.
headroom
The disk-headroom gate: the community PlanGate, the “free lunch”.
pipeline
The standing pipeline: warm machinery per class, and an operation is data.
plan
The plan gate: the front door’s one question before the bytes.
seam
The one remaining seam: temporary, and marked so.
server
The russh server: authentication, the exec dispatch, and the channel hand-off (the ACL model, the two-listener split).
space
Space admission: the disk floor, counted against the bytes still arriving.

Structs§

HostKey
The server’s persistent SSH host key.
MetricsSnapshot
A reading of ServerMetrics, taken at one moment.
RepoRef
A validated repository reference: a namespace and a name, and nothing else.
RequestId
A per-connection (or per-request) identifier, carried as a span field so every line emitted underneath is attributable without repeating it.
ServerMetrics
Everything an operator watches, as one flat block of atomics.

Enums§

PathReject
Why a repository path was refused.

Functions§

ensure_host_key
Return the server’s host key, minting and storing one on first boot.
resolve_repo_path
Turn an SSH command argument into a validated RepoRef.