Skip to main content

gunnar_ssh/
config.rs

1//! Listener configuration (SSH and gRPC are separate listeners).
2
3use std::net::SocketAddr;
4use std::time::Duration;
5
6/// How the SSH listener behaves.
7///
8/// The defaults are the appliance's: bind everywhere on 2222, publickey only,
9/// and three separate ways for a connection that is going nowhere to be closed.
10#[derive(Debug, Clone)]
11pub struct SshConfig {
12    /// Where to listen. Unlike the gRPC control plane — which defaults to
13    /// loopback because it is unauthenticated in v1 — this
14    /// listener authenticates every connection with a public key, so binding it
15    /// publicly is the intended configuration.
16    pub bind: SocketAddr,
17
18    /// The `SSH-2.0-…` identification string.
19    pub server_id: String,
20
21    /// Close a connection that has sent nothing for this long. Covers the
22    /// client that completes a handshake and then holds the socket open.
23    pub inactivity_timeout: Option<Duration>,
24
25    /// How long an authenticated session may sit without asking for a git
26    /// command before it is disconnected.
27    ///
28    /// This is the leak the inactivity timeout does *not* catch: a client that
29    /// authenticates, opens a session channel, and then answers keepalives
30    /// forever is never idle. Sixty seconds is far longer than any real client
31    /// takes between authenticating and sending its exec request.
32    pub exec_grace: Option<Duration>,
33
34    /// Constant-time rejection delay, so a wrong key cannot be distinguished
35    /// from a slow lookup.
36    pub auth_rejection_time: Duration,
37
38    /// How many authentication attempts one connection may make.
39    pub max_auth_attempts: usize,
40
41    /// Send a keepalive if nothing has arrived for this long.
42    pub keepalive_interval: Option<Duration>,
43
44    /// How many git transfers may be in flight across the whole server at once.
45    /// `None` turns the limit off.
46    ///
47    /// Before this existed the ceiling was `tokio`'s default of 512 blocking
48    /// threads, one per transfer, and going past it queued **silently**. This
49    /// ceiling is the same kind of thing, except that somebody chose the number
50    /// and both the client and the operator get told when it bites
51    /// ([`crate::AdmissionStats`]).
52    ///
53    /// The default is deliberately **below** the blocking-thread pool it is
54    /// protecting: see `gunnar_cli::server::serve::GIT_BLOCKING_THREADS`, whose
55    /// own test asserts the relationship rather than leaving it to a comment.
56    pub max_concurrent_transfers: Option<usize>,
57
58    /// Whether an unauthenticated caller may read **public** repositories
59    /// `true` by default.
60    ///
61    /// Turning it off closes the anonymous door at the listener, before any
62    /// repository is resolved and before the ACL is consulted, so no
63    /// `Visibility` setting can reopen it. That is the switch for an appliance
64    /// that must be able to state "this box has never served a stranger"; it is
65    /// **not** what protects private repositories, which are protected by the
66    /// ACL and are unreachable anonymously either way.
67    ///
68    /// The default is `true` because [`Visibility::Private`] is the default for
69    /// a repository: nothing is anonymously readable until someone deliberately
70    /// marks it public. See
71    /// [`SshConfig::without_anonymous_access`].
72    ///
73    /// [`Visibility::Private`]: gunnar_core::Visibility::Private
74    pub allow_anonymous: bool,
75
76    /// How many git transfers one principal is **guaranteed** at once, and its
77    /// share of a contended server. `None` turns the limit off.
78    ///
79    /// The server-wide limit alone is exhaustible by a single client, which
80    /// would then be denying service to everybody else; a fork-bomb of clones
81    /// is exactly the stated threat. The limit is per **principal**, not
82    /// per IP, because gunnar authenticates keys: a per-IP cap would throttle a
83    /// whole office behind one NAT and miss a botnet entirely.
84    ///
85    /// It is a **floor, not a wall.** Until 2026-08-03 it bound
86    /// unconditionally, and measured on a 32-core desktop that meant one
87    /// principal with 64 clients got 32 served and 32 refused against 224 idle slots: the
88    /// advertised 256 needed eight separate identities and no single-team
89    /// appliance could ever reach it. Now the number is what a principal always
90    /// gets and what it is held to once the server is contended; below that the
91    /// server-wide limit is the only ceiling. [`crate::admission`] has the rule
92    /// and the measurements.
93    pub max_transfers_per_principal: Option<usize>,
94
95    /// How many git reads may be served **without a key** at once.
96    /// `None` lets anonymous take an ordinary principal's max-min share.
97    ///
98    /// The companion to [`Self::allow_anonymous`], and the reason it needs one:
99    /// that flag decides *whether* the door is open, and this decides *how wide*.
100    /// Without it, the open-by-default anonymous door inherits the per-principal share —
101    /// which is work-conserving on purpose, so on an idle appliance a stranger's
102    /// ceiling is the entire server ([`crate::admission`] has the rule and why it
103    /// is right for a keyed identity and wrong for this one).
104    ///
105    /// The default is [`DEFAULT_MAX_ANONYMOUS_TRANSFERS`] (5), which is under
106    /// 2 % of the default server-wide 256 and one sixth of what a single keyed
107    /// identity is *guaranteed*. That asymmetry is the point: an operator must
108    /// never be made to wait behind traffic gunnar cannot name.
109    ///
110    /// [`DEFAULT_MAX_ANONYMOUS_TRANSFERS`]: crate::admission::DEFAULT_MAX_ANONYMOUS_TRANSFERS
111    pub max_anonymous_transfers: Option<usize>,
112
113    /// **The usage share's settings** (`admit-share`): the rolling-window
114    /// byte budget a namespace moves at the ordinary per-principal share, and
115    /// past which it is held to the anonymous ceiling. `limit: None`
116    /// (`--admit-limit none`) is PassThrough — the gate decides exactly as a
117    /// build without the feature. Defaults: 100 GB per 30 days.
118    #[cfg(feature = "admit-share")]
119    pub admit: edda_admit::Settings,
120
121    /// Make [`Self::max_transfers_per_principal`] an **unconditional** ceiling
122    /// again, contention or not.
123    ///
124    /// `false` by default, because the appliance case is one team behind a
125    /// handful of keys and a wall there only throttles the owner. Set it on a
126    /// host where an identity is a *tenant* and the cap is the thing being
127    /// sold: there, letting a tenant use an idle server is giving away what was
128    /// metered.
129    pub per_principal_limit_is_hard: bool,
130
131    /// The **memory budget** the server is allowed to hold for git operations
132    /// in flight, in bytes (gate 2 of [`crate::governor`]).
133    ///
134    /// A reservation gunnar owns, never a measurement of the machine. By the
135    /// OS-less law this crate may not read `/proc`, `sysinfo` or `getrusage`, so
136    /// it cannot size itself to the box and does not pretend to: this is the
137    /// number an **operator** sets, and the default
138    /// ([`DEFAULT_MEMORY_BUDGET_BYTES`](crate::governor::DEFAULT_MEMORY_BUDGET_BYTES),
139    /// 8 GiB) is the cgroup the bakeoff measurement was taken in, not a fit to
140    /// this host. **Lower it on a small machine.**
141    ///
142    /// `None` only where [`Self::max_concurrent_transfers`] is also `None`: an
143    /// unlimited gate is unlimited in both dimensions.
144    pub memory_budget_bytes: Option<u64>,
145
146    /// How long a transfer may **wait** for a slot before it is refused
147    /// ([`crate::governor::DEFAULT_ADMISSION_WAIT`]).
148    ///
149    /// `Some(Duration::ZERO)` restores the pre-2026-08-30 gate exactly — refuse
150    /// on the spot — and is the arm the before/after table in
151    /// the published benchmark numbers were measured against. `None` takes
152    /// the default.
153    ///
154    /// It is a *bound*, not a queue depth: nothing is enqueued, the waiting
155    /// session is one parked future, and the wait is counted
156    /// ([`crate::AdmissionStats::peak_wait_ms`]) and is the CoDel controller's
157    /// input. See [`crate::governor::DEFAULT_ADMISSION_WAIT`] for why refusing
158    /// a client that will reconnect costs more than parking it.
159    pub admission_wait: Option<Duration>,
160    /// How long an **authenticated** caller may be queued for a slot — and,
161    /// after a restart, for the stores to open — before it is refused.
162    /// `None`: keyed callers get [`SshConfig::admission_wait`] like everybody.
163    /// See [`crate::governor::DEFAULT_KEYED_ADMISSION_WAIT`].
164    pub keyed_admission_wait: Option<Duration>,
165    /// How long a transfer whose session ended is given to end by itself
166    /// before it is told to stop ([`crate::channel::SESSION_END_GRACE`]).
167    pub session_end_grace: Duration,
168    /// The same grace when others wait for the transfer's fan
169    /// ([`crate::channel::CROWDED_SESSION_END_GRACE`]): a worker a hung-up
170    /// client holds is one a queued client does not get (stinky row 19).
171    pub session_end_grace_crowded: Duration,
172}
173
174/// The port the appliance listens on by default. Port 22 usually belongs to
175/// something else on a developer machine, and gunnar never asks for privileges
176/// it does not need.
177pub const DEFAULT_PORT: u16 = 2222;
178
179/// The default server-wide concurrent-transfer ceiling.
180///
181/// **Chosen, not inherited.** Each in-flight transfer costs one blocking thread
182/// (`SyncIoBridge`), one open repository handle, and roughly one core's worth of
183/// work while it is packing. Measurement 3
184/// shows a 32-thread box already at 63% efficiency, so admitting far more
185/// concurrent transfers than the machine has cores buys queueing, not
186/// throughput. 256 leaves room for a burst on a 32–64 core appliance while
187/// staying well under the blocking pool that carries them.
188pub const DEFAULT_MAX_CONCURRENT_TRANSFERS: usize = 256;
189
190/// The default per-principal concurrent-transfer **floor and fair share**.
191///
192/// One eighth of the server-wide limit: the number any identity is guaranteed
193/// however busy the box is, and the share it is held to once the box is
194/// contended by eight or more of them.
195///
196/// It used to be a hard ceiling, and that made it the number a single-key
197/// client actually got — measured on a 32-core desktop 2026-08-03, one
198/// principal with 64 clients was served 32 and refused 32 while 224 slots sat idle. It is now a
199/// floor; see [`crate::admission`] for the rule and
200/// [`SshConfig::per_principal_limit_is_hard`] for pinning it back into a wall.
201pub const DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL: usize = 32;
202
203impl Default for SshConfig {
204    fn default() -> Self {
205        SshConfig {
206            bind: SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT)),
207            server_id: format!("SSH-2.0-gunnar_{}", env!("CARGO_PKG_VERSION")),
208            inactivity_timeout: Some(Duration::from_secs(600)),
209            exec_grace: Some(Duration::from_secs(60)),
210            auth_rejection_time: Duration::from_secs(1),
211            max_auth_attempts: 6,
212            // 15 s, not 60 (2026-10-06): with russh's keepalive_max of 3 a
213            // client whose link died is called dead in 60 s, not 4 minutes —
214            // and its transfer, its fan worker and its admission slot are
215            // released then (see `channel::SessionBound`).
216            keepalive_interval: Some(Duration::from_secs(15)),
217            allow_anonymous: true,
218            max_concurrent_transfers: Some(DEFAULT_MAX_CONCURRENT_TRANSFERS),
219            max_transfers_per_principal: Some(DEFAULT_MAX_TRANSFERS_PER_PRINCIPAL),
220            max_anonymous_transfers: Some(
221                crate::admission::DEFAULT_MAX_ANONYMOUS_TRANSFERS,
222            ),
223            per_principal_limit_is_hard: false,
224            #[cfg(feature = "admit-share")]
225            admit: edda_admit::Settings::default(),
226            memory_budget_bytes: Some(crate::governor::DEFAULT_MEMORY_BUDGET_BYTES),
227            admission_wait: Some(crate::governor::DEFAULT_ADMISSION_WAIT),
228            keyed_admission_wait: Some(crate::governor::DEFAULT_KEYED_ADMISSION_WAIT),
229            session_end_grace: crate::channel::SESSION_END_GRACE,
230            session_end_grace_crowded: crate::channel::CROWDED_SESSION_END_GRACE,
231        }
232    }
233}
234
235impl SshConfig {
236    /// A configuration bound to an ephemeral loopback port, for tests.
237    ///
238    /// The timeouts are shortened so a test that waits for one finishes in
239    /// under a second. It is a constructor, not a mode: nothing here changes
240    /// what is authenticated or what is dispatched.
241    pub fn ephemeral_loopback() -> Self {
242        SshConfig {
243            bind: SocketAddr::from(([127, 0, 0, 1], 0)),
244            inactivity_timeout: Some(Duration::from_secs(20)),
245            exec_grace: Some(Duration::from_secs(10)),
246            auth_rejection_time: Duration::from_millis(50),
247            // A TIMEOUT, shortened like the three above it — not an admission
248            // limit. `the_default_admission_limits_are_a_decision_and_are_ordered`
249            // holds the line that this constructor may not relax what is
250            // *admitted*; the bound on how long a refusal takes to arrive is a
251            // wait, and a test that spent two seconds per refusal would measure
252            // the clock rather than the gate.
253            admission_wait: Some(Duration::from_millis(100)),
254            // The same reasoning for the keyed bound: a test that wants it
255            // sets it (`tests/keyed_queue.rs`).
256            keyed_admission_wait: None,
257            ..SshConfig::default()
258        }
259    }
260
261    /// The same configuration with the anonymous read path closed.
262    ///
263    /// After this, a caller with no key cannot open a session at all, not even
264    /// to a repository marked [`Visibility::Public`](gunnar_core::Visibility).
265    pub fn without_anonymous_access(mut self) -> Self {
266        self.allow_anonymous = false;
267        self
268    }
269}
270
271/// **The SSH username that asks for the anonymous path**, and the only thing
272/// gunnar has ever read a username for.
273///
274/// Everywhere else the username is decorative (identity comes from the key,
275/// the ACL model) and it stays decorative here, because this name selects an
276/// *authentication method*, not an authorisation outcome. See
277/// `gunnar_ssh::server`'s module docs for why the choice was forced and why it
278/// cannot be a bypass:
279///
280/// * `ssh://anonymous@host/ns/repo.git` → the `none` method is accepted and the
281///   session is [`Principal::anonymous`](gunnar_core::Principal::anonymous),
282///   which holds nothing and can only read a public repository;
283/// * every other username → `none` is refused exactly as it was before this
284///   existed, and the client falls through to publickey.
285///
286/// A user who spells this name gets strictly *less* than one who does not, and
287/// a user who does not spell it cannot be silently downgraded into it.
288pub const ANONYMOUS_SSH_USER: &str = "anonymous";
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn the_default_admission_limits_are_a_decision_and_are_ordered() {
296        let config = SshConfig::default();
297        let server_wide = config
298            .max_concurrent_transfers
299            .expect("the appliance default must HAVE a limit; the defect was that it had none");
300        let per_principal = config
301            .max_transfers_per_principal
302            .expect("the per-principal default must exist too");
303        assert!(
304            per_principal < server_wide,
305            "a per-principal limit of {per_principal} against a server-wide {server_wide} means \
306             one principal can take the whole server, which is the half of the defect the global \
307             limit does not answer"
308        );
309    }
310
311    #[test]
312    fn the_test_constructor_does_not_quietly_change_the_limits() {
313        // `ephemeral_loopback` shortens timeouts. If it also relaxed admission,
314        // every integration test would be running a configuration no appliance
315        // has, and the gate would be untested where it matters.
316        let ephemeral = SshConfig::ephemeral_loopback();
317        let default = SshConfig::default();
318        assert_eq!(
319            ephemeral.max_concurrent_transfers,
320            default.max_concurrent_transfers
321        );
322        assert_eq!(
323            ephemeral.max_transfers_per_principal,
324            default.max_transfers_per_principal
325        );
326        assert_eq!(
327            ephemeral.allow_anonymous, default.allow_anonymous,
328            "the test constructor must not quietly open or close the anonymous path, or every \
329             integration test would be exercising a configuration no appliance has"
330        );
331    }
332
333    #[test]
334    fn anonymous_read_is_on_by_default_and_can_be_switched_off() {
335        // Both directions. A `without_anonymous_access` that did nothing would
336        // be invisible without the first assertion, and a default of `false`
337        // would make `Repo.visibility` mean nothing again without the second.
338        assert!(
339            SshConfig::default().allow_anonymous,
340            "a public repository must be readable without a key by default"
341        );
342        assert!(
343            !SshConfig::default()
344                .without_anonymous_access()
345                .allow_anonymous
346        );
347    }
348}