Skip to main content

kanade_shared/wire/
server_settings.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::MailSection;
4
5/// Upper bound on [`ServerSettings::agent_prune_days`] (100 years). A
6/// value this large already means "effectively never", and it keeps the
7/// cleanup task's `now - Duration::days(n)` subtraction comfortably inside
8/// `chrono::DateTime`'s representable range — `DateTime - Duration` panics
9/// on overflow, and an unbounded `u32` (~11.7 M years) would trip it.
10/// Enforced two ways: the PUT handler rejects a larger value, and
11/// [`ServerSettings::effective_agent_prune_days`] clamps to it so even a
12/// hand-written KV value can never panic the cleanup task.
13pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
14
15/// Built-in retention window (days) for the `collections` Object Store —
16/// the file bundles a `collect:` job uploads (#219). This is the value the
17/// bootstrap creates a fresh bucket with, and the fallback
18/// [`ServerSettings::effective_collect_retention_days`] resolves to when the
19/// operator hasn't overridden it. Kept here (not just in `bootstrap`) so the
20/// wire default and the bucket's initial `max_age` share one source of truth.
21pub const DEFAULT_COLLECT_RETENTION_DAYS: u32 = 30;
22
23/// Upper bound on [`ServerSettings::collect_retention_days`] (10 years).
24/// Bundles are debugging / audit artifacts, so an operator has no reason to
25/// keep them longer than this; the ceiling stops a fat-fingered value from
26/// pushing the `collections` Object Store's `max_age` to something absurd.
27/// (The bucket's `max_bytes` cap still bounds actual disk regardless.)
28/// Enforced by the PUT handler and clamped in
29/// [`ServerSettings::effective_collect_retention_days`] so even a
30/// hand-written KV value stays sane.
31pub const MAX_COLLECT_RETENTION_DAYS: u32 = 3650;
32
33/// Built-in default for [`ServerSettings::session_ttl_hours`] — how long a
34/// freshly-minted SPA/CLI login token stays valid when the operator hasn't
35/// overridden it. 24h balances "don't re-auth mid-workday" against a
36/// bounded window for a leaked token (the DB row is re-checked every
37/// request, so `disable` still takes effect immediately regardless).
38pub const DEFAULT_SESSION_TTL_HOURS: u32 = 24;
39
40/// Upper bound on [`ServerSettings::session_ttl_hours`] (365 days). Keeps
41/// login's `Utc::now() + Duration::hours(n)` comfortably inside `chrono`'s
42/// representable range (its nanosecond `i64` caps out ~292 years, so an
43/// unclamped `u32` of hours could overflow) and stops an operator pinning a
44/// practically-immortal session. Enforced by the PUT handler and clamped in
45/// [`ServerSettings::effective_session_ttl_hours`] so even a hand-written
46/// KV value stays bounded.
47pub const MAX_SESSION_TTL_HOURS: u32 = 8_760;
48
49/// Value stored in the `server_settings` KV bucket under the single key
50/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
51/// server configuration that isn't per-agent (so it doesn't belong in
52/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
53/// agent watches (so it doesn't belong in `fleet_config`). Managed via
54/// the SPA Settings page's "server settings" tab.
55///
56/// Every field is `Option<_>`: `None` (the default / the JSON value
57/// `null` / the field simply absent) means **unset — fall back to the
58/// built-in default** ([`ServerSettings::defaults`]), exactly like the
59/// agent layered-config scopes. The SPA renders the built-in default as a
60/// faint placeholder so a blank field shows what it resolves to, and when
61/// a real default is introduced here it appears in the UI (and takes
62/// effect for already-deployed-but-unset fleets) for free.
63///
64/// `#[serde(default)]` on the container keeps the document backward/forward
65/// compatible: a freshly-created or missing key decodes to all-`None`
66/// (pre-feature behaviour), an older backend reading a newer document
67/// ignores unknown fields, and a newer backend reading an older document
68/// fills the missing field with `None`. Keep that invariant — never add a
69/// field whose `None` doesn't mean "behave as before".
70#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
71#[serde(default)]
72pub struct ServerSettings {
73    /// Days a dead agent (one whose heartbeat stopped arriving) may
74    /// linger in the `agents` registry before the backend cleanup task
75    /// prunes its row.
76    ///
77    /// `None` (unset) falls back to the built-in default; with no default
78    /// configured that resolves to pruning **disabled** (see
79    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
80    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
81    /// than that many days. The `agents` table is a projection of the
82    /// heartbeat stream, so a machine that's merely offline (not gone)
83    /// reappears on its next heartbeat (~30s cadence); only
84    /// genuinely-retired machines stay gone.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub agent_prune_days: Option<u32>,
87
88    /// Agent group whose members are the trusted **controller-tier**
89    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
90    /// fetches an external URL) is dispatched ONLY to members of this group;
91    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
92    /// so an external fetch never lands on an employee endpoint by accident).
93    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
94    /// configured, which is the safe default for a fresh deployment.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub controller_group: Option<String>,
97
98    /// Non-secret SMTP relay settings for outbound email (compliance-alert
99    /// notifications, account setup links, …). Lives here (#884) rather than
100    /// in `backend.toml` so an operator can edit it from the SPA without
101    /// shell access to the host.
102    ///
103    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
104    /// / NATS notification path is unaffected. The SMTP **password is
105    /// deliberately not here** (KV is
106    /// readable over NATS): it stays sourced from the `MailPassword`
107    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
108    /// settings when the backend builds its `Mailer`. Changes take effect on
109    /// the **next backend restart** (the backend builds the `Mailer` once at
110    /// startup — no live rebuild), which is acceptable per the #884
111    /// discussion.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub mail: Option<MailSection>,
114
115    /// Retention window (days) for collected file bundles — the `collect:`
116    /// job archives uploaded to the `collections` Object Store (#219).
117    ///
118    /// Unlike the other fields, this one has a **real built-in default**
119    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
120    /// to it, so a blank field preserves the historical behaviour rather than
121    /// disabling retention. A positive value tells the backend to reconcile
122    /// the Object Store's `max_age` to that many days (see
123    /// `bootstrap::reconcile_collect_retention`), applied at boot and again
124    /// whenever this document is saved from the SPA. Clamped to
125    /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
126    /// untouched, so extending the window can't make the store grow unbounded.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub collect_retention_days: Option<u32>,
129
130    /// How many hours a freshly-minted login token (SPA / CLI) stays valid
131    /// before the caller must re-authenticate. Read backend-side by the
132    /// login handler when it mints the JWT `exp`; changing it affects
133    /// **tokens minted after the change**, not already-issued ones.
134    ///
135    /// Like [`collect_retention_days`](Self::collect_retention_days) this
136    /// carries a **real built-in default** ([`DEFAULT_SESSION_TTL_HOURS`],
137    /// 24h): `None` (unset) falls back to it, so a blank field resolves to a
138    /// usable window rather than an instantly-expired token. Clamped to
139    /// `1..=`[`MAX_SESSION_TTL_HOURS`]. The SPA renders `24` as a faint
140    /// placeholder on a blank field.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub session_ttl_hours: Option<u32>,
143}
144
145impl ServerSettings {
146    /// Built-in defaults applied when a field is unset (`None`) in the
147    /// stored document. Most fields default to `None` (no fleet-meaningful
148    /// default — a blank prune window means "disabled" rather than some
149    /// arbitrary number of days). The exceptions carry real defaults so a
150    /// blank field still resolves to a sensible value:
151    /// [`collect_retention_days`](Self::collect_retention_days)
152    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], preserving the historical 30-day
153    /// retention) and [`session_ttl_hours`](Self::session_ttl_hours)
154    /// ([`DEFAULT_SESSION_TTL_HOURS`], 24h).
155    ///
156    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
157    /// these as faint placeholders (mirroring the agent layered-config
158    /// page's built-in floor). Introducing a real default is a one-line
159    /// change here that automatically shows up in the UI and applies to
160    /// every deployment that hasn't overridden the field.
161    pub fn defaults() -> Self {
162        Self {
163            agent_prune_days: None,
164            controller_group: None,
165            mail: None,
166            collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
167            session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
168        }
169    }
170
171    /// The configured controller-tier runner group, trimmed, or `None` when
172    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
173    pub fn effective_controller_group(&self) -> Option<&str> {
174        self.controller_group
175            .as_deref()
176            .map(str::trim)
177            .filter(|g| !g.is_empty())
178    }
179
180    /// The effective dead-agent prune window in days: the stored value if
181    /// set, else the built-in default, else `0` (= pruning disabled). The
182    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
183    /// user-facing default — the cleanup task treats `0` as "don't prune".
184    ///
185    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
186    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
187    /// the task), even if a value larger than the PUT handler allows was
188    /// written to the KV out-of-band.
189    pub fn effective_agent_prune_days(&self) -> u32 {
190        self.agent_prune_days
191            .or(Self::defaults().agent_prune_days)
192            .unwrap_or(0)
193            .min(MAX_AGENT_PRUNE_DAYS)
194    }
195
196    /// The effective collect-bundle retention window in days: the stored
197    /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
198    /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
199    /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
200    /// The floor matters specifically because NATS treats `max_age: 0` as
201    /// **unlimited** retention, not "evict immediately": a stray `0` would
202    /// silently make bundles never expire (defeating the auto-expire intent),
203    /// so we coerce it to the shortest real window (1 day) instead. The PUT
204    /// handler already rejects `0` / over-cap, so this is the belt-and-braces
205    /// path for a hand-edited KV value.
206    pub fn effective_collect_retention_days(&self) -> u32 {
207        self.collect_retention_days
208            .or(Self::defaults().collect_retention_days)
209            .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
210            .clamp(1, MAX_COLLECT_RETENTION_DAYS)
211    }
212
213    /// The effective login-token lifetime in hours: the stored value if
214    /// set, else the built-in [`DEFAULT_SESSION_TTL_HOURS`]. Floored at 1
215    /// and clamped to [`MAX_SESSION_TTL_HOURS`] so a zero/absent/out-of-band
216    /// value can never mint an already-expired or overflow-inducing token.
217    /// The PUT handler already rejects `0` / over-cap, so this is the
218    /// belt-and-braces path for a hand-edited KV value.
219    pub fn effective_session_ttl_hours(&self) -> u32 {
220        self.session_ttl_hours
221            .or(Self::defaults().session_ttl_hours)
222            .unwrap_or(DEFAULT_SESSION_TTL_HOURS)
223            .clamp(1, MAX_SESSION_TTL_HOURS)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn default_is_unset() {
233        assert_eq!(ServerSettings::default().agent_prune_days, None);
234    }
235
236    #[test]
237    fn unset_resolves_to_disabled() {
238        // No stored value + no built-in default ⇒ effective 0 (disabled).
239        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
240    }
241
242    #[test]
243    fn stored_value_wins_over_default() {
244        let s = ServerSettings {
245            agent_prune_days: Some(30),
246            ..Default::default()
247        };
248        assert_eq!(s.effective_agent_prune_days(), 30);
249    }
250
251    #[test]
252    fn effective_clamps_to_max() {
253        // An out-of-band KV write larger than the PUT cap must not reach
254        // the cleanup task unclamped (else its DateTime subtraction panics).
255        let s = ServerSettings {
256            agent_prune_days: Some(u32::MAX),
257            ..Default::default()
258        };
259        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
260    }
261
262    #[test]
263    fn round_trips_through_json() {
264        let s = ServerSettings {
265            agent_prune_days: Some(30),
266            ..Default::default()
267        };
268        let json = serde_json::to_string(&s).unwrap();
269        assert_eq!(json, r#"{"agent_prune_days":30}"#);
270        let back: ServerSettings = serde_json::from_str(&json).unwrap();
271        assert_eq!(back, s);
272    }
273
274    #[test]
275    fn unset_serialises_to_empty_object() {
276        // `skip_serializing_if` keeps an all-unset doc minimal; it must
277        // round-trip back to all-`None`.
278        let s = ServerSettings::default();
279        let json = serde_json::to_string(&s).unwrap();
280        assert_eq!(json, "{}");
281        let back: ServerSettings = serde_json::from_str(&json).unwrap();
282        assert_eq!(back, s);
283    }
284
285    #[test]
286    fn explicit_null_decodes_to_unset() {
287        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
288        assert_eq!(s.agent_prune_days, None);
289    }
290
291    #[test]
292    fn empty_object_decodes_to_default() {
293        // A freshly-created key (or one written by an older backend that
294        // didn't know this field) must read back as the pre-feature
295        // behaviour, not fail to decode.
296        let s: ServerSettings = serde_json::from_str("{}").unwrap();
297        assert_eq!(s, ServerSettings::default());
298    }
299
300    #[test]
301    fn controller_group_effective_trims_and_blank_is_unset() {
302        assert_eq!(ServerSettings::default().effective_controller_group(), None);
303        let s = ServerSettings {
304            controller_group: Some("  feed-runners ".into()),
305            ..Default::default()
306        };
307        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
308        // A blank/whitespace value reads as unset (fail-safe: no runner).
309        let blank = ServerSettings {
310            controller_group: Some("   ".into()),
311            ..Default::default()
312        };
313        assert_eq!(blank.effective_controller_group(), None);
314    }
315
316    #[test]
317    fn controller_group_round_trips_and_omits_when_unset() {
318        let s = ServerSettings {
319            controller_group: Some("infra".into()),
320            ..Default::default()
321        };
322        let json = serde_json::to_string(&s).unwrap();
323        assert_eq!(json, r#"{"controller_group":"infra"}"#);
324        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
325        // Unset controller_group is omitted (skip_serializing_if).
326        assert_eq!(
327            serde_json::to_string(&ServerSettings::default()).unwrap(),
328            "{}"
329        );
330    }
331
332    #[test]
333    fn mail_round_trips_and_omits_when_unset() {
334        use crate::config::{MailEncryption, MailSection};
335
336        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
337        // stays minimal and decodes back to `None`.
338        assert_eq!(
339            serde_json::to_string(&ServerSettings::default()).unwrap(),
340            "{}"
341        );
342
343        let s = ServerSettings {
344            mail: Some(MailSection {
345                host: "smtp.example.com".into(),
346                port: 587,
347                encryption: MailEncryption::Starttls,
348                from: "kanade-noreply@example.com".into(),
349                username: Some("kanade-noreply".into()),
350            }),
351            ..Default::default()
352        };
353        let json = serde_json::to_string(&s).unwrap();
354        // Encryption serialises lowercase; the password is never present.
355        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
356        assert!(!json.contains("password"), "password must never serialise");
357        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
358    }
359
360    #[test]
361    fn mail_defaults_to_unset() {
362        assert_eq!(ServerSettings::default().mail, None);
363        // A doc written before this field existed (no `mail` key) decodes
364        // to `None` — email stays a no-op, the pre-feature behaviour.
365        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
366        assert_eq!(s.mail, None);
367        assert_eq!(s.agent_prune_days, Some(7));
368    }
369
370    #[test]
371    fn collect_retention_unset_resolves_to_builtin_default() {
372        // Blank (the derived Default) must preserve the historical 30-day
373        // window, not disable retention.
374        assert_eq!(ServerSettings::default().collect_retention_days, None);
375        assert_eq!(
376            ServerSettings::default().effective_collect_retention_days(),
377            DEFAULT_COLLECT_RETENTION_DAYS,
378        );
379        // The defaults() document surfaces the real default so the SPA can
380        // render it as a placeholder.
381        assert_eq!(
382            ServerSettings::defaults().collect_retention_days,
383            Some(DEFAULT_COLLECT_RETENTION_DAYS),
384        );
385    }
386
387    #[test]
388    fn collect_retention_stored_value_wins() {
389        let s = ServerSettings {
390            collect_retention_days: Some(90),
391            ..Default::default()
392        };
393        assert_eq!(s.effective_collect_retention_days(), 90);
394    }
395
396    #[test]
397    fn collect_retention_effective_clamps_out_of_band_writes() {
398        // A hand-written KV value past the PUT cap (or 0) must be clamped so
399        // the reconciled Object Store max_age stays sane.
400        let big = ServerSettings {
401            collect_retention_days: Some(u32::MAX),
402            ..Default::default()
403        };
404        assert_eq!(
405            big.effective_collect_retention_days(),
406            MAX_COLLECT_RETENTION_DAYS,
407        );
408        let zero = ServerSettings {
409            collect_retention_days: Some(0),
410            ..Default::default()
411        };
412        assert_eq!(zero.effective_collect_retention_days(), 1);
413    }
414
415    #[test]
416    fn collect_retention_round_trips_and_omits_when_unset() {
417        let s = ServerSettings {
418            collect_retention_days: Some(90),
419            ..Default::default()
420        };
421        let json = serde_json::to_string(&s).unwrap();
422        assert_eq!(json, r#"{"collect_retention_days":90}"#);
423        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
424        // Unset is omitted so a blank doc stays minimal.
425        assert!(
426            !serde_json::to_string(&ServerSettings::default())
427                .unwrap()
428                .contains("collect_retention_days")
429        );
430    }
431
432    #[test]
433    fn session_ttl_unset_resolves_to_builtin_default() {
434        // Blank (the derived Default) must resolve to the 24h default rather
435        // than 0 (which would mint already-expired tokens).
436        assert_eq!(ServerSettings::default().session_ttl_hours, None);
437        assert_eq!(
438            ServerSettings::default().effective_session_ttl_hours(),
439            DEFAULT_SESSION_TTL_HOURS,
440        );
441        // The defaults() document surfaces the real default so the SPA can
442        // render it as a placeholder.
443        assert_eq!(
444            ServerSettings::defaults().session_ttl_hours,
445            Some(DEFAULT_SESSION_TTL_HOURS),
446        );
447    }
448
449    #[test]
450    fn session_ttl_stored_value_wins() {
451        let s = ServerSettings {
452            session_ttl_hours: Some(72),
453            ..Default::default()
454        };
455        assert_eq!(s.effective_session_ttl_hours(), 72);
456    }
457
458    #[test]
459    fn session_ttl_effective_clamps_out_of_band_writes() {
460        // An out-of-band 0 must floor to 1 (else `now + 0h` is an
461        // instantly-expired token); a value past the cap clamps down so
462        // login's date math can't overflow.
463        let zero = ServerSettings {
464            session_ttl_hours: Some(0),
465            ..Default::default()
466        };
467        assert_eq!(zero.effective_session_ttl_hours(), 1);
468        let huge = ServerSettings {
469            session_ttl_hours: Some(u32::MAX),
470            ..Default::default()
471        };
472        assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
473    }
474
475    #[test]
476    fn session_ttl_round_trips_and_omits_when_unset() {
477        let s = ServerSettings {
478            session_ttl_hours: Some(48),
479            ..Default::default()
480        };
481        let json = serde_json::to_string(&s).unwrap();
482        assert_eq!(json, r#"{"session_ttl_hours":48}"#);
483        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
484        // Unset is omitted so a blank doc stays minimal.
485        assert!(
486            !serde_json::to_string(&ServerSettings::default())
487                .unwrap()
488                .contains("session_ttl_hours")
489        );
490    }
491
492    #[test]
493    fn accepts_unknown_fields_for_forward_compat() {
494        // A newer backend may have added knobs this build doesn't know;
495        // decoding must drop them rather than error.
496        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
497        let s: ServerSettings = serde_json::from_str(json).unwrap();
498        assert_eq!(s.agent_prune_days, Some(7));
499    }
500}