gunnar_ssh/server.rs
1//! The russh server: authentication, the exec dispatch, and the channel
2//! hand-off (the ACL model, the two-listener split).
3//!
4//! # The shape of a connection
5//!
6//! 0. **`auth_none`**, the anonymous read path, and the only place
7//! gunnar reads the SSH username. See below.
8//! 1. **`auth_publickey`**: russh has already verified that the client owns the
9//! offered key. gunnar fingerprints it (SHA-256 over the wire blob, OpenSSH's
10//! printed form, [`crate::access::fingerprint_of_key`]) and asks
11//! `gunnar-core`'s identity table through [`Control`]. The SSH *username* is
12//! logged and otherwise ignored: identity comes from the key, so
13//! `git@host:ns/repo.git` works and `git` means nothing (the ACL model).
14//! 2. **`channel_open_session`**. The channel is kept, because the exec request
15//! arrives as a separate message that only carries a [`ChannelId`], and the
16//! stream can only be taken from the `Channel` itself. A watchdog is armed
17//! here so a session that authenticates and then never execs cannot sit on
18//! the connection.
19//! 3. **`env_request`**, answered against [`ENV_ALLOWLIST`], which holds
20//! exactly one name: `GIT_PROTOCOL`. Everything else is refused. See below.
21//! 4. **`exec_request`**: [`Dispatch::authorise`] parses, resolves and checks
22//! the grant. Only then is the channel's stream handed to [`GitTransport`].
23//! 5. **everything else**. `shell_request`, `subsystem_request`,
24//! `pty_request` and `x11_request` are answered with a channel failure.
25//! **No shell, ever.**
26//!
27//! # Why the transport is spawned and not awaited
28//!
29//! russh calls handler methods from the session's own read loop (see
30//! `russh::server::Session::run`). Awaiting a whole clone inside `exec_request`
31//! would stop the loop that has to deliver the client's bytes to the channel;
32//! the transfer would deadlock on its first read. russh's own `sftp_server`
33//! example awaits inline; gunnar does not.
34//!
35//! # The exit status, and why the channel is split rather than consumed
36//!
37//! `Channel::into_stream()` wraps the channel in russh's `ChannelCloseOnDrop`,
38//! which spawns a task sending `ChannelMsg::Close` when the transport lets the
39//! stream go. That `Close` raced the `exit-status` the server sent next, and
40//! won often enough that a *successful* push reported failure. Measured
41//! 2026-08-03: a complete 244-byte advertisement delivered with `ssh` exiting
42//! **255**. See [`crate::channel`].
43//!
44//! The channel is therefore `split()` instead. The read half becomes an owned
45//! [`ChannelReader`], the write half stays with the server, and `exit-status`
46//! and `close` go out through **the same sender the data went through**, in
47//! order, after the transport has finished with it. Nothing sleeps and nothing
48//! races.
49//!
50//! # One span per connection, and what hangs off it
51//!
52//! ```text
53//! ssh.connection { conn, peer, principal, repo } gunnar::ssh
54//! └── git.transfer { service, access } gunnar::git
55//! ├── git.upload_pack | git.receive_pack gunnar::git
56//! └── policy.gate { ref_name } gunnar::policy
57//! ```
58//!
59//! `principal` and `repo` are declared empty when the connection opens and
60//! **recorded** when `auth_publickey` and `exec_request` learn them, so a line
61//! emitted at any depth carries all four fields without any call site restating
62//! them. That is the difference from what was here before: connection identity
63//! was an ad-hoc `?self.peer` copy-pasted to fourteen call sites, there was no
64//! id at all, and two lines from the same connection could not be tied
65//! together.
66//!
67//! Two hand-offs would drop the span if they were not made explicit, and each
68//! is a place where an operator would silently lose half a connection:
69//!
70//! * `tokio::spawn`: the exec watchdog and the transfer task both take
71//! `.instrument(span)`, because a spawned task does not inherit a
72//! thread-local span;
73//! * `spawn_blocking`: `gunnar-cli`'s `WireTransport::serve` captures
74//! `Span::current()` and enters it on the blocking thread, which is where the
75//! whole git conversation actually happens.
76//!
77//! # How an anonymous session arrives, and why it is `auth_none` on one
78//! # username
79//!
80//! SSH has no unauthenticated mode: a client must complete *some* userauth
81//! method before it may open a channel. Three shapes were available.
82//!
83//! | | why not |
84//! |---|---|
85//! | accept `none` for **every** username | **This is the one that would break the server.** OpenSSH's client always sends a `none` request first, to learn the method list. A server that answers it with SUCCESS authenticates *every* stock client as anonymous before it ever offers its key, so every existing push would start failing, and every existing user would silently lose their identity. Measured against the russh dispatch: `server/encrypted.rs` calls `auth_none` on that first probe regardless of the advertised `methods`. |
86//! | a **well-known published key** | The anonymous-FTP shape. It works, but a stranger has to obtain a key file before they can clone, which is worse than the problem it solves, and gunnar would then hold a key whose private half is public in the same table that authenticates real users. |
87//! | **`none`, on one reserved username** | Taken. |
88//!
89//! `ssh://anonymous@host/ns/repo.git` gets the `none` method accepted and a
90//! session whose principal is
91//! [`Principal::anonymous`](gunnar_core::Principal::anonymous). Every other
92//! username gets `none` refused, exactly as russh's default handler refused it
93//! before this existed, and the client falls through to publickey unchanged.
94//!
95//! **Why it cannot become a bypass**, in the order the objections come:
96//!
97//! * *Does it weaken private repositories?* No. The username selects the
98//! authentication method; the **authorisation** answer still comes from
99//! `AuthTable::may` (the ACL model), where the anonymous principal is one rule, `Read` on
100//! `Public`, and is refused everything else including `may_namespace` and
101//! `may_server`. There is no second ACL (one implementation).
102//! * *Can a caller claim to be someone by picking a username?* No. The
103//! anonymous principal is the **only** identity a username can select, it is
104//! never in the identity table, [`Principal::new`] and
105//! `AuthTable::add_principal` refuse its id, so no key and no grant can ever
106//! attach to it.
107//! * *Can an authenticated user be silently downgraded?* No. `none` is refused
108//! for their username, so nothing changes for them; and a user who *does*
109//! type `anonymous@` asked for strictly less access, which is not an
110//! escalation.
111//! * *Does the username now reach an authorisation decision, against the ACL model?* It
112//! reaches exactly one, in the direction of *removing* everything. The ACL model's
113//! rule exists so that a username cannot be used to become somebody; this
114//! uses it to become nobody.
115//!
116//! **`none` is not advertised** in [`RusshConfig::methods`], which stays
117//! publickey-only. Advertising it would tell every client that `none` is
118//! available when it is available only for one name (the failure list is
119//! supposed to be true), and russh calls `auth_none` on the client's opening
120//! probe either way, which is the only place it is ever needed.
121//!
122//! [`SshConfig::allow_anonymous`](crate::SshConfig::allow_anonymous) closes the
123//! whole path at the listener for an appliance that wants it shut.
124//!
125//! # `GIT_PROTOCOL`, and why it is an allowlist of one
126//!
127//! A git client announces protocol v2 over SSH by sending
128//! `GIT_PROTOCOL=version=2` as a channel environment request. Refusing every
129//! `env_request`, which this server used to do, meant every session fell back
130//! to v0. Stock `git` copes; `gix`'s blocking v0 client does not, because it
131//! requires `multi_ack_detailed` and `gunnar_wire::caps` correctly declines to
132//! advertise a capability it does not implement. The measured consequence was
133//! that `gunnar clone` could not clone from `gunnar serve` at all.
134//!
135//! Accepting the variable is **not** the same as accepting an environment. An
136//! SSH server that passes arbitrary client-supplied names through has handed
137//! the client `LD_PRELOAD`, `PATH` and every other lever there is. [`ENV_ALLOWLIST`]
138//! names the one variable gunnar reads; every other name is refused with a
139//! channel failure, exactly as before. The accepted value is not an input to
140//! any authorisation decision: it reaches [`ServiceRequest::git_protocol`] and
141//! nothing else, and it only ever selects the *framing* the wire answers in.
142
143use std::collections::HashMap;
144use std::net::SocketAddr;
145use std::sync::atomic::{AtomicBool, Ordering};
146use std::sync::Arc;
147use std::time::Duration;
148
149/// How long [`BoundServer::shutdown`] waits for sessions to answer the
150/// disconnect before it aborts the listener. Longer than the gRPC grace (2 s):
151/// a git transfer mid-pack deserves a moment to finish its write.
152pub const SSH_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
153
154use russh::keys::ssh_key::PublicKey;
155use russh::server::{Auth, Config as RusshConfig, Handler, Msg, Server, Session};
156use russh::{Channel, ChannelId, Disconnect, MethodKind, MethodSet};
157use tokio::net::TcpListener;
158use tokio::task::JoinHandle;
159use tracing::Instrument;
160
161use gunnar_core::observe::{RequestId, ServerMetrics, TARGET_GIT, TARGET_SSH};
162use gunnar_core::RepoRef;
163
164use crate::access::{fingerprint_of_key, Access, Fingerprint, Principal};
165use crate::admission::{Admission, AdmissionStats};
166use crate::channel::{
167 serve_until_session_ends, session_ended_error, session_over, ChannelReader, SessionBound,
168};
169use crate::command::GitService;
170use crate::command::{self, CommandError};
171use crate::config::{SshConfig, ANONYMOUS_SSH_USER};
172use crate::control::Control;
173use crate::governor::OpClass;
174use crate::seam::{Delivery, GitTransport, ServiceRequest};
175
176// ───────────────────────────────────────────────────────────────────────────
177// The authorisation decision, without any SSH in it
178// ───────────────────────────────────────────────────────────────────────────
179
180/// Why an exec request was not served.
181#[derive(Debug, thiserror::Error)]
182pub enum DispatchError {
183 /// The command did not parse. **The `bash` case, and every other one.**
184 #[error("{0}")]
185 Command(#[from] CommandError),
186 /// The repository does not exist.
187 #[error("no such repository: {0}")]
188 NoSuchRepo(RepoRef),
189 /// The principal does not hold enough access.
190 #[error("{principal} holds {held} on {repo}, needs {needed}")]
191 Denied {
192 /// Who asked.
193 principal: String,
194 /// What they asked for.
195 repo: RepoRef,
196 /// What the command requires.
197 needed: Access,
198 /// What they actually hold, rendered as `none` when they hold nothing.
199 held: String,
200 },
201 /// **This box is a read-only STANDBY** and the write is refused.
202 ///
203 /// A distinct arm, not a [`DispatchError::Denied`]: the writer holds the
204 /// grant — on a primary this push would land — so "you do not have access"
205 /// would be a lie that sends them to an administrator about permissions.
206 /// The honest sentence is that this box does not take client writes; push
207 /// to the primary. It is only ever reached by a principal who already
208 /// passed the grant check, so it leaks no repository the writer could not
209 /// already see.
210 #[error("{principal}: this box is a standby; writes go to the primary ({repo})")]
211 Standby {
212 /// Who asked.
213 principal: String,
214 /// The repository they tried to write.
215 repo: RepoRef,
216 /// The box is a browse replica, so the
217 /// refusal says so: a stranger who found a public repository here and
218 /// tried to push to it should learn what this box is, not merely that
219 /// it is frozen.
220 browse: bool,
221 },
222 /// **This box is a TWIN and serves no git at all** — not a read, not a
223 /// write, not to anybody (2026-09-21, lane T4).
224 ///
225 /// Its own arm and not a [`DispatchError::Standby`], because a standby is a
226 /// box that serves reads and refuses writes, and a twin is a box that
227 /// serves NEITHER: `serve.rs` says why in one line — *a read would grow the
228 /// caches the twin must not hold* — and the twin's whole value is that its
229 /// disk holds the primary's log and nothing derived.
230 ///
231 /// **Why this exists at the door rather than only at the bind.** The bind
232 /// is chosen ONCE, in `serve::start`, from `stores.is_twin()` at that
233 /// instant. A box becomes a twin over the wire, through
234 /// `Bootstrap.BecomeTwin`, long after that — and the owner's one-medium
235 /// ruling makes that the ONLY way it happens, because a twin is now
236 /// installed from the same image as a main with no per-box parameter to
237 /// say which it is. MEASURED under KVM by lane T3, 2026-09-21: a box
238 /// conferred at runtime kept `0.0.0.0:2222` bound for the rest of its life,
239 /// the `this box is a TWIN` warning never printed, and its volume ended up
240 /// holding five `objects.exploded` cache files. A socket that cannot be
241 /// moved without a restart is not a reason to serve git from it in the
242 /// meantime.
243 #[error("{principal}: this box is a TWIN; it serves no git to anyone — clone and push to the primary it follows ({repo})")]
244 Twin {
245 /// Who asked.
246 principal: String,
247 /// What they asked for.
248 repo: RepoRef,
249 },
250 /// **Anonymous reads are not served here** (`--anonymous refuse`); the
251 /// browse replica serves them, and `browse_host` names it when the
252 /// operator did. Raised before the repository is even looked up, so a
253 /// stranger learns nothing about what exists on the primary.
254 #[error("anonymous reads are refused on this box ({repo}); browse host: {browse_host:?}")]
255 AnonymousElsewhere {
256 /// The repository the stranger asked for.
257 repo: RepoRef,
258 /// Where anonymous reads are served, when the operator named it.
259 browse_host: Option<String>,
260 },
261 /// **The gate chain refuses this**: the disk is under its floor (every
262 /// build), or (`metered`) a Suspended or Retention tenant's push or LFS
263 /// upload, an anonymous read of a lapsed tenant's public repository, or a
264 /// push at the pack quota.
265 ///
266 /// Its own arm for the same reason [`DispatchError::Standby`] is one: the
267 /// caller holds the grant, so "you do not have access" would be a lie
268 /// that sends a paying customer to an administrator about permissions.
269 /// The honest sentence names the fact — free bytes and the floor, or the
270 /// state, the date and where to renew — and it is only ever reached AFTER
271 /// the grant check admitted the request, so it leaks nothing about a
272 /// repository the caller could not already see.
273 #[error("{namespace}: {line}")]
274 Plan {
275 /// The tenant.
276 namespace: String,
277 /// What the client is told, verbatim.
278 line: String,
279 },
280 /// The identity table or the catalog could not be read.
281 ///
282 /// **Deliberately not folded into [`DispatchError::Denied`].** The tables
283 /// live in storage (the Arrow-IPC ref log) and a read can fail; answering a storage
284 /// fault with "you do not have access" would turn an outage into a silent,
285 /// convincing permission denial that nobody would think to investigate.
286 /// The ACL model is explicit that `may(...)` returns `Result<bool>` for this
287 /// reason, and this variant is where that `Result` lands.
288 #[error("the control plane could not answer: {0}")]
289 Store(#[from] gunnar_core::Error),
290}
291
292impl DispatchError {
293 /// What the client is told.
294 ///
295 /// `None` means "answer with a channel failure and say nothing": the
296 /// request was not even a git command, so there is no git client on the
297 /// other end that would understand a pkt-line.
298 ///
299 /// **"No such repository" and "no permission" produce the same string on
300 /// purpose.** A client that could tell them apart could enumerate private
301 /// repositories one guess at a time. The server log keeps the distinction;
302 /// the wire does not.
303 ///
304 /// Those two arms are **one arm**, and were two identical `format!`s until
305 /// 2026-08-05. That is the twinning one implementation forbids, and this is the case it
306 /// forbids it for: the property is that the two strings are equal, so two
307 /// copies of the string put the property one careless edit away from being
308 /// false, in the direction where the wire starts leaking which private
309 /// repositories exist.
310 /// `a_missing_repository_and_a_forbidden_one_are_one_string_to_the_client`
311 /// guards it from the far side of a real session; this makes the guard hard
312 /// to need.
313 pub fn client_message(&self) -> Option<String> {
314 match self {
315 // A verb gunnar knows and refuses BY NAME gets its sentence; the
316 // rest of the parse failures are not git clients at all.
317 DispatchError::Command(CommandError::NotServed { verb, why }) => {
318 Some(format!("{verb}: {why}"))
319 }
320 DispatchError::Command(_) => None,
321 DispatchError::NoSuchRepo(repo) | DispatchError::Denied { repo, .. } => Some(format!(
322 "repository not found, or you do not have access: {repo}"
323 )),
324 // A standby refusal is told plainly — the writer is authorised, so
325 // there is no repository to hide from them, and "push to the
326 // primary" is the one thing they need to hear to succeed.
327 // A twin refusal is told plainly for the same reason, and it is told
328 // to EVERYONE including a stranger: it names no repository and
329 // reveals nothing this box holds — "this is a twin" is a fact about
330 // the box, not about its contents.
331 DispatchError::Twin { .. } => Some(
332 "this box is a TWIN: it holds a copy of the primary's transaction log and serves \
333 no git to anyone, read or write. Clone and push to the primary it follows"
334 .to_string(),
335 ),
336 DispatchError::Standby { browse: false, .. } => {
337 Some("this box is a standby; push to the primary".to_string())
338 }
339 DispatchError::Standby { browse: true, .. } => Some(
340 "this box is a browse replica: it serves public repositories read-only and \
341 takes no writes; push to the primary"
342 .to_string(),
343 ),
344 DispatchError::AnonymousElsewhere {
345 browse_host: Some(host),
346 repo,
347 } => Some(format!(
348 "anonymous reads are served by the browse replica, not here: clone \
349 {repo} from {host} (or authenticate with a registered key)"
350 )),
351 DispatchError::AnonymousElsewhere {
352 browse_host: None, ..
353 } => Some(
354 "this server does not serve anonymous reads; authenticate with a registered \
355 key, or use the browse replica"
356 .to_string(),
357 ),
358 // The gate's refusal is told in full: the state, the date and the
359 // URL are the three things a lapsed tenant needs to get going again,
360 // and the free bytes and the floor are what a full box's operator
361 // needs; hiding any of them would turn a fact into a support ticket.
362 DispatchError::Plan { line, .. } => Some(line.clone()),
363 // A fault, not a decision. The client is told the server broke,
364 // because telling it "you do not have access" would be a lie that
365 // sends the user to their administrator about permissions.
366 DispatchError::Store(_) => {
367 Some("the gunnar control plane is unavailable; try again".to_string())
368 }
369 }
370 }
371}
372
373/// Parse → resolve → check, with no SSH anywhere in it.
374///
375/// Kept separate from the [`Handler`] so the security decision can be tested
376/// directly, exhaustively and without a handshake, and so there is exactly one
377/// place where it is made.
378#[derive(Clone, Debug)]
379pub struct Dispatch {
380 control: Control,
381}
382
383impl Dispatch {
384 /// Build a dispatch over the control plane.
385 pub fn new(control: Control) -> Self {
386 Dispatch { control }
387 }
388
389 /// The control plane this dispatch authenticates and authorises against.
390 pub fn control(&self) -> &Control {
391 &self.control
392 }
393
394 /// Decide whether `principal` may run `raw` on this server.
395 ///
396 /// Parse (which includes the sandbox), resolve, then check the grant. A
397 /// storage failure at either lookup comes back as
398 /// [`DispatchError::Store`] rather than as a denial. See
399 /// [`crate::control`].
400 ///
401 /// **The client's environment is not a parameter here, and must not
402 /// become one.** The returned [`ServiceRequest`] carries
403 /// `git_protocol: None`; the handler attaches the allowlisted value with
404 /// [`ServiceRequest::with_git_protocol`] *after* this decision has already
405 /// been made, so there is no path by which anything the client typed into
406 /// its environment can reach the grant check.
407 pub fn authorise(
408 &self,
409 principal: &Principal,
410 key_fingerprint: Option<&Fingerprint>,
411 raw: &[u8],
412 ) -> Result<ServiceRequest, DispatchError> {
413 let parsed = command::parse(raw)?;
414 self.authorise_service(principal, key_fingerprint, parsed.service, parsed.repo)
415 }
416
417 /// The same decision, entered from a **URL path** rather than an exec line.
418 ///
419 /// Smart HTTP does not send `git-upload-pack '<path>'`; it sends
420 /// `POST /<path>/git-upload-pack`. The verb and the path arrive already
421 /// separated, and the path has no shell quoting on it, so there is nothing
422 /// for [`command::parse`] to do — but everything **after** the parse is
423 /// identical and must stay identical: resolve, ask the ACL, and refuse in
424 /// the one way that does not tell a caller whether a repository exists.
425 ///
426 /// So this is where that decision lives and [`Dispatch::authorise`] is now
427 /// a parser in front of it. Two entry points, one answer (one implementation). A second
428 /// copy of this in `gunnar-http` is precisely the thing that ends with SSH
429 /// and HTTP disagreeing about who may push, and the disagreement being
430 /// found by whoever pushes.
431 ///
432 /// `repo` is a [`RepoRef`], which is to say it has already been through
433 /// [`gunnar_core::resolve_repo_path`]. The sandbox is not optional on this
434 /// path either: the caller cannot construct a `RepoRef` without it.
435 pub fn authorise_service(
436 &self,
437 principal: &Principal,
438 key_fingerprint: Option<&Fingerprint>,
439 service: GitService,
440 path: RepoRef,
441 ) -> Result<ServiceRequest, DispatchError> {
442 let required = service.required_access();
443 // **A TWIN serves no git**, before anything else is asked — before the
444 // anonymous policy, before the catalog, before the grant. A read on a
445 // twin grows exactly the caches the twin exists not to hold, and the
446 // box's own `browse` flag (which `become_twin` sets) would otherwise
447 // OVERRIDE `--anonymous refuse` and serve a stranger. This is a live
448 // read of the same flag `ServerStores` flips, so the refusal begins the
449 // instant `Bootstrap.BecomeTwin` returns and does not wait for a
450 // restart to move the listener.
451 if self.control.is_twin() {
452 return Err(DispatchError::Twin {
453 principal: principal.id.clone(),
454 repo: path,
455 });
456 }
457 // **`--anonymous refuse`** (the browse replica), before the catalog is consulted:
458 // a stranger on a primary that has a browse replica is sent there, and
459 // learns nothing about what this box holds on the way.
460 if principal.id == gunnar_core::ANONYMOUS_PRINCIPAL_ID && self.control.refuses_anonymous() {
461 return Err(DispatchError::AnonymousElsewhere {
462 repo: path,
463 browse_host: self.control.browse_host().map(str::to_owned),
464 });
465 }
466 let (repo, prospective) = match self.control.resolve(&path)? {
467 Some(repo) => (repo, false),
468 // The repository does not exist. For exactly one shape of request
469 // — a PUSH, by a principal the control plane already permits to
470 // create repositories here — absence is the first-repository door
471 // rather than a refusal: the repository is created and the push
472 // proceeds through the unchanged grant check below.
473 //
474 // # Why a door here, and not a fourth verb or the control plane
475 //
476 // The shipped appliance runs `--no-grpc` (gRPC v1 is
477 // unauthenticated, loopback-only), has no shell by construction,
478 // and its exec surface is three git verbs that all want a
479 // repository — so after `--admin-key` enrolled the bootstrap
480 // admin (a8ba0665), the admin could authenticate and then do
481 // NOTHING, forever (measured under KVM, 2026-08-25). The
482 // alternatives lost on the code, not on taste:
483 //
484 // * **a create verb** would be a wire surface no git client
485 // speaks, would need its own client incantation, and does not
486 // fit the dispatch's own shape: `SERVICES` carries per-service
487 // *repo grant levels* and this decision compares levels — a
488 // verb whose target does not exist has no repo to hold a level
489 // on, so it would be a special case beside the table, and a
490 // probe-discoverable one for every stranger;
491 // * **turning gRPC on** is the door the unauthenticated v1 and
492 // `gunnar-grpc/tests/loopback_default.rs` exist to keep shut on
493 // a mailed medium: `Bootstrap.Enroll` with no credential is a
494 // race between the customer and whoever else finds the box.
495 //
496 // Push-to-create adds no verb, no parse production and no
497 // vocabulary: `Control::create_for_push` asks the exact two
498 // questions `Repos.CreateNamespace`/`Repos.Create` gate on, so
499 // this door and the RPC door can never disagree (one implementation) — and it
500 // sits HERE, where the two entry points (exec line, smart-HTTP
501 // URL) have already fanned in, so SSH and HTTP answer a push to a
502 // missing repository identically.
503 //
504 // Only a WRITE: ReceivePack, and (2026-09-28) an LFS upload.
505 // Reading never creates: a `git clone` of a typo, or an LFS
506 // download, stays an error.
507 //
508 // **Why the LFS upload too.** git-lfs's pre-push hook runs AFTER
509 // git has taken receive-pack's advertisement and BEFORE it sends
510 // a command, and it asks `git-lfs-transfer <repo> upload` first.
511 // MEASURED 2026-09-28 (git-lfs 3.8.0, and on a real box): into a
512 // repository that did not exist yet that exec was the fused
513 // not-found, the hook died — `batch request: exec request failed
514 // on channel 0: exit status 255` — and the push that would have
515 // created the repository never sent its commands. The upload is
516 // the same act as the push (it puts content in the repository),
517 // by the same principal, and it is asked the same two questions
518 // (`push_create_spec`), so it opens the same door and nobody else
519 // learns anything new. Nothing is written here either: the
520 // transport creates the repository when the first OBJECT arrives
521 // (`gunnar-server` `ProspectiveLfs`), so a client that only
522 // batches, or hangs up, leaves nothing.
523 //
524 // For everyone `create_for_push` answers `None` for, the refusal
525 // below is bit-identical to what it always was — the one fused
526 // "repository not found, or you do not have access" — so the
527 // door's existence is not observable to a key it does not admit.
528 //
529 // **Nothing is written here.** The door answers with the
530 // repository it WOULD create (`prospective`), the transport
531 // advertises that, and the catalog row and the store are made
532 // only once the client has sent commands that pass the hash
533 // check — see `Control::push_create_spec` for the ghost
534 // repository this order closes.
535 None if service == GitService::ReceivePack
536 || service == GitService::LfsTransfer(crate::command::Operation::Upload) =>
537 {
538 match self.control.push_create_spec(principal, &path)? {
539 Some(spec) => {
540 // The name filter (2026-10-05) — asked only of a
541 // pusher the door admits, so its sentence tells
542 // nobody else anything.
543 if let Some(line) = self.control.push_create_name_refusal(principal, &path)
544 {
545 return Err(DispatchError::Plan {
546 namespace: path.namespace().to_owned(),
547 line,
548 });
549 }
550 (spec.prospect()?, true)
551 }
552 None => return Err(DispatchError::NoSuchRepo(path)),
553 }
554 }
555 None => return Err(DispatchError::NoSuchRepo(path)),
556 };
557 let held = self.control.access_for(principal, &repo)?;
558 match held {
559 // **The standby freeze.** Checked here, INSIDE the authorised arm,
560 // so it is only ever reached by a writer the grant check already
561 // admitted — an unauthorised push still gets the fused not-found and
562 // learns nothing new. A write-requiring service by a non-`Machine`
563 // principal on a frozen box is refused: this box has abdicated or is
564 // a configured standby, and a client write here would be a split
565 // the primary never sees. The primary's own replication push is a
566 // `Machine` identity and sails through — that is what a standby is
567 // for.
568 Some(level)
569 if level.permits(required)
570 && required.permits(Access::Write)
571 && self.control.write_frozen_for(principal) =>
572 {
573 Err(DispatchError::Standby {
574 principal: principal.id.clone(),
575 repo: path,
576 browse: self.control.is_browse(),
577 })
578 }
579 // **An encrypted repository takes no LFS upload** (wiring tracer B
580 // 2026-10-08). git-lfs uploads in the pre-push hook, BEFORE the
581 // pack the encrypted gate reads, and an LFS object is the
582 // plaintext of a `filter=lfs` file that never went through
583 // `filter=gunnar-crypt`: refused here, for SSH and smart HTTP
584 // alike, before a byte is stored. Asked inside the authorised arm,
585 // so a stranger still gets the fused not-found. A download is never
586 // refused.
587 Some(level)
588 if level.permits(required)
589 && repo.encrypted
590 && service == GitService::LfsTransfer(crate::command::Operation::Upload) =>
591 {
592 Err(DispatchError::Plan {
593 namespace: path.namespace().to_owned(),
594 line: format!(
595 "{path} is encrypted, so it takes no LFS objects: LFS stores files as \
596 they are, outside your members' encryption. Track the file with \
597 `filter=gunnar-crypt` instead of `filter=lfs`"
598 ),
599 })
600 }
601 Some(level) if level.permits(required) => {
602 // **The gate chain**, asked LAST: after the grant (so a
603 // refusal leaks nothing to a stranger) and after the standby
604 // freeze (so a standby stays one whatever the gates say). A
605 // write asks `Write`; an anonymous read asks `AnonymousRead`;
606 // an authenticated read asks nothing at all, because the
607 // owner's clone is allowed in every state — data is never held
608 // hostage. See `crate::plan`.
609 let PlanOutcome {
610 notice: plan_notice,
611 ceiling: pack_ceiling,
612 pack_mark,
613 allowance: lfs_allowance,
614 } = self.ask_plan(principal, service, required, &repo)?;
615 Ok(ServiceRequest {
616 service,
617 repo,
618 prospective,
619 principal: principal.clone(),
620 access: level,
621 // The key that authenticated this session, carried
622 // rather than guessed back out of the identity table. A
623 // parameter and not a builder, so it cannot be forgotten into
624 // `None`.
625 key_fingerprint: key_fingerprint.cloned(),
626 git_protocol: None,
627 delivery: Delivery::Session,
628 // Derived from `key_fingerprint` by the transport rather
629 // than spelled here: see `ServiceRequest::credential`.
630 credential: None,
631 plan_notice,
632 pack_ceiling,
633 pack_mark,
634 lfs_allowance,
635 cancel: crate::channel::Cancel::new(),
636 })
637 }
638 other => Err(DispatchError::Denied {
639 principal: principal.id.clone(),
640 repo: path,
641 needed: required,
642 held: match other {
643 Some(level) => level.to_string(),
644 None => "none".to_string(),
645 },
646 }),
647 }
648 }
649
650 /// **Ask the gate chain**, for a request the grant check already admitted.
651 /// `Ok(Some(line))` is an allowed request that carries a warning (Grace);
652 /// `Err(Plan)` is the refusal, with the line the client is told.
653 ///
654 /// The three shapes, and why each asks what it asks:
655 ///
656 /// * a **write** (`git-receive-pack`, LFS upload) asks
657 /// [`PlanAction::Write`](crate::plan::PlanAction::Write), and if that is
658 /// allowed asks [`PlanGate::pack_quota`](crate::plan::PlanGate::pack_quota)
659 /// — both before a single pack byte is read;
660 /// * an **anonymous read** asks
661 /// [`PlanAction::AnonymousRead`](crate::plan::PlanAction::AnonymousRead):
662 /// a lapsed tenant's public surface is what costs the provider and earns
663 /// nothing;
664 /// * an **authenticated read** asks nothing. The owner clones their own
665 /// repositories in every state, and so does anyone they granted. That is
666 /// rule 1 of `gunnar_core::entitlement`, and it is enforced here by the
667 /// absence of a question rather than by an answer.
668 ///
669 /// **The chain.** Gates are asked in the order `Control::with_plan_gate`
670 /// added them — disk first — and the FIRST `Refuse` is the answer; a later
671 /// gate is not consulted, so a full box is told it is full and not also
672 /// that its plan lapsed. Each gate is asked BOTH its questions before the
673 /// next gate is asked anything: its ladder (`verdict`), then, for a write
674 /// that grows data, its quota (`pack_quota`). Within one gate that order is
675 /// what tells a Suspended tenant they are suspended rather than also full;
676 /// across gates it is what makes "disk first" mean disk first — the disk
677 /// gate only ever refuses from `pack_quota`, and a chain that asked every
678 /// ladder before any quota would let a plan's ladder outrank it whatever
679 /// the order (`tests/disk_headroom.rs` caught exactly that, red first).
680 /// Warnings from every allowing gate are joined.
681 ///
682 /// With no gate wired (`Control::with_plan_gate` never called) every
683 /// namespace is Free and this is a no-op. A `Machine` principal —
684 /// replication, the primary's copy arriving — is asked only of the gates
685 /// that say they apply to it ([`PlanGate::applies_to_machine`]): the disk
686 /// does, a plan does not, because refusing a lapsed tenant's replication
687 /// would make their standby diverge from the primary.
688 fn ask_plan(
689 &self,
690 principal: &Principal,
691 service: GitService,
692 required: Access,
693 repo: &gunnar_core::Repo,
694 ) -> Result<PlanOutcome, DispatchError> {
695 use crate::plan::{PlanAction, PlanVerdict};
696 let machine = principal.kind == gunnar_core::PrincipalKind::Machine;
697 let gates: Vec<_> = self
698 .control
699 .plan_gates()
700 .iter()
701 .filter(|gate| !machine || gate.applies_to_machine())
702 .collect();
703 if gates.is_empty() {
704 return Ok(PlanOutcome::default());
705 }
706 let anonymous = principal.id == gunnar_core::ANONYMOUS_PRINCIPAL_ID;
707 let action = if required.permits(Access::Write) {
708 PlanAction::Write
709 } else if anonymous {
710 PlanAction::AnonymousRead
711 } else {
712 return Ok(PlanOutcome::default());
713 };
714 let refused = |line: String| DispatchError::Plan {
715 namespace: repo.namespace.clone(),
716 line,
717 };
718 // `service` is named here so a future fourth write-shaped verb has to
719 // decide whether it grows the tenant's data.
720 let grows_data = matches!(
721 service,
722 GitService::ReceivePack | GitService::LfsTransfer(crate::command::Operation::Upload)
723 );
724 let mut notices: Vec<String> = Vec::new();
725 let mut ceiling: Option<crate::plan::PackCeiling> = None;
726 let mut allowance: Option<gunnar_lfs::Allowance> = None;
727 // **Marked BEFORE any gate walks the disk, and that ordering is the
728 // contract**, not an accident of where the line sits. A gate's
729 // `lfs_allowance` measures the namespace's bytes off the disk; an upload
730 // admitted DURING that walk may or may not have landed bytes for the
731 // walk to see. Taking the mark first puts such an admission after the
732 // mark, so it is charged — and charged twice if the walk saw it too,
733 // which refuses a shade early. Taking the mark after the walk would put
734 // it before the mark and possibly outside the walk, which admits too
735 // much. Only one of those two roundings is a quota that leaks. See
736 // `gunnar_lfs::ledger`.
737 let mark = gunnar_lfs::AllowanceLedger::shared().mark(&repo.namespace);
738 // …and the same standing in the PACK meter, taken in the same breath and
739 // for the same reason: `pack_ceiling` below measures the namespace's
740 // packfile bytes off the disk, and a push admitted during that walk has
741 // to land after this mark to be counted.
742 //
743 // **Two marks and not one, because they are two meters.**
744 // `caps["pack_bytes"]` and `caps["lfs_bytes"]` are capped separately on
745 // the signed fact and reported separately by `Usage.Read`, and the two
746 // numbers these marks are judged against are computed from different
747 // caps. One shared counter would charge an LFS upload against a push's
748 // ceiling — cutting a push mid-transfer over room its own cap never
749 // lost — which is a quota that refuses wrongly, the same defect as one
750 // that admits wrongly. They share a disk; they do not share a cap. See
751 // `gunnar_lfs::AllowanceLedger::shared_pack`.
752 let pack_mark = gunnar_lfs::AllowanceLedger::shared_pack().mark(&repo.namespace);
753 for gate in &gates {
754 match gate.verdict(&repo.namespace, action) {
755 PlanVerdict::Allow => {}
756 PlanVerdict::Warn(line) => notices.push(line),
757 PlanVerdict::Refuse(line) => return Err(refused(line)),
758 }
759 // The quota, only for a write this gate's ladder allowed.
760 if action == PlanAction::Write && grows_data {
761 if let PlanVerdict::Refuse(line) = gate.pack_quota(&repo.namespace) {
762 return Err(refused(line));
763 }
764 // **The smallest ceiling in the chain wins**, which is the
765 // byte-wise spelling of first-`Refuse`-wins one line above: a
766 // push has to satisfy every gate, so it is held to the
767 // tightest one, and the line the client reads is that gate's
768 // own. Asked only where `pack_quota` is asked, so a read and a
769 // deletion are never given a ceiling to trip over.
770 if let Some(asked) = gate.pack_ceiling(&repo.namespace) {
771 let tighter = match &ceiling {
772 Some(held) => asked.bytes < held.bytes,
773 None => true,
774 };
775 if tighter {
776 ceiling = Some(asked);
777 }
778 }
779 // …and the same tightest-wins rule for the LFS wire, for the
780 // same reason: a transfer has to satisfy every gate, so it is
781 // held to the smallest room any of them will admit, and the
782 // sentence the tenant reads is that gate's own.
783 if let Some(asked) = gate.lfs_allowance(&repo.namespace) {
784 let tighter = match &allowance {
785 Some(held) => asked.bytes < held.bytes,
786 None => true,
787 };
788 if tighter {
789 allowance = Some(asked);
790 }
791 }
792 }
793 }
794 Ok(PlanOutcome {
795 notice: (!notices.is_empty()).then(|| notices.join("; ")),
796 // The tightest gate's number and the standing taken before any gate
797 // looked, carried side by side rather than folded into one type:
798 // see `ServiceRequest::pack_mark` for why the ceiling stays exactly
799 // what `PlanGate::pack_ceiling` returned.
800 pack_mark: ceiling.is_some().then_some(pack_mark),
801 ceiling,
802 // The tightest gate's number, bound to the standing taken above it.
803 // `mark` is dropped unused when no gate asked for an allowance,
804 // which costs one map read and holds nothing: there is no
805 // reservation in a mark to leak.
806 allowance: allowance
807 .map(|allowance| gunnar_lfs::NamespaceAllowance::new(mark, allowance)),
808 })
809 }
810}
811
812/// What [`Dispatch::ask_plan`] learned: everything the gate chain wants carried
813/// into the transfer, as opposed to the refusals, which are returned as errors
814/// and never reach here.
815///
816/// A struct and not a tuple because both fields are `Option` and both are
817/// about the plan: a `(Option<String>, Option<PackCeiling>)` at the call site
818/// is two anonymous maybes that the next field added would make three.
819#[derive(Default)]
820struct PlanOutcome {
821 /// See [`crate::ServiceRequest::plan_notice`].
822 notice: Option<String>,
823 /// See [`crate::ServiceRequest::pack_ceiling`].
824 ceiling: Option<crate::plan::PackCeiling>,
825 /// See [`crate::ServiceRequest::pack_mark`]. `Some` exactly when `ceiling`
826 /// is: a mark with no number to be judged against is bookkeeping nobody
827 /// reads, and a number with no mark is the bug this closed.
828 pack_mark: Option<gunnar_lfs::Mark>,
829 /// See [`crate::ServiceRequest::lfs_allowance`]. A third `Option` on this
830 /// struct, and the reason the struct exists rather than a tuple.
831 allowance: Option<gunnar_lfs::NamespaceAllowance>,
832}
833
834// ───────────────────────────────────────────────────────────────────────────
835// The server
836// ───────────────────────────────────────────────────────────────────────────
837
838struct Shared {
839 dispatch: Dispatch,
840 transport: Arc<dyn GitTransport>,
841 exec_grace: Option<Duration>,
842 /// Whether `auth_none` on [`ANONYMOUS_SSH_USER`] is accepted.
843 allow_anonymous: bool,
844 admission: Arc<Admission>,
845 metrics: Arc<ServerMetrics>,
846 /// **Are the stores open?** See [`GunnarSsh::with_readiness`].
847 ready: Arc<std::sync::atomic::AtomicBool>,
848 /// [`SshConfig::max_auth_attempts`], carried here because the CONNECTION
849 /// enforces it and russh 0.61.2 does not. See
850 /// [`Connection::auth_attempts`].
851 config_max_auth_attempts: usize,
852 /// [`SshConfig::session_end_grace`] and
853 /// [`SshConfig::session_end_grace_crowded`].
854 session_end_grace: (Duration, Duration),
855 /// The sealed box's tunnel-address door ([`TUNNEL_ADDRESS_VERB`]), or
856 /// `None` on a build with no WireGuard tunnel.
857 tunnel_door: Option<TunnelAddressDoor>,
858 /// The repository-settings door ([`REPO_VERB`]), or `None` on a server
859 /// with no control plane (`serve --no-grpc`).
860 repo_door: Option<RepoDoor>,
861}
862
863/// **The one control verb on this listener** (2026-09-21, T17):
864/// `gunnar-tunnel-address [a.b.c.d/prefix]`.
865///
866/// The sealed installer runs `serve --no-grpc`, so its SSH door is its only
867/// door, and a box's WireGuard tunnel address is per box and not on the
868/// medium. So it arrives here, held to **server-wide Admin** and answered by
869/// the closure `gunnar-server` injects — the SAME function the gRPC doors call.
870/// No argument is a read. Output is `key value` lines on stdout, exit 0; a
871/// refusal is `error: <why>` on stdout, exit 1 (stdout and not stderr, so a
872/// client reading only the data band still gets the sentence). Not a git
873/// verb, so it never enters [`Dispatch::authorise`] or the transfer path.
874pub const TUNNEL_ADDRESS_VERB: &str = "gunnar-tunnel-address";
875
876/// **The operator's fire drill** (stinkies ssh-double-exec-error, 2026-09-30): ONE exec
877/// verb, held to server-wide Admin like [`TUNNEL_ADDRESS_VERB`], that says exactly one
878/// ERROR naming itself a drill and who asked — what the estate's watch raises on purpose
879/// to prove its alert path end to end. Nothing is wrong when it is said; no other caller
880/// can raise the server's ERROR at will.
881pub const FIRE_DRILL_VERB: &str = "gunnar-fire-drill";
882
883/// **A member's SSH public keys, for an encrypted repository's key ring**
884/// (2026-10-06): `gunnar-recipient-keys '<namespace>/<repository>' '<member>'`.
885///
886/// What `gunnar crypt add-member NAME --from-server` asks when it is given no
887/// control address — on an installed estate the forge's SSH door (tcp/2222
888/// through the front) is the one door a member's key opens; the control plane
889/// is bound to the WireGuard overlay. The SAME rule as `Repos.RecipientKeys`:
890/// the caller reads the repository (otherwise it is told what a stranger is
891/// told, the one "not found, or no access" sentence), and so must the person
892/// named — gunnar hands out keys only for someone who could read it anyway,
893/// never a directory of everybody's keys. Output is one OpenSSH public key
894/// line per key on stdout, exit 0; a refusal is `error: <why>` on stdout,
895/// exit [`EXIT_DENIED`] (stdout, like [`TUNNEL_ADDRESS_VERB`]'s). An anonymous
896/// session is refused the channel. Not a git verb: it never enters
897/// [`Dispatch::authorise`] or the transfer path, and it changes nothing.
898pub const RECIPIENT_KEYS_VERB: &str = "gunnar-recipient-keys";
899
900/// **Who this key is** (wiring tracer B 2026-10-08): `gunnar-whoami` answers the principal id
901/// the session authenticated as, one line, exit 0 — what `gunnar crypt init` names its first
902/// member by (the account name `add-member --from-server` looks members up by). Reveals nothing
903/// the key's holder does not already know. An anonymous session is refused the channel. Not a
904/// git verb.
905pub const WHOAMI_VERB: &str = "gunnar-whoami";
906
907/// **git-lfs's fallback verb**, answered with a sentence instead of a channel
908/// failure. Gunnar serves Git LFS over pure SSH only (`git-lfs-transfer`,
909/// git-lfs >= 3.0); git-lfs asks this verb only when a pure-SSH session failed
910/// or was refused, and then prints what is on stderr after `batch request:`.
911pub const LFS_AUTHENTICATE_VERB: &str = "git-lfs-authenticate";
912
913/// What [`LFS_AUTHENTICATE_VERB`] answers, on stderr with exit 1. It must never
914/// say `not found` / `no such file`: git-lfs reads those as "no such verb" and
915/// goes on to a guessed HTTPS endpoint, which Gunnar does not serve.
916pub const LFS_AUTHENTICATE_MESSAGE: &str =
917 "Gunnar serves Git LFS over pure SSH only (git-lfs-transfer, \
918git-lfs 3.0 or newer); there is no HTTPS LFS endpoint. The pure SSH transfer just failed or was \
919refused (the server was busy or restarting): run the same command again.\n";
920
921/// **A repository's settings, over the forge's door** (owner, 2026-10-14):
922/// `gunnar-repo show '<namespace>/<repository>'`,
923/// `gunnar-repo visibility '<namespace>/<repository>' public|private`,
924/// `gunnar-repo default-branch '<namespace>/<repository>' '<refs/heads/…>'`.
925///
926/// What the client `gunnar repo show|visibility|default-branch` asks. On an
927/// installed estate the forge's SSH door (tcp/2222 through the front) is the
928/// one door a person's key opens; the control plane is bound to the WireGuard
929/// overlay. The verb decides NOTHING itself: it hands the authenticated
930/// principal and the ask to [`RepoDoor`], which `gunnar-server` answers with
931/// the SAME `Repos.Get` / `Repos.SetVisibility` / `Repos.SetDefaultBranch`
932/// handlers the control plane mounts — so the account-admin rule for
933/// visibility, the repository-Admin rule for the default branch, the audit
934/// event and the browse replica's notice are the gRPC door's, not a copy.
935/// Output is `key value` lines on stdout, exit 0; a refusal is
936/// `error: <why>` on stdout, exit [`EXIT_DENIED`]. An anonymous session is
937/// refused the channel. Not a git verb: it never enters
938/// [`Dispatch::authorise`] or the transfer path.
939pub const REPO_VERB: &str = "gunnar-repo";
940
941/// What a [`REPO_VERB`] line asks.
942#[derive(Debug, Clone, PartialEq, Eq)]
943pub enum RepoAction {
944 /// `Repos.Get`.
945 Show,
946 /// `Repos.SetVisibility`: `true` is public.
947 Visibility(bool),
948 /// `Repos.SetDefaultBranch`, the branch exactly as sent (the server
949 /// validates it; the client expands a short name).
950 DefaultBranch(String),
951 /// `Repos.Create` of an empty repository in this object format (`true`
952 /// is sha256): what a client that knows its own format asks before its
953 /// first push, since a push-made repository can only be sha1.
954 Create {
955 /// `true`: a sha256 repository; `false`: sha1.
956 sha256: bool,
957 },
958}
959
960/// One [`REPO_VERB`] ask, with who asked: the principal the session's key
961/// authenticated as (re-read at the exec, never cached) and that key's
962/// fingerprint.
963#[derive(Debug, Clone, PartialEq, Eq)]
964pub struct RepoAsk {
965 /// The principal the session's key authenticated as.
966 pub principal: String,
967 /// That key's fingerprint.
968 pub fingerprint: String,
969 /// The repository the verb names.
970 pub repo: RepoRef,
971 /// The verb itself.
972 pub action: RepoAction,
973}
974
975/// What answers [`REPO_VERB`]: the ask in, the `key value` lines or the
976/// refusal's sentence out.
977pub type RepoDoor = Arc<
978 dyn Fn(
979 RepoAsk,
980 )
981 -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send>>
982 + Send
983 + Sync,
984>;
985
986/// Parse a [`REPO_VERB`] exec line. `None`: not this verb. `Some(Err)`: this
987/// verb, malformed. Each argument may be single-quoted the way git quotes a
988/// path; the path goes through the one sandbox
989/// ([`gunnar_core::resolve_repo_path`]).
990pub fn parse_repo_verb(raw: &[u8]) -> Option<Result<(RepoRef, RepoAction), String>> {
991 let text = std::str::from_utf8(raw).ok()?;
992 let mut tokens = text.split(' ').filter(|t| !t.is_empty());
993 if tokens.next()? != REPO_VERB {
994 return None;
995 }
996 let usage = || {
997 format!(
998 "the line is not `{REPO_VERB} show <namespace>/<repository>`, \
999 `{REPO_VERB} visibility <namespace>/<repository> public|private` or \
1000 `{REPO_VERB} default-branch <namespace>/<repository> <branch>` or \
1001 `{REPO_VERB} create <namespace>/<repository> sha1|sha256`"
1002 )
1003 };
1004 if raw.len() > crate::command::MAX_EXEC_LEN || raw.iter().any(|b| *b < 0x20 || *b == 0x7f) {
1005 return Some(Err(usage()));
1006 }
1007 let mut args = Vec::new();
1008 for token in tokens {
1009 match crate::command::unquote(token) {
1010 Ok(arg) => args.push(arg),
1011 Err(_) => return Some(Err(usage())),
1012 }
1013 }
1014 let (sub, path, value) = match args.as_slice() {
1015 [sub, path] => (sub.as_str(), path.as_str(), None),
1016 [sub, path, value] => (sub.as_str(), path.as_str(), Some(value.as_str())),
1017 _ => return Some(Err(usage())),
1018 };
1019 let action = match (sub, value) {
1020 ("show", None) => RepoAction::Show,
1021 ("visibility", Some("public")) => RepoAction::Visibility(true),
1022 ("visibility", Some("private")) => RepoAction::Visibility(false),
1023 ("visibility", Some(other)) => {
1024 return Some(Err(format!(
1025 "visibility is `public` or `private`, not `{}`",
1026 other.escape_default()
1027 )))
1028 }
1029 ("default-branch", Some(branch)) if !branch.is_empty() => {
1030 RepoAction::DefaultBranch(branch.to_string())
1031 }
1032 ("create", Some("sha256")) => RepoAction::Create { sha256: true },
1033 ("create", Some("sha1")) => RepoAction::Create { sha256: false },
1034 ("create", Some(other)) => {
1035 return Some(Err(format!(
1036 "an object format is `sha1` or `sha256`, not `{}`",
1037 other.escape_default()
1038 )))
1039 }
1040 _ => return Some(Err(usage())),
1041 };
1042 let repo = match gunnar_core::resolve_repo_path(path) {
1043 Ok(repo) => repo,
1044 Err(err) => return Some(Err(err.to_string())),
1045 };
1046 Some(Ok((repo, action)))
1047}
1048
1049/// Parse a [`RECIPIENT_KEYS_VERB`] exec line. `None`: not this verb.
1050/// `Some(Err)`: this verb, malformed. The path and the member may each be
1051/// single-quoted the way git quotes a path; the path goes through the one
1052/// sandbox ([`gunnar_core::resolve_repo_path`]) and the member through the
1053/// principal id rule.
1054pub fn parse_recipient_keys_verb(raw: &[u8]) -> Option<Result<(RepoRef, String), String>> {
1055 let text = std::str::from_utf8(raw).ok()?;
1056 let (verb, rest) = text.split_once(' ').unwrap_or((text, ""));
1057 if verb != RECIPIENT_KEYS_VERB {
1058 return None;
1059 }
1060 let usage =
1061 || format!("the line is not `{RECIPIENT_KEYS_VERB} <namespace>/<repository> <member>`");
1062 if raw.len() > crate::command::MAX_EXEC_LEN || raw.iter().any(|b| *b < 0x20 || *b == 0x7f) {
1063 return Some(Err(usage()));
1064 }
1065 let Some((path, member)) = rest.trim().rsplit_once(' ') else {
1066 return Some(Err(usage()));
1067 };
1068 let (Ok(path), Ok(member)) = (
1069 crate::command::unquote(path.trim()),
1070 crate::command::unquote(member.trim()),
1071 ) else {
1072 return Some(Err(usage()));
1073 };
1074 let repo = match gunnar_core::resolve_repo_path(&path) {
1075 Ok(repo) => repo,
1076 Err(err) => return Some(Err(err.to_string())),
1077 };
1078 if gunnar_core::Principal::new(
1079 member.clone(),
1080 member.clone(),
1081 gunnar_core::PrincipalKind::User,
1082 )
1083 .is_err()
1084 {
1085 return Some(Err(format!(
1086 "`{}` is not an account name",
1087 member.escape_default()
1088 )));
1089 }
1090 Some(Ok((repo, member)))
1091}
1092
1093/// **The answer to [`RECIPIENT_KEYS_VERB`]** for `caller`: `(exit status,
1094/// stdout)`. Reads the identity table and the catalog; changes nothing.
1095pub fn recipient_keys_answer(
1096 control: &Control,
1097 caller: &str,
1098 repo: &RepoRef,
1099 member: &str,
1100) -> (u32, String) {
1101 if control.is_twin() {
1102 let said = DispatchError::Twin {
1103 principal: caller.to_string(),
1104 repo: repo.clone(),
1105 }
1106 .client_message()
1107 .unwrap_or_default();
1108 return (EXIT_DENIED, format!("error: {said}\n"));
1109 }
1110 let hidden = || {
1111 let said = DispatchError::NoSuchRepo(repo.clone())
1112 .client_message()
1113 .unwrap_or_default();
1114 (EXIT_DENIED, format!("error: {said}\n"))
1115 };
1116 // The member is told Gunnar could not answer; what broke is the operator's (the journal).
1117 let fault = |err: gunnar_core::Error| {
1118 tracing::error!(target: "gunnar::ssh", event = "ssh.recipient_keys.fault", %err, "gunnar-recipient-keys: the identity table or the catalog could not be read");
1119 (
1120 EXIT_DENIED,
1121 "error: Gunnar could not answer this just now; try again in a while\n".to_string(),
1122 )
1123 };
1124 let entry = match control.catalog().get(repo) {
1125 Ok(Some(entry)) => entry,
1126 Ok(None) => return hidden(),
1127 Err(err) => return fault(err),
1128 };
1129 match control.auth().may(caller, &entry, gunnar_core::Level::Read) {
1130 Ok(true) => {}
1131 Ok(false) => return hidden(),
1132 Err(err) => return fault(err),
1133 }
1134 match control.auth().may(member, &entry, gunnar_core::Level::Read) {
1135 Ok(true) => {}
1136 Ok(false) => {
1137 return (
1138 EXIT_DENIED,
1139 format!(
1140 "error: {member} cannot read {}/{}: grant them access before adding them as a member of its key ring\n",
1141 repo.namespace(),
1142 repo.name()
1143 ),
1144 )
1145 }
1146 Err(err) => return fault(err),
1147 }
1148 // **The member's ONE encryption key** (CRYPT-YUBI 2026-10-26): a ring is wrapped to the
1149 // person's encryption public key, never to an SSH key.
1150 let Some(keys) = control.encryption_keys() else {
1151 return (
1152 EXIT_DENIED,
1153 "error: this Gunnar keeps no encryption keys\n".to_string(),
1154 );
1155 };
1156 match keys.public_of(member) {
1157 Ok(Some(public)) => (0, format!("{}\n", public.trim())),
1158 Ok(None) => (
1159 EXIT_DENIED,
1160 format!(
1161 "error: {member} has no encryption key yet: {member} makes one with `gunnar crypt key new` \
1162 (or by adding a security key on their Account page), then the ring can be wrapped to it\n"
1163 ),
1164 ),
1165 Err(gunnar_core::encryption_key::KeyRefusal::Store(err)) => fault(err),
1166 Err(other) => (EXIT_DENIED, format!("error: {other}\n")),
1167 }
1168}
1169
1170/// **The answer to [`gunnar_crypt::door::VERB`]** (`gunnar-user-key`, CRYPT-YUBI) for
1171/// `caller`, the session's own principal: `(exit status, stdout)`. Reads or changes the
1172/// caller's own encryption key only; there is no name to give.
1173pub fn user_key_answer(
1174 control: &Control,
1175 principal: &gunnar_core::Principal,
1176 request: gunnar_crypt::door::Request,
1177) -> (u32, String) {
1178 let caller = principal.id.as_str();
1179 use gunnar_core::encryption_key::{KeyRefusal, UnlockMethod};
1180 use gunnar_crypt::door::Request;
1181 if control.is_twin() {
1182 return (
1183 EXIT_DENIED,
1184 "error: this is a twin; ask the primary\n".to_string(),
1185 );
1186 }
1187 let Some(keys) = control.encryption_keys() else {
1188 return (
1189 EXIT_DENIED,
1190 "error: this Gunnar keeps no encryption keys\n".to_string(),
1191 );
1192 };
1193 let now = std::time::SystemTime::now()
1194 .duration_since(std::time::UNIX_EPOCH)
1195 .map(|d| d.as_millis() as u64)
1196 .unwrap_or(0);
1197 let said = |r: Result<gunnar_core::encryption_key::EncryptionKey, KeyRefusal>| match r {
1198 Ok(key) => (0, key.to_answer().text()),
1199 Err(KeyRefusal::Store(err)) => {
1200 tracing::error!(target: "gunnar::ssh", event = "ssh.user_key.fault", %err, "gunnar-user-key: the store could not be read or written");
1201 (
1202 EXIT_DENIED,
1203 "error: Gunnar could not answer this just now; try again in a while\n".to_string(),
1204 )
1205 }
1206 Err(other) => (EXIT_DENIED, format!("error: {other}\n")),
1207 };
1208 match request {
1209 Request::Get => match keys.get(caller) {
1210 Ok(Some(key)) => (0, key.to_answer().text()),
1211 Ok(None) => (0, String::new()),
1212 Err(e) => said(Err(e)),
1213 },
1214 // The account's audit, the same kinds the gRPC door writes (RA4-3).
1215 Request::Create { public_key, method } => {
1216 let kind = method.kind.clone();
1217 let r = UnlockMethod::from_line(method)
1218 .and_then(|m| keys.create(caller, &public_key, m, now));
1219 if r.is_ok() {
1220 control.encryption_key_event(
1221 principal,
1222 "encryption-key.created",
1223 &format!("first method {kind}"),
1224 );
1225 tracing::info!(target: "gunnar::audit", event = "access.encryption_key_created", principal = %caller, method = %kind, "an encryption key was created");
1226 }
1227 said(r)
1228 }
1229 Request::Add(method) => {
1230 let kind = method.kind.clone();
1231 let r = UnlockMethod::from_line(method).and_then(|m| keys.add_method(caller, m, now));
1232 if r.is_ok() {
1233 control.encryption_key_event(principal, "encryption-key.method-added", &kind);
1234 tracing::info!(target: "gunnar::audit", event = "access.unlock_method_added", principal = %caller, method = %kind, "an unlock method was added");
1235 }
1236 said(r)
1237 }
1238 Request::Remove(id) => {
1239 let r = keys.remove_method(caller, &id);
1240 if r.is_ok() {
1241 control.encryption_key_event(principal, "encryption-key.method-removed", &id);
1242 tracing::info!(target: "gunnar::audit", event = "access.unlock_method_removed", principal = %caller, method_id = %id, "an unlock method was removed");
1243 }
1244 said(r)
1245 }
1246 }
1247}
1248
1249/// What answers [`TUNNEL_ADDRESS_VERB`]: the address (empty = a read) in, the
1250/// answer's lines or the refusal out.
1251pub type TunnelAddressDoor = Arc<dyn Fn(&str) -> Result<String, String> + Send + Sync>;
1252
1253/// Parse a [`TUNNEL_ADDRESS_VERB`] exec line. `None`: not this verb. `Some(Err)`:
1254/// this verb, malformed. Printable ASCII only, at most one argument, short.
1255pub fn parse_tunnel_address_verb(raw: &[u8]) -> Option<Result<String, String>> {
1256 let text = std::str::from_utf8(raw).ok()?;
1257 let mut tokens = text.split(' ').filter(|t| !t.is_empty());
1258 if tokens.next()? != TUNNEL_ADDRESS_VERB {
1259 return None;
1260 }
1261 if raw.len() > 64 || !raw.iter().all(|b| (0x20..0x7f).contains(b)) {
1262 return Some(Err(
1263 "the line is not `gunnar-tunnel-address [a.b.c.d/prefix]`".to_string(),
1264 ));
1265 }
1266 let address = tokens.next().unwrap_or("").to_string();
1267 if tokens.next().is_some() {
1268 return Some(Err(
1269 "one argument at most: `gunnar-tunnel-address [a.b.c.d/prefix]`".to_string(),
1270 ));
1271 }
1272 Some(Ok(address))
1273}
1274
1275/// **The key exchanges gunnar's SSH door offers, post-quantum FIRST**
1276/// (2026-10-06, the PQ transport lane).
1277///
1278/// `mlkem768x25519-sha256` (draft-ietf-sshm-mlkem-hybrid-kex: ML-KEM-768 and
1279/// X25519, the session key from both) is what OpenSSH ≥ 9.9 offers first and
1280/// 10.0 makes its default — russh 0.63 runs it on RustCrypto's `ml-kem`. The
1281/// classical exchanges stay after it, so an older client still connects
1282/// (preferred, never required). `sntrup761x25519-sha512` is not here: russh
1283/// does not implement it.
1284///
1285/// **gunnar's own list, not russh's default**, though the two agree today:
1286/// a russh bump that reorders or drops its `Preferred::DEFAULT` must not
1287/// change what this door offers without a red test here. SSH lets the
1288/// CLIENT's order decide; the server's job is to offer the hybrid at all.
1289pub static KEX_ORDER: &[russh::kex::Name] = &[
1290 russh::kex::MLKEM768X25519_SHA256,
1291 russh::kex::CURVE25519,
1292 russh::kex::CURVE25519_PRE_RFC_8731,
1293 russh::kex::DH_GEX_SHA256,
1294 russh::kex::DH_G18_SHA512,
1295 russh::kex::DH_G17_SHA512,
1296 russh::kex::DH_G16_SHA512,
1297 russh::kex::DH_G15_SHA512,
1298 russh::kex::DH_G14_SHA256,
1299 russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
1300 russh::kex::EXTENSION_SUPPORT_AS_SERVER,
1301 russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
1302 russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
1303];
1304
1305/// russh's algorithm preferences with gunnar's [`KEX_ORDER`]; ciphers, MACs
1306/// and host-key algorithms stay russh's.
1307pub fn preferred() -> russh::Preferred {
1308 russh::Preferred {
1309 kex: std::borrow::Cow::Borrowed(KEX_ORDER),
1310 ..russh::Preferred::DEFAULT
1311 }
1312}
1313
1314/// gunnar's SSH listener.
1315pub struct GunnarSsh {
1316 config: SshConfig,
1317 shared: Arc<Shared>,
1318 /// **A connection that arrives at a refused LOCAL address is dropped**
1319 /// before a byte is spoken (`gunnar serve --wg-only`: the utility
1320 /// network's address — every box-to-box connection runs over the tunnel).
1321 refused_local: Option<RefusedLocal>,
1322}
1323
1324/// Is a connection that arrived at this local address refused?
1325pub type RefusedLocal = Arc<dyn Fn(std::net::IpAddr) -> bool + Send + Sync>;
1326
1327impl GunnarSsh {
1328 /// Build a server over the control plane and the wire seam.
1329 pub fn new(config: SshConfig, control: Control, transport: Arc<dyn GitTransport>) -> Self {
1330 let exec_grace = config.exec_grace;
1331 let allow_anonymous = config.allow_anonymous;
1332 let config_max_auth_attempts = config.max_auth_attempts;
1333 let session_end_grace = (config.session_end_grace, config.session_end_grace_crowded);
1334 // The memory budget travels with the two concurrency numbers: one
1335 // constructor, so a configuration cannot arrive with a governed
1336 // concurrency limit and an unmetered budget. `None` falls back to the
1337 // shipped constant rather than to "unlimited" — an operator who did not
1338 // set a budget wants the default, not the removal of a gate.
1339 let budget = config
1340 .memory_budget_bytes
1341 .unwrap_or(crate::governor::DEFAULT_MEMORY_BUDGET_BYTES);
1342 let wait = config
1343 .admission_wait
1344 .unwrap_or(crate::governor::DEFAULT_ADMISSION_WAIT);
1345 let admission = Arc::new(
1346 if config.per_principal_limit_is_hard {
1347 Admission::hard_capped(
1348 config.max_concurrent_transfers,
1349 config.max_transfers_per_principal,
1350 )
1351 .with_budget(budget)
1352 } else {
1353 Admission::with_memory_budget(
1354 config.max_concurrent_transfers,
1355 config.max_transfers_per_principal,
1356 budget,
1357 )
1358 }
1359 // The anonymous door and its width are configured together, so
1360 // an appliance that opens the door cannot forget the ceiling.
1361 .with_anonymous_ceiling(config.max_anonymous_transfers)
1362 .with_wait(wait)
1363 .with_keyed_wait(config.keyed_admission_wait),
1364 );
1365 // The usage share (`admit-share`): the configured policy, unseeded
1366 // until `with_flow` hands it the durable ledger to seed from.
1367 #[cfg(feature = "admit-share")]
1368 let admission = Arc::new(
1369 Arc::try_unwrap(admission)
1370 .expect("the gate is not shared yet")
1371 .with_policy(config.admit.build()),
1372 );
1373 GunnarSsh {
1374 config,
1375 refused_local: None,
1376 shared: Arc::new(Shared {
1377 dispatch: Dispatch::new(control),
1378 transport,
1379 exec_grace,
1380 allow_anonymous,
1381 admission,
1382 metrics: Arc::new(ServerMetrics::new()),
1383 // Ready unless somebody hands us a flag to watch. A listener
1384 // built by a test has nothing to wait for, and a default of
1385 // "not ready" would make every such test hang on a sentence
1386 // about a boot that never happened.
1387 ready: Arc::new(std::sync::atomic::AtomicBool::new(true)),
1388 config_max_auth_attempts,
1389 session_end_grace,
1390 tunnel_door: None,
1391 repo_door: None,
1392 }),
1393 }
1394 }
1395
1396 /// **Watch a readiness flag**, and answer git traffic with a *retryable*
1397 /// sentence until it is set.
1398 ///
1399 /// # The boot window — MEASURED 2026-08-30
1400 ///
1401 /// After a power cut the appliance answered ssh :2222 and grpc :50051
1402 /// **0.6–0.7 s before its stores had finished their post-crash open**, and
1403 /// a read in that window came back `the repository is unavailable; try
1404 /// again`. That sentence is `gunnar_wire::Error::Store`'s, it arrives with
1405 /// a *denial* exit status, and it is what a client is told when a
1406 /// repository is genuinely broken — so a harness could not tell "not yet"
1407 /// from "gone". The first power-cut door reported **64 acked pushes
1408 /// missing**; a boot one second later found every one of them, and the same
1409 /// window wedged a whole bench run's clone and fetch phases.
1410 ///
1411 /// The fix is not to bind later — PID 1 must answer quickly or an operator
1412 /// watching a console sees a dead box — but to answer **honestly**. While
1413 /// the flag is clear every git exec is refused in git's own protocol with
1414 /// [`EXIT_BUSY`] (75, EX_TEMPFAIL — the same status the admission gate
1415 /// uses, which is the status that means *retry*) and a sentence containing
1416 /// the word **starting**, which is the word a script may key on. Nothing is
1417 /// served, nothing is opened, and nothing looks like a missing repository.
1418 ///
1419 /// A flag and not a trait: `gunnar-ssh` may not read the operating system,
1420 /// and "have the stores finished opening" is `gunnar-server`'s fact to
1421 /// publish, not this crate's to discover.
1422 pub fn with_readiness(mut self, ready: Arc<std::sync::atomic::AtomicBool>) -> Self {
1423 // `Arc::get_mut` cannot be used: `bind` has not run, but `Shared` is
1424 // already behind an `Arc`. Rebuilding it is one allocation at start-up
1425 // and keeps every field in one place.
1426 let shared = Arc::try_unwrap(self.shared).unwrap_or_else(|shared| {
1427 panic!(
1428 "readiness must be set before the listener is shared: {} strong refs",
1429 Arc::strong_count(&shared)
1430 )
1431 });
1432 self.shared = Arc::new(Shared { ready, ..shared });
1433 self
1434 }
1435
1436 /// **Refuse every connection that arrives at a local address `refused`
1437 /// names** — closed at accept, nothing said, logged once per peer at
1438 /// debug. `gunnar serve --wg-only` names the utility address: the public's
1439 /// git clients still reach SSH, a box over plain IP does not.
1440 pub fn with_refused_local(mut self, refused: RefusedLocal) -> Self {
1441 self.refused_local = Some(refused);
1442 self
1443 }
1444
1445 /// **Answer [`TUNNEL_ADDRESS_VERB`]** with `door` — `gunnar-server` hands in
1446 /// its `appliance::tunnel_address_answer`, the function both gRPC doors
1447 /// call. Without one the verb is answered "no WireGuard tunnel".
1448 pub fn with_tunnel_address_door(mut self, door: TunnelAddressDoor) -> Self {
1449 let shared = Arc::try_unwrap(self.shared).unwrap_or_else(|shared| {
1450 panic!(
1451 "the tunnel door must be set before the listener is shared: {} strong refs",
1452 Arc::strong_count(&shared)
1453 )
1454 });
1455 self.shared = Arc::new(Shared {
1456 tunnel_door: Some(door),
1457 ..shared
1458 });
1459 self
1460 }
1461
1462 /// **Answer [`REPO_VERB`]** with `door` — `gunnar-server` hands in the
1463 /// control plane's own `Repos` handlers. Without one the verb is answered
1464 /// "this server answers repository settings only on its control plane".
1465 pub fn with_repo_door(mut self, door: RepoDoor) -> Self {
1466 let shared = Arc::try_unwrap(self.shared).unwrap_or_else(|shared| {
1467 panic!(
1468 "the repository door must be set before the listener is shared: {} strong refs",
1469 Arc::strong_count(&shared)
1470 )
1471 });
1472 self.shared = Arc::new(Shared {
1473 repo_door: Some(door),
1474 ..shared
1475 });
1476 self
1477 }
1478
1479 /// **Give the admission gate the per-namespace flow ledger.**
1480 ///
1481 /// The gate is what all three front doors — this exec line, smart HTTP and
1482 /// the gRPC transfer gate — ask before a transfer, so it is the one place a
1483 /// refusal can be charged to a tenant once rather than three times. The
1484 /// exec line's own `no-grant` denial reaches the same ledger back through
1485 /// `Admission::flow`.
1486 ///
1487 /// A listener built without one refuses exactly as it always did and counts
1488 /// the refusal only in its own `AdmissionStats`, which is what a test that
1489 /// drives `gunnar-ssh` alone wants — and what every build before the ledger
1490 /// existed did.
1491 ///
1492 /// The gate is rebuilt rather than mutated for the reason
1493 /// [`Self::with_readiness`] rebuilds `Shared`: `bind` has not run, but the
1494 /// `Arc` is already there.
1495 pub fn with_flow(mut self, flow: Arc<gunnar_core::FlowLedger>) -> Self {
1496 let shared =
1497 Arc::get_mut(&mut self.shared).expect("the shared state is not published until `bind`");
1498 let gate = Arc::try_unwrap(std::mem::replace(
1499 &mut shared.admission,
1500 Arc::new(Admission::unlimited()),
1501 ))
1502 .unwrap_or_else(|gate| {
1503 panic!(
1504 "the flow ledger must be set before the gate is shared: {} strong refs",
1505 Arc::strong_count(&gate)
1506 )
1507 });
1508 // admit-share: the policy is rebuilt SEEDED from this ledger — its
1509 // table is then a cache of the ledger, filled once per namespace.
1510 #[cfg(feature = "admit-share")]
1511 let gate = gate.with_policy(
1512 self.config
1513 .admit
1514 .build_seeded(Some(Admission::flow_seed(Arc::clone(&flow)))),
1515 );
1516 shared.admission = Arc::new(gate.with_flow(flow));
1517 self
1518 }
1519
1520 /// Share the server-wide counters with the rest of the process.
1521 ///
1522 /// **Not a second set of counters.** `gunnar serve` mints one
1523 /// [`ServerMetrics`] and hands the same `Arc` to this listener and to the
1524 /// git transport, so "connections refused" and "pushes rejected by gate"
1525 /// are numbers about one server rather than two crates' private tallies.
1526 /// A listener built without this one still counts, into a set nobody else
1527 /// can read, which is what a test that only drives `gunnar-ssh` wants.
1528 pub fn with_metrics(mut self, metrics: Arc<ServerMetrics>) -> Self {
1529 let shared =
1530 Arc::get_mut(&mut self.shared).expect("the shared state is not published until `bind`");
1531 shared.metrics = metrics;
1532 self
1533 }
1534
1535 /// The counters this listener writes to.
1536 pub fn metrics(&self) -> &Arc<ServerMetrics> {
1537 &self.shared.metrics
1538 }
1539
1540 /// The russh configuration this server runs with.
1541 ///
1542 /// `methods` is publickey **only**, and stays that way even with the
1543 /// anonymous path open: `none` is available for exactly one username,
1544 /// so advertising it to everybody would make the failure list a lie and
1545 /// invite every client to try something that will not work. russh calls
1546 /// [`Handler::auth_none`] on the client's opening probe regardless of what
1547 /// is advertised, which is the only place it is needed.
1548 ///
1549 /// `password` is not offered and there is no handler for it, so the
1550 /// rejecting default is never the last line of defence.
1551 fn russh_config(&self, host_key: russh::keys::PrivateKey) -> RusshConfig {
1552 RusshConfig {
1553 server_id: russh::SshId::Standard(self.config.server_id.clone().into()),
1554 preferred: preferred(),
1555 methods: MethodSet::from(&[MethodKind::PublicKey][..]),
1556 auth_rejection_time: self.config.auth_rejection_time,
1557 auth_rejection_time_initial: Some(Duration::ZERO),
1558 keys: vec![host_key],
1559 max_auth_attempts: self.config.max_auth_attempts,
1560 inactivity_timeout: self.config.inactivity_timeout,
1561 keepalive_interval: self.config.keepalive_interval,
1562 nodelay: true,
1563 ..RusshConfig::default()
1564 }
1565 }
1566
1567 /// Bind the listener and start accepting.
1568 ///
1569 /// Returns once the socket is bound, so a caller (or a test) knows the
1570 /// real address before the first client connects. That matters for
1571 /// [`SshConfig::ephemeral_loopback`], where the port is chosen by the
1572 /// kernel.
1573 pub async fn bind(self, host_key: russh::keys::PrivateKey) -> std::io::Result<BoundServer> {
1574 let listener = TcpListener::bind(self.config.bind).await?;
1575 let local_addr = listener.local_addr()?;
1576 let russh_config = Arc::new(self.russh_config(host_key));
1577 let shared = self.shared.clone();
1578 let admission = Arc::clone(&self.shared.admission);
1579 let metrics = Arc::clone(&self.shared.metrics);
1580 let (handle, _) = tokio::sync::broadcast::channel::<String>(1);
1581
1582 let join = tokio::spawn(accept_loop(
1583 listener,
1584 russh_config,
1585 Factory { shared },
1586 handle.clone(),
1587 self.refused_local.clone(),
1588 ));
1589
1590 tracing::info!(
1591 target: TARGET_SSH,
1592 %local_addr,
1593 max_concurrent_transfers = ?admission.max_concurrent(),
1594 max_transfers_per_principal = ?admission.max_per_principal(),
1595 per_principal_is_hard = admission.per_principal_is_hard(),
1596 "gunnar ssh listener bound"
1597 );
1598 Ok(BoundServer {
1599 local_addr,
1600 handle,
1601 join,
1602 admission,
1603 metrics,
1604 })
1605 }
1606}
1607
1608/// How long the accept loop waits after the kernel refused it a descriptor,
1609/// before it asks again. A session ending in that window frees what the next
1610/// `accept` needs; nothing shorter gains anything, nothing longer is felt.
1611pub const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
1612
1613/// Is this `accept` failure the kind the loop must outlive?
1614///
1615/// The resource ones — no descriptor left in the process (`EMFILE`) or in the
1616/// system (`ENFILE`), no buffer or memory for the socket — and the one the
1617/// client causes, `ECONNABORTED` (it hung up between SYN and `accept`).
1618/// Everything else (`EBADF`, `EINVAL`, a closed listener) is the listener
1619/// itself being gone, and the loop must return that.
1620pub fn accept_error_is_transient(err: &std::io::Error) -> bool {
1621 // Linux errno values, spelled here rather than through a `libc` crate this
1622 // crate does not otherwise carry: ENOMEM 12, ENFILE 23, EMFILE 24,
1623 // ECONNABORTED 103, ENOBUFS 105.
1624 matches!(err.raw_os_error(), Some(12 | 23 | 24 | 103 | 105))
1625 || matches!(
1626 err.kind(),
1627 std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::OutOfMemory
1628 )
1629}
1630
1631/// **The accept loop, owned here so that it cannot die of `EMFILE`.**
1632///
1633/// russh's `run_on_socket` is the same loop with one difference that cost a
1634/// whole appliance: its `Err(e) => return Err(e)` on `accept`. MEASURED
1635/// 2026-08-29 on the rig: the 202nd repository touched since boot ran the
1636/// process out of descriptors, the next `accept` failed with `EMFILE`, the
1637/// loop returned, and every later client got `kex_exchange_identification:
1638/// read: Connection reset by peer` until reboot — the kernel kept completing
1639/// handshakes into a backlog nobody drained — while gRPC on its own listener
1640/// answered as if nothing were wrong. A server that has no descriptor for the
1641/// next client must say so and try again in a moment, not resign.
1642///
1643/// So on a transient failure ([`accept_error_is_transient`]) it logs a WARN
1644/// on `gunnar::ssh` — a row on the appliance dashboard — sleeps
1645/// [`ACCEPT_BACKOFF`] and continues. Everything else is what russh did: one
1646/// task per connection, `set_nodelay`, `run_stream`, the session raced against
1647/// the shutdown broadcast so `BoundServer::shutdown` still disconnects every
1648/// live session by application, and session errors go through
1649/// [`note_session_end`] exactly as `Server::handle_session_error` did.
1650async fn accept_loop(
1651 listener: TcpListener,
1652 config: Arc<RusshConfig>,
1653 mut factory: Factory,
1654 shutdown: tokio::sync::broadcast::Sender<String>,
1655 refused_local: Option<RefusedLocal>,
1656) -> std::io::Result<()> {
1657 let mut shutdown_rx = shutdown.subscribe();
1658 let mut refused_in_a_row: u32 = 0;
1659 loop {
1660 tokio::select! {
1661 _ = shutdown_rx.recv() => {
1662 tracing::debug!(target: TARGET_SSH, "ssh listener shutdown requested");
1663 return Ok(());
1664 }
1665 accepted = listener.accept() => {
1666 let (socket, peer) = match accepted {
1667 Ok(pair) => pair,
1668 Err(err) if accept_error_is_transient(&err) => {
1669 refused_in_a_row += 1;
1670 tracing::warn!(
1671 target: TARGET_SSH,
1672 event = "ssh.accept.refused",
1673 error = %err,
1674 refused_in_a_row,
1675 "ssh accept failed: {err}; the listener stays up and retries in {} ms",
1676 ACCEPT_BACKOFF.as_millis()
1677 );
1678 tokio::time::sleep(ACCEPT_BACKOFF).await;
1679 continue;
1680 }
1681 Err(err) => {
1682 tracing::error!(
1683 target: TARGET_SSH,
1684 event = "ssh.accept.failed",
1685 error = %err,
1686 "ssh accept failed with an error the listener cannot outlive"
1687 );
1688 return Err(err);
1689 }
1690 };
1691 if let (Some(refused), Ok(local)) = (&refused_local, socket.local_addr()) {
1692 if refused(local.ip()) {
1693 tracing::debug!(target: TARGET_SSH, event = "ssh.accept.plain_ip_refused", %peer, %local, "a connection at the utility address is refused (--wg-only): boxes speak over the tunnel");
1694 drop(socket);
1695 continue;
1696 }
1697 }
1698 if refused_in_a_row > 0 {
1699 tracing::info!(
1700 target: TARGET_SSH,
1701 event = "ssh.accept.recovered",
1702 refused_in_a_row,
1703 "ssh accept works again"
1704 );
1705 refused_in_a_row = 0;
1706 }
1707 let handler = factory.new_client(Some(peer));
1708 let config = Arc::clone(&config);
1709 let mut session_shutdown = shutdown.subscribe();
1710 tokio::spawn(async move {
1711 if config.nodelay {
1712 if let Err(err) = socket.set_nodelay(true) {
1713 tracing::debug!(target: TARGET_SSH, %err, "set_nodelay failed");
1714 }
1715 }
1716 let session = match russh::server::run_stream(config, socket, handler).await {
1717 Ok(session) => session,
1718 Err(err) => {
1719 note_session_end(&err);
1720 return;
1721 }
1722 };
1723 let handle = session.handle();
1724 tokio::select! {
1725 reason = session_shutdown.recv() => {
1726 let reason = reason.unwrap_or_default();
1727 let _ = handle
1728 .disconnect(russh::Disconnect::ByApplication, reason, String::new())
1729 .await;
1730 }
1731 ended = session => {
1732 if let Err(err) = ended {
1733 note_session_end(&err);
1734 }
1735 }
1736 }
1737 });
1738 }
1739 }
1740 }
1741}
1742
1743/// A bound, running listener.
1744pub struct BoundServer {
1745 local_addr: SocketAddr,
1746 /// The shutdown broadcast every live session and the accept loop listen on.
1747 handle: tokio::sync::broadcast::Sender<String>,
1748 join: JoinHandle<std::io::Result<()>>,
1749 admission: Arc<Admission>,
1750 metrics: Arc<ServerMetrics>,
1751}
1752
1753impl BoundServer {
1754 /// The address actually bound, with the kernel's choice of port filled in.
1755 pub fn local_addr(&self) -> SocketAddr {
1756 self.local_addr
1757 }
1758
1759 /// What the admission gate has done since this listener started.
1760 ///
1761 /// This is the visibility half of the fix. A limit whose saturation cannot
1762 /// be read is the same undiagnosable "git is slow" the accidental 512-thread
1763 /// ceiling produced; `admitted`, `refused_*` and `peak_in_flight` are what
1764 /// an operator, or a test, looks at instead.
1765 pub fn admission(&self) -> AdmissionStats {
1766 self.admission.stats()
1767 }
1768
1769 /// The admission gate itself, so a **second transport can route through the
1770 /// SAME one** (one implementation).
1771 ///
1772 /// `gunnar-http` is a second front door onto the same server, and a git
1773 /// transfer over HTTP has to count against the same server-wide and
1774 /// per-principal ceilings — and the same memory budget — as one over SSH,
1775 /// or an appliance's real concurrency is silently twice the number written
1776 /// on the box. Handing out the `Arc` (rather than rebuilding a gate from
1777 /// the same numbers) is what makes it one gate with one set of counters,
1778 /// not two that happen to agree: on 2026-08-31 the HTTP front was found with no gate
1779 /// at all, and two gates would have been the next-worst answer.
1780 pub fn admission_gate(&self) -> Arc<Admission> {
1781 Arc::clone(&self.admission)
1782 }
1783
1784 /// The server-wide counters.
1785 pub fn metrics(&self) -> gunnar_core::observe::MetricsSnapshot {
1786 self.metrics.snapshot()
1787 }
1788
1789 /// The counter block itself, for a caller that wants to keep reading it
1790 /// after this listener is consumed by [`BoundServer::shutdown`].
1791 pub fn metrics_handle(&self) -> &Arc<ServerMetrics> {
1792 &self.metrics
1793 }
1794
1795 /// Ask every session to disconnect, then wait for the accept loop to stop
1796 /// — for at most [`SSH_SHUTDOWN_GRACE`].
1797 ///
1798 /// **Bounded, the way the gRPC half already is** (`GRPC_SHUTDOWN_GRACE` in
1799 /// gunnar-server): the accept loop only ends when its sessions do, and a
1800 /// session whose peer never answers the disconnect — MEASURED 2026-09-28 on
1801 /// loki, a primary's replication session into a standby being stopped, 1 run
1802 /// in 17 under a parallel test binary — held `shutdown` for an hour. A
1803 /// shutdown that can hang forever is a restart, a drain or a growth that can
1804 /// hang forever. Past the grace the listener task is aborted and the reason
1805 /// is logged; the sessions go with the process.
1806 pub async fn shutdown(self, reason: &str) -> std::io::Result<()> {
1807 self.shutdown_within(reason, SSH_SHUTDOWN_GRACE).await
1808 }
1809
1810 /// [`Self::shutdown`] with an explicit grace (tests use a short one).
1811 pub async fn shutdown_within(self, reason: &str, grace: Duration) -> std::io::Result<()> {
1812 let _ = self.handle.send(reason.to_string());
1813 let abort = self.join.abort_handle();
1814 match tokio::time::timeout(grace, self.join).await {
1815 Ok(Ok(result)) => result,
1816 Ok(Err(err)) if err.is_cancelled() => Ok(()),
1817 Ok(Err(err)) => Err(std::io::Error::other(err)),
1818 Err(_elapsed) => {
1819 tracing::warn!(
1820 target: TARGET_SSH,
1821 ?grace,
1822 "the SSH listener did not stop within its grace: a session did not \
1823 answer the disconnect. Aborting the listener so the shutdown completes."
1824 );
1825 abort.abort();
1826 Ok(())
1827 }
1828 }
1829 }
1830}
1831
1832struct Factory {
1833 shared: Arc<Shared>,
1834}
1835
1836impl Server for Factory {
1837 type Handler = Connection;
1838
1839 fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Connection {
1840 // ── ONE span per connection, and one id ────────────────────────────
1841 //
1842 // `principal` and `repo` are declared `Empty` and filled in by
1843 // `auth_publickey` and `exec_request` as they become known, so every
1844 // line emitted under this connection carries all three WITHOUT the
1845 // call site repeating them. Before this, connection identity in the
1846 // SSH logs was an ad-hoc `?self.peer` copy-pasted to fourteen call
1847 // sites, and there was no way at all to tie two lines together.
1848 let conn = RequestId::next();
1849 let span = tracing::info_span!(
1850 target: TARGET_SSH,
1851 "ssh.connection",
1852 %conn,
1853 peer = tracing::field::Empty,
1854 principal = tracing::field::Empty,
1855 repo = tracing::field::Empty,
1856 );
1857 if let Some(peer) = peer_addr {
1858 span.record("peer", tracing::field::display(peer));
1859 }
1860 self.shared.metrics.add_connections_opened(1);
1861 span.in_scope(|| tracing::debug!(target: TARGET_SSH, "ssh connection accepted"));
1862 Connection {
1863 shared: self.shared.clone(),
1864 span,
1865 principal: None,
1866 key_fingerprint: None,
1867 channels: HashMap::new(),
1868 lfs_slots: HashMap::new(),
1869 exec_seen: Arc::new(AtomicBool::new(false)),
1870 misuse_said: false,
1871 auth_attempts: 0,
1872 session_over: tokio::sync::watch::channel(false).0,
1873 }
1874 }
1875
1876 /// The session loop stopped. Usually because the client hung up, which is
1877 /// how every git client ends a transfer and is not news.
1878 ///
1879 /// A free function does the deciding, for the same reason
1880 /// [`note_teardown`] is one: the rule can then be asserted without standing
1881 /// up a russh session, and what is asserted is the line an operator reads.
1882 fn handle_session_error(&mut self, error: anyhow::Error) {
1883 note_session_end(&error);
1884 }
1885}
1886
1887/// How many `git-lfs-transfer` channels of one connection share one admission slot
1888/// ([`Connection::lfs_slots`]): git-lfs's pool is `lfs.concurrenttransfers` + 1 (9 by
1889/// default); 32 leaves room for a client that raised it, and bounds what one slot carries.
1890pub const LFS_CHANNELS_PER_SLOT: usize = 32;
1891
1892/// One open session channel, before its exec request arrives.
1893struct OpenChannel {
1894 /// The channel itself. The exec request carries only a [`ChannelId`], and
1895 /// the byte stream can only be taken from the `Channel`.
1896 channel: Channel<Msg>,
1897 /// The value of `GIT_PROTOCOL`, if the client sent it and
1898 /// [`ENV_ALLOWLIST`] accepted the name. Kept beside the channel it was sent
1899 /// on so that it cannot outlive it or be read for a different one.
1900 git_protocol: Option<String>,
1901}
1902
1903/// One client connection.
1904struct Connection {
1905 shared: Arc<Shared>,
1906 /// The `ssh.connection` span every line from this connection is emitted
1907 /// under. Cloned (cheap: it is an id, not the data) rather than borrowed
1908 /// at each entry point, because entering it borrows `self` immutably while
1909 /// every handler needs `&mut self`.
1910 span: tracing::Span,
1911 /// Set by `auth_publickey`, and the only thing that authorises anything.
1912 principal: Option<Principal>,
1913 /// The key `auth_publickey` authenticated this session with.
1914 ///
1915 /// Beside `principal` rather than inside it, because a principal is a
1916 /// server-side identity that may hold several keys and this is the one that
1917 /// made *this* connection. Stays `None` for the anonymous path, which
1918 /// authenticated with no key at all.
1919 key_fingerprint: Option<Fingerprint>,
1920 channels: HashMap<ChannelId, OpenChannel>,
1921 /// **The admission slot this connection's `git-lfs-transfer` channels share**, per
1922 /// repository and operation (GHUNT-7, 2026-10-28). Stock git-lfs multiplexes its
1923 /// pure-SSH pool over ONE connection (`lfs.ssh.automultiplex`): `concurrenttransfers`
1924 /// (8) + 1 channels, opened at once and kept open, mostly idle. A slot per channel let
1925 /// one git-lfs on a box whose limit is under nine wait for slots its own idle channels
1926 /// held: the push failed after the keyed wait, or hung. One client, one slot; a slot
1927 /// carries at most [`LFS_CHANNELS_PER_SLOT`] channels, past that a channel asks the
1928 /// gate as any transfer does.
1929 lfs_slots: HashMap<(String, GitService), std::sync::Weak<crate::admission::AdmissionGuard>>,
1930 exec_seen: Arc<AtomicBool>,
1931 /// A request for a channel this connection does not hold open (a client's second
1932 /// `exec` on one channel) was said already: WARN once per connection, then quiet —
1933 /// a client's protocol misuse is never the server's ERROR (stinkies ssh-double-exec-error).
1934 misuse_said: bool,
1935 /// **How many publickey offers this connection has had refused.**
1936 ///
1937 /// Counted here because russh does not count it. `SshConfig:: /// max_auth_attempts` is handed to russh 0.61.2's `Config`, which declares
1938 /// the field, defaults it to 10 and `Debug`-prints it — and never compares
1939 /// it to anything. VERIFIED by reading the whole of that crate's source:
1940 /// the only three occurrences are the declaration, the default and the
1941 /// `Debug` impl. (russh 0.63 does compare it, and carries tests named for
1942 /// exactly this; gunnar is pinned to 0.61.2 through an exact `ssh-key`
1943 /// dependency, and a limit that works is not worth an SSH-stack bump to
1944 /// obtain.)
1945 ///
1946 /// So until 2026-09-11 a connection could offer keys without bound: a
1947 /// dictionary of public keys against the identity table, at
1948 /// `auth_rejection_time` apiece, on one TCP connection that never had to be
1949 /// re-established.
1950 auth_attempts: usize,
1951 /// **Set when this session ends** — by `Drop`, because russh drops the
1952 /// handler on every way a session ends (hang-up, unanswered keepalives,
1953 /// inactivity, protocol error, shutdown). Every transfer's writer watches
1954 /// it ([`SessionBound`]): russh's own writer parks on the SSH window and
1955 /// nothing wakes it when the session dies, which left a fetch to a dead
1956 /// client holding its fan worker and admission slot forever (cov-13
1957 /// finding 4, 2026-10-06).
1958 session_over: tokio::sync::watch::Sender<bool>,
1959}
1960
1961impl Drop for Connection {
1962 fn drop(&mut self) {
1963 self.session_over.send_replace(true);
1964 }
1965}
1966
1967/// Why a session that authenticated may no longer act.
1968///
1969/// Two variants and not a `bool`, for [`Control::principal_for_key`]'s reason:
1970/// "nobody holds this key any more" and "the identity table could not be read"
1971/// call for entirely different operator action, and both refuse.
1972#[derive(Debug)]
1973enum Stale {
1974 /// The key that opened this session no longer resolves to anyone, or
1975 /// resolves to somebody else. Revoked, expired, or re-registered.
1976 Revoked,
1977 /// The identity table could not be read. Refused, because failing open is
1978 /// not on the table — the same answer `auth_publickey` gives.
1979 Unreadable(gunnar_core::Error),
1980}
1981
1982impl Connection {
1983 /// **Who this session may act as right now — asked again, per exec.**
1984 ///
1985 /// `auth_publickey` caches the principal, and until 2026-09-11 every exec
1986 /// on the connection was authorised out of that cache. Revocation
1987 /// therefore took effect immediately for a NEW connection and never for an
1988 /// open one: `DocRef::read` caches nothing, so the identity table was
1989 /// current the whole time and nothing asked it. A long-lived session — and
1990 /// git over SSH holds one open for as long as a client wants to keep
1991 /// pushing — outlived the credential that opened it, for as long as it
1992 /// liked. An operator who revokes a leaked key and sees the revocation take
1993 /// effect on their own next connection has every reason to believe it took
1994 /// effect everywhere.
1995 ///
1996 /// Cheap enough to do per exec: it is the same single
1997 /// `principal_by_fingerprint` lookup the connection already pays once at
1998 /// auth, and an exec is about to fork a git process and move a pack.
1999 ///
2000 /// A key that now belongs to a DIFFERENT identity is refused rather than
2001 /// adopted. This session authenticated as somebody; if the key has since
2002 /// been registered to somebody else, this is not that session, and
2003 /// continuing as the new holder would make re-registration a way to inherit
2004 /// an open connection.
2005 ///
2006 /// The anonymous path has no fingerprint — it authenticated with no key at
2007 /// all — so there is nothing to re-read, and `Principal::anonymous()` is not
2008 /// in the identity table to be revoked from. It keeps the cached value, and
2009 /// what anonymity may DO is still `Dispatch::authorise`'s answer.
2010 fn live_principal(&self) -> Result<Principal, Stale> {
2011 let Some(cached) = self.principal.clone() else {
2012 return Err(Stale::Revoked);
2013 };
2014 let Some(fingerprint) = self.key_fingerprint.as_ref() else {
2015 return Ok(cached);
2016 };
2017 match self
2018 .shared
2019 .dispatch
2020 .control()
2021 .principal_for_key(fingerprint)
2022 {
2023 Ok(Some(now)) if now.id == cached.id => Ok(now),
2024 Ok(_) => Err(Stale::Revoked),
2025 Err(err) => Err(Stale::Unreadable(err)),
2026 }
2027 }
2028
2029 /// **Count a refused publickey offer, and say whether this connection has
2030 /// had its last one.**
2031 ///
2032 /// One place, called from every refusing arm of `auth_publickey`, so a new
2033 /// refusal reason cannot arrive uncounted — which is how a limit stops
2034 /// applying to exactly the case somebody added last.
2035 ///
2036 /// `0` is no limit, which is russh's own reading of the same field and the
2037 /// reading an operator who writes `0` expects.
2038 ///
2039 /// The `none` probe is deliberately NOT counted. Every OpenSSH client opens
2040 /// with one to learn the method list, so counting it would spend one of the
2041 /// six on the ordinary first packet of an ordinary session — and it carries
2042 /// no key, so it cannot be the dictionary attempt the limit is for.
2043 fn refused_auth(&mut self) -> bool {
2044 self.auth_attempts += 1;
2045 let max = self.shared.config_max_auth_attempts;
2046 max > 0 && self.auth_attempts >= max
2047 }
2048
2049 /// The error that ends a connection which has offered too many keys.
2050 ///
2051 /// An `Err` out of a handler, because russh 0.61.2's `auth_publickey` is
2052 /// handed no session and no handle, so there is nothing to call
2053 /// `disconnect` on — and the run loop treating a handler error as the end
2054 /// of the session is exactly the outcome wanted. It is a `warn!`, not a
2055 /// `debug!`: one of these is a client with a full agent, and a hundred is
2056 /// somebody reading the identity table one key at a time.
2057 fn too_many_auth_attempts(&self) -> anyhow::Error {
2058 tracing::warn!(
2059 target: TARGET_SSH,
2060 event = "ssh.auth.too_many",
2061 attempts = self.auth_attempts,
2062 max = self.shared.config_max_auth_attempts,
2063 "disconnecting: too many refused publickey offers on one connection"
2064 );
2065 self.shared.metrics.add_connections_refused(1);
2066 anyhow::anyhow!(
2067 "too many authentication failures ({} refused publickey offers)",
2068 self.auth_attempts
2069 )
2070 }
2071}
2072
2073impl Handler for Connection {
2074 type Error = anyhow::Error;
2075
2076 /// **The anonymous read path.** Accept the `none` method for exactly
2077 /// one username, and refuse it for every other.
2078 ///
2079 /// The refusing branch is byte-for-byte what russh's default handler did
2080 /// before this method existed: `Auth::reject()`, which makes russh drop
2081 /// `MethodKind::None` from the advertised set and let the client proceed to
2082 /// publickey. So a client that does not ask for anonymity is on exactly the
2083 /// path it was on yesterday.
2084 ///
2085 /// The accepting branch does **not** consult the catalog, the repository or
2086 /// the ACL: it establishes *who* is asking (nobody), and *what nobody may
2087 /// do* is `Dispatch::authorise`'s answer, through
2088 /// [`AuthTable::may`](gunnar_core::AuthTable::may), like everyone else.
2089 /// Deciding here would be a second ACL.
2090 async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
2091 let span = self.span.clone();
2092 let _entered = span.enter();
2093 if !self.shared.allow_anonymous || user != ANONYMOUS_SSH_USER {
2094 // Not a refusal worth a `warn!`: EVERY OpenSSH client opens with a
2095 // `none` probe to learn the method list, so this is the ordinary
2096 // first packet of an ordinary authenticated session.
2097 tracing::debug!(
2098 target: TARGET_SSH,
2099 event = "ssh.auth.none.refused",
2100 reason = if self.shared.allow_anonymous {
2101 "not-the-anonymous-user"
2102 } else {
2103 "anonymous-access-disabled"
2104 },
2105 decorative_ssh_user = %user,
2106 "the none method is refused; publickey is the only way in"
2107 );
2108 return Ok(Auth::reject());
2109 }
2110
2111 let anonymous = Principal::anonymous();
2112 span.record("principal", tracing::field::display(&anonymous.id));
2113 self.shared.metrics.add_connections_anonymous(1);
2114 tracing::info!(
2115 target: TARGET_SSH,
2116 event = "ssh.auth.anonymous",
2117 // Named so nobody later reads this line as "a key was accepted".
2118 // It is on `gunnar::ssh` at `info` because "who is reading my
2119 // public repositories" must be answerable from a production log.
2120 authenticated = false,
2121 "anonymous session accepted: no key, public repositories only"
2122 );
2123 self.principal = Some(anonymous);
2124 Ok(Auth::Accept)
2125 }
2126
2127 async fn auth_publickey(
2128 &mut self,
2129 user: &str,
2130 key: russh::server::VerifiedPublicKey<'_>,
2131 ) -> Result<Auth, Self::Error> {
2132 // russh 0.65: the key arrives with the algorithm its signature was verified
2133 // under; the identity is the key alone, as before.
2134 let public_key: &PublicKey = key.public_key;
2135 let span = self.span.clone();
2136 let _entered = span.enter();
2137 // Over the key's WIRE BLOB, not over its type: russh's `ssh-key` is
2138 // 0.7.0-rc.10 and `gunnar-core`'s is 0.6.7, so this is the only value
2139 // that can reach the identity table. It is byte-for-byte the
2140 // string `ssh-keygen -lf` prints for the same key.
2141 //
2142 // `None` means the key would not re-encode. That used to be an
2143 // `expect()`, a panic in the auth handler, over client-supplied bytes.
2144 // It is a refusal now; `fingerprint_of_key` has already
2145 // logged why.
2146 let Some(fingerprint) = fingerprint_of_key(public_key) else {
2147 let refusal = refuse_unencodable_key(&self.shared.metrics, user);
2148 if self.refused_auth() {
2149 return Err(self.too_many_auth_attempts());
2150 }
2151 return Ok(refusal);
2152 };
2153 let started = std::time::Instant::now();
2154 // `peer` is a field of the enclosing span from here down, so it is not
2155 // repeated on any line below (one implementation, and the whole point of the span).
2156 match self
2157 .shared
2158 .dispatch
2159 .control()
2160 .principal_for_key(&fingerprint)
2161 {
2162 Ok(Some(principal)) => {
2163 span.record("principal", tracing::field::display(&principal.id));
2164 self.shared.metrics.add_connections_accepted(1);
2165 tracing::info!(
2166 target: TARGET_SSH,
2167 event = "ssh.auth.accepted",
2168 %fingerprint,
2169 // Logged so an operator can see what was sent, and named so
2170 // nobody later mistakes it for an input to the decision.
2171 decorative_ssh_user = %user,
2172 elapsed_ms = started.elapsed().as_millis() as u64,
2173 "publickey accepted"
2174 );
2175 self.principal = Some(principal);
2176 // Kept, not recomputed and not dropped. This is the
2177 // value the identity table was probed with one line above, so
2178 // it is the key that authenticated, by construction.
2179 self.key_fingerprint = Some(fingerprint);
2180 Ok(Auth::Accept)
2181 }
2182 Ok(None) => {
2183 self.shared.metrics.add_connections_refused(1);
2184 tracing::warn!(
2185 target: TARGET_SSH,
2186 event = "ssh.auth.refused",
2187 reason = "unknown-key",
2188 %fingerprint,
2189 decorative_ssh_user = %user,
2190 elapsed_ms = started.elapsed().as_millis() as u64,
2191 "publickey refused: no principal holds this key"
2192 );
2193 if self.refused_auth() {
2194 return Err(self.too_many_auth_attempts());
2195 }
2196 Ok(Auth::reject())
2197 }
2198 // The key is still refused, since failing open is not on the table, but
2199 // this is a FAULT, not "unknown key", and the log has to say so.
2200 // An identity table that has become unreadable looks exactly like
2201 // an empty one from the client's side (the ACL model).
2202 Err(err) => {
2203 self.shared.metrics.add_connections_refused(1);
2204 self.shared.metrics.add_auth_faults(1);
2205 tracing::error!(
2206 target: TARGET_SSH,
2207 event = "ssh.auth.refused",
2208 reason = "identity-table-unreadable",
2209 %fingerprint,
2210 decorative_ssh_user = %user,
2211 elapsed_ms = started.elapsed().as_millis() as u64,
2212 %err,
2213 "publickey refused: the identity table could not be read"
2214 );
2215 if self.refused_auth() {
2216 return Err(self.too_many_auth_attempts());
2217 }
2218 Ok(Auth::reject())
2219 }
2220 }
2221 }
2222
2223 async fn channel_open_session(
2224 &mut self,
2225 channel: Channel<Msg>,
2226 // russh 0.63 (2026-10-03): the open is answered through this handle, not
2227 // a returned bool; dropping it unanswered would refuse the channel.
2228 reply: russh::server::ChannelOpenHandle,
2229 session: &mut Session,
2230 ) -> Result<(), Self::Error> {
2231 // The exec request arrives later, carrying only a `ChannelId`, and the
2232 // byte stream can only be taken from the `Channel` itself, so it is
2233 // kept here. russh's own sftp example does the same.
2234 self.channels.insert(
2235 channel.id(),
2236 OpenChannel {
2237 channel,
2238 git_protocol: None,
2239 },
2240 );
2241
2242 // A session that authenticates, opens a channel and then does nothing is
2243 // not idle, because it answers keepalives, so the inactivity timeout never
2244 // fires and the connection would be held indefinitely. Arm a watchdog.
2245 if let Some(grace) = self.shared.exec_grace {
2246 let handle = session.handle();
2247 let exec_seen = self.exec_seen.clone();
2248 // `.instrument`, not a captured `peer`: a spawned task does NOT
2249 // inherit the thread-local span, so the watchdog's line would
2250 // otherwise be the one line in the whole connection that could not
2251 // be tied back to it.
2252 let span = self.span.clone();
2253 tokio::spawn(
2254 async move {
2255 tokio::time::sleep(grace).await;
2256 if !exec_seen.load(Ordering::SeqCst) {
2257 tracing::info!(
2258 target: TARGET_SSH,
2259 event = "ssh.watchdog.disconnect",
2260 ?grace,
2261 "disconnecting: authenticated but never asked for a git command"
2262 );
2263 note_teardown(
2264 "watchdog-disconnect",
2265 handle
2266 .disconnect(
2267 Disconnect::ByApplication,
2268 "no git command requested".to_string(),
2269 String::new(),
2270 )
2271 .await,
2272 );
2273 }
2274 }
2275 .instrument(span),
2276 );
2277 }
2278 reply.accept().await;
2279 Ok(())
2280 }
2281
2282 async fn exec_request(
2283 &mut self,
2284 channel: ChannelId,
2285 data: &[u8],
2286 session: &mut Session,
2287 ) -> Result<(), Self::Error> {
2288 self.exec_seen.store(true, Ordering::SeqCst);
2289 let span = self.span.clone();
2290 let _entered = span.enter();
2291
2292 // **The credential is re-read here, not remembered.** See
2293 // [`Connection::live_principal`]: russh will not dispatch an exec
2294 // before authentication succeeds, so an unauthenticated session is
2295 // unreachable rather than checked — but a session whose key was
2296 // REVOKED while it was open is entirely reachable, and until
2297 // 2026-09-11 it was authorised out of a cache filled at auth time.
2298 let principal = match self.live_principal() {
2299 Ok(principal) => principal,
2300 Err(stale) => {
2301 self.shared.metrics.add_exec_refused(1);
2302 let (reason, detail) = match &stale {
2303 Stale::Revoked => ("key-revoked", String::new()),
2304 Stale::Unreadable(err) => ("identity-table-unreadable", format!("{err}")),
2305 };
2306 tracing::warn!(
2307 target: TARGET_SSH,
2308 event = "ssh.exec.denied",
2309 reason,
2310 fingerprint = self.key_fingerprint.as_ref().map(tracing::field::display),
2311 detail = %detail,
2312 "exec denied: the key that opened this session no longer authorises it"
2313 );
2314 // Answered in git's own protocol, like every other denial on
2315 // this path: an `ERR ` pkt-line is what `git` prints verbatim
2316 // after `remote: `. A bare `channel_failure` here would put the
2317 // client back on "Could not read from remote repository", which
2318 // is the failure `report_error` was found unused for.
2319 session.channel_success(channel)?;
2320 session.data(channel, err_packet(REVOKED_MESSAGE)?)?;
2321 session.exit_status_request(channel, EXIT_DENIED)?;
2322 session.close(channel)?;
2323 // And the session goes with it. Refusing the one exec would
2324 // leave the connection open for the next one, which is the
2325 // finding restated a request later: a revoked key must not keep
2326 // a channel it can keep asking on.
2327 note_teardown(
2328 "revoked-key-disconnect",
2329 session
2330 .handle()
2331 .disconnect(
2332 Disconnect::ByApplication,
2333 "the key that opened this session no longer authorises it".to_string(),
2334 String::new(),
2335 )
2336 .await,
2337 );
2338 return Ok(());
2339 }
2340 };
2341
2342 // ── the operator's fire drill: ONE ERROR on purpose ─────────────
2343 //
2344 // What the estate's watch raises to prove its alert path end to end (estate-box
2345 // 8A). Held to server-wide Admin before it says anything; an anonymous session
2346 // is refused the channel like the tunnel verb below.
2347 if std::str::from_utf8(data).map(str::trim) == Ok(FIRE_DRILL_VERB) {
2348 if self.key_fingerprint.is_none() {
2349 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.refused", reason = "anonymous-control-verb", "exec refused: {FIRE_DRILL_VERB} needs a key");
2350 session.channel_failure(channel)?;
2351 return Ok(());
2352 }
2353 let (exit, out) = match self
2354 .shared
2355 .dispatch
2356 .control()
2357 .auth()
2358 .may_server(&principal.id, gunnar_core::Level::Admin)
2359 {
2360 Ok(true) => {
2361 tracing::error!(
2362 target: TARGET_SSH,
2363 event = "ssh.fire_drill",
2364 principal = %principal.id,
2365 "fire drill: {} asked for this ERROR to prove the alert path end to end — nothing is wrong",
2366 principal.id
2367 );
2368 (
2369 0,
2370 "fire drill: one ERROR said on this server's log (nothing is wrong)\n"
2371 .to_string(),
2372 )
2373 }
2374 Ok(false) => {
2375 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.denied", reason = "not-server-admin", principal = %principal.id, "{FIRE_DRILL_VERB} refused");
2376 (EXIT_DENIED, format!("error: {FIRE_DRILL_VERB} needs server-wide Admin, which {} does not hold\n", principal.id))
2377 }
2378 Err(err) => (
2379 EXIT_DENIED,
2380 format!("error: the identity table could not be read: {err}\n"),
2381 ),
2382 };
2383 session.channel_success(channel)?;
2384 session.data(channel, out.into_bytes())?;
2385 session.exit_status_request(channel, exit)?;
2386 session.close(channel)?;
2387 return Ok(());
2388 }
2389
2390 // ── git-lfs's fallback: answered, never a channel failure (2026-10-25) ──
2391 if std::str::from_utf8(data)
2392 .ok()
2393 .and_then(|l| l.split_whitespace().next())
2394 == Some(LFS_AUTHENTICATE_VERB)
2395 {
2396 tracing::info!(
2397 target: TARGET_SSH,
2398 event = "ssh.lfs_authenticate.answered",
2399 principal = %principal.id,
2400 command = %String::from_utf8_lossy(data),
2401 "git-lfs asked for its HTTPS fallback: its pure-SSH transfer failed or was refused just before"
2402 );
2403 session.channel_success(channel)?;
2404 session.extended_data(channel, 1, LFS_AUTHENTICATE_MESSAGE.as_bytes().to_vec())?;
2405 session.exit_status_request(channel, 1)?;
2406 session.close(channel)?;
2407 return Ok(());
2408 }
2409
2410 // ── who this key is (wiring tracer B 2026-10-08) ──
2411 if std::str::from_utf8(data).map(str::trim) == Ok(WHOAMI_VERB) {
2412 if self.key_fingerprint.is_none() {
2413 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.refused", reason = "anonymous-control-verb", "exec refused: {WHOAMI_VERB} needs a key");
2414 session.channel_failure(channel)?;
2415 return Ok(());
2416 }
2417 session.channel_success(channel)?;
2418 session.data(channel, format!("{}\n", principal.id).into_bytes())?;
2419 session.exit_status_request(channel, 0)?;
2420 session.close(channel)?;
2421 return Ok(());
2422 }
2423
2424 // ── a repository's settings (owner, 2026-10-14) ──
2425 //
2426 // Decided by the control plane's own `Repos` handlers through
2427 // [`RepoDoor`]: this listener only proves who asks (the key, re-read
2428 // above) and carries the answer. An anonymous session is refused the
2429 // channel, before the pause and the plan gates (not a transfer).
2430 if let Some(parsed) = parse_repo_verb(data) {
2431 let Some(fingerprint) = self.key_fingerprint.as_ref().map(|f| f.to_string()) else {
2432 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.refused", reason = "anonymous-control-verb", "exec refused: {REPO_VERB} needs a key");
2433 session.channel_failure(channel)?;
2434 return Ok(());
2435 };
2436 let (exit, out) = match parsed {
2437 Err(why) => (1, format!("error: {why}\n")),
2438 Ok((repo, action)) => match self.shared.repo_door.clone() {
2439 None => (
2440 1,
2441 "error: this server answers repository settings only on its control plane\n".to_string(),
2442 ),
2443 Some(door) => {
2444 let ask = RepoAsk { principal: principal.id.clone(), fingerprint, repo: repo.clone(), action: action.clone() };
2445 let answer = door(ask).await;
2446 tracing::info!(
2447 target: TARGET_SSH,
2448 event = "ssh.control",
2449 verb = REPO_VERB,
2450 principal = %principal.id,
2451 repo = %repo,
2452 action = ?action,
2453 decision = if answer.is_ok() { "allow" } else { "deny" },
2454 "repository settings asked over the forge's door"
2455 );
2456 match answer {
2457 Ok(said) => (0, if said.ends_with('\n') { said } else { format!("{said}\n") }),
2458 Err(why) => (EXIT_DENIED, format!("error: {why}\n")),
2459 }
2460 }
2461 },
2462 };
2463 session.channel_success(channel)?;
2464 session.data(channel, out.into_bytes())?;
2465 session.exit_status_request(channel, exit)?;
2466 session.close(channel)?;
2467 return Ok(());
2468 }
2469
2470 // ── a member's keys for an encrypted repository's ring (2026-10-06) ──
2471 //
2472 // A read of the identity table, held to the same rule as
2473 // `Repos.RecipientKeys` (see [`recipient_keys_answer`]). Before the
2474 // pause and the plan gates for the tunnel verb's reason: not a
2475 // transfer. An anonymous session is refused the channel.
2476 if let Some(parsed) = parse_recipient_keys_verb(data) {
2477 if self.key_fingerprint.is_none() {
2478 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.refused", reason = "anonymous-control-verb", "exec refused: {RECIPIENT_KEYS_VERB} needs a key");
2479 session.channel_failure(channel)?;
2480 return Ok(());
2481 }
2482 let (exit, out) = match parsed {
2483 Err(why) => (1, format!("error: {why}\n")),
2484 Ok((repo, member)) => {
2485 let (exit, out) = recipient_keys_answer(
2486 self.shared.dispatch.control(),
2487 &principal.id,
2488 &repo,
2489 &member,
2490 );
2491 tracing::info!(
2492 target: TARGET_SSH,
2493 event = "ssh.control",
2494 verb = RECIPIENT_KEYS_VERB,
2495 principal = %principal.id,
2496 repo = %repo,
2497 member = %member,
2498 decision = if exit == 0 { "allow" } else { "deny" },
2499 "recipient keys asked over the forge's door"
2500 );
2501 (exit, out)
2502 }
2503 };
2504 session.channel_success(channel)?;
2505 session.data(channel, out.into_bytes())?;
2506 session.exit_status_request(channel, exit)?;
2507 session.close(channel)?;
2508 return Ok(());
2509 }
2510
2511 // ── the caller's own encryption key (CRYPT-YUBI 2026-10-26) ──────
2512 //
2513 // The session's principal reads or changes ITS OWN key and methods: the
2514 // public half, and wraps that are ciphertext to this server. Not a
2515 // transfer; an anonymous session is refused the channel.
2516 if let Some(parsed) = gunnar_crypt::door::parse_request(data) {
2517 if self.key_fingerprint.is_none() {
2518 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.refused", reason = "anonymous-control-verb", "exec refused: {} needs a key", gunnar_crypt::door::VERB);
2519 session.channel_failure(channel)?;
2520 return Ok(());
2521 }
2522 let (exit, out) = match parsed {
2523 Err(why) => (1, format!("error: {why}\n")),
2524 Ok(request) => {
2525 let verb = match &request {
2526 gunnar_crypt::door::Request::Get => "get",
2527 gunnar_crypt::door::Request::Create { .. } => "create",
2528 gunnar_crypt::door::Request::Add(_) => "add",
2529 gunnar_crypt::door::Request::Remove(_) => "remove",
2530 };
2531 let (exit, out) =
2532 user_key_answer(self.shared.dispatch.control(), &principal, request);
2533 tracing::info!(
2534 target: TARGET_SSH,
2535 event = "ssh.control",
2536 verb = gunnar_crypt::door::VERB,
2537 action = verb,
2538 principal = %principal.id,
2539 decision = if exit == 0 { "allow" } else { "deny" },
2540 "own encryption key over the forge's door"
2541 );
2542 (exit, out)
2543 }
2544 };
2545 session.channel_success(channel)?;
2546 session.data(channel, out.into_bytes())?;
2547 session.exit_status_request(channel, exit)?;
2548 session.close(channel)?;
2549 return Ok(());
2550 }
2551
2552 // ── the ONE control verb: the tunnel address (T17) ─────────────
2553 //
2554 // Before the pause and the plan gates: those are about transfers, and
2555 // this is not one. Never through `Dispatch::authorise`, which knows
2556 // only git verbs. An anonymous session gets the channel refused like a
2557 // shell would; everyone else is held to server-wide Admin BEFORE the
2558 // door runs.
2559 if let Some(parsed) = parse_tunnel_address_verb(data) {
2560 if self.key_fingerprint.is_none() {
2561 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.refused", reason = "anonymous-control-verb", "exec refused: {TUNNEL_ADDRESS_VERB} needs a key");
2562 session.channel_failure(channel)?;
2563 return Ok(());
2564 }
2565 let (exit, out) = match self
2566 .shared
2567 .dispatch
2568 .control()
2569 .auth()
2570 .may_server(&principal.id, gunnar_core::Level::Admin)
2571 {
2572 Ok(false) => {
2573 tracing::warn!(target: TARGET_SSH, event = "ssh.exec.denied", reason = "not-server-admin", principal = %principal.id, "{TUNNEL_ADDRESS_VERB} refused");
2574 (EXIT_DENIED, format!("error: {TUNNEL_ADDRESS_VERB} needs server-wide Admin, which {} does not hold\n", principal.id))
2575 }
2576 Err(err) => (
2577 EXIT_DENIED,
2578 format!("error: the identity table could not be read: {err}\n"),
2579 ),
2580 Ok(true) => match parsed {
2581 Err(why) => (1, format!("error: {why}\n")),
2582 Ok(address) => match &self.shared.tunnel_door {
2583 None => (
2584 1,
2585 "error: this box has no WireGuard tunnel (built without wg-client)\n"
2586 .to_string(),
2587 ),
2588 Some(door) => match door(&address) {
2589 Ok(answer) => {
2590 tracing::warn!(target: TARGET_SSH, event = "ssh.control", verb = TUNNEL_ADDRESS_VERB, principal = %principal.id, address = %address, decision = "allow", "tunnel address door answered");
2591 (
2592 0,
2593 if answer.ends_with('\n') {
2594 answer
2595 } else {
2596 format!("{answer}\n")
2597 },
2598 )
2599 }
2600 Err(why) => {
2601 tracing::warn!(target: TARGET_SSH, event = "ssh.control", verb = TUNNEL_ADDRESS_VERB, principal = %principal.id, address = %address, decision = "deny", reason = %why, "tunnel address door refused");
2602 (1, format!("error: {why}\n"))
2603 }
2604 },
2605 },
2606 },
2607 };
2608 session.channel_success(channel)?;
2609 session.data(channel, out.into_bytes())?;
2610 session.exit_status_request(channel, exit)?;
2611 session.close(channel)?;
2612 return Ok(());
2613 }
2614
2615 // ── the PAUSED door, before the plan gates ──────────────────────
2616 //
2617 // MEASURED under KVM (bench R1, 2026-09-15): with the gate paused for
2618 // a growth and `/data` already unmounted, a push knocked, the plan
2619 // gates ran FIRST, and the disk-floor check statvfs'd the read-only
2620 // guard tmpfs under the mountpoint — the client was told "/data has
2621 // 64.0 KiB free … no push until space is reclaimed (`gunnar control
2622 // repo delete --purge`)". The wrong sentence, with advice to delete
2623 // repositories, on a box that is growing. The pause is the box's own
2624 // state and says nothing about any repository, so it is answered
2625 // before the command is even resolved: the same words and the same
2626 // EX_TEMPFAIL for every principal and every path.
2627 if let Some(pause) = self.shared.admission.paused() {
2628 self.shared.admission.note_refused_paused();
2629 tracing::info!(
2630 target: TARGET_GIT,
2631 event = "git.transfer.refused",
2632 reason = pause.reason(),
2633 command = %String::from_utf8_lossy(data),
2634 "transfer REFUSED: the admission gate is paused"
2635 );
2636 session.channel_success(channel)?;
2637 session.data(channel, err_packet(pause.words())?)?;
2638 session.exit_status_request(channel, EXIT_BUSY)?;
2639 session.close(channel)?;
2640 return Ok(());
2641 }
2642
2643 let request =
2644 match self
2645 .shared
2646 .dispatch
2647 .authorise(&principal, self.key_fingerprint.as_ref(), data)
2648 {
2649 Ok(request) => request,
2650 Err(err) => {
2651 self.shared.metrics.add_exec_refused(1);
2652 // **THE counting point for a tenant's `auth_failures`**: a
2653 // principal that authenticated and then held no grant on
2654 // the repository it named.
2655 //
2656 // Only `Denied`, and deliberately only `Denied`. The other
2657 // arms are a different sentence: `NoSuchRepo` hides a
2658 // typo behind the same wire message on purpose and would
2659 // charge a namespace that may not exist; `Standby` and
2660 // `Plan` are the box and the plan refusing, not the
2661 // credential; `Command` never named a repository at all.
2662 // And the OTHER half of "authentication failed" — a key
2663 // that resolves to nobody — happens before any exec line
2664 // and belongs to no namespace, so it stays where it is, on
2665 // the box's own `connections_refused`.
2666 if let (DispatchError::Denied { repo, .. }, Some(flow)) =
2667 (&err, self.shared.admission.flow())
2668 {
2669 flow.auth_failure(repo.namespace(), gunnar_core::now_ms());
2670 }
2671 let raw = String::from_utf8_lossy(data);
2672 match err.client_message() {
2673 // Not a git command at all: `bash`, `scp`, an empty
2674 // string. Refuse the channel request itself; the client is
2675 // not speaking a protocol that could read a reply.
2676 // git-lfs asks SSH for its credentials (`git-lfs-authenticate`,
2677 // `git-lfs-transfer`) before every transfer and goes to its
2678 // https endpoint when refused: a client's known first step,
2679 // said at INFO (RUN A 2026-10-08: seven a minute during one LFS
2680 // push failed the log gate as WARN spam). Anything else stays WARN.
2681 None if is_lfs_over_ssh(&raw) => {
2682 tracing::info!(
2683 target: TARGET_SSH,
2684 event = "ssh.exec.refused",
2685 reason = "lfs-over-ssh",
2686 command = %raw,
2687 "exec refused: git-lfs asks SSH first and uses its https endpoint"
2688 );
2689 session.channel_failure(channel)?;
2690 }
2691 None => {
2692 tracing::warn!(
2693 target: TARGET_SSH,
2694 event = "ssh.exec.refused",
2695 reason = "not-a-git-command",
2696 command = %raw,
2697 %err,
2698 "exec refused"
2699 );
2700 session.channel_failure(channel)?;
2701 }
2702 // A git client asked for something it may not have. Accept
2703 // the channel and answer in its own protocol, so it prints
2704 // the reason instead of "Could not read from remote
2705 // repository".
2706 Some(message) => {
2707 tracing::warn!(
2708 target: TARGET_SSH,
2709 event = "ssh.exec.denied",
2710 // The distinction the *wire* deliberately hides:
2711 // "no such repository" and "no permission" are one
2712 // string to the client so a caller cannot enumerate
2713 // private repositories. The log keeps it.
2714 reason = match &err {
2715 DispatchError::NoSuchRepo(_) => "no-such-repository",
2716 DispatchError::Denied { .. } => "no-grant",
2717 DispatchError::Standby { .. } => "standby-read-only",
2718 DispatchError::Twin { .. } => "twin-serves-no-git",
2719 DispatchError::AnonymousElsewhere { .. } => {
2720 "anonymous-elsewhere"
2721 }
2722 DispatchError::Plan { .. } => "gate-refused",
2723 DispatchError::Store(_) => "control-plane-unavailable",
2724 DispatchError::Command(_) => "not-a-git-command",
2725 },
2726 command = %raw,
2727 %err,
2728 "exec denied"
2729 );
2730 session.channel_success(channel)?;
2731 session.data(channel, err_packet(&message)?)?;
2732 session.exit_status_request(channel, EXIT_DENIED)?;
2733 session.close(channel)?;
2734 }
2735 }
2736 return Ok(());
2737 }
2738 };
2739
2740 let Some(open) = self.channels.remove(&channel) else {
2741 // The CLIENT's misuse (a second exec on one channel, or a channel never
2742 // opened): refused, said at WARN once per connection — any caller that may
2743 // run one git command could otherwise raise the server's ERROR at will.
2744 if std::mem::replace(&mut self.misuse_said, true) {
2745 tracing::debug!(target: TARGET_SSH, ?channel, "exec request for a channel that is not open (said already on this connection)");
2746 } else {
2747 tracing::warn!(
2748 target: TARGET_SSH,
2749 event = "ssh.exec.misuse",
2750 ?channel,
2751 "exec request for a channel that is not open (a second exec on one channel, or one never opened) — refused; said once per connection"
2752 );
2753 }
2754 session.channel_failure(channel)?;
2755 return Ok(());
2756 };
2757
2758 // The repository is known from here on, so it goes on the CONNECTION
2759 // span: every line the transfer emits underneath inherits it, and the
2760 // "which repository was that connection touching" question is answered
2761 // even for a line that has nothing to do with git.
2762 span.record("repo", tracing::field::display(&request.repo));
2763
2764 // Attached *after* the decision above, never before it: an environment
2765 // variable selects the framing of the answer and nothing else.
2766 let request = request.with_git_protocol(open.git_protocol);
2767
2768 // ── the explicit ceiling ───────────────────────────────────────────
2769 //
2770 // After authorisation, deliberately. "The server is busy" is a fact
2771 // about the server, and telling it to a client that may not touch this
2772 // repository at all would leak that the repository exists. The order
2773 // also means the gate meters *transfers gunnar was going to serve*,
2774 // which is what the counter is supposed to mean.
2775 //
2776 // It refuses rather than queues; `crate::admission` sets out why at
2777 // length. In one line: a git client retries, and a fast refusal with a
2778 // sentence in it beats a socket that goes quiet while still holding the
2779 // session, the channel and a slot.
2780 // The class the governor meters this operation as. `git-upload-pack` is
2781 // clone, fetch AND ls-remote on the wire and cannot be told apart before
2782 // the stream is open, which is *after* this decision by design; see
2783 // `OpClass::for_ssh_service` for what that costs and why the middle
2784 // class is the right guess.
2785 // ── the BOOT WINDOW door, before admission ─────────────────────────
2786 //
2787 // After the repository is known and authorised, so a stranger cannot
2788 // learn from this whether a repository exists, and before a slot is
2789 // taken, so a boot does not spend its gate on requests it cannot serve.
2790 // See `GunnarSsh::with_readiness` for what was measured.
2791 // A KEYED caller is queued for the stores to open, up to its keyed
2792 // wait: a restart (a release, an upgrade) lasts seconds, and git-lfs's
2793 // pure-SSH transfer cannot retry a refusal — it falls back to
2794 // `git-lfs-authenticate` and fails the push (gunnar.rs 2026-10-25). A
2795 // stranger is still answered at once.
2796 if !self.shared.ready.load(std::sync::atomic::Ordering::Relaxed)
2797 && request.principal.id != gunnar_core::ANONYMOUS_PRINCIPAL_ID
2798 {
2799 let until =
2800 std::time::Instant::now() + self.shared.admission.wait_for(&request.principal.id);
2801 while !self.shared.ready.load(std::sync::atomic::Ordering::Relaxed)
2802 && std::time::Instant::now() < until
2803 {
2804 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2805 }
2806 }
2807 if !self.shared.ready.load(std::sync::atomic::Ordering::Relaxed) {
2808 tracing::info!(
2809 target: TARGET_GIT,
2810 event = "git.transfer.refused",
2811 reason = "starting",
2812 service = %request.service,
2813 "transfer REFUSED: the stores are still opening after a restart"
2814 );
2815 session.channel_success(channel)?;
2816 session.data(channel, err_packet(STARTING_MESSAGE)?)?;
2817 session.exit_status_request(channel, EXIT_BUSY)?;
2818 session.close(channel)?;
2819 return Ok(());
2820 }
2821
2822 let class = OpClass::for_ssh_service(request.service);
2823 // One git-lfs, one slot (GHUNT-7, `Connection::lfs_slots`): a `git-lfs-transfer`
2824 // channel joins the slot an earlier channel of this connection holds for the same
2825 // repository and operation, while it carries fewer than LFS_CHANNELS_PER_SLOT.
2826 // Each channel holds the slot twice (the task and its metered stream).
2827 let lfs_key = matches!(request.service, GitService::LfsTransfer(_))
2828 .then(|| (request.repo.to_string(), request.service));
2829 let joined = lfs_key
2830 .as_ref()
2831 .and_then(|k| self.lfs_slots.get(k))
2832 .and_then(std::sync::Weak::upgrade)
2833 .filter(|slot| Arc::strong_count(slot) < 2 * LFS_CHANNELS_PER_SLOT);
2834 // `admit_within`, not `try_admit`: over the limit the transfer parks for
2835 // a bounded moment instead of being refused on the spot. A refused
2836 // client reconnects, and a reconnect is a whole SSH handshake — more
2837 // than the wait costs. `crate::governor::DEFAULT_ADMISSION_WAIT` has the
2838 // measurement; `--admission-wait-ms 0` is the old behaviour exactly.
2839 let admitted = if let Some(slot) = joined {
2840 slot
2841 } else {
2842 match self
2843 .shared
2844 .admission
2845 .admit_within(&request.principal.id, &request.repo.namespace, class)
2846 .await
2847 {
2848 Ok((guard, waited)) => {
2849 // AGGREGATE, at most once a second. A row per waiting client is
2850 // the appliance's whole twenty-row console under load (the
2851 // measured 5411 in one 256-client run); a row per second
2852 // carrying the totals is the same fact, readable.
2853 if !waited.is_zero() && self.shared.admission.wait_row_due() {
2854 let stats = self.shared.admission.stats();
2855 tracing::info!(
2856 target: TARGET_GIT,
2857 event = "git.transfer.waited",
2858 class = class.name(),
2859 waited_ms = waited.as_millis() as u64,
2860 waited_total = stats.admitted_after_wait,
2861 mean_wait_ms = stats.mean_wait_ms(),
2862 peak_wait_ms = stats.peak_wait_ms,
2863 in_flight = stats.in_flight,
2864 limit = stats.limit,
2865 "transfers are WAITING for a slot and are then served: {} so far, mean \
2866 {} ms, worst {} ms against a bound of {} ms",
2867 stats.admitted_after_wait,
2868 stats.mean_wait_ms(),
2869 stats.peak_wait_ms,
2870 self.shared.admission.wait().as_millis() as u64
2871 );
2872 }
2873 let slot = Arc::new(guard);
2874 if let Some(k) = lfs_key {
2875 self.lfs_slots.retain(|_, w| w.strong_count() > 0);
2876 self.lfs_slots.insert(k, Arc::downgrade(&slot));
2877 }
2878 slot
2879 }
2880 Err(refusal) => {
2881 let stats = self.shared.admission.stats();
2882 tracing::warn!(
2883 target: TARGET_GIT,
2884 event = "git.transfer.refused",
2885 // Which gate, not just "a gate": the anonymous ceiling
2886 // fires with the box nearly empty, and a line that
2887 // still said `admission-gate-full` there would send an
2888 // operator to raise a limit that was never reached.
2889 reason = refusal.reason(),
2890 service = %request.service,
2891 class = class.name(),
2892 %refusal,
2893 in_flight = stats.in_flight,
2894 peak_in_flight = stats.peak_in_flight,
2895 // The governed limit, so a log line says what was actually
2896 // in force rather than what was configured. Without it the
2897 // shed is invisible again.
2898 limit = stats.limit,
2899 reserved_bytes = stats.reserved_bytes,
2900 refused_total = stats.refused(),
2901 // The bound the transfer waited out before this. Without it
2902 // a reader cannot tell an instant refusal from one the
2903 // server spent two seconds trying to avoid.
2904 waited_ms = self.shared.admission.wait().as_millis() as u64,
2905 peak_wait_ms = stats.peak_wait_ms,
2906 "transfer REFUSED by the admission gate"
2907 );
2908 // Answered in the client's own protocol, like a denial, so git
2909 // prints the reason instead of "Could not read from remote
2910 // repository". Saturation the operator can see and the user
2911 // cannot is only half a fix.
2912 session.channel_success(channel)?;
2913 session.data(channel, err_packet(&refusal.client_message())?)?;
2914 session.exit_status_request(channel, EXIT_BUSY)?;
2915 session.close(channel)?;
2916 return Ok(());
2917 }
2918 }
2919 };
2920
2921 // ── the span the whole transfer runs under ─────────────────────────
2922 //
2923 // A CHILD of `ssh.connection`, so it inherits `conn`, `peer`,
2924 // `principal` and `repo` and states none of them again. `service` is
2925 // the one thing that is new. Everything `gunnar-cli`'s transport and
2926 // `gunnar-policy`'s gate emit lands underneath it, in the same
2927 // process, without either of them being handed an id to repeat.
2928 let transfer = tracing::info_span!(
2929 target: TARGET_GIT,
2930 "git.transfer",
2931 service = %request.service,
2932 access = %request.access,
2933 );
2934 transfer.in_scope(|| {
2935 tracing::info!(
2936 target: TARGET_GIT,
2937 event = "git.transfer.started",
2938 git_protocol = ?request.git_protocol,
2939 "serving"
2940 )
2941 });
2942 let in_flight = self.shared.metrics.transfer_started();
2943 let metrics = Arc::clone(&self.shared.metrics);
2944 let started = std::time::Instant::now();
2945
2946 session.channel_success(channel)?;
2947
2948 let transport = self.shared.transport.clone();
2949 // Split, NOT `into_stream()`. `into_stream` wraps the channel in
2950 // russh's `ChannelCloseOnDrop`, which spawns a `Close` the instant the
2951 // transport lets the stream go, and that `Close` raced (and beat) the
2952 // `exit-status` sent next, which is how a successful push came to
2953 // report failure. Neither half closes the channel on drop, so the two
2954 // messages below are ordered by the sender's own queue.
2955 let (read_half, write_half) = open.channel.split();
2956 // METERED, so the memory ledger learns this transfer's real size as it
2957 // happens rather than being charged a flat per-class constant at
2958 // admission. `crate::admission::Metered` is the only place a byte count
2959 // reaches the governor, and it wraps the duplex the transport was
2960 // already going to be handed — no second copy, no second truth.
2961 let stream = crate::admission::Metered::new(
2962 tokio::io::join(
2963 ChannelReader::new(read_half),
2964 // Bound to the session: a write parked on the SSH window ends
2965 // with an error when the session does (see `SessionBound`).
2966 SessionBound::new(write_half.make_writer(), self.session_over.subscribe()),
2967 ),
2968 Arc::clone(&admitted),
2969 );
2970 // Spawned, not awaited: see the module docs. Awaiting here would stop
2971 // the session loop that has to feed this very stream.
2972 // ── THE STANDING FAN, not a fresh thread ────────────────────────
2973 //
2974 // This used to be `tokio::spawn` alone, and the task itself was never
2975 // the cost — tasks are cheap. The cost is behind it: `SyncIoBridge`
2976 // hands the transfer to tokio's BLOCKING pool, which spawns an OS
2977 // thread on demand, per operation, up to 512 of them. That is
2978 // per-request initialisation, and it is what the fans replace.
2979 //
2980 // The future is driven by `Handle::block_on` on a worker that was
2981 // already alive and parked before this client connected. A full queue
2982 // refuses here, which is the only place a refusal is free.
2983 let handle = tokio::runtime::Handle::current();
2984 let pipelines = crate::pipeline::fans();
2985 let over = self.session_over.subscribe();
2986 let (calm_grace, crowded_grace) = self.shared.session_end_grace;
2987 let body = async move {
2988 // ── the CoDel dequeue ──────────────────────────────────────
2989 //
2990 // The FIRST line of the task, deliberately. Everything between
2991 // `try_admit` above and this point is the transfer WAITING:
2992 // tokio's ready queue, and behind it the blocking-pool handoff
2993 // that `SyncIoBridge` needs before a single byte moves. That
2994 // wait is the queue the record called invisible, and this call is
2995 // what makes the governor able to see it. Measured here rather
2996 // than at the end because sojourn is wait time; service time
2997 // filed as sojourn would make every large clone look like a
2998 // standing queue.
2999 admitted.entered_service();
3000 // ★ The WHOLE transfer is bound to the session, not only its writes
3001 // (`SessionBound`): a client that goes silent while main is still
3002 // building the pack has no write parked on the window to wake, and
3003 // the build can take minutes (the UpCloud mockup's LD1/LD2,
3004 // 2026-10-09: 2 min 10 s on TCG). When the session ends — the
3005 // keepalives unanswered, a hang-up — the transfer ends with it and
3006 // gives back its fan worker, admission slot and store handle.
3007 let cancel = request.cancel.clone();
3008 let served = serve_until_session_ends(transport.serve(request, Box::new(stream)), over.clone(), &cancel, || {
3009 // Stinky row 19 (2026-10-10): a hung-up client's worker is one
3010 // a queued client waits for, so the grace is short when any do.
3011 crate::channel::grace_for(calm_grace, crowded_grace, pipelines.queued(class))
3012 }, || {
3013 crate::seam::TransportError::Io(session_ended_error())
3014 })
3015 .await;
3016 let status = match served {
3017 Ok(()) => 0,
3018 Err(err) => {
3019 metrics.add_transfers_failed(1);
3020 // ── the refusal the client used to never hear ───────
3021 //
3022 // Until 2026-08-05 this arm logged the whole reason and
3023 // sent the client an exit status and nothing else, so
3024 // every post-authorisation refusal — the repository's
3025 // store lock held by a `gunnar gc`, a storage fault on
3026 // open, an unwired service — reached the user as
3027 // `fatal: the remote end hung up unexpectedly`. The two
3028 // gates ABOVE this one, in this same function, both
3029 // answer in the client's own protocol; this one did not,
3030 // and the asymmetry is what made it invisible.
3031 //
3032 // `client_message()` is `None` whenever bytes may
3033 // already have gone out, so this cannot append a
3034 // pkt-line to a half-written pack — see
3035 // `TransportError::client_message`.
3036 let told = err.client_message();
3037 tracing::warn!(
3038 target: TARGET_GIT,
3039 event = "git.transfer.failed",
3040 %err,
3041 // Whether the user was given a reason, said in the
3042 // log, because "the client was told" and "the client
3043 // saw a dead socket" must not be the same line.
3044 client_told = told.is_some(),
3045 "transport ended with an error"
3046 );
3047 if let Some(message) = told {
3048 match err_packet(&message) {
3049 Ok(packet) => {
3050 // Raced against the session's end: `data_bytes`
3051 // waits for SSH window like the stream does.
3052 let sent = tokio::select! {
3053 sent = write_half.data_bytes(packet) => sent,
3054 () = session_over(over.clone()) => Err(russh::Error::IO(session_ended_error())),
3055 };
3056 note_teardown("refusal-packet", sent)
3057 }
3058 Err(err) => tracing::error!(
3059 target: TARGET_GIT,
3060 %err,
3061 "the refusal could not be encoded, so the client is \
3062 getting only an exit status"
3063 ),
3064 }
3065 }
3066 EXIT_TRANSPORT_FAILED
3067 }
3068 };
3069 // The channel is still open here, nothing has been dropped that
3070 // could have closed it, so the status arrives before the close,
3071 // through the same sender the data went through.
3072 if write_half.exit_status(status).await.is_err() {
3073 // WARN only when the client missed a verdict it needed. A
3074 // client that took everything it asked for and hung up
3075 // before the `exit-status` has lost nothing — it is how
3076 // `ls-remote` and `fetch` end — and warning about it made
3077 // `grep -i error` useless on a completely healthy server:
3078 // MEASURED 2026-08-06, 9 successful transfers, 9 of these.
3079 // A client that missed a NON-ZERO status did lose
3080 // something: it believes the transfer worked.
3081 if status == 0 {
3082 tracing::debug!(
3083 target: TARGET_GIT,
3084 event = "git.exit_status.undelivered",
3085 ?channel,
3086 status,
3087 "the client hung up before the exit status; it had already \
3088 been served"
3089 );
3090 } else {
3091 tracing::warn!(
3092 target: TARGET_GIT,
3093 event = "git.exit_status.undelivered",
3094 ?channel,
3095 status,
3096 "the client was gone before a NON-ZERO exit status, so it \
3097 does not know this transfer failed"
3098 );
3099 }
3100 }
3101 note_teardown("channel-close", write_half.close().await);
3102 // The duration an operator asked "why was that clone slow"
3103 // about: the WHOLE transfer, from admission to the last byte
3104 // acknowledged, not the state machine's own view of itself.
3105 tracing::info!(
3106 target: TARGET_GIT,
3107 event = "git.transfer.finished",
3108 status,
3109 elapsed_ms = started.elapsed().as_millis() as u64,
3110 "transfer finished"
3111 );
3112 // Moved into this task and dropped here, so the slot is held for the
3113 // whole transfer and released by `Drop` rather than by a call that
3114 // an early return could skip. The in-flight gauge is released the
3115 // same way, and for the same reason.
3116 drop(admitted);
3117 drop(in_flight);
3118 }
3119 .instrument(transfer);
3120
3121 // ── WHAT THE FAN IS ACTUALLY CARRYING ───────────────────────────────
3122 //
3123 // `Pipelines` has counted `served`, `queued` and `workers` since it was
3124 // written and NOTHING reported them, so the only evidence the fans were
3125 // doing anything was the absence of refusals — which is not evidence,
3126 // it is silence. Asked "is the queue filling", the honest answer was an
3127 // inference from the workload's shape.
3128 //
3129 // `submit` already returns the queue depth after the push, so this
3130 // costs one integer. Emitted only when an operation ACTUALLY QUEUED
3131 // (depth > 1, i.e. it did not go straight to a free worker), because a
3132 // line per transfer on a 102-client arm is noise that hides the signal.
3133 // `served` and `workers` ride along so one line answers "how deep, out
3134 // of how many, having done how much".
3135 match pipelines.submit(
3136 class,
3137 Box::new(move || {
3138 handle.block_on(body);
3139 }),
3140 ) {
3141 Ok(depth) if depth > 1 => tracing::info!(
3142 target: TARGET_GIT,
3143 event = "git.pipeline.queued",
3144 class = ?class,
3145 depth,
3146 workers = pipelines.workers(class),
3147 served = pipelines.served(class),
3148 "an operation waited for a worker"
3149 ),
3150 Ok(_) => {}
3151 Err(refused) => {
3152 // The fan is saturated. Say so in the client's own protocol
3153 // rather than dropping the channel — a dead socket is how a
3154 // refusal becomes "the remote end hung up unexpectedly".
3155 tracing::warn!(
3156 target: TARGET_GIT,
3157 event = "git.transfer.refused",
3158 class = ?class,
3159 depth = pipelines.queued(class),
3160 workers = pipelines.workers(class),
3161 served = pipelines.served(class),
3162 %refused,
3163 "the standing fan for this class refused the operation"
3164 );
3165 return Err(anyhow::anyhow!("{refused}"));
3166 }
3167 }
3168 Ok(())
3169 }
3170
3171 // ── everything below is the "no shell, ever" half ──────────────────────
3172 //
3173 // russh's defaults for these return `Ok(())` **without** answering the
3174 // channel request, which leaves a client waiting rather than failing. Each
3175 // one is answered explicitly.
3176
3177 async fn shell_request(
3178 &mut self,
3179 channel: ChannelId,
3180 session: &mut Session,
3181 ) -> Result<(), Self::Error> {
3182 self.refuse(channel, session, "shell").await
3183 }
3184
3185 async fn subsystem_request(
3186 &mut self,
3187 channel: ChannelId,
3188 name: &str,
3189 session: &mut Session,
3190 ) -> Result<(), Self::Error> {
3191 // Includes `sftp`. gunnar serves git and nothing else.
3192 self.span.in_scope(|| {
3193 tracing::warn!(
3194 target: TARGET_SSH,
3195 event = "ssh.request.refused",
3196 subsystem = %name,
3197 "subsystem refused"
3198 )
3199 });
3200 self.refuse(channel, session, "subsystem").await
3201 }
3202
3203 async fn pty_request(
3204 &mut self,
3205 channel: ChannelId,
3206 _term: &str,
3207 _col_width: u32,
3208 _row_height: u32,
3209 _pix_width: u32,
3210 _pix_height: u32,
3211 _modes: &[(russh::Pty, u32)],
3212 session: &mut Session,
3213 ) -> Result<(), Self::Error> {
3214 self.refuse(channel, session, "pty").await
3215 }
3216
3217 async fn x11_request(
3218 &mut self,
3219 channel: ChannelId,
3220 _single_connection: bool,
3221 _x11_auth_protocol: &str,
3222 _x11_auth_cookie: &str,
3223 _x11_screen_number: u32,
3224 session: &mut Session,
3225 ) -> Result<(), Self::Error> {
3226 self.refuse(channel, session, "x11").await
3227 }
3228
3229 async fn env_request(
3230 &mut self,
3231 channel: ChannelId,
3232 variable_name: &str,
3233 variable_value: &str,
3234 session: &mut Session,
3235 ) -> Result<(), Self::Error> {
3236 // An ALLOWLIST, not a filter: everything that is not named here is
3237 // refused, so adding a variable is a deliberate edit to one array and
3238 // the default for anything new is "no". See the module docs for why an
3239 // SSH server that passes an environment through is an attack surface.
3240 let span = self.span.clone();
3241 let _entered = span.enter();
3242 if !ENV_ALLOWLIST.contains(&variable_name) {
3243 // Same refusal, same event, same fields — two levels. See
3244 // [`ENV_ROUTINE`] for the 406-of-406 measurement that split them.
3245 if env_refusal_is_routine(variable_name) {
3246 tracing::debug!(
3247 target: TARGET_SSH,
3248 event = "ssh.env.refused",
3249 reason = "not-allowlisted",
3250 routine = true,
3251 variable = %variable_name,
3252 "env refused: gunnar reads exactly one environment variable"
3253 );
3254 } else {
3255 tracing::warn!(
3256 target: TARGET_SSH,
3257 event = "ssh.env.refused",
3258 reason = "not-allowlisted",
3259 routine = false,
3260 variable = %variable_name,
3261 "env refused: gunnar reads exactly one environment variable"
3262 );
3263 }
3264 session.channel_failure(channel)?;
3265 return Ok(());
3266 }
3267
3268 // A value gunnar will not read is not worth holding. `version=2` is
3269 // nine bytes; the cap is generous and exists so a client cannot make
3270 // the server keep an unbounded string per channel.
3271 if variable_value.len() > MAX_ENV_VALUE {
3272 tracing::warn!(
3273 target: TARGET_SSH,
3274 event = "ssh.env.refused",
3275 reason = "value-too-long",
3276 variable = %variable_name,
3277 length = variable_value.len(),
3278 "env refused: the value is implausibly long"
3279 );
3280 session.channel_failure(channel)?;
3281 return Ok(());
3282 }
3283
3284 let Some(open) = self.channels.get_mut(&channel) else {
3285 tracing::warn!(
3286 target: TARGET_SSH,
3287 ?channel,
3288 "env request for a channel that was never opened"
3289 );
3290 session.channel_failure(channel)?;
3291 return Ok(());
3292 };
3293 if variable_name != ENV_GIT_PROTOCOL {
3294 // `ENV_ALLOWLIST` grew a name and nothing here was taught to carry
3295 // it. Fail closed and say so, rather than quietly filing an unknown
3296 // variable in the `GIT_PROTOCOL` slot.
3297 tracing::error!(
3298 target: TARGET_SSH,
3299 variable = %variable_name,
3300 "env allowlisted but not carried anywhere; refusing"
3301 );
3302 session.channel_failure(channel)?;
3303 return Ok(());
3304 }
3305 tracing::debug!(
3306 target: TARGET_SSH,
3307 variable = %variable_name,
3308 value = %variable_value,
3309 "env accepted"
3310 );
3311 open.git_protocol = Some(variable_value.to_string());
3312 session.channel_success(channel)?;
3313 Ok(())
3314 }
3315
3316 async fn channel_close(
3317 &mut self,
3318 channel: ChannelId,
3319 _session: &mut Session,
3320 ) -> Result<(), Self::Error> {
3321 self.channels.remove(&channel);
3322 Ok(())
3323 }
3324}
3325
3326impl Connection {
3327 async fn refuse(
3328 &mut self,
3329 channel: ChannelId,
3330 session: &mut Session,
3331 what: &'static str,
3332 ) -> Result<(), anyhow::Error> {
3333 self.span.in_scope(|| {
3334 tracing::warn!(
3335 target: TARGET_SSH,
3336 event = "ssh.request.refused",
3337 request = what,
3338 "refused: gunnar has no shell"
3339 )
3340 });
3341 session.channel_failure(channel)?;
3342 Ok(())
3343 }
3344}
3345
3346/// The session loop ended: say so in words an operator's `grep -i error` will
3347/// not find, unless it really was an error.
3348///
3349/// See [`peer_hung_up`]. `error=early eof` fired on **every healthy fetch**
3350/// before this existed.
3351fn note_session_end(error: &anyhow::Error) {
3352 if peer_hung_up(error.as_ref()) {
3353 tracing::debug!(
3354 target: TARGET_SSH,
3355 event = "ssh.session.closed",
3356 reason = "peer-hung-up",
3357 "the client closed the connection"
3358 );
3359 return;
3360 }
3361 tracing::debug!(target: TARGET_SSH, %error, "ssh session ended with an error");
3362}
3363
3364/// Note that a best-effort teardown step did not work.
3365///
3366/// # Best-effort stays best-effort. What changes is that it stops being silent
3367///
3368/// Closing a channel and disconnecting a watchdogged session are both allowed
3369/// to fail: the peer may already be gone, which is the ordinary case and not
3370/// worth an operator's attention. Neither ever became an error, and neither
3371/// should. But a **systemic** failure to tear sessions down, from descriptor
3372/// exhaustion, the failure mode the unbounded handle cache used to
3373/// produce, was previously indistinguishable from a quiet server, because
3374/// `let _ =` leaves nothing behind at any level.
3375///
3376/// `debug!` rather than `warn!`: one of these is normal and a thousand is not,
3377/// and the thing that tells them apart is a count in a journal, not a severity
3378/// on a line that fires every time a client hangs up first.
3379/// The one encoder for a client-visible refusal: `ERR <message>` as a pkt-line.
3380///
3381/// Three call sites now answer a refusal in the client's own protocol — the
3382/// dispatch denial, the admission gate and a transfer refused before its first
3383/// byte — and a fourth is a matter of time. One implementation: the framing is written once,
3384/// so a refusal cannot acquire a subtly different shape depending on which gate
3385/// produced it. `ERR` is git's own out-of-band error line, which every git
3386/// version since 1.6 prints verbatim instead of "Could not read from remote
3387/// repository".
3388fn err_packet(message: &str) -> anyhow::Result<Vec<u8>> {
3389 let mut packet = Vec::new();
3390 gunnar_pktline::write_text(&mut packet, format!("ERR {message}").as_bytes())
3391 .map_err(|e| anyhow::anyhow!("encoding ERR packet: {e}"))?;
3392 Ok(packet)
3393}
3394
3395///
3396/// # …and "the peer is already gone" is not a failure at all
3397///
3398/// Closing a channel whose peer has already hung up returns
3399/// `russh::Error::SendError`, and until 2026-08-06 that was logged as
3400/// `ssh.teardown.failed err=Channel send error` on **every healthy transfer**
3401/// (MEASURED that day: 9 successful transfers, 6 of these). It is the same
3402/// fact `handle_session_error` and the undelivered exit status already report,
3403/// counted a third time in the third set of words with "err" in it. It is
3404/// separated here by [`peer_hung_up`] so the systemic case this function exists
3405/// for — descriptor exhaustion, a wedged sender — is still the only thing that
3406/// says `failed`.
3407fn note_teardown<E: std::error::Error + 'static>(step: &'static str, result: Result<(), E>) {
3408 if let Err(err) = result {
3409 if peer_hung_up(&err) {
3410 tracing::debug!(
3411 target: TARGET_SSH,
3412 event = "ssh.teardown.peer_gone",
3413 step,
3414 "the peer had already gone, so this teardown step had nothing to say \
3415 it to"
3416 );
3417 return;
3418 }
3419 tracing::debug!(
3420 target: TARGET_SSH,
3421 event = "ssh.teardown.failed",
3422 step,
3423 %err,
3424 "a session teardown step did not complete"
3425 );
3426 }
3427}
3428
3429/// **Is this failure just "the peer hung up first"?**
3430///
3431/// The one classifier for that question (one implementation). Three places used to answer it
3432/// independently, and all three answered *"no"*, which is why a completely
3433/// healthy `gunnar ls-remote` left this in the journal:
3434///
3435/// ```text
3436/// DEBUG ssh session ended with an error error=early eof
3437/// WARN the client was gone before the exit status channel=ChannelId(2) status=0
3438/// DEBUG ssh.teardown.failed step="channel-close" err=Channel send error
3439/// INFO transfer finished status=0 elapsed_ms=106
3440/// ```
3441///
3442/// MEASURED on the reference desktop 2026-08-06 from the live server's own journal, at
3443/// `10:55:10`, for a transfer that succeeded. Nothing was damaged — `status=0`,
3444/// `transfer finished` — but three lines carrying `error`, `err` and `failed`
3445/// per healthy call means `grep -i error` finds only noise, and that is the
3446/// first command an operator runs.
3447///
3448/// # Classified on the cause, never on the message text
3449///
3450/// A `contains("early eof")` would be a guard that silently stops matching the
3451/// day tokio rewords its `read_exact`, and the failure would be invisible
3452/// because it fails in the *quiet* direction — back to noise, not to a crash.
3453/// So this walks the source chain and matches concrete things:
3454/// `io::ErrorKind`, and russh's own named variants. `early eof` is
3455/// `ErrorKind::UnexpectedEof` (tokio's `read_exact`) and `Channel send error`
3456/// is `russh::Error::SendError`; neither string appears below.
3457///
3458/// # What it deliberately does NOT swallow
3459///
3460/// Only the socket going away. A key exchange failure, a MAC failure, a
3461/// protocol desync, a decryption failure — every one of those is still an error
3462/// and still logged as one. `tests/teardown_noise.rs` asserts both directions,
3463/// because a classifier that answered `true` for everything would produce a
3464/// beautifully quiet journal for a server that was on fire.
3465fn peer_hung_up(err: &(dyn std::error::Error + 'static)) -> bool {
3466 use std::io::ErrorKind;
3467
3468 let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(err);
3469 while let Some(e) = cause {
3470 if let Some(io) = e.downcast_ref::<std::io::Error>() {
3471 if matches!(
3472 io.kind(),
3473 ErrorKind::UnexpectedEof
3474 | ErrorKind::BrokenPipe
3475 | ErrorKind::ConnectionReset
3476 | ErrorKind::ConnectionAborted
3477 | ErrorKind::NotConnected
3478 ) {
3479 return true;
3480 }
3481 }
3482 if let Some(r) = e.downcast_ref::<russh::Error>() {
3483 if matches!(
3484 r,
3485 // The channel's sender is gone: nothing left to send to.
3486 russh::Error::SendError
3487 // russh's own names for a hung-up peer.
3488 | russh::Error::HUP
3489 | russh::Error::Disconnect
3490 ) {
3491 return true;
3492 }
3493 }
3494 cause = e.source();
3495 }
3496 false
3497}
3498
3499/// Refuse a key that would not re-encode.
3500///
3501/// A free function taking only the counters, so the decision this makes (a
3502/// **rejection**, and a counted one) can be asserted without standing up a
3503/// russh session. The alternative, an `expect()`, aborted the auth handler.
3504///
3505/// `auth_faults` is deliberately **not** moved: that counter means "the
3506/// identity table could not be read", and this key never reached it. The `warn`
3507/// [`crate::access::fingerprint_of_key`] emits carries
3508/// `reason = "key-does-not-re-encode"`, which is what tells the two apart.
3509fn refuse_unencodable_key(metrics: &gunnar_core::observe::ServerMetrics, user: &str) -> Auth {
3510 metrics.add_connections_refused(1);
3511 tracing::warn!(
3512 target: TARGET_SSH,
3513 event = "ssh.auth.refused",
3514 reason = "key-does-not-re-encode",
3515 decorative_ssh_user = %user,
3516 "publickey refused: the offered key did not re-encode"
3517 );
3518 Auth::reject()
3519}
3520
3521/// The one environment variable a git client may send: how it asks for
3522/// protocol v2 over SSH.
3523pub const ENV_GIT_PROTOCOL: &str = "GIT_PROTOCOL";
3524
3525/// **Every** environment variable this server accepts from a client.
3526///
3527/// An allowlist, checked by `env_request`. `LD_PRELOAD`, `PATH`, `GIT_DIR` and
3528/// everything else a client might try is refused with a channel failure, the
3529/// same answer this server gave to *all* environment requests before
3530/// [`ENV_GIT_PROTOCOL`] was needed. Nothing here is ever executed, exported to
3531/// a process or consulted by the grant check; gunnar has no shell and spawns
3532/// nothing, so the only reason an accepted name exists is that the *wire* reads
3533/// it.
3534pub const ENV_ALLOWLIST: [&str; 1] = [ENV_GIT_PROTOCOL];
3535
3536/// The refused names that mean **nothing at all**, because OpenSSH sends them
3537/// on its own without being asked.
3538///
3539/// Still refused — [`ENV_ALLOWLIST`] is unchanged and this list grants nothing.
3540/// What it changes is the *level* the refusal is reported at, and the reason it
3541/// has to exist is a measurement.
3542///
3543/// MEASURED on the reference desktop 2026-08-05, an 18-repository ingest over real SSH with stock
3544/// git 2.53.0 as the client, 203 connections, `RUST_LOG=info`:
3545///
3546/// | WARN lines | of which `ssh.env.refused` for `LANG`/`COLORTERM` |
3547/// |---|---|
3548/// | 406 | **406 (100%)** |
3549///
3550/// Zero warnings of any other kind were emitted in the whole run. OpenSSH's
3551/// shipped `ssh_config` carries `SendEnv LANG LC_*`, and desktop terminals add
3552/// `COLORTERM`, so every `git` over `ssh` produces two of these before it has
3553/// asked for anything. An operator who greps for `WARN` on a live gunnar
3554/// therefore reads noise at a 100% false-positive rate and will not see the
3555/// first real one — which is the same disease as a check that cannot go red,
3556/// wearing the other colour.
3557///
3558/// A client naming something NOT on this list is a different event and keeps
3559/// its `warn`: `LD_PRELOAD`, `PATH`, `GIT_DIR`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`
3560/// are what an attacker tries, and nothing sends them by accident.
3561pub const ENV_ROUTINE: [&str; 4] = ["LANG", "LANGUAGE", "COLORTERM", "TERM"];
3562
3563/// Whether `name` is a routine refusal ([`ENV_ROUTINE`], or any `LC_*`, which
3564/// OpenSSH sends as a glob).
3565pub fn env_refusal_is_routine(name: &str) -> bool {
3566 ENV_ROUTINE.contains(&name) || name.starts_with("LC_")
3567}
3568
3569/// The longest environment value the server will hold.
3570///
3571/// `version=2` is nine bytes. This is a bound on what one channel can make the
3572/// server remember, not a syntax check; the grammar is `gunnar-wire`'s.
3573pub const MAX_ENV_VALUE: usize = 256;
3574
3575/// Exit status reported when access was refused. Matches what `git` itself
3576/// reports for a failed remote helper.
3577pub const EXIT_DENIED: u32 = 128;
3578
3579/// **What a client is told when the key that opened its session stopped
3580/// authorising it.**
3581///
3582/// One string, named here rather than spelled at the refusal site, so the test
3583/// that asserts what reaches the client reads the same constant the server
3584/// sends. It says what happened and what to do, because "denied" on a
3585/// connection that worked a second ago is otherwise indistinguishable from the
3586/// server having broken.
3587///
3588/// It does NOT distinguish a revoked key from an unreadable identity table, for
3589/// the reason the whole of this surface merges "no such repository" with "no
3590/// permission": the difference is operator information, and the log keeps it.
3591pub const REVOKED_MESSAGE: &str =
3592 "the key that opened this session no longer authorises it; reconnect";
3593
3594/// Exit status reported when the wire failed mid-transfer.
3595pub const EXIT_TRANSPORT_FAILED: u32 = 1;
3596
3597/// Exit status reported when the admission gate refused a transfer.
3598///
3599/// `EX_TEMPFAIL` from `sysexits.h`, and deliberately **not** [`EXIT_DENIED`]:
3600/// "try again" and "you may not" are different answers, and a retry script that
3601/// cannot tell them apart either hammers a permission error forever or gives up
3602/// on a transient one.
3603pub const EXIT_BUSY: u32 = 75;
3604
3605/// What a client is told while the server's stores are still opening.
3606///
3607/// **The word `starting` is load-bearing** and is asserted by
3608/// `tests/readiness.rs`: it is what separates this from
3609/// `gunnar_wire::Error::Store`'s `the repository is unavailable; try again`,
3610/// which is what a client hears when a repository is genuinely broken. The
3611/// power-cut door read the second sentence during the first condition and
3612/// reported 64 acked pushes missing that were all present a second later. A
3613/// harness, a CI script and a person all have to be able to tell "not yet" from
3614/// "gone", and the only place that distinction can be made is here, in the
3615/// words.
3616///
3617/// It carries [`EXIT_BUSY`], not [`EXIT_DENIED`], for the same reason: 75 means
3618/// retry.
3619pub const STARTING_MESSAGE: &str =
3620 "the server is starting: its stores are still opening after a restart, and it will serve \
3621 this in a moment; retry";
3622
3623#[cfg(test)]
3624mod tests {
3625 use super::*;
3626
3627 /// `gunnar-recipient-keys`: the path through the one sandbox (git's
3628 /// quoting taken off), the member through the principal id rule, and
3629 /// nothing else on the line; another verb is not this one.
3630 #[test]
3631 fn the_recipient_keys_line_takes_a_sandboxed_path_and_an_account_name() {
3632 let ok = |line: &str| {
3633 parse_recipient_keys_verb(line.as_bytes())
3634 .expect("this verb")
3635 .expect("well formed")
3636 };
3637 let (repo, member) = ok("gunnar-recipient-keys 'team/vault.git' 'bob'");
3638 assert_eq!(
3639 (repo.namespace(), repo.name(), member.as_str()),
3640 ("team", "vault", "bob")
3641 );
3642 let (repo, member) = ok("gunnar-recipient-keys team/vault bob");
3643 assert_eq!(
3644 (repo.namespace(), repo.name(), member.as_str()),
3645 ("team", "vault", "bob")
3646 );
3647 let (repo, _) = ok("gunnar-recipient-keys '/team/vault.git' bob");
3648 assert_eq!(repo.name(), "vault");
3649 assert!(parse_recipient_keys_verb(b"git-upload-pack 'team/vault.git'").is_none());
3650 assert!(parse_recipient_keys_verb(b"gunnar-recipient-keysx a/b c").is_none());
3651 for bad in [
3652 "gunnar-recipient-keys",
3653 "gunnar-recipient-keys 'team/vault.git'",
3654 "gunnar-recipient-keys '../etc/passwd' bob",
3655 "gunnar-recipient-keys team/vault 'bo b'",
3656 "gunnar-recipient-keys team/vault anonymous",
3657 "gunnar-recipient-keys team/vault 'bob",
3658 "gunnar-recipient-keys team/vault bob\u{1}",
3659 ] {
3660 assert!(
3661 matches!(parse_recipient_keys_verb(bad.as_bytes()), Some(Err(_))),
3662 "{bad:?} is refused"
3663 );
3664 }
3665 let long = format!("gunnar-recipient-keys team/vault {}", "b".repeat(600));
3666 assert!(matches!(
3667 parse_recipient_keys_verb(long.as_bytes()),
3668 Some(Err(_))
3669 ));
3670 }
3671
3672 /// **The door offers the post-quantum hybrid first**, from gunnar's own
3673 /// list: `mlkem768x25519-sha256`, then the classical fallbacks, and the
3674 /// strict-kex markers (Terrapin) kept.
3675 #[test]
3676 fn the_door_offers_mlkem768x25519_first_from_gunnars_own_list() {
3677 let p = preferred();
3678 assert!(
3679 std::ptr::eq(p.kex.as_ref().as_ptr(), KEX_ORDER.as_ptr()),
3680 "the server's list is gunnar's KEX_ORDER, not whatever russh defaults to"
3681 );
3682 let names: Vec<&str> = p.kex.iter().map(|n| n.as_ref()).collect();
3683 assert_eq!(
3684 names[..3],
3685 [
3686 "mlkem768x25519-sha256",
3687 "curve25519-sha256",
3688 "curve25519-sha256@libssh.org"
3689 ],
3690 "{names:?}"
3691 );
3692 assert!(
3693 names.contains(&"kex-strict-s-v00@openssh.com"),
3694 "strict kex stays: {names:?}"
3695 );
3696 assert_eq!(
3697 p.cipher,
3698 russh::Preferred::DEFAULT.cipher,
3699 "only the key exchange is gunnar's own"
3700 );
3701 }
3702
3703 /// The accept loop outlives a descriptor shortage and a client that hung
3704 /// up mid-handshake; it returns on a listener that is gone.
3705 #[test]
3706 fn transient_accept_errors_are_named() {
3707 for errno in [24, 23, 105, 12, 103] {
3708 assert!(
3709 accept_error_is_transient(&std::io::Error::from_raw_os_error(errno)),
3710 "errno {errno}"
3711 );
3712 }
3713 for errno in [9, 22, 88] {
3714 assert!(
3715 !accept_error_is_transient(&std::io::Error::from_raw_os_error(errno)),
3716 "errno {errno}"
3717 );
3718 }
3719 assert!(!accept_error_is_transient(&std::io::Error::other("closed")));
3720 }
3721
3722 /// A teardown that fails is **reported**, and one that works is
3723 /// silent.
3724 ///
3725 /// Driven through the helper rather than through a live channel: making
3726 /// `Channel::close()` fail on demand means a peer that has vanished at an
3727 /// exact moment, which is a race to arrange and a race to assert on. What
3728 /// the helper decides is the whole of what changed, `let _ =` against a
3729 /// line in the journal, and both directions are asserted, because a
3730 /// `note_teardown` that logged unconditionally would put a line in an
3731 /// operator's journal on every clean disconnect and make the signal
3732 /// useless.
3733 /// Capture every `tracing` line `emit` produces, rendered as
3734 /// `<target> <field>=<value> …`.
3735 ///
3736 /// One capture for every test in this module that reads the journal (one
3737 /// implementation): there were two copies of this layer forty lines apart, and a second
3738 /// copy of a capture is a second chance to filter out the line under test.
3739 fn captured_lines(emit: impl FnOnce()) -> Vec<String> {
3740 use std::sync::{Arc, Mutex};
3741 use tracing_subscriber::layer::SubscriberExt;
3742 use tracing_subscriber::Layer as _;
3743
3744 #[derive(Clone, Default)]
3745 struct Capture(Arc<Mutex<Vec<String>>>);
3746 struct CaptureLayer(Capture);
3747 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CaptureLayer {
3748 fn on_event(
3749 &self,
3750 event: &tracing::Event<'_>,
3751 _: tracing_subscriber::layer::Context<'_, S>,
3752 ) {
3753 struct Render(String);
3754 impl tracing::field::Visit for Render {
3755 fn record_debug(
3756 &mut self,
3757 field: &tracing::field::Field,
3758 value: &dyn std::fmt::Debug,
3759 ) {
3760 self.0.push_str(&format!("{}={value:?} ", field.name()));
3761 }
3762 }
3763 let mut render = Render(String::new());
3764 event.record(&mut render);
3765 self.0 .0.lock().expect("capture").push(format!(
3766 "{} {}",
3767 event.metadata().target(),
3768 render.0
3769 ));
3770 }
3771 }
3772
3773 let capture = Capture::default();
3774 let subscriber = tracing_subscriber::registry().with(
3775 CaptureLayer(capture.clone())
3776 .with_filter(tracing_subscriber::filter::LevelFilter::TRACE),
3777 );
3778 tracing::subscriber::with_default(subscriber, emit);
3779 let lines = capture.0.lock().expect("capture").clone();
3780 lines
3781 }
3782
3783 #[test]
3784 fn a_failed_teardown_step_is_reported_and_a_clean_one_is_not() {
3785 let lines = captured_lines(|| {
3786 note_teardown::<std::io::Error>("channel-close", Ok(()));
3787 note_teardown(
3788 "watchdog-disconnect",
3789 Err(std::io::Error::other("the peer was already gone")),
3790 );
3791 });
3792
3793 assert_eq!(
3794 lines.len(),
3795 1,
3796 "expected exactly one line — the failure — got {lines:?}"
3797 );
3798 assert!(
3799 lines[0].starts_with(gunnar_core::observe::TARGET_SSH),
3800 "the line is not on the ssh target: {}",
3801 lines[0]
3802 );
3803 assert!(
3804 lines[0].contains("watchdog-disconnect") && lines[0].contains("already gone"),
3805 "the line names neither the step nor the error: {}",
3806 lines[0]
3807 );
3808 }
3809
3810 /// **RED-WHEN-BROKEN: a real failure is still reported as one.**
3811 ///
3812 /// The dangerous direction of [`peer_hung_up`] is not missing a hung-up
3813 /// peer — that only restores the noise. It is answering `true` for
3814 /// everything, which gives a beautifully quiet journal for a server that is
3815 /// on fire, and does it in a way no other test would notice.
3816 ///
3817 /// So every case is asserted in both directions, against **rendered log
3818 /// output** rather than against the predicate, because the predicate is
3819 /// private and the log line is what an operator has.
3820 #[test]
3821 fn a_real_failure_is_still_reported_as_one() {
3822 // Benign: the peer went away. Each of these is a shape MEASURED in the
3823 // live journal on 2026-08-06 for a transfer that succeeded.
3824 let benign: Vec<(&str, Box<dyn std::error::Error + 'static>)> = vec![
3825 // `err=Channel send error` — the channel's sender is gone.
3826 ("russh SendError", Box::new(russh::Error::SendError)),
3827 ("russh HUP", Box::new(russh::Error::HUP)),
3828 ("russh Disconnect", Box::new(russh::Error::Disconnect)),
3829 // `error=early eof` — tokio's `read_exact` on a closed socket.
3830 (
3831 "early eof",
3832 Box::new(std::io::Error::new(
3833 std::io::ErrorKind::UnexpectedEof,
3834 "early eof",
3835 )),
3836 ),
3837 (
3838 "broken pipe",
3839 Box::new(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
3840 ),
3841 (
3842 "connection reset",
3843 Box::new(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
3844 ),
3845 ];
3846 for (what, err) in benign {
3847 let lines = captured_lines(|| note_teardown("channel-close", Err(BoxedError(err))));
3848 assert_eq!(lines.len(), 1, "{what}: expected one line, got {lines:?}");
3849 let line = lines[0].to_ascii_lowercase();
3850 assert!(
3851 line.contains("peer_gone"),
3852 "{what} was not classified as a hung-up peer: {}",
3853 lines[0]
3854 );
3855 assert!(
3856 !line.contains("error") && !line.contains("err=") && !line.contains("fail"),
3857 "{what} is benign but its line is still found by `grep -i error`: {}",
3858 lines[0]
3859 );
3860 }
3861
3862 // NOT benign: the protocol itself went wrong. These must keep saying so
3863 // — this is the half that stops the classifier becoming a blanket yes.
3864 let real: Vec<(&str, Box<dyn std::error::Error + 'static>)> = vec![
3865 ("key exchange", Box::new(russh::Error::Kex)),
3866 ("packet auth", Box::new(russh::Error::PacketAuth)),
3867 ("decryption", Box::new(russh::Error::DecryptionError)),
3868 ("inconsistent state", Box::new(russh::Error::Inconsistent)),
3869 ("no common algo", Box::new(russh::Error::WrongChannel)),
3870 (
3871 "permission denied on disk",
3872 Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
3873 ),
3874 (
3875 "out of descriptors",
3876 Box::new(std::io::Error::from(std::io::ErrorKind::Other)),
3877 ),
3878 ];
3879 for (what, err) in real {
3880 let lines = captured_lines(|| note_teardown("channel-close", Err(BoxedError(err))));
3881 assert_eq!(lines.len(), 1, "{what}: expected one line, got {lines:?}");
3882 assert!(
3883 lines[0].contains("ssh.teardown.failed"),
3884 "{what} was swallowed as benign, so the classifier is a blanket yes: {}",
3885 lines[0]
3886 );
3887 assert!(
3888 lines[0].contains("err="),
3889 "{what} lost the error itself, so the line names no cause: {}",
3890 lines[0]
3891 );
3892 }
3893 }
3894
3895 /// A boxed error that is still `std::error::Error`, so the table above can
3896 /// hold two concrete types in one `Vec` and still reach the classifier's
3897 /// `downcast_ref` through `source()`.
3898 #[derive(Debug)]
3899 struct BoxedError(Box<dyn std::error::Error + 'static>);
3900
3901 impl std::fmt::Display for BoxedError {
3902 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3903 self.0.fmt(f)
3904 }
3905 }
3906
3907 impl std::error::Error for BoxedError {
3908 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
3909 Some(self.0.as_ref())
3910 }
3911 }
3912
3913 /// The session-level half of the same rule: an ended session says nothing
3914 /// an operator greps for when the client simply hung up, and still says
3915 /// `error` when it did not.
3916 ///
3917 /// Asserted through the real `Handler` method rather than the predicate,
3918 /// because the method is what russh calls.
3919 #[test]
3920 fn a_hung_up_session_is_not_logged_as_an_error() {
3921 let quiet = captured_lines(|| {
3922 note_session_end(&anyhow::Error::new(std::io::Error::new(
3923 std::io::ErrorKind::UnexpectedEof,
3924 "early eof",
3925 )));
3926 });
3927 assert_eq!(quiet.len(), 1, "expected one line, got {quiet:?}");
3928 let line = quiet[0].to_ascii_lowercase();
3929 assert!(
3930 line.contains("peer-hung-up") && !line.contains("error"),
3931 "a client hanging up is still logged as an error: {}",
3932 quiet[0]
3933 );
3934
3935 let loud = captured_lines(|| {
3936 note_session_end(&anyhow::Error::new(russh::Error::Kex));
3937 });
3938 assert_eq!(loud.len(), 1, "expected one line, got {loud:?}");
3939 assert!(
3940 loud[0].to_ascii_lowercase().contains("error"),
3941 "a key-exchange failure was swallowed as a hung-up peer: {}",
3942 loud[0]
3943 );
3944 }
3945
3946 /// An unencodable key is **refused and counted**, never accepted.
3947 ///
3948 /// The assertion is on the applied output, the `Auth` value that goes back
3949 /// to russh and the counter an operator reads, not on "it did not panic".
3950 /// A body that returned `Auth::Accept` would let a key nobody could
3951 /// fingerprint onto the server, which is the one outcome worse than the
3952 /// panic this replaced.
3953 #[test]
3954 fn a_key_that_does_not_re_encode_is_rejected_and_counted() {
3955 let metrics = gunnar_core::observe::ServerMetrics::default();
3956 let auth = refuse_unencodable_key(&metrics, "decorative");
3957
3958 assert!(
3959 matches!(auth, Auth::Reject { .. }),
3960 "an unencodable key must be rejected, got {auth:?}"
3961 );
3962 let snapshot = metrics.snapshot();
3963 assert_eq!(
3964 snapshot.connections_refused, 1,
3965 "the refusal must be visible to an operator"
3966 );
3967 // Not an identity-table fault, and the counters must not blur the two.
3968 assert_eq!(snapshot.auth_faults, 0);
3969 assert_eq!(snapshot.connections_accepted, 0);
3970 }
3971}
3972
3973/// Pure: whether an exec line is git-lfs asking SSH (`git-lfs-authenticate …`,
3974/// `git-lfs-transfer …`) — refused, and said at INFO.
3975pub fn is_lfs_over_ssh(command: &str) -> bool {
3976 let first = command.split_whitespace().next().unwrap_or("");
3977 // The transfer verb is named through the dispatch table's own LFS row, never spelled here
3978 // (`command_parsing::the_dispatch_table_is_the_only_place_a_verb_is_named`).
3979 // `git-lfs-authenticate` is no verb gunnar dispatches: it is git-lfs's own probe.
3980 first == "git-lfs-authenticate"
3981 || crate::command::SERVICES.iter().any(|spec| {
3982 spec.verb == first && matches!(spec.service, crate::command::GitService::LfsTransfer(_))
3983 })
3984}
3985
3986#[cfg(test)]
3987mod lfs_over_ssh_tests {
3988 /// ★ git-lfs's SSH probe is a client's known step, not a defect (RUN A 2026-10-08).
3989 #[test]
3990 fn git_lfs_asking_ssh_is_known_and_anything_else_is_not() {
3991 assert!(super::is_lfs_over_ssh(
3992 "git-lfs-authenticate /cbc/vault upload"
3993 ));
3994 assert!(super::is_lfs_over_ssh(
3995 "git-lfs-transfer 'acme/site' download"
3996 ));
3997 for other in [
3998 "bash",
3999 "scp -t /etc",
4000 "",
4001 "git-lfs-authenticatex /a upload",
4002 "echo git-lfs-authenticate",
4003 ] {
4004 assert!(!super::is_lfs_over_ssh(other), "{other:?}");
4005 }
4006 }
4007}