Skip to main content

max / makenotwork

57.7 KB · 1787 lines History Blame Raw
1 //! TOML configuration loading and types.
2
3 use serde::Deserialize;
4 use std::collections::HashMap;
5 use std::path::{Path, PathBuf};
6
7 use crate::error::{PomError, Result};
8 use crate::peer::OnMissing;
9 use crate::types::DnsRecordType;
10
11 #[derive(Debug, Clone, Deserialize)]
12 pub struct Config {
13 /// Serve-mode settings (intervals, listen address, pruning).
14 #[serde(default)]
15 pub serve: ServeConfig,
16 /// This PoM instance's identity (name, optional fixed ID).
17 #[serde(default)]
18 pub instance: InstanceConfig,
19 /// Monitored targets, keyed by short name (e.g. "mnw", "go").
20 #[serde(default)]
21 pub targets: HashMap<String, TargetConfig>,
22 /// Peer PoM instances for mesh monitoring, keyed by peer name.
23 #[serde(default)]
24 pub peers: HashMap<String, PeerConfig>,
25 /// Where this instance keeps its database and instance ID.
26 #[serde(default)]
27 pub storage: StorageConfig,
28 /// Email alert configuration via Postmark. `None` disables alerting.
29 pub alerts: Option<AlertConfig>,
30 }
31
32 #[derive(Debug, Clone, Default, Deserialize)]
33 pub struct StorageConfig {
34 /// Absolute path to `pom.db`. Unset falls back to the XDG data directory,
35 /// which is what the service unit and a hand-run CLI disagree about: set
36 /// this on any instance whose database is not in the invoking user's own
37 /// `~/.local/share`, and the two agree by construction.
38 pub db_path: Option<PathBuf>,
39 }
40
41 // Manual Debug (below) redacts the token, keep field lists in sync when adding
42 // fields. Secrets in a derived Debug are a latent leak on any future log/panic.
43 #[derive(Clone, Deserialize)]
44 pub struct AlertConfig {
45 /// Postmark server API token. Can also be set via `POM_POSTMARK_TOKEN` env var.
46 pub postmark_token: Option<String>,
47 /// Recipient email address for alert notifications.
48 pub to: String,
49 /// Sender email address for alert notifications.
50 #[serde(default = "default_alert_from")]
51 pub from: String,
52 /// Minimum seconds between repeated alerts for the same target.
53 #[serde(default = "default_cooldown_secs")]
54 pub cooldown_secs: u64,
55 /// WAM ticket manager URL (tailnet). When set, alerts also create WAM tickets.
56 pub wam_url: Option<String>,
57 /// Bearer token for the WAM ticket API. WAM fails closed (`MNW/wam` requires
58 /// `Authorization: Bearer <token>` on every request), so without this a
59 /// configured `wam_url` still gets 401s and ticket creation silently falls
60 /// back to email. Can also be set via the `POM_WAM_TOKEN` env var.
61 pub wam_token: Option<String>,
62 /// MNW base URL (e.g. "https://makenot.work"). When set together with
63 /// `alerts_ingest_token`, alerts are also pushed to MNW's operator log via
64 /// `POST /api/internal/alerts`. Either unset disables the MNW sink.
65 pub mnw_url: Option<String>,
66 /// Bearer token for MNW's alert-ingestion endpoint. Can also be set via the
67 /// `POM_ALERTS_INGEST_TOKEN` env var. Distinct from any CLI service token.
68 pub alerts_ingest_token: Option<String>,
69 }
70
71 impl std::fmt::Debug for AlertConfig {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("AlertConfig")
74 .field(
75 "postmark_token",
76 &self.postmark_token.as_ref().map(|_| "***"),
77 )
78 .field("to", &self.to)
79 .field("from", &self.from)
80 .field("cooldown_secs", &self.cooldown_secs)
81 .field("wam_url", &self.wam_url)
82 .field("wam_token", &self.wam_token.as_ref().map(|_| "***"))
83 .field("mnw_url", &self.mnw_url)
84 .field(
85 "alerts_ingest_token",
86 &self.alerts_ingest_token.as_ref().map(|_| "***"),
87 )
88 .finish()
89 }
90 }
91
92 #[derive(Debug, Clone, Default, Deserialize)]
93 pub struct InstanceConfig {
94 /// Human-readable instance name. Falls back to OS hostname if unset.
95 pub name: Option<String>,
96 /// Fixed instance UUID. Auto-generated and persisted to disk if unset.
97 pub id: Option<String>,
98 }
99
100 #[derive(Clone, Deserialize)]
101 pub struct PeerConfig {
102 /// Network address of the peer (host:port).
103 pub address: String,
104 /// Action to take when the peer is declared missing.
105 #[serde(default)]
106 pub on_missing: OnMissing,
107 /// Number of consecutive heartbeat failures before declaring the peer missing.
108 /// Defaults to 3 at runtime if unset.
109 pub grace_count: Option<u32>,
110 /// Bearer token for authenticating with this peer's API.
111 pub token: Option<String>,
112 }
113
114 impl std::fmt::Debug for PeerConfig {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("PeerConfig")
117 .field("address", &self.address)
118 .field("on_missing", &self.on_missing)
119 .field("grace_count", &self.grace_count)
120 .field("token", &self.token.as_ref().map(|_| "***"))
121 .finish()
122 }
123 }
124
125 // Manual Debug (below) redacts `api_token`; keep field lists in sync.
126 #[derive(Clone, Deserialize)]
127 pub struct ServeConfig {
128 /// Seconds between health check cycles for all targets.
129 #[serde(default = "default_serve_interval")]
130 pub interval_secs: u64,
131 /// Number of days of history to retain before pruning.
132 #[serde(default = "default_prune_days")]
133 pub prune_days: i64,
134 /// Socket address the API server binds to (e.g. "127.0.0.1:9100").
135 #[serde(default = "default_listen")]
136 pub listen: String,
137 /// Seconds between peer heartbeat probes.
138 #[serde(default = "default_peer_heartbeat")]
139 pub peer_heartbeat_secs: u64,
140 /// Seconds between TLS certificate checks.
141 #[serde(default = "default_tls_check_interval")]
142 pub tls_check_interval_secs: u64,
143 /// Seconds between route accessibility checks for all targets.
144 #[serde(default = "default_route_check_interval")]
145 pub route_check_interval_secs: u64,
146 /// Seconds between DNS record verification checks.
147 #[serde(default = "default_dns_check_interval")]
148 pub dns_check_interval_secs: u64,
149 /// Seconds between CORS preflight verification checks.
150 #[serde(default = "default_cors_check_interval")]
151 pub cors_check_interval_secs: u64,
152 /// Seconds between WHOIS domain expiry checks.
153 #[serde(default = "default_whois_check_interval")]
154 pub whois_check_interval_secs: u64,
155 /// Seconds between staleness sweeps of the configured test suites. This is
156 /// not how often tests run: the sweep runs a suite only when its last run is
157 /// older than that target's `tests.staleness_days`, or when it has never run.
158 #[serde(default = "default_test_sweep_interval")]
159 pub test_sweep_interval_secs: u64,
160 /// Bearer token required for API access. If set, all /api/* requests must
161 /// include `Authorization: Bearer <token>`. Can also be set via POM_API_TOKEN env var.
162 pub api_token: Option<String>,
163 /// Enable the HTML dashboard at `GET /`. Disabled by default.
164 #[serde(default)]
165 pub dashboard: bool,
166 /// Consecutive checks that must agree on a new status before a health/ssh
167 /// transition fires an alert or opens an incident (N-of-M debounce). Default
168 /// 2: a single transient blip no longer pages or logs a false incident. Set 1
169 /// to alert on the first differing check (the pre-debounce behavior).
170 #[serde(default = "default_confirmations")]
171 pub confirmations: u32,
172 }
173
174 impl std::fmt::Debug for ServeConfig {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.debug_struct("ServeConfig")
177 .field("interval_secs", &self.interval_secs)
178 .field("prune_days", &self.prune_days)
179 .field("listen", &self.listen)
180 .field("peer_heartbeat_secs", &self.peer_heartbeat_secs)
181 .field("tls_check_interval_secs", &self.tls_check_interval_secs)
182 .field("route_check_interval_secs", &self.route_check_interval_secs)
183 .field("dns_check_interval_secs", &self.dns_check_interval_secs)
184 .field("cors_check_interval_secs", &self.cors_check_interval_secs)
185 .field("whois_check_interval_secs", &self.whois_check_interval_secs)
186 .field("test_sweep_interval_secs", &self.test_sweep_interval_secs)
187 .field("api_token", &self.api_token.as_ref().map(|_| "***"))
188 .field("dashboard", &self.dashboard)
189 .field("confirmations", &self.confirmations)
190 .finish()
191 }
192 }
193
194 fn default_confirmations() -> u32 {
195 2
196 }
197
198 impl Default for ServeConfig {
199 fn default() -> Self {
200 Self {
201 interval_secs: 300,
202 prune_days: 30,
203 listen: default_listen(),
204 peer_heartbeat_secs: 60,
205 tls_check_interval_secs: 3600,
206 route_check_interval_secs: 300,
207 dns_check_interval_secs: 3600,
208 cors_check_interval_secs: 3600,
209 whois_check_interval_secs: 86400,
210 test_sweep_interval_secs: default_test_sweep_interval(),
211 api_token: None,
212 dashboard: false,
213 confirmations: default_confirmations(),
214 }
215 }
216 }
217
218 fn default_peer_heartbeat() -> u64 {
219 // 1 minute: detects peer failures within the grace period
220 60
221 }
222
223 fn default_tls_check_interval() -> u64 {
224 // 1 hour: certificates change slowly, no need to probe frequently
225 3600
226 }
227
228 fn default_route_check_interval() -> u64 {
229 // 5 minutes: same cadence as health checks, catches broken pages quickly
230 300
231 }
232
233 fn default_dns_check_interval() -> u64 {
234 // 1 hour: DNS records change infrequently, same cadence as TLS checks
235 3600
236 }
237
238 fn default_cors_check_interval() -> u64 {
239 // 1 hour: CORS policies change infrequently
240 3600
241 }
242
243 fn default_whois_check_interval() -> u64 {
244 // 24 hours: domain registration data changes on the order of days/months
245 86400
246 }
247
248 fn default_test_sweep_interval() -> u64 {
249 // 1 hour. The sweep is cheap (one DB read per target); the suite it may
250 // trigger is not, which is why `staleness_days` gates the run itself.
251 3600
252 }
253
254 fn default_serve_interval() -> u64 {
255 // 5 minutes: frequent enough to catch outages within an SLA window,
256 // infrequent enough to avoid noise
257 300
258 }
259
260 fn default_prune_days() -> i64 {
261 // 30 days: enough history for monthly reporting, keeps DB small
262 30
263 }
264
265 fn default_listen() -> String {
266 "127.0.0.1:9100".to_string()
267 }
268
269 #[derive(Debug, Clone, Deserialize)]
270 pub struct TargetConfig {
271 /// Human-readable display name for this target.
272 pub label: String,
273 /// HTTP health check configuration. `None` disables health monitoring.
274 pub health: Option<HealthConfig>,
275 /// Remote test runner configuration. `None` disables test execution.
276 pub tests: Option<TestsConfig>,
277 /// TLS certificate monitoring configuration. `None` disables TLS checks.
278 pub tls: Option<TlsConfig>,
279 /// Expected routes to check for accessibility. Empty disables route checks.
280 /// Requires `health` config for base URL derivation.
281 #[serde(default)]
282 pub expected_routes: Vec<String>,
283 /// DNS records to verify. Empty disables DNS checks.
284 #[serde(default)]
285 pub dns: Vec<DnsRecord>,
286 /// WHOIS domain expiry monitoring. `None` disables WHOIS checks.
287 pub whois: Option<WhoisConfig>,
288 /// CORS preflight checks. Empty disables CORS checks.
289 #[serde(default)]
290 pub cors: Vec<CorsCheck>,
291 /// Local filesystem backup verification. `None` disables backup checks.
292 pub backups: Option<BackupConfig>,
293 /// SSH banner check (TCP connect + verify "SSH-" banner). `None` disables.
294 pub ssh_banner: Option<SshBannerConfig>,
295 /// Scan-pipeline health check against `<base_url>/admin/uploads/health.json`.
296 /// `None` disables.
297 pub scan_pipeline: Option<ScanPipelineConfig>,
298 /// SyncKit field-version readout against
299 /// `<base_url>/api/internal/synckit/client-versions`. `None` disables.
300 pub synckit_fleet: Option<SyncKitFleetConfig>,
301 /// Local checkout of the code this target runs, used by `pom versions` to
302 /// count how far the live build is behind. `None` leaves that column blank.
303 pub repo: Option<RepoConfig>,
304 /// Local systemd daemon liveness / crash-loop / failed-unit check. `None`
305 /// disables. Probes the host PoM runs on, not a remote target.
306 pub systemd: Option<SystemdConfig>,
307 /// Local CA-bundle freshness check. `None` disables. Like `systemd`, this
308 /// probes the host PoM runs on rather than a remote target.
309 pub ca_bundle: Option<CaBundleConfig>,
310 }
311
312 /// Local CA-bundle freshness monitoring for a host target.
313 ///
314 /// The trailing indicator for this already exists: the TLS check validates every
315 /// target against the host trust store as well as the web PKI, so a bundle that
316 /// has gone thin shows up as a chain the host rejects. This is the leading one.
317 /// It answers "is the bundle drifting" before anything fails, which matters
318 /// because multithreaded has no in-binary root fallback on any outbound path and
319 /// would otherwise report the problem as a login outage.
320 #[derive(Debug, Clone, Deserialize)]
321 pub struct CaBundleConfig {
322 /// Package providing the trust anchors. Defaults to "ca-certificates".
323 #[serde(default = "default_ca_bundle_package")]
324 pub package: String,
325 /// Concatenated PEM bundle to count certificates in. Defaults to
326 /// "/etc/ssl/certs/ca-certificates.crt".
327 #[serde(default = "default_ca_bundle_path")]
328 pub bundle_path: PathBuf,
329 /// Fewer certificates than this reads as a truncated or emptied bundle.
330 /// Defaults to 80, comfortably under the ~120 a stock Ubuntu carries and
331 /// well above anything that would still be a working trust store.
332 #[serde(default = "default_ca_bundle_min_certs")]
333 pub min_certs: usize,
334 /// Stamp file whose mtime records the last successful `apt update`.
335 /// Defaults to "/var/lib/apt/periodic/update-success-stamp".
336 #[serde(default = "default_ca_bundle_stamp")]
337 pub update_stamp: PathBuf,
338 /// A stamp older than this means the package lists are stale, so
339 /// installed-equals-candidate proves nothing. Defaults to 48 hours.
340 #[serde(default = "default_ca_bundle_stamp_max_age_hours")]
341 pub update_stamp_max_age_hours: i64,
342 /// Seconds between checks. Defaults to 1 hour.
343 #[serde(default = "default_ca_bundle_interval")]
344 pub interval_secs: u64,
345 }
346
347 fn default_ca_bundle_package() -> String {
348 "ca-certificates".to_string()
349 }
350
351 fn default_ca_bundle_path() -> PathBuf {
352 PathBuf::from("/etc/ssl/certs/ca-certificates.crt")
353 }
354
355 fn default_ca_bundle_min_certs() -> usize {
356 // Ubuntu 24.04 ships ~121. The floor is not a freshness signal, it catches a
357 // truncated or half-written bundle, which a version comparison calls fine.
358 80
359 }
360
361 fn default_ca_bundle_stamp() -> PathBuf {
362 PathBuf::from("/var/lib/apt/periodic/update-success-stamp")
363 }
364
365 fn default_ca_bundle_stamp_max_age_hours() -> i64 {
366 // apt-daily.timer runs daily, so 48h allows one missed run before saying so.
367 48
368 }
369
370 fn default_ca_bundle_interval() -> u64 {
371 // 1 hour. The package moves a few times a year; this is about noticing
372 // within a day, not within a minute.
373 3600
374 }
375
376 /// A local git checkout to measure a target's live build against.
377 ///
378 /// Local-only by design: the count is taken against whatever the checkout has
379 /// on HEAD right now, on the machine running `pom versions`. On a host without
380 /// the repo the column goes blank instead of the command failing.
381 #[derive(Debug, Clone, Deserialize)]
382 pub struct RepoConfig {
383 /// Absolute path to the checkout.
384 pub path: PathBuf,
385 /// Path within the repo the count is scoped to (e.g. "server" in a repo
386 /// holding several deployables). `None` counts every commit on HEAD.
387 pub subdir: Option<String>,
388 }
389
390 /// Local systemd unit monitoring for a host target. Watches named daemons for
391 /// liveness and crash-loops, and optionally sweeps the host for any failed unit.
392 #[derive(Debug, Clone, Deserialize)]
393 pub struct SystemdConfig {
394 /// Units to watch for liveness (e.g. sandod, bentod, wam, pom).
395 #[serde(default)]
396 pub units: Vec<SystemdUnit>,
397 /// Also alert on any unit in the failed state on the host, including
398 /// oneshot/timer-driven units a liveness watch would never enumerate
399 /// (the sandod-backup-fetch class). Defaults to true.
400 #[serde(default = "default_systemd_check_failed")]
401 pub check_failed: bool,
402 /// `NRestarts` at or above this reads as a crash-loop even while the unit
403 /// still shows `activating`. Defaults to 5.
404 #[serde(default = "default_systemd_restart_threshold")]
405 pub restart_threshold: i64,
406 /// Seconds between checks. Defaults to 60.
407 #[serde(default = "default_systemd_interval")]
408 pub interval_secs: u64,
409 }
410
411 /// One watched systemd unit.
412 #[derive(Debug, Clone, Deserialize)]
413 pub struct SystemdUnit {
414 /// Unit name including its suffix (e.g. "sandod.service").
415 pub name: String,
416 /// Whether the unit lives on the `--user` bus rather than the system bus.
417 /// bentod runs under `systemd --user`, so this must be set for it.
418 #[serde(default)]
419 pub user: bool,
420 }
421
422 fn default_systemd_check_failed() -> bool {
423 true
424 }
425
426 fn default_systemd_restart_threshold() -> i64 {
427 // 5 automatic restarts: a healthy long-lived daemon does not flap this much,
428 // and it is well below the thousands bentod racked up while going unnoticed.
429 5
430 }
431
432 fn default_systemd_interval() -> u64 {
433 // 1 minute: a crash-loop should surface fast, and a local systemctl probe is
434 // cheap.
435 60
436 }
437
438 #[derive(Debug, Clone, Deserialize)]
439 pub struct ScanPipelineConfig {
440 /// Base URL of the makenotwork instance (e.g. "https://makenot.work").
441 pub base_url: String,
442 /// Check interval. Defaults to 300s (5 min).
443 #[serde(default = "default_scan_pipeline_interval")]
444 pub interval_secs: u64,
445 /// HTTP request timeout. Defaults to 10s.
446 #[serde(default = "default_scan_pipeline_timeout")]
447 pub timeout_secs: u64,
448 }
449
450 fn default_scan_pipeline_interval() -> u64 {
451 300
452 }
453 fn default_scan_pipeline_timeout() -> u64 {
454 10
455 }
456
457 /// SyncKit field-version readout for a target.
458 ///
459 /// Carries no token of its own: the endpoint is authed with the same
460 /// `alerts.alerts_ingest_token` PoM already holds for pushing alerts to MNW, and
461 /// copying that secret into a second config block would mean a rotation has two
462 /// places to miss. Without an `alerts_ingest_token`, this check does not spawn.
463 #[derive(Debug, Clone, Deserialize)]
464 pub struct SyncKitFleetConfig {
465 /// Base URL of the makenotwork instance (e.g. "https://makenot.work").
466 pub base_url: String,
467 /// Activity window handed to the server as `?days=`. Defaults to 30, the
468 /// server's own default; it clamps anything outside 1..=365.
469 #[serde(default = "default_synckit_fleet_window_days")]
470 pub window_days: u32,
471 /// Check interval. Defaults to 3600s.
472 #[serde(default = "default_synckit_fleet_interval")]
473 pub interval_secs: u64,
474 /// HTTP request timeout. Defaults to 10s.
475 #[serde(default = "default_synckit_fleet_timeout")]
476 pub timeout_secs: u64,
477 }
478
479 fn default_synckit_fleet_window_days() -> u32 {
480 30
481 }
482 fn default_synckit_fleet_interval() -> u64 {
483 // Hourly. A fleet's version mix moves when users update, which is days-scale;
484 // polling it as often as a liveness check would buy nothing and put a
485 // GROUP BY over sync_devices on a five-minute timer.
486 3600
487 }
488 fn default_synckit_fleet_timeout() -> u64 {
489 10
490 }
491
492 #[derive(Debug, Clone, Deserialize)]
493 pub struct DnsRecord {
494 /// Hostname to resolve (e.g. "makenot.work").
495 pub name: String,
496 /// DNS record type: A, AAAA, CNAME, MX, TXT.
497 pub record_type: DnsRecordType,
498 /// Expected values (order-independent set comparison).
499 pub expected: Vec<String>,
500 }
501
502 #[derive(Debug, Clone, Deserialize)]
503 pub struct WhoisConfig {
504 /// Domain to check (e.g. "makenot.work").
505 pub domain: String,
506 /// Alert when registration expires within this many days. Defaults to 30.
507 #[serde(default = "default_whois_warn_days")]
508 pub warn_days: u32,
509 }
510
511 fn default_whois_warn_days() -> u32 {
512 30
513 }
514
515 #[derive(Debug, Clone, Deserialize)]
516 pub struct CorsCheck {
517 /// URL to send the preflight OPTIONS request to.
518 pub url: String,
519 /// Expected `Access-Control-Allow-Origin` value.
520 pub origin: String,
521 /// HTTP method to include in `Access-Control-Request-Method`.
522 #[serde(default = "default_cors_method")]
523 pub method: String,
524 }
525
526 fn default_cors_method() -> String {
527 "PUT".to_string()
528 }
529
530 #[derive(Debug, Clone, Deserialize)]
531 pub struct BackupConfig {
532 /// Filesystem directory containing backup files (e.g. "/opt/backups/postgres").
533 pub directory: String,
534 /// Database names to check for backups (e.g. `["makenotwork", "multithreaded"]`).
535 pub databases: Vec<String>,
536 /// Maximum age in hours before a backup is considered stale.
537 #[serde(default = "default_max_age_hours")]
538 pub max_age_hours: u64,
539 /// Seconds between backup verification checks.
540 #[serde(default = "default_backup_interval")]
541 pub interval_secs: u64,
542 }
543
544 fn default_max_age_hours() -> u64 {
545 // 25 hours: allows for some cron drift from the daily 03:00 UTC schedule
546 25
547 }
548
549 fn default_backup_interval() -> u64 {
550 // 1 hour: backups are daily, hourly checks are sufficient
551 3600
552 }
553
554 /// SSH banner check, TCP connect and verify the server responds with "SSH-".
555 #[derive(Debug, Clone, Deserialize)]
556 pub struct SshBannerConfig {
557 /// Hostname or IP to connect to.
558 pub host: String,
559 /// TCP port (defaults to 22).
560 #[serde(default = "default_ssh_banner_port")]
561 pub port: u16,
562 /// Connection timeout in seconds.
563 #[serde(default = "default_ssh_banner_timeout")]
564 pub timeout_secs: u64,
565 }
566
567 fn default_ssh_banner_port() -> u16 {
568 22
569 }
570
571 fn default_ssh_banner_timeout() -> u64 {
572 5
573 }
574
575 #[derive(Debug, Clone, Deserialize)]
576 pub struct TlsConfig {
577 /// Hostname to connect to for the TLS check.
578 pub host: String,
579 /// TCP port for the TLS connection.
580 #[serde(default = "default_tls_port")]
581 pub port: u16,
582 /// Days before expiry at which to start warning.
583 #[serde(default = "default_tls_warn_days")]
584 pub warn_days: u32,
585 }
586
587 fn default_tls_port() -> u16 {
588 443
589 }
590
591 fn default_tls_warn_days() -> u32 {
592 // 2 weeks: enough lead time to renew before expiry
593 14
594 }
595
596 #[derive(Debug, Clone, Deserialize)]
597 pub struct HealthConfig {
598 /// URL of the health endpoint to check.
599 pub url: String,
600 /// HTTP request timeout in seconds for this health check.
601 #[serde(default = "default_health_timeout")]
602 pub timeout_secs: u64,
603 /// Per-target interval override for serve mode.
604 pub interval_secs: Option<u64>,
605 /// Response validation expectations.
606 pub expect: Option<HealthExpectation>,
607 /// Latency trending and drift detection.
608 pub trending: Option<TrendingConfig>,
609 }
610
611 #[derive(Debug, Clone, Deserialize)]
612 pub struct TrendingConfig {
613 /// Number of hours of history used to compute the baseline average latency.
614 #[serde(default = "default_baseline_window_hours")]
615 pub baseline_window_hours: u64,
616 /// Multiplier over the baseline average that constitutes a latency spike.
617 #[serde(default = "default_spike_threshold")]
618 pub spike_threshold: f64,
619 }
620
621 fn default_baseline_window_hours() -> u64 {
622 // 7 days: captures weekly traffic patterns for stable baseline
623 168
624 }
625
626 fn default_spike_threshold() -> f64 {
627 // 2x baseline average: significant deviation without false positives
628 // from normal variance
629 2.0
630 }
631
632 #[derive(Debug, Clone, Deserialize, Default)]
633 pub struct HealthExpectation {
634 /// Expected HTTP status code (e.g. 200). `None` accepts any 2xx.
635 pub status_code: Option<u16>,
636 /// JSON field paths and their expected string values (e.g. `{"status": "operational"}`).
637 #[serde(default)]
638 pub json_fields: HashMap<String, String>,
639 /// Substring that must appear in the response body.
640 pub body_contains: Option<String>,
641 }
642
643 #[derive(Debug, Clone, Deserialize)]
644 pub struct TestsConfig {
645 /// SSH host alias (from `~/.ssh/config`) for the test runner machine.
646 ///
647 /// Omit when the runner is the host PoM runs on: the command is then
648 /// executed locally. Pointing this at the local machine's own address
649 /// instead only works if a regular sshd is listening, which is not a given
650 /// on a Tailscale-SSH host (tailscaled does not intercept a node
651 /// connecting to itself).
652 #[serde(default)]
653 pub ssh: Option<String>,
654 /// Shell command to run the tests, remotely or locally per `ssh`.
655 pub command: String,
656 /// Maximum seconds to wait for the test command before killing it.
657 #[serde(default = "default_test_timeout")]
658 pub timeout_secs: u64,
659 /// Number of days after which a test run is considered stale.
660 #[serde(default = "default_staleness_days")]
661 pub staleness_days: u64,
662 }
663
664 fn default_staleness_days() -> u64 {
665 // 1 week: tests older than a week may not reflect current code
666 7
667 }
668
669 fn default_health_timeout() -> u64 {
670 // 10 seconds: generous for most HTTP endpoints, avoids false positives
671 // on slow networks
672 10
673 }
674
675 fn default_test_timeout() -> u64 {
676 // 10 minutes: full CI suites can take time, especially on slow machines
677 600
678 }
679
680 fn default_alert_from() -> String {
681 "PoM Alerts <pom-alerts@makenot.work>".to_string()
682 }
683
684 fn default_cooldown_secs() -> u64 {
685 // 5 minutes: prevents alert storms during sustained outages
686 300
687 }
688
689 impl Config {
690 pub fn load(path: Option<&Path>) -> Result<Self> {
691 let config_path = match path {
692 Some(p) => p.to_path_buf(),
693 None => default_config_path()?,
694 };
695
696 if !config_path.exists() {
697 return Err(PomError::Config(format!(
698 "Config file not found: {}",
699 config_path.display()
700 )));
701 }
702
703 let contents = std::fs::read_to_string(&config_path)?;
704 let mut config: Config = toml::from_str(&contents)?;
705
706 // Environment variables take precedence over the config file, so a token
707 // can be rotated without editing pom.toml. This is the documented
708 // behavior; the earlier code applied env only when the config value was
709 // absent, so a stale pom.toml token silently won over the env rotation.
710 if let Ok(token) = std::env::var("POM_POSTMARK_TOKEN")
711 && let Some(ref mut alerts) = config.alerts
712 {
713 alerts.postmark_token = Some(token);
714 }
715 if let Ok(token) = std::env::var("POM_ALERTS_INGEST_TOKEN")
716 && let Some(ref mut alerts) = config.alerts
717 {
718 alerts.alerts_ingest_token = Some(token);
719 }
720 if let Ok(token) = std::env::var("POM_WAM_TOKEN")
721 && let Some(ref mut alerts) = config.alerts
722 {
723 alerts.wam_token = Some(token);
724 }
725 if let Ok(token) = std::env::var("POM_API_TOKEN") {
726 config.serve.api_token = Some(token);
727 }
728
729 // Validate no duplicate target labels
730 let mut seen_labels = std::collections::HashSet::new();
731 for (name, target) in &config.targets {
732 if !seen_labels.insert(target.label.to_lowercase()) {
733 return Err(PomError::Config(format!(
734 "duplicate target label: \"{}\" (on target \"{name}\")",
735 target.label
736 )));
737 }
738 }
739
740 // Validate expected_routes: they need `health` for base-URL derivation,
741 // so a target that lists routes but has no `[health]` would silently never
742 // check them. Fail loudly instead of no-op'ing. Each path must be rooted.
743 for (name, target) in &config.targets {
744 if !target.expected_routes.is_empty() && target.health.is_none() {
745 return Err(PomError::Config(format!(
746 "target {name}: expected_routes requires a [health] config \
747 (the route base URL is derived from it)"
748 )));
749 }
750 for route in &target.expected_routes {
751 if !route.starts_with('/') {
752 return Err(PomError::Config(format!(
753 "target {name}: expected_route \"{route}\" must start with '/'"
754 )));
755 }
756 }
757 // A relative repo path would resolve against whatever directory pom
758 // happened to be launched from, so the same config would measure a
759 // different checkout under systemd than it does from a shell.
760 if let Some(repo) = &target.repo
761 && !repo.path.is_absolute()
762 {
763 return Err(PomError::Config(format!(
764 "target {name}: repo.path \"{}\" must be absolute",
765 repo.path.display()
766 )));
767 }
768 }
769
770 // Same rationale as repo.path: a relative db_path would resolve against
771 // the launch directory, so the service and a shell would open different
772 // files from the same config.
773 if let Some(db) = &config.storage.db_path
774 && !db.is_absolute()
775 {
776 return Err(PomError::Config(format!(
777 "storage.db_path \"{}\" must be absolute",
778 db.display()
779 )));
780 }
781
782 Ok(config)
783 }
784
785 /// Resolve where this instance's database lives, and say how it was decided.
786 ///
787 /// Configured wins over the environment. The unconfigured path reads
788 /// `XDG_DATA_HOME`, which is how `pom serve` under systemd and `pom test` in
789 /// a shell came to open two different databases on the same host: the unit
790 /// sets the variable and an interactive login does not.
791 pub fn db_path(&self) -> Result<DbLocation> {
792 match &self.storage.db_path {
793 Some(path) => Ok(DbLocation {
794 path: path.clone(),
795 source: DbPathSource::Config,
796 }),
797 None => Ok(DbLocation {
798 path: xdg_db_path()?,
799 source: DbPathSource::XdgDataHome,
800 }),
801 }
802 }
803
804 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
805 self.targets.get(name)
806 }
807
808 pub fn target_names(&self) -> Vec<String> {
809 let mut names: Vec<_> = self.targets.keys().cloned().collect();
810 names.sort();
811 names
812 }
813
814 pub fn instance_name(&self) -> String {
815 self.instance.name.clone().unwrap_or_else(|| {
816 hostname::get().map_or_else(
817 |_| "unknown".to_string(),
818 |h| h.to_string_lossy().into_owned(),
819 )
820 })
821 }
822 }
823
824 pub fn default_config_path() -> Result<PathBuf> {
825 let config_dir = dirs::config_dir()
826 .ok_or_else(|| PomError::Config("Could not determine config directory".into()));
827 Ok(config_dir?.join("pom").join("pom.toml"))
828 }
829
830 /// A resolved database location, carrying how it was resolved so an error can
831 /// name the thing the operator has to change.
832 #[derive(Debug, Clone, PartialEq, Eq)]
833 pub struct DbLocation {
834 pub path: PathBuf,
835 pub source: DbPathSource,
836 }
837
838 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
839 pub enum DbPathSource {
840 /// `storage.db_path` in the config file.
841 Config,
842 /// The XDG data directory, i.e. `XDG_DATA_HOME` or its per-platform default.
843 XdgDataHome,
844 }
845
846 impl DbLocation {
847 /// The directory the database and the instance ID share.
848 pub fn dir(&self) -> &Path {
849 self.path.parent().unwrap_or(Path::new("."))
850 }
851 }
852
853 impl std::fmt::Display for DbLocation {
854 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
855 write!(f, "{} (from {})", self.path.display(), self.source)
856 }
857 }
858
859 impl std::fmt::Display for DbPathSource {
860 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
861 match self {
862 Self::Config => f.write_str("storage.db_path"),
863 Self::XdgDataHome => f.write_str("the XDG data directory"),
864 }
865 }
866 }
867
868 /// The unconfigured database path: `$XDG_DATA_HOME/pom/pom.db` or the
869 /// per-platform equivalent. Resolution only — it creates nothing.
870 pub fn xdg_db_path() -> Result<PathBuf> {
871 let data_dir = dirs::data_local_dir()
872 .ok_or_else(|| PomError::Config("Could not determine data directory".into()))?;
873 Ok(data_dir.join("pom").join("pom.db"))
874 }
875
876 #[cfg(test)]
877 mod tests {
878 use super::*;
879
880 #[test]
881 fn parse_full_config() {
882 let toml = r#"
883 [serve]
884 interval_secs = 120
885 listen = "127.0.0.1:9100"
886 peer_heartbeat_secs = 30
887
888 [instance]
889 name = "hetzner"
890
891 [targets.mnw]
892 label = "MakeNotWork"
893 [targets.mnw.health]
894 url = "https://makenot.work/health"
895 timeout_secs = 5
896 [targets.mnw.tests]
897 ssh = "hetzner"
898 command = "cd /srv/mnw && ./ci.sh"
899
900 [peers.astra]
901 address = "100.0.0.1:9100"
902 on_missing = "alert"
903 grace_count = 5
904 "#;
905
906 let config: Config = toml::from_str(toml).unwrap();
907 assert_eq!(config.serve.interval_secs, 120);
908 assert_eq!(config.serve.listen, "127.0.0.1:9100");
909 assert_eq!(config.serve.peer_heartbeat_secs, 30);
910 assert_eq!(config.instance.name.as_deref(), Some("hetzner"));
911 assert_eq!(config.target_names(), vec!["mnw"]);
912
913 let mnw = config.get_target("mnw").unwrap();
914 assert_eq!(mnw.label, "MakeNotWork");
915 assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5);
916 assert_eq!(mnw.tests.as_ref().unwrap().ssh.as_deref(), Some("hetzner"));
917
918 let astra = config.peers.get("astra").unwrap();
919 assert_eq!(astra.address, "100.0.0.1:9100");
920 assert_eq!(astra.on_missing, OnMissing::Alert);
921 assert_eq!(astra.grace_count, Some(5));
922 }
923
924 #[test]
925 fn empty_config_uses_defaults() {
926 let config: Config = toml::from_str("").unwrap();
927 assert_eq!(config.serve.interval_secs, 300);
928 assert_eq!(config.serve.prune_days, 30);
929 assert_eq!(config.serve.listen, "127.0.0.1:9100");
930 assert_eq!(config.serve.peer_heartbeat_secs, 60);
931 assert!(config.targets.is_empty());
932 assert!(config.peers.is_empty());
933 assert!(config.instance.name.is_none());
934 }
935
936 #[test]
937 fn peer_on_missing_defaults_to_log() {
938 let toml = r#"
939 [peers.test]
940 address = "10.0.0.1:9100"
941 "#;
942 let config: Config = toml::from_str(toml).unwrap();
943 let peer = config.peers.get("test").unwrap();
944 assert_eq!(peer.on_missing, OnMissing::Log);
945 assert_eq!(peer.grace_count, None);
946 assert!(peer.token.is_none());
947 }
948
949 #[test]
950 fn peer_with_token() {
951 let toml = r#"
952 [peers.test]
953 address = "10.0.0.1:9100"
954 token = "peer-secret-123"
955 "#;
956 let config: Config = toml::from_str(toml).unwrap();
957 let peer = config.peers.get("test").unwrap();
958 assert_eq!(peer.token.as_deref(), Some("peer-secret-123"));
959 }
960
961 #[test]
962 fn serve_api_token_from_config() {
963 let toml = r#"
964 [serve]
965 api_token = "my-api-secret"
966 "#;
967 let config: Config = toml::from_str(toml).unwrap();
968 assert_eq!(config.serve.api_token.as_deref(), Some("my-api-secret"));
969 }
970
971 #[test]
972 fn serve_api_token_defaults_to_none() {
973 let config: Config = toml::from_str("").unwrap();
974 assert!(config.serve.api_token.is_none());
975 }
976
977 #[test]
978 fn instance_name_falls_back_to_hostname() {
979 let config: Config = toml::from_str("").unwrap();
980 let name = config.instance_name();
981 assert!(!name.is_empty());
982 }
983
984 #[test]
985 fn config_without_alerts_section() {
986 let config: Config = toml::from_str("").unwrap();
987 assert!(config.alerts.is_none());
988 }
989
990 #[test]
991 fn config_with_alerts_section() {
992 let toml = r#"
993 [alerts]
994 postmark_token = "test-token"
995 to = "alerts@example.com"
996 "#;
997 let config: Config = toml::from_str(toml).unwrap();
998 let alerts = config.alerts.unwrap();
999 assert_eq!(alerts.postmark_token.as_deref(), Some("test-token"));
1000 assert_eq!(alerts.to, "alerts@example.com");
1001 assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
1002 assert_eq!(alerts.cooldown_secs, 300);
1003 }
1004
1005 #[test]
1006 fn config_alerts_wam_token() {
1007 let toml = r#"
1008 [alerts]
1009 to = "alerts@example.com"
1010 wam_url = "http://wam.tailnet:9000"
1011 wam_token = "test-wam-token"
1012 "#;
1013 let config: Config = toml::from_str(toml).unwrap();
1014 let alerts = config.alerts.unwrap();
1015 assert_eq!(alerts.wam_url.as_deref(), Some("http://wam.tailnet:9000"));
1016 assert_eq!(alerts.wam_token.as_deref(), Some("test-wam-token"));
1017 }
1018
1019 #[test]
1020 fn config_alerts_wam_token_defaults_to_none() {
1021 let toml = r#"
1022 [alerts]
1023 to = "alerts@example.com"
1024 wam_url = "http://wam.tailnet:9000"
1025 "#;
1026 let config: Config = toml::from_str(toml).unwrap();
1027 assert!(config.alerts.unwrap().wam_token.is_none());
1028 }
1029
1030 #[test]
1031 fn config_with_tls() {
1032 let toml = r#"
1033 [targets.mnw]
1034 label = "MakeNotWork"
1035 [targets.mnw.tls]
1036 host = "makenot.work"
1037 port = 8443
1038 warn_days = 30
1039 "#;
1040 let config: Config = toml::from_str(toml).unwrap();
1041 let mnw = config.get_target("mnw").unwrap();
1042 let tls = mnw.tls.as_ref().unwrap();
1043 assert_eq!(tls.host, "makenot.work");
1044 assert_eq!(tls.port, 8443);
1045 assert_eq!(tls.warn_days, 30);
1046 }
1047
1048 #[test]
1049 fn config_tls_defaults() {
1050 let toml = r#"
1051 [targets.mnw]
1052 label = "MakeNotWork"
1053 [targets.mnw.tls]
1054 host = "makenot.work"
1055 "#;
1056 let config: Config = toml::from_str(toml).unwrap();
1057 let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap();
1058 assert_eq!(tls.port, 443);
1059 assert_eq!(tls.warn_days, 14);
1060 }
1061
1062 #[test]
1063 fn config_without_tls() {
1064 let toml = r#"
1065 [targets.mnw]
1066 label = "MakeNotWork"
1067 "#;
1068 let config: Config = toml::from_str(toml).unwrap();
1069 assert!(config.get_target("mnw").unwrap().tls.is_none());
1070 }
1071
1072 #[test]
1073 fn config_tls_check_interval_default() {
1074 let config: Config = toml::from_str("").unwrap();
1075 assert_eq!(config.serve.tls_check_interval_secs, 3600);
1076 }
1077
1078 #[test]
1079 fn config_tls_check_interval_custom() {
1080 let toml = r"
1081 [serve]
1082 tls_check_interval_secs = 1800
1083 ";
1084 let config: Config = toml::from_str(toml).unwrap();
1085 assert_eq!(config.serve.tls_check_interval_secs, 1800);
1086 }
1087
1088 #[test]
1089 fn config_with_health_expect() {
1090 let toml = r#"
1091 [targets.mnw]
1092 label = "MakeNotWork"
1093 [targets.mnw.health]
1094 url = "https://makenot.work/health"
1095 [targets.mnw.health.expect]
1096 status_code = 200
1097 body_contains = "operational"
1098 json_fields = { "status" = "operational", "checks.db" = "ok" }
1099 "#;
1100 let config: Config = toml::from_str(toml).unwrap();
1101 let expect = config
1102 .get_target("mnw")
1103 .unwrap()
1104 .health
1105 .as_ref()
1106 .unwrap()
1107 .expect
1108 .as_ref()
1109 .unwrap();
1110 assert_eq!(expect.status_code, Some(200));
1111 assert_eq!(expect.body_contains.as_deref(), Some("operational"));
1112 assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
1113 assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok");
1114 }
1115
1116 #[test]
1117 fn config_health_without_expect() {
1118 let toml = r#"
1119 [targets.mnw]
1120 label = "MakeNotWork"
1121 [targets.mnw.health]
1122 url = "https://makenot.work/health"
1123 "#;
1124 let config: Config = toml::from_str(toml).unwrap();
1125 assert!(
1126 config
1127 .get_target("mnw")
1128 .unwrap()
1129 .health
1130 .as_ref()
1131 .unwrap()
1132 .expect
1133 .is_none()
1134 );
1135 }
1136
1137 #[test]
1138 fn config_with_trending() {
1139 let toml = r#"
1140 [targets.mnw]
1141 label = "MakeNotWork"
1142 [targets.mnw.health]
1143 url = "https://makenot.work/health"
1144 [targets.mnw.health.trending]
1145 baseline_window_hours = 48
1146 spike_threshold = 1.5
1147 "#;
1148 let config: Config = toml::from_str(toml).unwrap();
1149 let trending = config
1150 .get_target("mnw")
1151 .unwrap()
1152 .health
1153 .as_ref()
1154 .unwrap()
1155 .trending
1156 .as_ref()
1157 .unwrap();
1158 assert_eq!(trending.baseline_window_hours, 48);
1159 assert!((trending.spike_threshold - 1.5).abs() < f64::EPSILON);
1160 }
1161
1162 #[test]
1163 fn config_trending_defaults() {
1164 let toml = r#"
1165 [targets.mnw]
1166 label = "MakeNotWork"
1167 [targets.mnw.health]
1168 url = "https://makenot.work/health"
1169 [targets.mnw.health.trending]
1170 "#;
1171 let config: Config = toml::from_str(toml).unwrap();
1172 let trending = config
1173 .get_target("mnw")
1174 .unwrap()
1175 .health
1176 .as_ref()
1177 .unwrap()
1178 .trending
1179 .as_ref()
1180 .unwrap();
1181 assert_eq!(trending.baseline_window_hours, 168);
1182 assert!((trending.spike_threshold - 2.0).abs() < f64::EPSILON);
1183 }
1184
1185 #[test]
1186 fn config_without_trending() {
1187 let toml = r#"
1188 [targets.mnw]
1189 label = "MakeNotWork"
1190 [targets.mnw.health]
1191 url = "https://makenot.work/health"
1192 "#;
1193 let config: Config = toml::from_str(toml).unwrap();
1194 assert!(
1195 config
1196 .get_target("mnw")
1197 .unwrap()
1198 .health
1199 .as_ref()
1200 .unwrap()
1201 .trending
1202 .is_none()
1203 );
1204 }
1205
1206 #[test]
1207 fn config_health_expect_empty() {
1208 let toml = r#"
1209 [targets.mnw]
1210 label = "MakeNotWork"
1211 [targets.mnw.health]
1212 url = "https://makenot.work/health"
1213 [targets.mnw.health.expect]
1214 "#;
1215 let config: Config = toml::from_str(toml).unwrap();
1216 let expect = config
1217 .get_target("mnw")
1218 .unwrap()
1219 .health
1220 .as_ref()
1221 .unwrap()
1222 .expect
1223 .as_ref()
1224 .unwrap();
1225 assert_eq!(expect.status_code, None);
1226 assert!(expect.json_fields.is_empty());
1227 assert_eq!(expect.body_contains, None);
1228 }
1229
1230 #[test]
1231 fn config_staleness_days_default() {
1232 let toml = r#"
1233 [targets.mnw]
1234 label = "MakeNotWork"
1235 [targets.mnw.tests]
1236 ssh = "host"
1237 command = "./ci.sh"
1238 "#;
1239 let config: Config = toml::from_str(toml).unwrap();
1240 assert_eq!(
1241 config
1242 .get_target("mnw")
1243 .unwrap()
1244 .tests
1245 .as_ref()
1246 .unwrap()
1247 .staleness_days,
1248 7
1249 );
1250 }
1251
1252 #[test]
1253 fn config_staleness_days_custom() {
1254 let toml = r#"
1255 [targets.mnw]
1256 label = "MakeNotWork"
1257 [targets.mnw.tests]
1258 ssh = "host"
1259 command = "./ci.sh"
1260 staleness_days = 14
1261 "#;
1262 let config: Config = toml::from_str(toml).unwrap();
1263 assert_eq!(
1264 config
1265 .get_target("mnw")
1266 .unwrap()
1267 .tests
1268 .as_ref()
1269 .unwrap()
1270 .staleness_days,
1271 14
1272 );
1273 }
1274
1275 #[test]
1276 fn config_with_alerts_custom_defaults() {
1277 let toml = r#"
1278 [alerts]
1279 to = "alerts@example.com"
1280 from = "Custom <custom@example.com>"
1281 cooldown_secs = 60
1282 "#;
1283 let config: Config = toml::from_str(toml).unwrap();
1284 let alerts = config.alerts.unwrap();
1285 assert!(alerts.postmark_token.is_none());
1286 assert_eq!(alerts.from, "Custom <custom@example.com>");
1287 assert_eq!(alerts.cooldown_secs, 60);
1288 }
1289
1290 #[test]
1291 fn config_expected_routes() {
1292 let toml = r#"
1293 [targets.mnw]
1294 label = "MakeNotWork"
1295 expected_routes = ["/", "/discover", "/login", "/docs"]
1296 [targets.mnw.health]
1297 url = "https://makenot.work/api/health"
1298 "#;
1299 let config: Config = toml::from_str(toml).unwrap();
1300 let mnw = config.get_target("mnw").unwrap();
1301 assert_eq!(
1302 mnw.expected_routes,
1303 vec!["/", "/discover", "/login", "/docs"]
1304 );
1305 }
1306
1307 #[test]
1308 fn config_expected_routes_default_empty() {
1309 let toml = r#"
1310 [targets.mnw]
1311 label = "MakeNotWork"
1312 "#;
1313 let config: Config = toml::from_str(toml).unwrap();
1314 assert!(config.get_target("mnw").unwrap().expected_routes.is_empty());
1315 }
1316
1317 #[test]
1318 fn config_route_check_interval_default() {
1319 let config: Config = toml::from_str("").unwrap();
1320 assert_eq!(config.serve.route_check_interval_secs, 300);
1321 }
1322
1323 #[test]
1324 fn config_route_check_interval_custom() {
1325 let toml = r"
1326 [serve]
1327 route_check_interval_secs = 600
1328 ";
1329 let config: Config = toml::from_str(toml).unwrap();
1330 assert_eq!(config.serve.route_check_interval_secs, 600);
1331 }
1332
1333 #[test]
1334 fn config_dns_check_interval_default() {
1335 let config: Config = toml::from_str("").unwrap();
1336 assert_eq!(config.serve.dns_check_interval_secs, 3600);
1337 }
1338
1339 #[test]
1340 fn config_dns_check_interval_custom() {
1341 let toml = r"
1342 [serve]
1343 dns_check_interval_secs = 1800
1344 ";
1345 let config: Config = toml::from_str(toml).unwrap();
1346 assert_eq!(config.serve.dns_check_interval_secs, 1800);
1347 }
1348
1349 #[test]
1350 fn config_with_dns_records() {
1351 let toml = r#"
1352 [targets.mnw]
1353 label = "MakeNotWork"
1354
1355 [[targets.mnw.dns]]
1356 name = "makenot.work"
1357 record_type = "A"
1358 expected = ["5.78.144.244"]
1359
1360 [[targets.mnw.dns]]
1361 name = "git.makenot.work"
1362 record_type = "A"
1363 expected = ["5.78.144.244"]
1364 "#;
1365 let config: Config = toml::from_str(toml).unwrap();
1366 let mnw = config.get_target("mnw").unwrap();
1367 assert_eq!(mnw.dns.len(), 2);
1368 assert_eq!(mnw.dns[0].name, "makenot.work");
1369 assert_eq!(mnw.dns[0].record_type, DnsRecordType::A);
1370 assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
1371 assert_eq!(mnw.dns[1].name, "git.makenot.work");
1372 }
1373
1374 #[test]
1375 fn config_dns_default_empty() {
1376 let toml = r#"
1377 [targets.mnw]
1378 label = "MakeNotWork"
1379 "#;
1380 let config: Config = toml::from_str(toml).unwrap();
1381 assert!(config.get_target("mnw").unwrap().dns.is_empty());
1382 }
1383
1384 #[test]
1385 fn config_with_whois() {
1386 let toml = r#"
1387 [targets.mnw]
1388 label = "MakeNotWork"
1389
1390 [targets.mnw.whois]
1391 domain = "makenot.work"
1392 warn_days = 60
1393 "#;
1394 let config: Config = toml::from_str(toml).unwrap();
1395 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
1396 assert_eq!(whois.domain, "makenot.work");
1397 assert_eq!(whois.warn_days, 60);
1398 }
1399
1400 #[test]
1401 fn config_whois_default_warn_days() {
1402 let toml = r#"
1403 [targets.mnw]
1404 label = "MakeNotWork"
1405
1406 [targets.mnw.whois]
1407 domain = "makenot.work"
1408 "#;
1409 let config: Config = toml::from_str(toml).unwrap();
1410 let whois = config.get_target("mnw").unwrap().whois.as_ref().unwrap();
1411 assert_eq!(whois.warn_days, 30);
1412 }
1413
1414 #[test]
1415 fn config_without_whois() {
1416 let toml = r#"
1417 [targets.mnw]
1418 label = "MakeNotWork"
1419 "#;
1420 let config: Config = toml::from_str(toml).unwrap();
1421 assert!(config.get_target("mnw").unwrap().whois.is_none());
1422 }
1423
1424 #[test]
1425 fn config_with_systemd() {
1426 let toml = r#"
1427 [targets.fw13]
1428 label = "fw13 daemons"
1429
1430 [targets.fw13.systemd]
1431 restart_threshold = 3
1432 interval_secs = 30
1433 check_failed = false
1434
1435 [[targets.fw13.systemd.units]]
1436 name = "sandod.service"
1437
1438 [[targets.fw13.systemd.units]]
1439 name = "bentod.service"
1440 user = true
1441 "#;
1442 let config: Config = toml::from_str(toml).unwrap();
1443 let sd = config.get_target("fw13").unwrap().systemd.as_ref().unwrap();
1444 assert_eq!(sd.restart_threshold, 3);
1445 assert_eq!(sd.interval_secs, 30);
1446 assert!(!sd.check_failed);
1447 assert_eq!(sd.units.len(), 2);
1448 assert_eq!(sd.units[0].name, "sandod.service");
1449 assert!(!sd.units[0].user, "system bus by default");
1450 assert_eq!(sd.units[1].name, "bentod.service");
1451 assert!(sd.units[1].user, "bentod is on the --user bus");
1452 }
1453
1454 #[test]
1455 fn config_systemd_defaults() {
1456 let toml = r#"
1457 [targets.fw13]
1458 label = "fw13 daemons"
1459 [targets.fw13.systemd]
1460 [[targets.fw13.systemd.units]]
1461 name = "pom.service"
1462 "#;
1463 let config: Config = toml::from_str(toml).unwrap();
1464 let sd = config.get_target("fw13").unwrap().systemd.as_ref().unwrap();
1465 assert!(sd.check_failed, "failed-unit sweep on by default");
1466 assert_eq!(sd.restart_threshold, 5);
1467 assert_eq!(sd.interval_secs, 60);
1468 }
1469
1470 #[test]
1471 fn config_without_systemd() {
1472 let toml = r#"
1473 [targets.mnw]
1474 label = "MakeNotWork"
1475 "#;
1476 let config: Config = toml::from_str(toml).unwrap();
1477 assert!(config.get_target("mnw").unwrap().systemd.is_none());
1478 }
1479
1480 #[test]
1481 fn defaults_systemd() {
1482 assert!(default_systemd_check_failed(), "failed sweep on by default");
1483 assert_eq!(default_systemd_restart_threshold(), 5);
1484 assert_eq!(default_systemd_interval(), 60, "1-minute liveness cadence");
1485 }
1486
1487 #[test]
1488 fn config_dashboard_default_false() {
1489 let config: Config = toml::from_str("").unwrap();
1490 assert!(!config.serve.dashboard);
1491 }
1492
1493 #[test]
1494 fn config_dashboard_enabled() {
1495 let toml = r"
1496 [serve]
1497 dashboard = true
1498 ";
1499 let config: Config = toml::from_str(toml).unwrap();
1500 assert!(config.serve.dashboard);
1501 }
1502
1503 #[test]
1504 fn config_expected_routes_without_slash_detected() {
1505 let toml = r#"
1506 [targets.mnw]
1507 label = "MakeNotWork"
1508 expected_routes = ["discover", "/login"]
1509 "#;
1510 let config: Config = toml::from_str(toml).unwrap();
1511 let bad_routes: Vec<_> = config
1512 .get_target("mnw")
1513 .unwrap()
1514 .expected_routes
1515 .iter()
1516 .filter(|r| !r.starts_with('/'))
1517 .collect();
1518 assert_eq!(bad_routes, vec!["discover"]);
1519 }
1520
1521 #[test]
1522 fn config_whois_check_interval_default() {
1523 let config: Config = toml::from_str("").unwrap();
1524 assert_eq!(config.serve.whois_check_interval_secs, 86400);
1525 }
1526
1527 #[test]
1528 fn config_whois_check_interval_custom() {
1529 let toml = r"
1530 [serve]
1531 whois_check_interval_secs = 43200
1532 ";
1533 let config: Config = toml::from_str(toml).unwrap();
1534 assert_eq!(config.serve.whois_check_interval_secs, 43200);
1535 }
1536
1537 // Defaults-pin tests, every `default_*` constant function is pinned to
1538 // its expected value. Catches `replace fn -> u64 with 0/1` mutations and
1539 // accidental drift when defaults are tweaked. These constants encode
1540 // operational policy (check cadence, retention, etc.) so changes should
1541 // be deliberate.
1542
1543 #[test]
1544 fn defaults_numeric_intervals() {
1545 assert_eq!(default_peer_heartbeat(), 60, "peer heartbeat = 1 min");
1546 assert_eq!(default_tls_check_interval(), 3600, "tls = 1 hour");
1547 assert_eq!(default_route_check_interval(), 300, "routes = 5 min");
1548 assert_eq!(default_dns_check_interval(), 3600, "dns = 1 hour");
1549 assert_eq!(default_cors_check_interval(), 3600, "cors = 1 hour");
1550 assert_eq!(default_whois_check_interval(), 86400, "whois = 24 hours");
1551 assert_eq!(default_serve_interval(), 300, "serve = 5 min");
1552 assert_eq!(default_prune_days(), 30, "prune = 30 days");
1553 }
1554
1555 #[test]
1556 fn defaults_listen_address() {
1557 assert_eq!(default_listen(), "127.0.0.1:9100");
1558 }
1559
1560 #[test]
1561 fn defaults_warn_thresholds() {
1562 assert_eq!(default_whois_warn_days(), 30, "whois 30 days lead time");
1563 assert_eq!(default_tls_warn_days(), 14, "tls 14 days lead time");
1564 assert_eq!(default_tls_port(), 443);
1565 }
1566
1567 #[test]
1568 fn defaults_cors() {
1569 assert_eq!(default_cors_method(), "PUT");
1570 assert_eq!(default_max_age_hours(), 25, "25h allows cron drift");
1571 }
1572
1573 #[test]
1574 fn defaults_backup() {
1575 assert_eq!(default_backup_interval(), 3600, "hourly backup check");
1576 }
1577
1578 #[test]
1579 fn defaults_ssh_banner() {
1580 assert_eq!(default_ssh_banner_port(), 22);
1581 assert_eq!(default_ssh_banner_timeout(), 5);
1582 }
1583
1584 #[test]
1585 fn defaults_latency_baseline() {
1586 assert_eq!(default_baseline_window_hours(), 168, "7 days");
1587 // Spike threshold compares as f64; pin with bit-exact match.
1588 assert_eq!(default_spike_threshold().to_bits(), 2.0_f64.to_bits());
1589 }
1590
1591 #[test]
1592 fn defaults_health_and_test_timeouts() {
1593 assert_eq!(default_health_timeout(), 10);
1594 assert_eq!(default_test_timeout(), 600, "10-minute CI suite budget");
1595 assert_eq!(default_staleness_days(), 7);
1596 }
1597
1598 #[test]
1599 fn defaults_alerts() {
1600 assert_eq!(default_alert_from(), "PoM Alerts <pom-alerts@makenot.work>");
1601 assert_eq!(default_cooldown_secs(), 300, "5-minute alert cooldown");
1602 }
1603
1604 // Config method tests
1605
1606 #[test]
1607 fn instance_name_returns_configured_value() {
1608 let toml = r#"
1609 [serve]
1610 [instance]
1611 name = "test-host"
1612 [targets.x]
1613 label = "X"
1614 [targets.x.health]
1615 url = "https://example.com"
1616 "#;
1617 let config: Config = toml::from_str(toml).unwrap();
1618 assert_eq!(config.instance_name(), "test-host");
1619 }
1620
1621 #[test]
1622 fn instance_name_falls_back_to_non_empty() {
1623 // When `name` is None, fall back to hostname or "unknown", must not be
1624 // empty regardless. Catches the `instance_name -> String with "xyzzy"`
1625 // mutant and the empty-string variant.
1626 let toml = r#"
1627 [serve]
1628 [instance]
1629 [targets.x]
1630 label = "X"
1631 [targets.x.health]
1632 url = "https://example.com"
1633 "#;
1634 let config: Config = toml::from_str(toml).unwrap();
1635 let name = config.instance_name();
1636 assert!(!name.is_empty(), "fallback must produce a non-empty name");
1637 // It also must not be the cargo-mutants sentinel.
1638 assert_ne!(name, "xyzzy");
1639 }
1640
1641 #[test]
1642 fn default_config_path_ends_in_pom_toml() {
1643 // The exact dir varies per OS, but the suffix is stable.
1644 // Catches `default_config_path -> Ok(Default::default())` (which would
1645 // return an empty PathBuf and fail the ends_with check).
1646 let path = default_config_path().unwrap();
1647 assert!(
1648 path.ends_with("pom/pom.toml") || path.ends_with("pom\\pom.toml"),
1649 "expected .../pom/pom.toml, got {path:?}"
1650 );
1651 }
1652
1653 #[test]
1654 fn xdg_db_path_ends_in_pom_db() {
1655 // Same rationale as default_config_path: the exact dir varies per OS,
1656 // the suffix is stable.
1657 let path = xdg_db_path().unwrap();
1658 assert!(
1659 path.ends_with("pom/pom.db") || path.ends_with("pom\\pom.db"),
1660 "expected .../pom/pom.db, got {path:?}"
1661 );
1662 }
1663
1664 #[test]
1665 fn configured_db_path_wins_over_the_environment() {
1666 // The whole point of storage.db_path: the service unit and a hand-run
1667 // CLI must resolve to the same file whatever XDG_DATA_HOME says.
1668 let toml = r#"
1669 [serve]
1670 [storage]
1671 db_path = "/var/lib/pom/pom.db"
1672 "#;
1673 let config: Config = toml::from_str(toml).unwrap();
1674 let loc = config.db_path().unwrap();
1675 assert_eq!(loc.path, PathBuf::from("/var/lib/pom/pom.db"));
1676 assert_eq!(loc.source, DbPathSource::Config);
1677 assert_eq!(loc.dir(), Path::new("/var/lib/pom"));
1678 }
1679
1680 #[test]
1681 fn unconfigured_db_path_falls_back_to_xdg() {
1682 let toml = "[serve]\n";
1683 let config: Config = toml::from_str(toml).unwrap();
1684 let loc = config.db_path().unwrap();
1685 assert_eq!(loc.path, xdg_db_path().unwrap());
1686 assert_eq!(loc.source, DbPathSource::XdgDataHome);
1687 }
1688
1689 #[test]
1690 fn config_load_rejects_relative_db_path() {
1691 let toml = r#"
1692 [serve]
1693 [storage]
1694 db_path = "pom.db"
1695 "#;
1696 let dir = std::env::temp_dir().join(format!("pom-cfg-{}", std::process::id()));
1697 std::fs::create_dir_all(&dir).unwrap();
1698 let path = dir.join("relative-db.toml");
1699 std::fs::write(&path, toml).unwrap();
1700 let err = Config::load(Some(&path)).unwrap_err();
1701 assert!(
1702 err.to_string().contains("must be absolute"),
1703 "expected an absolute-path complaint, got {err}"
1704 );
1705 std::fs::remove_file(&path).ok();
1706 }
1707
1708 #[test]
1709 fn config_load_rejects_route_without_leading_slash() {
1710 // Catches `delete ! in Config::load` (L431): without the `!`, the
1711 // validator would only reject routes that DO start with '/', wrong.
1712 let toml = r#"
1713 [serve]
1714 [targets.bad]
1715 label = "Bad"
1716 expected_routes = ["no-leading-slash"]
1717 [targets.bad.health]
1718 url = "https://example.com"
1719 "#;
1720 let tmp = std::env::temp_dir().join(format!("pom_test_{}.toml", std::process::id()));
1721 std::fs::write(&tmp, toml).unwrap();
1722 let result = Config::load(Some(tmp.as_path()));
1723 let _ = std::fs::remove_file(&tmp);
1724 assert!(
1725 matches!(result, Err(PomError::Config(_))),
1726 "expected Config error rejecting bad route; got {result:?}"
1727 );
1728 }
1729
1730 #[test]
1731 fn config_load_rejects_expected_routes_without_health() {
1732 // expected_routes needs [health] for base-URL derivation, a target that
1733 // lists routes but has no health config would silently never check them.
1734 let toml = r#"
1735 [serve]
1736 [targets.noroutes]
1737 label = "NoHealth"
1738 expected_routes = ["/status"]
1739 "#;
1740 let tmp = std::env::temp_dir().join(format!("pom_test_nh_{}.toml", std::process::id()));
1741 std::fs::write(&tmp, toml).unwrap();
1742 let result = Config::load(Some(tmp.as_path()));
1743 let _ = std::fs::remove_file(&tmp);
1744 assert!(
1745 matches!(result, Err(PomError::Config(_))),
1746 "expected_routes without [health] must be rejected; got {result:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn debug_redacts_secrets() {
1752 let alerts = AlertConfig {
1753 postmark_token: Some("super-secret-token".to_string()),
1754 to: "a@b.c".to_string(),
1755 from: "PoM".to_string(),
1756 cooldown_secs: 300,
1757 wam_url: None,
1758 wam_token: Some("super-secret-wam-token".to_string()),
1759 mnw_url: None,
1760 alerts_ingest_token: Some("super-secret-ingest-token".to_string()),
1761 };
1762 let rendered = format!("{alerts:?}");
1763 assert!(
1764 !rendered.contains("super-secret-token"),
1765 "token must be redacted in Debug"
1766 );
1767 assert!(
1768 !rendered.contains("super-secret-wam-token"),
1769 "wam token must be redacted in Debug"
1770 );
1771 assert!(
1772 !rendered.contains("super-secret-ingest-token"),
1773 "ingest token must be redacted in Debug"
1774 );
1775 assert!(rendered.contains("***"), "redaction marker expected");
1776
1777 let serve = ServeConfig {
1778 api_token: Some("api-secret-xyz".to_string()),
1779 ..ServeConfig::default()
1780 };
1781 assert!(
1782 !format!("{serve:?}").contains("api-secret-xyz"),
1783 "api_token must be redacted"
1784 );
1785 }
1786 }
1787