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(crate::admission::DEFAULT_MAX_ANONYMOUS_TRANSFERS),
221 per_principal_limit_is_hard: false,
222 #[cfg(feature = "admit-share")]
223 admit: edda_admit::Settings::default(),
224 memory_budget_bytes: Some(crate::governor::DEFAULT_MEMORY_BUDGET_BYTES),
225 admission_wait: Some(crate::governor::DEFAULT_ADMISSION_WAIT),
226 keyed_admission_wait: Some(crate::governor::DEFAULT_KEYED_ADMISSION_WAIT),
227 session_end_grace: crate::channel::SESSION_END_GRACE,
228 session_end_grace_crowded: crate::channel::CROWDED_SESSION_END_GRACE,
229 }
230 }
231}
232
233impl SshConfig {
234 /// A configuration bound to an ephemeral loopback port, for tests.
235 ///
236 /// The timeouts are shortened so a test that waits for one finishes in
237 /// under a second. It is a constructor, not a mode: nothing here changes
238 /// what is authenticated or what is dispatched.
239 pub fn ephemeral_loopback() -> Self {
240 SshConfig {
241 bind: SocketAddr::from(([127, 0, 0, 1], 0)),
242 inactivity_timeout: Some(Duration::from_secs(20)),
243 exec_grace: Some(Duration::from_secs(10)),
244 auth_rejection_time: Duration::from_millis(50),
245 // A TIMEOUT, shortened like the three above it — not an admission
246 // limit. `the_default_admission_limits_are_a_decision_and_are_ordered`
247 // holds the line that this constructor may not relax what is
248 // *admitted*; the bound on how long a refusal takes to arrive is a
249 // wait, and a test that spent two seconds per refusal would measure
250 // the clock rather than the gate.
251 admission_wait: Some(Duration::from_millis(100)),
252 // The same reasoning for the keyed bound: a test that wants it
253 // sets it (`tests/keyed_queue.rs`).
254 keyed_admission_wait: None,
255 ..SshConfig::default()
256 }
257 }
258
259 /// The same configuration with the anonymous read path closed.
260 ///
261 /// After this, a caller with no key cannot open a session at all, not even
262 /// to a repository marked [`Visibility::Public`](gunnar_core::Visibility).
263 pub fn without_anonymous_access(mut self) -> Self {
264 self.allow_anonymous = false;
265 self
266 }
267}
268
269/// **The SSH username that asks for the anonymous path**, and the only thing
270/// gunnar has ever read a username for.
271///
272/// Everywhere else the username is decorative (identity comes from the key,
273/// the ACL model) and it stays decorative here, because this name selects an
274/// *authentication method*, not an authorisation outcome. See
275/// `gunnar_ssh::server`'s module docs for why the choice was forced and why it
276/// cannot be a bypass:
277///
278/// * `ssh://anonymous@host/ns/repo.git` → the `none` method is accepted and the
279/// session is [`Principal::anonymous`](gunnar_core::Principal::anonymous),
280/// which holds nothing and can only read a public repository;
281/// * every other username → `none` is refused exactly as it was before this
282/// existed, and the client falls through to publickey.
283///
284/// A user who spells this name gets strictly *less* than one who does not, and
285/// a user who does not spell it cannot be silently downgraded into it.
286pub const ANONYMOUS_SSH_USER: &str = "anonymous";
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn the_default_admission_limits_are_a_decision_and_are_ordered() {
294 let config = SshConfig::default();
295 let server_wide = config
296 .max_concurrent_transfers
297 .expect("the appliance default must HAVE a limit; the defect was that it had none");
298 let per_principal = config
299 .max_transfers_per_principal
300 .expect("the per-principal default must exist too");
301 assert!(
302 per_principal < server_wide,
303 "a per-principal limit of {per_principal} against a server-wide {server_wide} means \
304 one principal can take the whole server, which is the half of the defect the global \
305 limit does not answer"
306 );
307 }
308
309 #[test]
310 fn the_test_constructor_does_not_quietly_change_the_limits() {
311 // `ephemeral_loopback` shortens timeouts. If it also relaxed admission,
312 // every integration test would be running a configuration no appliance
313 // has, and the gate would be untested where it matters.
314 let ephemeral = SshConfig::ephemeral_loopback();
315 let default = SshConfig::default();
316 assert_eq!(
317 ephemeral.max_concurrent_transfers,
318 default.max_concurrent_transfers
319 );
320 assert_eq!(
321 ephemeral.max_transfers_per_principal,
322 default.max_transfers_per_principal
323 );
324 assert_eq!(
325 ephemeral.allow_anonymous, default.allow_anonymous,
326 "the test constructor must not quietly open or close the anonymous path, or every \
327 integration test would be exercising a configuration no appliance has"
328 );
329 }
330
331 #[test]
332 fn anonymous_read_is_on_by_default_and_can_be_switched_off() {
333 // Both directions. A `without_anonymous_access` that did nothing would
334 // be invisible without the first assertion, and a default of `false`
335 // would make `Repo.visibility` mean nothing again without the second.
336 assert!(
337 SshConfig::default().allow_anonymous,
338 "a public repository must be readable without a key by default"
339 );
340 assert!(
341 !SshConfig::default()
342 .without_anonymous_access()
343 .allow_anonymous
344 );
345 }
346}