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::GitTransportOne 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_requestandx11_requestare all answered with a channel failure (seeserver). The only thing that can be executed is one of the verbs incommand::SERVICES, and there is noexecpath to a process at all: the “command” selects an in-process state machine. - No environment, bar one allowlisted name.
env_requestacceptsGIT_PROTOCOLand 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 toServiceRequestafter the grant check, so it can never reach an authorisation decision. - No filesystem.
gunnar_core::resolve_repo_pathparses the exec argument into a validated namespace and name, never into aPath. 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 throughgunnar_store::SecretStore. Seegunnar_core::host_key::ensure_host_key. - No OS users. The SSH username is decorative.
git@host:ns/repo.gitworks andgitmeans nothing; identity comes from the offered key’s SHA-256 fingerprint (the ACL model). - No unbounded concurrency, and no invisible one.
admissioncaps 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 wastokio’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.connectionspan carrying aRequestId, the peer, and — as they become known — the principal and the repository (server). A transfer runs in agit.transferspan beneath it, andgunnar-cli’s transport andgunnar-policy’s gate emit underneath that, so a whole clone is greppable byconn=<id>and no call site repeats a field the span already carries (one implementation).ServerMetricsholds 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"] }swapsaws-lc-rsforring, which is smaller but still C and assembly,- keep
aws-lc-rsand 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-gituses 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.
- Metrics
Snapshot - A reading of
ServerMetrics, taken at one moment. - RepoRef
- A validated repository reference: a namespace and a name, and nothing else.
- Request
Id - A per-connection (or per-request) identifier, carried as a span field so every line emitted underneath is attributable without repeating it.
- Server
Metrics - Everything an operator watches, as one flat block of atomics.
Enums§
- Path
Reject - 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.