Skip to main content

max / makenotwork

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