Skip to main content

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}