max / makenotwork
1 file changed,
+104 insertions,
-13 deletions
| @@ -26,7 +26,9 @@ pub struct Config { | |||
| 26 | 26 | pub alerts: Option<AlertConfig>, | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | - | #[derive(Debug, Clone, Deserialize)] | |
| 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)] | |
| 30 | 32 | pub struct AlertConfig { | |
| 31 | 33 | /// Postmark server API token. Can also be set via `POM_POSTMARK_TOKEN` env var. | |
| 32 | 34 | pub postmark_token: Option<String>, | |
| @@ -42,6 +44,18 @@ pub struct AlertConfig { | |||
| 42 | 44 | pub wam_url: Option<String>, | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 47 | + | impl std::fmt::Debug for AlertConfig { | |
| 48 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 49 | + | f.debug_struct("AlertConfig") | |
| 50 | + | .field("postmark_token", &self.postmark_token.as_ref().map(|_| "***")) | |
| 51 | + | .field("to", &self.to) | |
| 52 | + | .field("from", &self.from) | |
| 53 | + | .field("cooldown_secs", &self.cooldown_secs) | |
| 54 | + | .field("wam_url", &self.wam_url) | |
| 55 | + | .finish() | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 45 | 59 | #[derive(Debug, Clone, Default, Deserialize)] | |
| 46 | 60 | pub struct InstanceConfig { | |
| 47 | 61 | /// Human-readable instance name. Falls back to OS hostname if unset. | |
| @@ -50,7 +64,7 @@ pub struct InstanceConfig { | |||
| 50 | 64 | pub id: Option<String>, | |
| 51 | 65 | } | |
| 52 | 66 | ||
| 53 | - | #[derive(Debug, Clone, Deserialize)] | |
| 67 | + | #[derive(Clone, Deserialize)] | |
| 54 | 68 | pub struct PeerConfig { | |
| 55 | 69 | /// Network address of the peer (host:port). | |
| 56 | 70 | pub address: String, | |
| @@ -64,7 +78,19 @@ pub struct PeerConfig { | |||
| 64 | 78 | pub token: Option<String>, | |
| 65 | 79 | } | |
| 66 | 80 | ||
| 67 | - | #[derive(Debug, Clone, Deserialize)] | |
| 81 | + | impl std::fmt::Debug for PeerConfig { | |
| 82 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 83 | + | f.debug_struct("PeerConfig") | |
| 84 | + | .field("address", &self.address) | |
| 85 | + | .field("on_missing", &self.on_missing) | |
| 86 | + | .field("grace_count", &self.grace_count) | |
| 87 | + | .field("token", &self.token.as_ref().map(|_| "***")) | |
| 88 | + | .finish() | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Manual Debug (below) redacts `api_token`; keep field lists in sync. | |
| 93 | + | #[derive(Clone, Deserialize)] | |
| 68 | 94 | pub struct ServeConfig { | |
| 69 | 95 | /// Seconds between health check cycles for all targets. | |
| 70 | 96 | #[serde(default = "default_serve_interval")] | |
| @@ -107,6 +133,25 @@ pub struct ServeConfig { | |||
| 107 | 133 | pub confirmations: u32, | |
| 108 | 134 | } | |
| 109 | 135 | ||
| 136 | + | impl std::fmt::Debug for ServeConfig { | |
| 137 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 138 | + | f.debug_struct("ServeConfig") | |
| 139 | + | .field("interval_secs", &self.interval_secs) | |
| 140 | + | .field("prune_days", &self.prune_days) | |
| 141 | + | .field("listen", &self.listen) | |
| 142 | + | .field("peer_heartbeat_secs", &self.peer_heartbeat_secs) | |
| 143 | + | .field("tls_check_interval_secs", &self.tls_check_interval_secs) | |
| 144 | + | .field("route_check_interval_secs", &self.route_check_interval_secs) | |
| 145 | + | .field("dns_check_interval_secs", &self.dns_check_interval_secs) | |
| 146 | + | .field("cors_check_interval_secs", &self.cors_check_interval_secs) | |
| 147 | + | .field("whois_check_interval_secs", &self.whois_check_interval_secs) | |
| 148 | + | .field("api_token", &self.api_token.as_ref().map(|_| "***")) | |
| 149 | + | .field("dashboard", &self.dashboard) | |
| 150 | + | .field("confirmations", &self.confirmations) | |
| 151 | + | .finish() | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 110 | 155 | fn default_confirmations() -> u32 { | |
| 111 | 156 | 2 | |
| 112 | 157 | } | |
| @@ -428,18 +473,16 @@ impl Config { | |||
| 428 | 473 | let contents = std::fs::read_to_string(&config_path)?; | |
| 429 | 474 | let mut config: Config = toml::from_str(&contents)?; | |
| 430 | 475 | ||
| 431 | - | // Allow postmark_token from environment variable (preferred over config file) | |
| 432 | - | if let Some(ref mut alerts) = config.alerts | |
| 433 | - | && alerts.postmark_token.is_none() | |
| 434 | - | && let Ok(token) = std::env::var("POM_POSTMARK_TOKEN") | |
| 476 | + | // Environment variables take precedence over the config file, so a token | |
| 477 | + | // can be rotated without editing pom.toml. This is the documented | |
| 478 | + | // behavior; the earlier code applied env only when the config value was | |
| 479 | + | // absent, so a stale pom.toml token silently won over the env rotation. | |
| 480 | + | if let Ok(token) = std::env::var("POM_POSTMARK_TOKEN") | |
| 481 | + | && let Some(ref mut alerts) = config.alerts | |
| 435 | 482 | { | |
| 436 | 483 | alerts.postmark_token = Some(token); | |
| 437 | 484 | } | |
| 438 | - | ||
| 439 | - | // Allow api_token from environment variable (preferred over config file) | |
| 440 | - | if config.serve.api_token.is_none() | |
| 441 | - | && let Ok(token) = std::env::var("POM_API_TOKEN") | |
| 442 | - | { | |
| 485 | + | if let Ok(token) = std::env::var("POM_API_TOKEN") { | |
| 443 | 486 | config.serve.api_token = Some(token); | |
| 444 | 487 | } | |
| 445 | 488 | ||
| @@ -454,8 +497,16 @@ impl Config { | |||
| 454 | 497 | } | |
| 455 | 498 | } | |
| 456 | 499 | ||
| 457 | - | // Validate expected_routes paths start with '/' | |
| 500 | + | // Validate expected_routes: they need `health` for base-URL derivation, | |
| 501 | + | // so a target that lists routes but has no `[health]` would silently never | |
| 502 | + | // check them. Fail loudly instead of no-op'ing. Each path must be rooted. | |
| 458 | 503 | for (name, target) in &config.targets { | |
| 504 | + | if !target.expected_routes.is_empty() && target.health.is_none() { | |
| 505 | + | return Err(PomError::Config(format!( | |
| 506 | + | "target {name}: expected_routes requires a [health] config \ | |
| 507 | + | (the route base URL is derived from it)" | |
| 508 | + | ))); | |
| 509 | + | } | |
| 459 | 510 | for route in &target.expected_routes { | |
| 460 | 511 | if !route.starts_with('/') { | |
| 461 | 512 | return Err(PomError::Config(format!( | |
| @@ -1150,4 +1201,44 @@ url = "https://example.com" | |||
| 1150 | 1201 | "expected Config error rejecting bad route; got {result:?}" | |
| 1151 | 1202 | ); | |
| 1152 | 1203 | } | |
| 1204 | + | ||
| 1205 | + | #[test] | |
| 1206 | + | fn config_load_rejects_expected_routes_without_health() { | |
| 1207 | + | // expected_routes needs [health] for base-URL derivation — a target that | |
| 1208 | + | // lists routes but has no health config would silently never check them. | |
| 1209 | + | let toml = r#" | |
| 1210 | + | [serve] | |
| 1211 | + | [targets.noroutes] | |
| 1212 | + | label = "NoHealth" | |
| 1213 | + | expected_routes = ["/status"] | |
| 1214 | + | "#; | |
| 1215 | + | let tmp = std::env::temp_dir().join(format!("pom_test_nh_{}.toml", std::process::id())); | |
| 1216 | + | std::fs::write(&tmp, toml).unwrap(); | |
| 1217 | + | let result = Config::load(Some(tmp.as_path())); | |
| 1218 | + | let _ = std::fs::remove_file(&tmp); | |
| 1219 | + | assert!( | |
| 1220 | + | matches!(result, Err(PomError::Config(_))), | |
| 1221 | + | "expected_routes without [health] must be rejected; got {result:?}" | |
| 1222 | + | ); | |
| 1223 | + | } | |
| 1224 | + | ||
| 1225 | + | #[test] | |
| 1226 | + | fn debug_redacts_secrets() { | |
| 1227 | + | let alerts = AlertConfig { | |
| 1228 | + | postmark_token: Some("super-secret-token".to_string()), | |
| 1229 | + | to: "a@b.c".to_string(), | |
| 1230 | + | from: "PoM".to_string(), | |
| 1231 | + | cooldown_secs: 300, | |
| 1232 | + | wam_url: None, | |
| 1233 | + | }; | |
| 1234 | + | let rendered = format!("{alerts:?}"); | |
| 1235 | + | assert!(!rendered.contains("super-secret-token"), "token must be redacted in Debug"); | |
| 1236 | + | assert!(rendered.contains("***"), "redaction marker expected"); | |
| 1237 | + | ||
| 1238 | + | let serve = ServeConfig { | |
| 1239 | + | api_token: Some("api-secret-xyz".to_string()), | |
| 1240 | + | ..ServeConfig::default() | |
| 1241 | + | }; | |
| 1242 | + | assert!(!format!("{serve:?}").contains("api-secret-xyz"), "api_token must be redacted"); | |
| 1243 | + | } | |
| 1153 | 1244 | } |