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/// Built-in default for [`ServerSettings::check_status_stale_days`] (#1032②) —
50/// how many days a `check_status` row may go without a fresh result before the
51/// Compliance view treats it as **stale** and hides it (a PC that stopped
52/// running a check: excluded from its target via a dynamic group, decommissioned,
53/// or its schedule removed). 30 days is comfortably longer than a typical daily
54/// / weekly compliance cadence, so an in-scope PC is never mistakenly hidden,
55/// while a genuinely out-of-scope PC's frozen row drops out within the month.
56/// A real default (like `collect_retention_days`) so the feature works out of
57/// the box; the operator shortens it for prompter hiding or sets `0` to disable.
58pub const DEFAULT_CHECK_STATUS_STALE_DAYS: u32 = 30;
59
60/// Upper bound on [`ServerSettings::check_status_stale_days`] (10 years).
61/// Staleness is purely a display cutoff (`now - Duration::days(n)` compared to a
62/// row's `recorded_at`), so the ceiling just keeps that subtraction inside
63/// `chrono::DateTime`'s range and stops an absurd value; enforced by the PUT
64/// handler and clamped in [`ServerSettings::effective_check_status_stale_days`].
65pub const MAX_CHECK_STATUS_STALE_DAYS: u32 = 3650;
66
67/// Built-in default for [`SupportCode::ttl_minutes`] — how long an unlock
68/// grant lasts when the operator hasn't set a per-code window. 15 minutes is
69/// about the length of a helpdesk call: long enough that the desk isn't
70/// re-typing the code mid-troubleshoot, short enough that a machine left
71/// unattended after the call re-locks on its own.
72pub const DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES: u32 = 15;
73
74/// Upper bound on [`SupportCode::ttl_minutes`] (8 hours). An unlock grant is
75/// standing permission for an end user's machine to run privileged jobs, so
76/// it must not outlive a working day even by operator error — a longer window
77/// is a `visible_to` targeting decision, not an unlock one. Enforced by the
78/// support-code endpoint and clamped in [`SupportCode::effective_ttl_minutes`]
79/// so a hand-written KV value stays bounded too.
80pub const MAX_SUPPORT_UNLOCK_TTL_MINUTES: u32 = 480;
81
82/// Per-bucket disk caps (MiB) for the five NATS Object Stores, reconciled
83/// onto each backing `OBJ_*` stream at backend boot and on save (#1247).
84/// `None` (unset) ⇒ the built-in default below, so a blank SPA field
85/// preserves the out-of-box budget and a deployment that never opens the
86/// Settings page is still capped.
87///
88/// Why these exist: `agent_releases` / `app_packages` / `scripts` were
89/// created with no caps at all, and caps added to code AFTER a bucket
90/// existed never reached the live broker (`ensure_object_store` tolerates
91/// the 10058 config-drift error rather than reconciling — which is how
92/// `OBJ_result_output` grew to 6.76 GB against a nominal 1 GiB cap).
93/// The reconcile path (`bootstrap::reconcile_object_store_max_bytes`) makes
94/// the configured value actually land, so these defaults are also the
95/// **fix** for already-drifted buckets, applied on the first boot after
96/// upgrade.
97#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
98#[serde(default)]
99pub struct ObjectStoreCaps {
100 /// `result_output` — overflow stdout/stderr blobs. Transport +
101 /// replay buffer (the projector derefs within seconds into SQLite),
102 /// so this is a runaway-output backstop, not a history budget.
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub result_output_mib: Option<u32>,
105 /// `agent_releases` — one exe per version (~100 MB each). Sized for
106 /// ~20 recent versions.
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub agent_releases_mib: Option<u32>,
109 /// `app_packages` — operator-curated installers; the largest bucket
110 /// in practice (1.5 GB at measurement time, uncapped).
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub app_packages_mib: Option<u32>,
113 /// `scripts` — manifest script bodies; tiny payloads, so the cap is
114 /// a cardinality backstop.
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub scripts_mib: Option<u32>,
117 /// `collections` — collect-job bundles. Its `max_age` has its own
118 /// knob ([`ServerSettings::collect_retention_days`]); this caps the
119 /// disk regardless of window.
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub collections_mib: Option<u32>,
122}
123
124/// Built-in per-bucket caps (MiB) — the values fresh buckets are born
125/// with and unset SPA fields resolve to. Total ≈ 13.5 GiB, comfortably
126/// inside the broker-wide `max_file_store: 50GB` once the ~5.3 GiB of
127/// stream reservations (bootstrap.rs) are accounted for.
128pub const DEFAULT_RESULT_OUTPUT_CAP_MIB: u32 = 1024;
129
130/// How long `OBJECT_RESULT_OUTPUT` keeps an overflowed stdout/stderr blob.
131///
132/// Seven days, not the thirty it used to be, because the blob is redundant
133/// almost immediately: the results projector derefs it into the row before
134/// the INSERT, so SQLite holds the text and the SPA reads it from there. What
135/// the window buys is one thing only — a `-WipeDb` re-projection replays
136/// `STREAM_RESULTS` and re-derefs, so an object that has aged out comes back
137/// as empty stdout on the rebuilt row.
138///
139/// Thirty days of copies nobody reads is what pushed the bucket into its cap,
140/// and the cap is a hard wall rather than an eviction trigger (#1321): once
141/// full, every result over the inline threshold became undeliverable
142/// fleet-wide. A week keeps the recovery path useful for any incident anyone
143/// is still investigating while cutting the standing volume by ~4x.
144///
145/// Bounded above by `STREAM_RESULTS`'s own 30-day window in any case: past
146/// that there is no message to replay, so a longer object window buys
147/// nothing.
148pub const DEFAULT_RESULT_OUTPUT_RETENTION_DAYS: u32 = 7;
149
150/// Upper bound accepted from an operator. Matches `STREAM_RESULTS`'s window —
151/// beyond it the messages are gone, so the objects would outlive the only
152/// thing that could ask for them.
153pub const MAX_RESULT_OUTPUT_RETENTION_DAYS: u32 = 30;
154pub const DEFAULT_AGENT_RELEASES_CAP_MIB: u32 = 2048;
155pub const DEFAULT_APP_PACKAGES_CAP_MIB: u32 = 5120;
156pub const DEFAULT_SCRIPTS_CAP_MIB: u32 = 256;
157pub const DEFAULT_COLLECTIONS_CAP_MIB: u32 = 5120;
158
159/// Upper bound per bucket (50 GiB) — the broker-wide `max_file_store`
160/// default, so a single bucket can never be configured to eat the whole
161/// file store by fat-finger. Enforced by the PUT handler and clamped in
162/// the `effective_*` accessors so a hand-written KV value stays bounded.
163pub const MAX_OBJECT_STORE_CAP_MIB: u32 = 51_200;
164
165/// Upper bound on the SUM of the five effective bucket caps (45.2 GiB):
166/// the broker-wide 50 GiB minus the ~4.8 GiB the streams reserve
167/// (INVENTORY 1024 + RESULTS 2048 + EXEC 64 + EVENTS 256 + AUDIT 512 +
168/// OBS_EVENTS 512 + NOTIFICATIONS 512 MiB — bootstrap.rs). Without an
169/// aggregate bound, five individually-legal caps could total 250 GiB and
170/// every `update_stream` would be refused by the broker (10047) — the KV
171/// document claiming caps the streams don't have. Enforced by the PUT
172/// handler on the merged document.
173pub const MAX_OBJECT_STORE_TOTAL_MIB: u32 = 46_272;
174
175impl ObjectStoreCaps {
176 fn effective(v: Option<u32>, default: u32) -> u32 {
177 v.unwrap_or(default).clamp(1, MAX_OBJECT_STORE_CAP_MIB)
178 }
179
180 pub fn effective_result_output_mib(&self) -> u32 {
181 Self::effective(self.result_output_mib, DEFAULT_RESULT_OUTPUT_CAP_MIB)
182 }
183 pub fn effective_agent_releases_mib(&self) -> u32 {
184 Self::effective(self.agent_releases_mib, DEFAULT_AGENT_RELEASES_CAP_MIB)
185 }
186 pub fn effective_app_packages_mib(&self) -> u32 {
187 Self::effective(self.app_packages_mib, DEFAULT_APP_PACKAGES_CAP_MIB)
188 }
189 pub fn effective_scripts_mib(&self) -> u32 {
190 Self::effective(self.scripts_mib, DEFAULT_SCRIPTS_CAP_MIB)
191 }
192 pub fn effective_collections_mib(&self) -> u32 {
193 Self::effective(self.collections_mib, DEFAULT_COLLECTIONS_CAP_MIB)
194 }
195
196 /// `(bucket, effective cap in MiB)` for every object store, in
197 /// bootstrap order — the reconcile loop's input.
198 pub fn effective_all(&self) -> [(&'static str, u32); 5] {
199 [
200 (
201 crate::kv::OBJECT_AGENT_RELEASES,
202 self.effective_agent_releases_mib(),
203 ),
204 (
205 crate::kv::OBJECT_APP_PACKAGES,
206 self.effective_app_packages_mib(),
207 ),
208 (crate::kv::OBJECT_SCRIPTS, self.effective_scripts_mib()),
209 (
210 crate::kv::OBJECT_RESULT_OUTPUT,
211 self.effective_result_output_mib(),
212 ),
213 (
214 crate::kv::OBJECT_COLLECTIONS,
215 self.effective_collections_mib(),
216 ),
217 ]
218 }
219}
220
221/// One operator-issued support code — the "裏コマンド" that reveals
222/// `client.unlock`-scoped jobs in the Client App (see
223/// [`crate::manifest::ClientHint::unlock`]).
224///
225/// **Only the argon2id hash is stored.** Verification needs no plaintext, so
226/// unlike the SMTP password (#884, which must stay in the HKLM registry
227/// because SMTP AUTH needs the real string) a support code can live in KV
228/// safely — an agent reads the hash to verify a typed code locally, which is
229/// what keeps unlocking working while the backend is down, exactly when the
230/// desk needs it most.
231///
232/// The plaintext exists only in transit: the operator types it once into the
233/// SPA, the backend hashes it, and no layer ever stores or returns it. The
234/// API blanks [`hash`](Self::hash) on the way out too — an operator rotating
235/// a code sets a new one, they never read the old one back.
236#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
237#[serde(default)]
238pub struct SupportCode {
239 /// The scope this code opens, matched byte-for-byte against a job's
240 /// `client.unlock`. Unique within the list; a slug (`[A-Za-z0-9._-]`).
241 pub scope: String,
242 /// argon2id PHC-format hash of the code. Empty ⇒ **no code configured**
243 /// for this scope, which fails closed (nothing can be unlocked with it) —
244 /// that's also what an API response looks like, since the hash is blanked
245 /// before it leaves the backend.
246 #[serde(skip_serializing_if = "String::is_empty")]
247 pub hash: String,
248 /// Human label for the code (`"ヘルプデスク一次窓口"`), shown in the
249 /// Client App's support-mode banner so the user can see which desk opened
250 /// their machine. `None` ⇒ the banner falls back to the scope slug.
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub label: Option<String>,
253 /// How long a grant from this code lasts, in minutes. `None` ⇒
254 /// [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Clamped to
255 /// `1..=`[`MAX_SUPPORT_UNLOCK_TTL_MINUTES`].
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub ttl_minutes: Option<u32>,
258 /// Temporarily suspend the code without deleting it (and without having
259 /// to re-issue a new secret afterwards) — a disabled code never verifies.
260 /// `false` (the default) ⇒ live.
261 #[serde(skip_serializing_if = "std::ops::Not::not")]
262 pub disabled: bool,
263}
264
265impl SupportCode {
266 /// The grant window this code mints, in minutes: the configured value if
267 /// set, else [`DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES`]. Floored at 1 and
268 /// clamped to [`MAX_SUPPORT_UNLOCK_TTL_MINUTES`] so a `0` (which would
269 /// mint an already-expired grant, i.e. an unlock that silently does
270 /// nothing) or an out-of-band giant value can't reach the grant store.
271 pub fn effective_ttl_minutes(&self) -> u32 {
272 self.ttl_minutes
273 .unwrap_or(DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES)
274 .clamp(1, MAX_SUPPORT_UNLOCK_TTL_MINUTES)
275 }
276
277 /// Whether this entry can ever grant anything: live and carrying a hash.
278 /// Both halves fail closed — a blanked (API-redacted) or hand-cleared
279 /// hash opens nothing, and neither does a disabled code.
280 pub fn is_usable(&self) -> bool {
281 !self.disabled && !self.hash.is_empty()
282 }
283}
284
285/// The agent-readable projection of [`ServerSettings::support_codes`], stored
286/// under [`crate::kv::KEY_SUPPORT_CODES`] in `fleet_config`.
287///
288/// `server_settings` also carries secrets an agent has no business reading
289/// (the installer's NATS token, in clear at the KV layer, SMTP relay
290/// settings). The support-unlock check needs only the argon2id hashes, so the
291/// backend publishes just those here and agents read this instead. Keep it
292/// that way: [`Self::from_settings`] copies `support_codes` explicitly rather
293/// than cloning the settings and stripping fields, so a field added to
294/// `ServerSettings` later can never leak into this document by default.
295///
296/// Deliberately no `deny_unknown_fields`: a future field must not break
297/// agents that predate it.
298#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
299#[serde(default)]
300pub struct SupportCodesProjection {
301 pub support_codes: Vec<SupportCode>,
302}
303
304impl SupportCodesProjection {
305 pub fn from_settings(settings: &ServerSettings) -> Self {
306 Self {
307 support_codes: settings.support_codes.clone(),
308 }
309 }
310}
311
312/// Self-service agent-installer knobs (`GET /api/agents/installer`) —
313/// what the generated ZIP bakes in for a fresh PC. Kept server-side so the
314/// restricted "download user" (viewer + the `agent-install` feature only)
315/// never chooses — and never sees — these values.
316// PartialEq/Eq beyond the minimal derive list: `ServerSettings` derives
317// them, so every section must too.
318#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(default)]
320pub struct AgentInstallSection {
321 /// NATS URL baked into the installer's agent.toml. None → the backend's
322 /// own configured `[nats] url`.
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub nats_url: Option<String>,
325 /// NATS token baked into install-agent.ps1. WRITE-ONLY: redacted from
326 /// every GET response (GET /api/server-settings is viewer+).
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub nats_token: Option<String>,
329 /// Read-only indicator computed by redacted() so the SPA can show
330 /// "configured" without ever seeing the value. Never accepted from PUT.
331 #[serde(skip_deserializing)]
332 pub nats_token_set: bool,
333 /// Agent-role NATS user baked into the installer next to the token, so a
334 /// host installed today already holds the credential the broker will
335 /// later require. Shared by every agent by design. WRITE-ONLY exactly
336 /// like `nats_token`, and a pair with `nats_password`: the installer
337 /// embeds them only when both are present.
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub nats_user: Option<String>,
340 /// Read-only indicator for `nats_user`, computed by redacted().
341 #[serde(skip_deserializing)]
342 pub nats_user_set: bool,
343 /// Password for `nats_user`. WRITE-ONLY, see `nats_user`.
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub nats_password: Option<String>,
346 /// Read-only indicator for `nats_password`, computed by redacted().
347 #[serde(skip_deserializing)]
348 pub nats_password_set: bool,
349 /// Ask the generated Windows installer to pass `-RequireSignedCommands`
350 /// to `deploy-agent.ps1`, so a fresh agent starts enforcing signed
351 /// commands from first boot instead of leaving that as a manual
352 /// post-install step (#1155/#1165 day-1 gap).
353 ///
354 /// `None`/`Some(false)` → omitted, matching every other unset knob here.
355 /// `Some(true)` is honored by the installer ONLY when this backend also
356 /// has a command-signing key configured — see
357 /// `agent_installer::resolve_enforcement`. A backend with no key must
358 /// never generate an installer that turns on enforcement with an empty
359 /// keyring; that request is instead surfaced in the installer's
360 /// README.txt rather than silently dropped.
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub require_signed_commands: Option<bool>,
363}
364
365// Hand-written so a stray `{:?}` / `?settings` in a log line can never
366// print the write-only credentials.
367impl std::fmt::Debug for AgentInstallSection {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 let mask = |v: &Option<String>| v.as_ref().map(|_| "<redacted>");
370 f.debug_struct("AgentInstallSection")
371 .field("nats_url", &self.nats_url)
372 .field("nats_token", &mask(&self.nats_token))
373 .field("nats_token_set", &self.nats_token_set)
374 .field("nats_user", &mask(&self.nats_user))
375 .field("nats_user_set", &self.nats_user_set)
376 .field("nats_password", &mask(&self.nats_password))
377 .field("nats_password_set", &self.nats_password_set)
378 .field("require_signed_commands", &self.require_signed_commands)
379 .finish()
380 }
381}
382
383/// Which authentication the operator expects the broker to apply to every
384/// connection. An **expectation**, not a switch: it changes nothing about how
385/// any process connects, it only defines which connection reports the
386/// backend raises a warning for (see the backend's `nats_auth_findings`).
387#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
388#[serde(rename_all = "lowercase")]
389pub enum NatsAuthMode {
390 /// One fleet-wide token. Today's shape, and what an unset field means.
391 #[default]
392 Token,
393 /// Per-role NATS users (`authorization { users: [...] }`).
394 Users,
395}
396
397impl NatsAuthMode {
398 pub fn as_str(self) -> &'static str {
399 match self {
400 NatsAuthMode::Token => "token",
401 NatsAuthMode::Users => "users",
402 }
403 }
404}
405
406/// Value stored in the `server_settings` KV bucket under the single key
407/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
408/// server configuration that isn't per-agent (so it doesn't belong in
409/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
410/// agent watches (so it doesn't belong in `fleet_config`). Managed via
411/// the SPA Settings page's "server settings" tab.
412///
413/// Every field is `Option<_>`: `None` (the default / the JSON value
414/// `null` / the field simply absent) means **unset — fall back to the
415/// built-in default** ([`ServerSettings::defaults`]), exactly like the
416/// agent layered-config scopes. The SPA renders the built-in default as a
417/// faint placeholder so a blank field shows what it resolves to, and when
418/// a real default is introduced here it appears in the UI (and takes
419/// effect for already-deployed-but-unset fleets) for free.
420///
421/// `#[serde(default)]` on the container keeps the document backward/forward
422/// compatible: a freshly-created or missing key decodes to all-`None`
423/// (pre-feature behaviour), an older backend reading a newer document
424/// ignores unknown fields, and a newer backend reading an older document
425/// fills the missing field with `None`. Keep that invariant — never add a
426/// field whose `None` doesn't mean "behave as before".
427#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
428#[serde(default)]
429pub struct ServerSettings {
430 /// Days a dead agent (one whose heartbeat stopped arriving) may
431 /// linger in the `agents` registry before the backend cleanup task
432 /// prunes its row.
433 ///
434 /// `None` (unset) falls back to the built-in default; with no default
435 /// configured that resolves to pruning **disabled** (see
436 /// [`ServerSettings::effective_agent_prune_days`]). A positive value
437 /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
438 /// than that many days. The `agents` table is a projection of the
439 /// heartbeat stream, so a machine that's merely offline (not gone)
440 /// reappears on its next heartbeat (~30s cadence); only
441 /// genuinely-retired machines stay gone.
442 #[serde(skip_serializing_if = "Option::is_none")]
443 pub agent_prune_days: Option<u32>,
444
445 /// Agent group whose members are the trusted **controller-tier**
446 /// runners. A job with `tier: controller` (e.g. a `feed:` job that
447 /// fetches an external URL) is dispatched ONLY to members of this group;
448 /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
449 /// so an external fetch never lands on an employee endpoint by accident).
450 /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
451 /// configured, which is the safe default for a fresh deployment.
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub controller_group: Option<String>,
454
455 /// Non-secret SMTP relay settings for outbound email (compliance-alert
456 /// notifications, account setup links, …). Lives here (#884) rather than
457 /// in `backend.toml` so an operator can edit it from the SPA without
458 /// shell access to the host.
459 ///
460 /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
461 /// / NATS notification path is unaffected. The SMTP **password is
462 /// deliberately not here** (KV is
463 /// readable over NATS): it stays sourced from the `MailPassword`
464 /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
465 /// settings when the backend builds its `Mailer`. Changes take effect on
466 /// the **next backend restart** (the backend builds the `Mailer` once at
467 /// startup — no live rebuild), which is acceptable per the #884
468 /// discussion.
469 #[serde(skip_serializing_if = "Option::is_none")]
470 pub mail: Option<MailSection>,
471
472 /// Self-service agent-installer knobs — see [`AgentInstallSection`].
473 /// `None` (unset) ⇒ the installer falls back to this backend's own
474 /// `[nats] url` and embeds no token. The `nats_token` inside is
475 /// write-only: [`ServerSettings::redacted`] strips it from every
476 /// response and the PUT merge preserves a stored token across a
477 /// url-only edit (the same support-code trap: a form round-trip must
478 /// never silently clear a secret).
479 #[serde(skip_serializing_if = "Option::is_none")]
480 pub agent_install: Option<AgentInstallSection>,
481
482 /// Retention window (days) for collected file bundles — the `collect:`
483 /// job archives uploaded to the `collections` Object Store (#219).
484 ///
485 /// Unlike the other fields, this one has a **real built-in default**
486 /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
487 /// to it, so a blank field preserves the historical behaviour rather than
488 /// disabling retention. A positive value tells the backend to reconcile
489 /// the Object Store's `max_age` to that many days (see
490 /// `bootstrap::reconcile_collect_retention`), applied at boot and again
491 /// whenever this document is saved from the SPA. Clamped to
492 /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
493 /// untouched, so extending the window can't make the store grow unbounded.
494 #[serde(skip_serializing_if = "Option::is_none")]
495 pub collect_retention_days: Option<u32>,
496
497 /// How long `OBJECT_RESULT_OUTPUT` keeps an overflowed stdout/stderr
498 /// blob, in days. Absent / null falls back to
499 /// [`DEFAULT_RESULT_OUTPUT_RETENTION_DAYS`].
500 ///
501 /// Unlike `collect_retention_days`, this window is NOT how long the data
502 /// survives — the results projector derefs each blob into the row before
503 /// the INSERT, so the text lives in SQLite and the SPA reads it from
504 /// there regardless. What it bounds is how long a `-WipeDb`
505 /// re-projection can still recover that text: replay re-derefs, and an
506 /// aged-out object comes back as empty stdout on the rebuilt row.
507 ///
508 /// So this trades recovery window against standing volume, and volume is
509 /// what filled the bucket into its cap (#1321 — a hard wall, not
510 /// eviction) and stopped result delivery fleet-wide. Clamped to
511 /// [`MAX_RESULT_OUTPUT_RETENTION_DAYS`], which is `STREAM_RESULTS`'s own
512 /// window: past it there is no message left to replay.
513 #[serde(skip_serializing_if = "Option::is_none")]
514 pub result_output_retention_days: Option<u32>,
515
516 /// How many hours a freshly-minted login token (SPA / CLI) stays valid
517 /// before the caller must re-authenticate. Read backend-side by the
518 /// login handler when it mints the JWT `exp`; changing it affects
519 /// **tokens minted after the change**, not already-issued ones.
520 ///
521 /// Like [`collect_retention_days`](Self::collect_retention_days) this
522 /// carries a **real built-in default** ([`DEFAULT_SESSION_TTL_HOURS`],
523 /// 24h): `None` (unset) falls back to it, so a blank field resolves to a
524 /// usable window rather than an instantly-expired token. Clamped to
525 /// `1..=`[`MAX_SESSION_TTL_HOURS`]. The SPA renders `24` as a faint
526 /// placeholder on a blank field.
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub session_ttl_hours: Option<u32>,
529
530 /// Days a `check_status` row may go without a fresh result before the
531 /// Compliance page treats it as **stale** and hides it (#1032②). A PC that
532 /// stops running a check — excluded from its target via a dynamic group,
533 /// decommissioned, or its schedule removed — stops refreshing its row's
534 /// `recorded_at`; once that timestamp is older than this window the row is
535 /// omitted from the default `/api/checks` view and its counts, so a machine
536 /// no longer in scope stops showing as permanently failing a check it never
537 /// runs.
538 ///
539 /// Like [`collect_retention_days`](Self::collect_retention_days) this carries
540 /// a **real built-in default** ([`DEFAULT_CHECK_STATUS_STALE_DAYS`], 30d), so
541 /// the feature works with no configuration. `0` **disables** staleness
542 /// (every row shown, the pre-feature behaviour); a positive value is the
543 /// cutoff, clamped to [`MAX_CHECK_STATUS_STALE_DAYS`]. The row is never
544 /// deleted — hiding is non-destructive so history and the compliance-alert
545 /// prior-status are preserved.
546 #[serde(skip_serializing_if = "Option::is_none")]
547 pub check_status_stale_days: Option<u32>,
548
549 /// Per-bucket disk caps (MiB) for the five NATS Object Stores (#1247)
550 /// — see [`ObjectStoreCaps`]. `None` (unset) ⇒ every bucket resolves
551 /// to its built-in default, so a blank field preserves the out-of-box
552 /// budget. Applied to the backing `OBJ_*` streams at backend boot and
553 /// whenever this document is saved from the SPA
554 /// (`bootstrap::reconcile_object_store_max_bytes`), which is also what
555 /// finally delivers caps to buckets created before the caps existed.
556 #[serde(skip_serializing_if = "Option::is_none")]
557 pub object_store_caps: Option<ObjectStoreCaps>,
558
559 /// The broker authentication mode the operator expects — see
560 /// [`NatsAuthMode`]. `None` (unset) is `token`, the behaviour before this
561 /// field existed. Read by the backend's connection audit only; no
562 /// connection is made differently because of it.
563 #[serde(skip_serializing_if = "Option::is_none")]
564 pub nats_auth_mode: Option<NatsAuthMode>,
565
566 /// When the **effective** expected mode last changed. Written by the
567 /// backend alone, at the moment a save moves the mode, and never taken
568 /// from a client: the audit holds its findings back for a short window
569 /// after this instant so connections re-establishing under the new mode
570 /// are not reported while they converge. A re-save of the same mode (or a
571 /// restart) leaves it alone, so the window cannot be extended by saving.
572 #[serde(skip_serializing_if = "Option::is_none")]
573 pub nats_auth_mode_changed_at: Option<chrono::DateTime<chrono::Utc>>,
574
575 /// Operator-issued support codes — the helpdesk "裏コマンド" that reveals
576 /// `client.unlock`-scoped jobs in the Client App. See [`SupportCode`].
577 ///
578 /// The one non-`Option` field in this document, because a list has an
579 /// honest empty state: `[]` (the default) means **no codes configured**,
580 /// so every `client.unlock` job stays hidden from everyone — the same
581 /// fail-closed "behave as before" the `None`s give the other fields.
582 ///
583 /// Unlike the rest of the document this is read by **agents** as well as
584 /// the backend (each agent verifies a typed code against these hashes
585 /// locally, so an unlock still works during a backend outage), and it is
586 /// **not** editable through the generic `PUT /api/server-settings` merge:
587 /// a secret gets its own set-and-forget endpoint so a redacted document
588 /// round-tripping through the SPA form can never blank a live code.
589 #[serde(skip_serializing_if = "Vec::is_empty")]
590 pub support_codes: Vec<SupportCode>,
591}
592
593impl ServerSettings {
594 /// Built-in defaults applied when a field is unset (`None`) in the
595 /// stored document. Most fields default to `None` (no fleet-meaningful
596 /// default — a blank prune window means "disabled" rather than some
597 /// arbitrary number of days). The exceptions carry real defaults so a
598 /// blank field still resolves to a sensible value:
599 /// [`collect_retention_days`](Self::collect_retention_days)
600 /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], preserving the historical 30-day
601 /// retention), [`result_output_retention_days`](Self::result_output_retention_days)
602 /// ([`DEFAULT_RESULT_OUTPUT_RETENTION_DAYS`], 7 days — deliberately NOT
603 /// the historical 30, see that constant) and
604 /// [`session_ttl_hours`](Self::session_ttl_hours)
605 /// ([`DEFAULT_SESSION_TTL_HOURS`], 24h).
606 ///
607 /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
608 /// these as faint placeholders (mirroring the agent layered-config
609 /// page's built-in floor). Introducing a real default is a one-line
610 /// change here that automatically shows up in the UI and applies to
611 /// every deployment that hasn't overridden the field.
612 pub fn defaults() -> Self {
613 Self {
614 agent_prune_days: None,
615 controller_group: None,
616 mail: None,
617 agent_install: None,
618 collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
619 result_output_retention_days: Some(DEFAULT_RESULT_OUTPUT_RETENTION_DAYS),
620 session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
621 check_status_stale_days: Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
622 nats_auth_mode: None,
623 nats_auth_mode_changed_at: None,
624 // Real per-bucket defaults, so the SPA renders them as faint
625 // placeholders and unset deployments are capped out of the box.
626 object_store_caps: Some(ObjectStoreCaps {
627 result_output_mib: Some(DEFAULT_RESULT_OUTPUT_CAP_MIB),
628 agent_releases_mib: Some(DEFAULT_AGENT_RELEASES_CAP_MIB),
629 app_packages_mib: Some(DEFAULT_APP_PACKAGES_CAP_MIB),
630 scripts_mib: Some(DEFAULT_SCRIPTS_CAP_MIB),
631 collections_mib: Some(DEFAULT_COLLECTIONS_CAP_MIB),
632 }),
633 // No built-in code: a deployment that configures none has no
634 // unlockable jobs, which is the only safe default for a secret.
635 support_codes: Vec::new(),
636 }
637 }
638
639 /// The caps document with every bucket resolved: stored values where
640 /// set, built-in defaults elsewhere, each clamped to
641 /// `1..=MAX_OBJECT_STORE_CAP_MIB` so an out-of-band KV write can't
642 /// reach the broker unsanitised.
643 pub fn effective_object_store_caps(&self) -> ObjectStoreCaps {
644 let c = self.object_store_caps.clone().unwrap_or_default();
645 ObjectStoreCaps {
646 result_output_mib: Some(c.effective_result_output_mib()),
647 agent_releases_mib: Some(c.effective_agent_releases_mib()),
648 app_packages_mib: Some(c.effective_app_packages_mib()),
649 scripts_mib: Some(c.effective_scripts_mib()),
650 collections_mib: Some(c.effective_collections_mib()),
651 }
652 }
653
654 /// The live code for `scope`, or `None` when the scope has no code, its
655 /// code is disabled, or the hash was blanked (an API-redacted document).
656 /// Every caller of this is a gate, so all three cases fail closed.
657 pub fn support_code(&self, scope: &str) -> Option<&SupportCode> {
658 self.support_codes
659 .iter()
660 .find(|c| c.scope == scope && c.is_usable())
661 }
662
663 /// The same document with every support-code hash blanked — what an HTTP
664 /// response is allowed to contain. Scope / label / TTL stay visible so
665 /// the SPA can list and manage the codes; the secret material never
666 /// leaves the backend, not even to an operator-authenticated caller.
667 /// (`GET /api/server-settings` is viewer+, so an unredacted document
668 /// would hand every read-only account an offline-crackable hash.)
669 ///
670 /// Same treatment for `agent_install.nats_token`: it is a live broker
671 /// credential baked into installer ZIPs, so it never leaves the backend
672 /// either — the response carries only `nats_token_set` (computed HERE,
673 /// never accepted from a client) so the SPA can render "configured".
674 /// `nats_user` / `nats_password` get the same `*_set` treatment.
675 #[must_use]
676 pub fn redacted(mut self) -> Self {
677 for c in &mut self.support_codes {
678 c.hash.clear();
679 }
680 if let Some(ai) = self.agent_install.as_mut() {
681 ai.nats_token_set = ai.nats_token.is_some();
682 ai.nats_token = None;
683 ai.nats_user_set = ai.nats_user.is_some();
684 ai.nats_user = None;
685 ai.nats_password_set = ai.nats_password.is_some();
686 ai.nats_password = None;
687 }
688 self
689 }
690
691 /// The configured controller-tier runner group, trimmed, or `None` when
692 /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
693 pub fn effective_controller_group(&self) -> Option<&str> {
694 self.controller_group
695 .as_deref()
696 .map(str::trim)
697 .filter(|g| !g.is_empty())
698 }
699
700 /// The effective dead-agent prune window in days: the stored value if
701 /// set, else the built-in default, else `0` (= pruning disabled). The
702 /// final `unwrap_or(0)` is the absent-everywhere floor, not a
703 /// user-facing default — the cleanup task treats `0` as "don't prune".
704 ///
705 /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
706 /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
707 /// the task), even if a value larger than the PUT handler allows was
708 /// written to the KV out-of-band.
709 pub fn effective_agent_prune_days(&self) -> u32 {
710 self.agent_prune_days
711 .or(Self::defaults().agent_prune_days)
712 .unwrap_or(0)
713 .min(MAX_AGENT_PRUNE_DAYS)
714 }
715
716 /// The effective collect-bundle retention window in days: the stored
717 /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
718 /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
719 /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
720 /// The floor matters specifically because NATS treats `max_age: 0` as
721 /// **unlimited** retention, not "evict immediately": a stray `0` would
722 /// silently make bundles never expire (defeating the auto-expire intent),
723 /// so we coerce it to the shortest real window (1 day) instead. The PUT
724 /// handler already rejects `0` / over-cap, so this is the belt-and-braces
725 /// path for a hand-edited KV value.
726 pub fn effective_collect_retention_days(&self) -> u32 {
727 self.collect_retention_days
728 .or(Self::defaults().collect_retention_days)
729 .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
730 .clamp(1, MAX_COLLECT_RETENTION_DAYS)
731 }
732
733 /// The effective `result_output` retention window in days: the stored
734 /// value if set, else the built-in default. Floored at 1 and clamped to
735 /// [`MAX_RESULT_OUTPUT_RETENTION_DAYS`] so a hand-written KV value can
736 /// neither disable retention outright nor outlive the stream it exists
737 /// to serve.
738 pub fn effective_result_output_retention_days(&self) -> u32 {
739 self.result_output_retention_days
740 .or(Self::defaults().result_output_retention_days)
741 .unwrap_or(DEFAULT_RESULT_OUTPUT_RETENTION_DAYS)
742 .clamp(1, MAX_RESULT_OUTPUT_RETENTION_DAYS)
743 }
744
745 /// The effective login-token lifetime in hours: the stored value if
746 /// set, else the built-in [`DEFAULT_SESSION_TTL_HOURS`]. Floored at 1
747 /// and clamped to [`MAX_SESSION_TTL_HOURS`] so a zero/absent/out-of-band
748 /// value can never mint an already-expired or overflow-inducing token.
749 /// The PUT handler already rejects `0` / over-cap, so this is the
750 /// belt-and-braces path for a hand-edited KV value.
751 pub fn effective_session_ttl_hours(&self) -> u32 {
752 self.session_ttl_hours
753 .or(Self::defaults().session_ttl_hours)
754 .unwrap_or(DEFAULT_SESSION_TTL_HOURS)
755 .clamp(1, MAX_SESSION_TTL_HOURS)
756 }
757
758 /// The expected broker authentication mode, with unset resolved to
759 /// [`NatsAuthMode::Token`].
760 pub fn effective_nats_auth_mode(&self) -> NatsAuthMode {
761 self.nats_auth_mode.unwrap_or_default()
762 }
763
764 /// The effective check-staleness window in days: the stored value if set,
765 /// else the built-in [`DEFAULT_CHECK_STATUS_STALE_DAYS`]. **`0` means
766 /// disabled** (no row is ever hidden as stale) — deliberately NOT floored to
767 /// 1 (unlike collect/session), because 0 is a meaningful "show everything"
768 /// value here, the same convention as [`agent_prune_days`](Self::agent_prune_days).
769 /// Clamped to [`MAX_CHECK_STATUS_STALE_DAYS`] so an out-of-band KV write
770 /// can't overflow the `now - Duration::days(n)` cutoff math.
771 pub fn effective_check_status_stale_days(&self) -> u32 {
772 self.check_status_stale_days
773 .or(Self::defaults().check_status_stale_days)
774 .unwrap_or(DEFAULT_CHECK_STATUS_STALE_DAYS)
775 .min(MAX_CHECK_STATUS_STALE_DAYS)
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
784 fn default_is_unset() {
785 assert_eq!(ServerSettings::default().agent_prune_days, None);
786 }
787
788 #[test]
789 fn unset_resolves_to_disabled() {
790 // No stored value + no built-in default ⇒ effective 0 (disabled).
791 assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
792 }
793
794 #[test]
795 fn stored_value_wins_over_default() {
796 let s = ServerSettings {
797 agent_prune_days: Some(30),
798 ..Default::default()
799 };
800 assert_eq!(s.effective_agent_prune_days(), 30);
801 }
802
803 #[test]
804 fn effective_clamps_to_max() {
805 // An out-of-band KV write larger than the PUT cap must not reach
806 // the cleanup task unclamped (else its DateTime subtraction panics).
807 let s = ServerSettings {
808 agent_prune_days: Some(u32::MAX),
809 ..Default::default()
810 };
811 assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
812 }
813
814 #[test]
815 fn round_trips_through_json() {
816 let s = ServerSettings {
817 agent_prune_days: Some(30),
818 ..Default::default()
819 };
820 let json = serde_json::to_string(&s).unwrap();
821 assert_eq!(json, r#"{"agent_prune_days":30}"#);
822 let back: ServerSettings = serde_json::from_str(&json).unwrap();
823 assert_eq!(back, s);
824 }
825
826 #[test]
827 fn unset_serialises_to_empty_object() {
828 // `skip_serializing_if` keeps an all-unset doc minimal; it must
829 // round-trip back to all-`None`.
830 let s = ServerSettings::default();
831 let json = serde_json::to_string(&s).unwrap();
832 assert_eq!(json, "{}");
833 let back: ServerSettings = serde_json::from_str(&json).unwrap();
834 assert_eq!(back, s);
835 }
836
837 #[test]
838 fn explicit_null_decodes_to_unset() {
839 let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
840 assert_eq!(s.agent_prune_days, None);
841 }
842
843 #[test]
844 fn empty_object_decodes_to_default() {
845 // A freshly-created key (or one written by an older backend that
846 // didn't know this field) must read back as the pre-feature
847 // behaviour, not fail to decode.
848 let s: ServerSettings = serde_json::from_str("{}").unwrap();
849 assert_eq!(s, ServerSettings::default());
850 }
851
852 #[test]
853 fn controller_group_effective_trims_and_blank_is_unset() {
854 assert_eq!(ServerSettings::default().effective_controller_group(), None);
855 let s = ServerSettings {
856 controller_group: Some(" feed-runners ".into()),
857 ..Default::default()
858 };
859 assert_eq!(s.effective_controller_group(), Some("feed-runners"));
860 // A blank/whitespace value reads as unset (fail-safe: no runner).
861 let blank = ServerSettings {
862 controller_group: Some(" ".into()),
863 ..Default::default()
864 };
865 assert_eq!(blank.effective_controller_group(), None);
866 }
867
868 #[test]
869 fn controller_group_round_trips_and_omits_when_unset() {
870 let s = ServerSettings {
871 controller_group: Some("infra".into()),
872 ..Default::default()
873 };
874 let json = serde_json::to_string(&s).unwrap();
875 assert_eq!(json, r#"{"controller_group":"infra"}"#);
876 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
877 // Unset controller_group is omitted (skip_serializing_if).
878 assert_eq!(
879 serde_json::to_string(&ServerSettings::default()).unwrap(),
880 "{}"
881 );
882 }
883
884 #[test]
885 fn mail_round_trips_and_omits_when_unset() {
886 use crate::config::{MailEncryption, MailSection};
887
888 // Unset mail is omitted (skip_serializing_if) — a mail-less doc
889 // stays minimal and decodes back to `None`.
890 assert_eq!(
891 serde_json::to_string(&ServerSettings::default()).unwrap(),
892 "{}"
893 );
894
895 let s = ServerSettings {
896 mail: Some(MailSection {
897 host: "smtp.example.com".into(),
898 port: 587,
899 encryption: MailEncryption::Starttls,
900 from: "kanade-noreply@example.com".into(),
901 username: Some("kanade-noreply".into()),
902 }),
903 ..Default::default()
904 };
905 let json = serde_json::to_string(&s).unwrap();
906 // Encryption serialises lowercase; the password is never present.
907 assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
908 assert!(!json.contains("password"), "password must never serialise");
909 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
910 }
911
912 #[test]
913 fn mail_defaults_to_unset() {
914 assert_eq!(ServerSettings::default().mail, None);
915 // A doc written before this field existed (no `mail` key) decodes
916 // to `None` — email stays a no-op, the pre-feature behaviour.
917 let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
918 assert_eq!(s.mail, None);
919 assert_eq!(s.agent_prune_days, Some(7));
920 }
921
922 #[test]
923 fn collect_retention_unset_resolves_to_builtin_default() {
924 // Blank (the derived Default) must preserve the historical 30-day
925 // window, not disable retention.
926 assert_eq!(ServerSettings::default().collect_retention_days, None);
927 assert_eq!(
928 ServerSettings::default().effective_collect_retention_days(),
929 DEFAULT_COLLECT_RETENTION_DAYS,
930 );
931 // The defaults() document surfaces the real default so the SPA can
932 // render it as a placeholder.
933 assert_eq!(
934 ServerSettings::defaults().collect_retention_days,
935 Some(DEFAULT_COLLECT_RETENTION_DAYS),
936 );
937 }
938
939 #[test]
940 fn collect_retention_stored_value_wins() {
941 let s = ServerSettings {
942 collect_retention_days: Some(90),
943 ..Default::default()
944 };
945 assert_eq!(s.effective_collect_retention_days(), 90);
946 }
947
948 #[test]
949 fn collect_retention_effective_clamps_out_of_band_writes() {
950 // A hand-written KV value past the PUT cap (or 0) must be clamped so
951 // the reconciled Object Store max_age stays sane.
952 let big = ServerSettings {
953 collect_retention_days: Some(u32::MAX),
954 ..Default::default()
955 };
956 assert_eq!(
957 big.effective_collect_retention_days(),
958 MAX_COLLECT_RETENTION_DAYS,
959 );
960 let zero = ServerSettings {
961 collect_retention_days: Some(0),
962 ..Default::default()
963 };
964 assert_eq!(zero.effective_collect_retention_days(), 1);
965 }
966
967 #[test]
968 fn result_output_retention_defaults_to_a_week_not_the_old_month() {
969 // The built-in default IS the fix: existing buckets were born at 30
970 // days and reconcile down to this. If someone raises it back the
971 // bucket grows again, so the value is pinned rather than left to the
972 // constant's own definition.
973 assert_eq!(DEFAULT_RESULT_OUTPUT_RETENTION_DAYS, 7);
974 assert_eq!(
975 ServerSettings::default().effective_result_output_retention_days(),
976 7
977 );
978 }
979
980 #[test]
981 fn result_output_retention_is_capped_by_the_stream_it_serves() {
982 // Beyond STREAM_RESULTS' own window there is no message left to
983 // replay, so a longer object window keeps blobs nothing can ask for.
984 assert_eq!(MAX_RESULT_OUTPUT_RETENTION_DAYS, 30);
985 let big = ServerSettings {
986 result_output_retention_days: Some(u32::MAX),
987 ..Default::default()
988 };
989 assert_eq!(
990 big.effective_result_output_retention_days(),
991 MAX_RESULT_OUTPUT_RETENTION_DAYS
992 );
993 // …and 0 is floored rather than disabling retention, which would put
994 // the bucket straight back to unbounded growth.
995 let zero = ServerSettings {
996 result_output_retention_days: Some(0),
997 ..Default::default()
998 };
999 assert_eq!(zero.effective_result_output_retention_days(), 1);
1000 }
1001
1002 #[test]
1003 fn collect_retention_round_trips_and_omits_when_unset() {
1004 let s = ServerSettings {
1005 collect_retention_days: Some(90),
1006 ..Default::default()
1007 };
1008 let json = serde_json::to_string(&s).unwrap();
1009 assert_eq!(json, r#"{"collect_retention_days":90}"#);
1010 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1011 // Unset is omitted so a blank doc stays minimal.
1012 assert!(
1013 !serde_json::to_string(&ServerSettings::default())
1014 .unwrap()
1015 .contains("collect_retention_days")
1016 );
1017 }
1018
1019 #[test]
1020 fn session_ttl_unset_resolves_to_builtin_default() {
1021 // Blank (the derived Default) must resolve to the 24h default rather
1022 // than 0 (which would mint already-expired tokens).
1023 assert_eq!(ServerSettings::default().session_ttl_hours, None);
1024 assert_eq!(
1025 ServerSettings::default().effective_session_ttl_hours(),
1026 DEFAULT_SESSION_TTL_HOURS,
1027 );
1028 // The defaults() document surfaces the real default so the SPA can
1029 // render it as a placeholder.
1030 assert_eq!(
1031 ServerSettings::defaults().session_ttl_hours,
1032 Some(DEFAULT_SESSION_TTL_HOURS),
1033 );
1034 }
1035
1036 #[test]
1037 fn session_ttl_stored_value_wins() {
1038 let s = ServerSettings {
1039 session_ttl_hours: Some(72),
1040 ..Default::default()
1041 };
1042 assert_eq!(s.effective_session_ttl_hours(), 72);
1043 }
1044
1045 #[test]
1046 fn session_ttl_effective_clamps_out_of_band_writes() {
1047 // An out-of-band 0 must floor to 1 (else `now + 0h` is an
1048 // instantly-expired token); a value past the cap clamps down so
1049 // login's date math can't overflow.
1050 let zero = ServerSettings {
1051 session_ttl_hours: Some(0),
1052 ..Default::default()
1053 };
1054 assert_eq!(zero.effective_session_ttl_hours(), 1);
1055 let huge = ServerSettings {
1056 session_ttl_hours: Some(u32::MAX),
1057 ..Default::default()
1058 };
1059 assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
1060 }
1061
1062 #[test]
1063 fn session_ttl_round_trips_and_omits_when_unset() {
1064 let s = ServerSettings {
1065 session_ttl_hours: Some(48),
1066 ..Default::default()
1067 };
1068 let json = serde_json::to_string(&s).unwrap();
1069 assert_eq!(json, r#"{"session_ttl_hours":48}"#);
1070 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1071 // Unset is omitted so a blank doc stays minimal.
1072 assert!(
1073 !serde_json::to_string(&ServerSettings::default())
1074 .unwrap()
1075 .contains("session_ttl_hours")
1076 );
1077 }
1078
1079 #[test]
1080 fn check_stale_unset_resolves_to_builtin_default() {
1081 // Blank (the derived Default) resolves to the 30-day default — ON out
1082 // of the box, so the feature works without configuration.
1083 assert_eq!(ServerSettings::default().check_status_stale_days, None);
1084 assert_eq!(
1085 ServerSettings::default().effective_check_status_stale_days(),
1086 DEFAULT_CHECK_STATUS_STALE_DAYS,
1087 );
1088 // defaults() surfaces the real default for the SPA placeholder.
1089 assert_eq!(
1090 ServerSettings::defaults().check_status_stale_days,
1091 Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
1092 );
1093 }
1094
1095 #[test]
1096 fn check_stale_zero_disables() {
1097 // Explicit 0 means "disable staleness" (show everything) — NOT floored
1098 // to 1 like collect/session; same convention as agent_prune_days.
1099 let s = ServerSettings {
1100 check_status_stale_days: Some(0),
1101 ..Default::default()
1102 };
1103 assert_eq!(s.effective_check_status_stale_days(), 0);
1104 }
1105
1106 #[test]
1107 fn check_stale_stored_value_wins_and_clamps() {
1108 let s = ServerSettings {
1109 check_status_stale_days: Some(7),
1110 ..Default::default()
1111 };
1112 assert_eq!(s.effective_check_status_stale_days(), 7);
1113 let big = ServerSettings {
1114 check_status_stale_days: Some(u32::MAX),
1115 ..Default::default()
1116 };
1117 assert_eq!(
1118 big.effective_check_status_stale_days(),
1119 MAX_CHECK_STATUS_STALE_DAYS,
1120 );
1121 }
1122
1123 #[test]
1124 fn check_stale_round_trips_and_omits_when_unset() {
1125 let s = ServerSettings {
1126 check_status_stale_days: Some(14),
1127 ..Default::default()
1128 };
1129 let json = serde_json::to_string(&s).unwrap();
1130 assert_eq!(json, r#"{"check_status_stale_days":14}"#);
1131 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1132 assert!(
1133 !serde_json::to_string(&ServerSettings::default())
1134 .unwrap()
1135 .contains("check_status_stale_days")
1136 );
1137 }
1138
1139 #[test]
1140 fn support_codes_absent_by_default_and_omitted_from_the_wire() {
1141 // The pre-feature document must round-trip byte-identically: no
1142 // `support_codes` key, so an older backend / agent reading it sees
1143 // exactly what it saw before, and nothing is unlockable.
1144 let s = ServerSettings::default();
1145 assert!(s.support_codes.is_empty());
1146 assert_eq!(serde_json::to_string(&s).unwrap(), "{}");
1147 assert!(s.support_code("support").is_none());
1148 }
1149
1150 #[test]
1151 fn support_code_lookup_fails_closed() {
1152 let s = ServerSettings {
1153 support_codes: vec![
1154 SupportCode {
1155 scope: "support".into(),
1156 hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
1157 label: Some("ヘルプデスク".into()),
1158 ..Default::default()
1159 },
1160 SupportCode {
1161 scope: "admin".into(),
1162 hash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA".into(),
1163 disabled: true,
1164 ..Default::default()
1165 },
1166 SupportCode {
1167 // Hash blanked — what an API-redacted document looks like.
1168 // It must never be treated as a live code.
1169 scope: "blank".into(),
1170 ..Default::default()
1171 },
1172 ],
1173 ..Default::default()
1174 };
1175 assert!(s.support_code("support").is_some());
1176 assert!(s.support_code("admin").is_none(), "disabled must not match");
1177 assert!(
1178 s.support_code("blank").is_none(),
1179 "blank hash must not match"
1180 );
1181 assert!(s.support_code("nope").is_none());
1182 }
1183
1184 #[test]
1185 fn redacted_blanks_hashes_but_keeps_the_roster() {
1186 // The SPA still needs to see WHICH scopes exist (to list / rotate /
1187 // delete them); it must never see the hash.
1188 let s = ServerSettings {
1189 support_codes: vec![SupportCode {
1190 scope: "support".into(),
1191 hash: "$argon2id$secret".into(),
1192 label: Some("ヘルプデスク".into()),
1193 ttl_minutes: Some(30),
1194 disabled: false,
1195 }],
1196 ..Default::default()
1197 };
1198 let json = serde_json::to_string(&s.redacted()).unwrap();
1199 assert!(!json.contains("argon2"), "wire leaked the hash: {json}");
1200 assert!(!json.contains("hash"), "wire leaked the hash key: {json}");
1201 assert!(json.contains("\"scope\":\"support\""), "wire: {json}");
1202 assert!(json.contains("\"ttl_minutes\":30"), "wire: {json}");
1203 }
1204
1205 #[test]
1206 fn support_code_ttl_clamps() {
1207 let unset = SupportCode::default();
1208 assert_eq!(
1209 unset.effective_ttl_minutes(),
1210 DEFAULT_SUPPORT_UNLOCK_TTL_MINUTES
1211 );
1212 // 0 would mint an already-expired grant (an unlock that silently
1213 // does nothing) — floored to the shortest real window instead.
1214 let zero = SupportCode {
1215 ttl_minutes: Some(0),
1216 ..Default::default()
1217 };
1218 assert_eq!(zero.effective_ttl_minutes(), 1);
1219 let huge = SupportCode {
1220 ttl_minutes: Some(u32::MAX),
1221 ..Default::default()
1222 };
1223 assert_eq!(huge.effective_ttl_minutes(), MAX_SUPPORT_UNLOCK_TTL_MINUTES);
1224 }
1225
1226 #[test]
1227 fn accepts_unknown_fields_for_forward_compat() {
1228 // A newer backend may have added knobs this build doesn't know;
1229 // decoding must drop them rather than error.
1230 let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
1231 let s: ServerSettings = serde_json::from_str(json).unwrap();
1232 assert_eq!(s.agent_prune_days, Some(7));
1233 }
1234
1235 #[test]
1236 fn object_store_caps_unset_resolves_to_builtin_defaults() {
1237 // Blank doc ⇒ every bucket capped at its built-in default — the
1238 // out-of-box fix for uncapped / drifted buckets (#1247).
1239 let s = ServerSettings::default();
1240 assert_eq!(s.object_store_caps, None);
1241 let c = s.effective_object_store_caps();
1242 assert_eq!(c.result_output_mib, Some(DEFAULT_RESULT_OUTPUT_CAP_MIB));
1243 assert_eq!(c.agent_releases_mib, Some(DEFAULT_AGENT_RELEASES_CAP_MIB));
1244 assert_eq!(c.app_packages_mib, Some(DEFAULT_APP_PACKAGES_CAP_MIB));
1245 assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
1246 assert_eq!(c.collections_mib, Some(DEFAULT_COLLECTIONS_CAP_MIB));
1247 }
1248
1249 #[test]
1250 fn object_store_caps_partial_override_keeps_other_defaults() {
1251 let s = ServerSettings {
1252 object_store_caps: Some(ObjectStoreCaps {
1253 app_packages_mib: Some(8192),
1254 ..Default::default()
1255 }),
1256 ..Default::default()
1257 };
1258 let c = s.effective_object_store_caps();
1259 assert_eq!(c.app_packages_mib, Some(8192));
1260 assert_eq!(c.scripts_mib, Some(DEFAULT_SCRIPTS_CAP_MIB));
1261 }
1262
1263 #[test]
1264 fn object_store_caps_clamps_out_of_band_writes() {
1265 let s = ServerSettings {
1266 object_store_caps: Some(ObjectStoreCaps {
1267 result_output_mib: Some(0),
1268 app_packages_mib: Some(u32::MAX),
1269 ..Default::default()
1270 }),
1271 ..Default::default()
1272 };
1273 let c = s.effective_object_store_caps();
1274 // 0 floors to 1 MiB — NATS treats max_bytes: 0 as unlimited, the
1275 // exact failure mode this feature exists to remove.
1276 assert_eq!(c.result_output_mib, Some(1));
1277 assert_eq!(c.app_packages_mib, Some(MAX_OBJECT_STORE_CAP_MIB));
1278 }
1279
1280 #[test]
1281 fn object_store_caps_round_trips_and_omits_when_unset() {
1282 let s = ServerSettings {
1283 object_store_caps: Some(ObjectStoreCaps {
1284 scripts_mib: Some(512),
1285 ..Default::default()
1286 }),
1287 ..Default::default()
1288 };
1289 let json = serde_json::to_string(&s).unwrap();
1290 assert_eq!(json, r#"{"object_store_caps":{"scripts_mib":512}}"#);
1291 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1292 assert!(
1293 !serde_json::to_string(&ServerSettings::default())
1294 .unwrap()
1295 .contains("object_store_caps")
1296 );
1297 }
1298
1299 #[test]
1300 fn agent_install_defaults_to_unset_and_omits_when_unset() {
1301 assert_eq!(ServerSettings::default().agent_install, None);
1302 assert_eq!(ServerSettings::defaults().agent_install, None);
1303 // A doc written before this field existed decodes to `None` — the
1304 // installer then falls back to the backend's own [nats] url, the
1305 // pre-feature behaviour.
1306 let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
1307 assert_eq!(s.agent_install, None);
1308 assert!(
1309 !serde_json::to_string(&ServerSettings::default())
1310 .unwrap()
1311 .contains("agent_install")
1312 );
1313 }
1314
1315 #[test]
1316 fn agent_install_round_trips() {
1317 let s = ServerSettings {
1318 agent_install: Some(AgentInstallSection {
1319 nats_url: Some("nats://broker.corp:4222".into()),
1320 nats_token: Some("s3cret".into()),
1321 nats_token_set: false,
1322 require_signed_commands: None,
1323 ..Default::default()
1324 }),
1325 ..Default::default()
1326 };
1327 let json = serde_json::to_string(&s).unwrap();
1328 assert_eq!(
1329 json,
1330 r#"{"agent_install":{"nats_url":"nats://broker.corp:4222","nats_token":"s3cret","nats_token_set":false,"nats_user_set":false,"nats_password_set":false}}"#
1331 );
1332 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1333 }
1334
1335 #[test]
1336 fn agent_install_require_signed_commands_round_trips() {
1337 let s = ServerSettings {
1338 agent_install: Some(AgentInstallSection {
1339 require_signed_commands: Some(true),
1340 ..Default::default()
1341 }),
1342 ..Default::default()
1343 };
1344 let json = serde_json::to_string(&s).unwrap();
1345 assert_eq!(
1346 json,
1347 r#"{"agent_install":{"nats_token_set":false,"nats_user_set":false,"nats_password_set":false,"require_signed_commands":true}}"#
1348 );
1349 assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
1350 }
1351
1352 #[test]
1353 fn agent_install_token_set_is_never_accepted_from_the_wire() {
1354 // `skip_deserializing`: a client sending nats_token_set gets it
1355 // dropped, so the indicator can only ever come from redacted()
1356 // looking at the real stored token — never from a claim.
1357 let s: ServerSettings = serde_json::from_str(
1358 r#"{"agent_install":{"nats_url":"nats://b:4222","nats_token_set":true}}"#,
1359 )
1360 .unwrap();
1361 let ai = s.agent_install.unwrap();
1362 assert!(!ai.nats_token_set);
1363 assert_eq!(ai.nats_url.as_deref(), Some("nats://b:4222"));
1364 }
1365
1366 #[test]
1367 fn redacted_strips_the_install_token_but_reports_its_presence() {
1368 // GET /api/server-settings is viewer+, so the stored NATS token must
1369 // never survive redacted() — not even to an operator. The boolean is
1370 // all the SPA gets to render "configured".
1371 let with_token = ServerSettings {
1372 agent_install: Some(AgentInstallSection {
1373 nats_url: Some("nats://broker.corp:4222".into()),
1374 nats_token: Some("s3cret".into()),
1375 nats_token_set: false,
1376 require_signed_commands: None,
1377 ..Default::default()
1378 }),
1379 ..Default::default()
1380 };
1381 let redacted = with_token.redacted();
1382 let ai = redacted.agent_install.as_ref().unwrap();
1383 assert_eq!(ai.nats_token, None, "token must not survive redacted()");
1384 assert!(ai.nats_token_set);
1385 // The URL is not a secret — it stays.
1386 assert_eq!(ai.nats_url.as_deref(), Some("nats://broker.corp:4222"));
1387 // And on the wire the token key is gone entirely (not null).
1388 let json = serde_json::to_string(&redacted).unwrap();
1389 assert!(!json.contains("s3cret"), "wire leaked the token: {json}");
1390 assert!(!json.contains("nats_token\""), "wire: {json}");
1391 assert!(json.contains(r#""nats_token_set":true"#), "wire: {json}");
1392
1393 // No token configured → indicator false, nothing to strip.
1394 let sans_token = ServerSettings {
1395 agent_install: Some(AgentInstallSection {
1396 nats_url: Some("nats://broker.corp:4222".into()),
1397 ..Default::default()
1398 }),
1399 ..Default::default()
1400 }
1401 .redacted();
1402 let ai = sans_token.agent_install.as_ref().unwrap();
1403 assert!(!ai.nats_token_set);
1404 }
1405
1406 #[test]
1407 fn redacted_strips_the_user_pair_but_reports_presence() {
1408 let redacted = ServerSettings {
1409 agent_install: Some(AgentInstallSection {
1410 nats_user: Some("agent-u".into()),
1411 nats_password: Some("p@ss'w0rd".into()),
1412 ..Default::default()
1413 }),
1414 ..Default::default()
1415 }
1416 .redacted();
1417 let ai = redacted.agent_install.as_ref().unwrap();
1418 assert_eq!(ai.nats_user, None);
1419 assert_eq!(ai.nats_password, None);
1420 assert!(ai.nats_user_set && ai.nats_password_set);
1421 let json = serde_json::to_string(&redacted).unwrap();
1422 assert!(!json.contains("agent-u"), "wire leaked the user: {json}");
1423 assert!(!json.contains("w0rd"), "wire leaked the password: {json}");
1424 assert!(json.contains(r#""nats_user_set":true"#), "wire: {json}");
1425 assert!(json.contains(r#""nats_password_set":true"#), "wire: {json}");
1426 }
1427
1428 #[test]
1429 fn user_pair_flags_are_never_accepted_from_the_wire() {
1430 let s: ServerSettings = serde_json::from_str(
1431 r#"{"agent_install":{"nats_user_set":true,"nats_password_set":true}}"#,
1432 )
1433 .unwrap();
1434 let ai = s.agent_install.unwrap();
1435 assert!(!ai.nats_user_set && !ai.nats_password_set);
1436 }
1437
1438 #[test]
1439 fn debug_never_prints_the_credentials() {
1440 let ai = AgentInstallSection {
1441 nats_token: Some("tok-secret".into()),
1442 nats_user: Some("user-secret".into()),
1443 nats_password: Some("pw-secret".into()),
1444 ..Default::default()
1445 };
1446 let dbg = format!("{ai:?}");
1447 for needle in ["tok-secret", "user-secret", "pw-secret"] {
1448 assert!(!dbg.contains(needle), "Debug leaked {needle}: {dbg}");
1449 }
1450 }
1451}