Skip to main content

seer_core/
config.rs

1//! Configuration file support for Seer.
2//!
3//! Loads settings from `~/.seer/config.toml` (a missing or malformed file
4//! falls back to built-in defaults; values are clamped to safe ranges).
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10use tracing::debug;
11
12/// Upper clamp for `timeouts.ct_secs`; the CT-log client is built with it so
13/// any configured per-request timeout fits under it.
14pub(crate) const MAX_CT_SECS: u64 = 120;
15
16/// Seer configuration loaded from `~/.seer/config.toml`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct SeerConfig {
20    /// Default output format ("human", "json", "yaml")
21    pub output_format: String,
22    /// Default DNS nameserver spec. Accepts a bare IP/hostname with an
23    /// optional port (UDP, e.g. `"8.8.8.8"`, `"dns.google"`,
24    /// `"[2001:4860:4860::8888]:53"`), `tls://host[:port]` for DNS over TLS
25    /// (default port 853, e.g. `"tls://1.1.1.1"`), or
26    /// `https://host[:port][/path]` for DNS over HTTPS (default port 443,
27    /// default path `/dns-query`, e.g. `"https://cloudflare-dns.com/dns-query"`).
28    /// Parsed by [`crate::dns::NameserverSpec`]; invalid specs surface as an
29    /// error on first use rather than at load time.
30    pub nameserver: Option<String>,
31    /// Timeout settings
32    pub timeouts: TimeoutConfig,
33    /// Bulk operation settings
34    pub bulk: BulkConfig,
35    /// Watchlist settings (`seer watch`)
36    pub watch: WatchConfig,
37    /// TUI settings (`seer tui`)
38    pub tui: TuiConfig,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(default)]
43pub struct TimeoutConfig {
44    /// WHOIS query timeout in seconds
45    pub whois_secs: u64,
46    /// RDAP query timeout in seconds
47    pub rdap_secs: u64,
48    /// DNS query timeout in seconds
49    pub dns_secs: u64,
50    /// HTTP/SSL check timeout in seconds
51    pub http_secs: u64,
52    /// Certificate Transparency log request timeout in seconds (subdomain
53    /// enumeration). Separate from `http_secs`: CT aggregators routinely take
54    /// tens of seconds to answer for a busy domain, far past a site probe.
55    pub ct_secs: u64,
56    /// Overall deadline for one `seer audit` run, in seconds: every check
57    /// still running when it passes is reported incomplete.
58    pub audit_secs: u64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct BulkConfig {
64    /// Default concurrency for bulk operations
65    pub concurrency: usize,
66    /// Rate limit delay in milliseconds between operations
67    pub rate_limit_ms: u64,
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71#[serde(default)]
72pub struct WatchConfig {
73    /// Webhook URL the `seer watch` check-all report is POSTed to as JSON.
74    /// `None` (the default) disables delivery; the CLI `--webhook` flag
75    /// overrides this value.
76    pub webhook_url: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(default)]
81pub struct TuiConfig {
82    /// TUI color theme name. Known names: "frappe" (default) and "latte";
83    /// read through [`SeerConfig::tui_theme`], which falls back to "frappe"
84    /// on anything unrecognized so config parsing stays tolerant.
85    pub theme: String,
86}
87
88impl Default for TuiConfig {
89    fn default() -> Self {
90        Self {
91            theme: "frappe".to_string(),
92        }
93    }
94}
95
96impl Default for SeerConfig {
97    fn default() -> Self {
98        Self {
99            output_format: "human".to_string(),
100            nameserver: None,
101            timeouts: TimeoutConfig::default(),
102            bulk: BulkConfig::default(),
103            watch: WatchConfig::default(),
104            tui: TuiConfig::default(),
105        }
106    }
107}
108
109impl Default for TimeoutConfig {
110    fn default() -> Self {
111        Self {
112            whois_secs: 15,
113            // Matches the RDAP client's own DEFAULT_TIMEOUT (15s) and the
114            // documented value; the client uses 15s, so the default must too.
115            rdap_secs: 15,
116            dns_secs: 5,
117            http_secs: 10,
118            ct_secs: 30,
119            audit_secs: 60,
120        }
121    }
122}
123
124impl Default for BulkConfig {
125    fn default() -> Self {
126        Self {
127            concurrency: 10,
128            rate_limit_ms: 100,
129        }
130    }
131}
132
133impl SeerConfig {
134    /// Returns the path to the config file (`~/.seer/config.toml`).
135    pub fn config_path() -> Option<PathBuf> {
136        std::env::home_dir().map(|home| home.join(".seer").join("config.toml"))
137    }
138
139    /// Loads config from `~/.seer/config.toml`, falling back to defaults if not found.
140    pub fn load() -> Self {
141        let Some(path) = Self::config_path() else {
142            return Self::default();
143        };
144
145        if !path.exists() {
146            return Self::default();
147        }
148
149        match std::fs::read_to_string(&path) {
150            Ok(content) => Self::parse_or_default(&content, &path),
151            Err(e) => {
152                debug!(?path, error = %e, "Could not read config, using defaults");
153                Self::default()
154            }
155        }
156    }
157
158    /// Parses config file content, falling back to defaults on malformed
159    /// TOML. The failure is reported on stderr in addition to `tracing` —
160    /// at the default log level a `warn!` is invisible, and `seer config`
161    /// would then display defaults as though they came from the file.
162    fn parse_or_default(content: &str, path: &std::path::Path) -> Self {
163        match toml::from_str::<SeerConfig>(content) {
164            Ok(config) => {
165                debug!(?path, "Loaded config");
166                config.clamped()
167            }
168            Err(e) => {
169                tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
170                // Config loads several times per process (startup + per-command
171                // and per-client construction); warn on stderr only once — the
172                // CLI always loads first at startup, so the warning also lands
173                // before the TUI enters its alternate screen.
174                static WARN_ONCE: std::sync::Once = std::sync::Once::new();
175                WARN_ONCE.call_once(|| {
176                    eprintln!(
177                        "Warning: ignoring malformed config file {} (using defaults):\n{}",
178                        path.display(),
179                        e
180                    );
181                });
182                Self::default()
183            }
184        }
185    }
186
187    /// Clamp loaded values into sane ranges. Without this, a user
188    /// (accidentally or maliciously) setting `bulk.concurrency = 0` would
189    /// hand `buffer_unordered(0)` to every bulk fan-out, which never starts a
190    /// task and hangs; `bulk.concurrency = 10000` would spawn thousands of
191    /// concurrent connections. Timeouts of `0` would error every network call
192    /// immediately. The bounds are per-protocol (a config file can't bypass
193    /// them): concurrency 1–50; whois/rdap timeouts 1–300s; dns 1–60s;
194    /// http and CT 1–120s; audit 10–300s.
195    fn clamped(mut self) -> Self {
196        self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
197        // A multi-day per-operation delay would stall bulk runs indefinitely;
198        // cap at 60s (0 is valid — no inter-operation delay).
199        self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
200        self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
201        self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
202        self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
203        self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
204        self.timeouts.ct_secs = self.timeouts.ct_secs.clamp(1, MAX_CT_SECS);
205        self.timeouts.audit_secs = self.timeouts.audit_secs.clamp(10, 300);
206        self
207    }
208
209    /// Returns the WHOIS timeout as a Duration.
210    pub fn whois_timeout(&self) -> Duration {
211        Duration::from_secs(self.timeouts.whois_secs)
212    }
213
214    /// Returns the RDAP timeout as a Duration.
215    pub fn rdap_timeout(&self) -> Duration {
216        Duration::from_secs(self.timeouts.rdap_secs)
217    }
218
219    /// Returns the DNS timeout as a Duration.
220    pub fn dns_timeout(&self) -> Duration {
221        Duration::from_secs(self.timeouts.dns_secs)
222    }
223
224    /// Returns the HTTP timeout as a Duration.
225    pub fn http_timeout(&self) -> Duration {
226        Duration::from_secs(self.timeouts.http_secs)
227    }
228
229    /// Returns the Certificate Transparency log request timeout as a Duration.
230    pub fn ct_timeout(&self) -> Duration {
231        Duration::from_secs(self.timeouts.ct_secs)
232    }
233
234    /// Returns the overall `seer audit` deadline as a Duration.
235    pub fn audit_timeout(&self) -> Duration {
236        Duration::from_secs(self.timeouts.audit_secs)
237    }
238
239    /// Returns the inter-operation bulk rate-limit delay as a Duration.
240    pub fn bulk_rate_limit(&self) -> Duration {
241        Duration::from_millis(self.bulk.rate_limit_ms)
242    }
243
244    /// Returns the configured TUI theme, clamped to a known canonical name.
245    ///
246    /// Accepts "frappe" / "frappé" / "latte" (case-insensitive, trimmed);
247    /// anything else falls back to "frappe" so a typo'd config degrades to
248    /// the default look instead of erroring. Keep the accepted set in sync
249    /// with the TUI's `Theme::from_name` (seer-cli/src/tui/theme.rs).
250    pub fn tui_theme(&self) -> &'static str {
251        match self.tui.theme.trim().to_lowercase().as_str() {
252            "latte" => "latte",
253            _ => "frappe",
254        }
255    }
256
257    /// Generates a default config file content as TOML.
258    pub fn default_toml() -> String {
259        toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_default_config() {
269        let config = SeerConfig::default();
270        assert_eq!(config.output_format, "human");
271        assert_eq!(config.timeouts.whois_secs, 15);
272        assert_eq!(config.timeouts.rdap_secs, 15);
273        assert_eq!(config.timeouts.dns_secs, 5);
274        assert_eq!(config.bulk.concurrency, 10);
275    }
276
277    #[test]
278    fn test_parse_config_toml() {
279        let toml_str = r#"
280output_format = "json"
281nameserver = "1.1.1.1"
282
283[timeouts]
284whois_secs = 20
285rdap_secs = 45
286
287[bulk]
288concurrency = 20
289"#;
290        let config: SeerConfig = toml::from_str(toml_str).unwrap();
291        assert_eq!(config.output_format, "json");
292        assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
293        assert_eq!(config.timeouts.whois_secs, 20);
294        assert_eq!(config.timeouts.rdap_secs, 45);
295        assert_eq!(config.timeouts.dns_secs, 5); // default
296        assert_eq!(config.bulk.concurrency, 20);
297    }
298
299    #[test]
300    fn nameserver_accepts_dot_doh_specs_opaquely() {
301        // The nameserver key is an opaque spec string: DoT/DoH forms load
302        // unchanged and are validated by NameserverSpec::parse on first use,
303        // not at config-load time.
304        let config: SeerConfig = toml::from_str(r#"nameserver = "tls://1.1.1.1""#).unwrap();
305        assert_eq!(config.nameserver.as_deref(), Some("tls://1.1.1.1"));
306    }
307
308    #[test]
309    fn test_default_toml_roundtrip() {
310        let toml_str = SeerConfig::default_toml();
311        let config: SeerConfig = toml::from_str(&toml_str).unwrap();
312        assert_eq!(config.output_format, "human");
313    }
314
315    #[test]
316    fn test_timeout_durations() {
317        let config = SeerConfig::default();
318        assert_eq!(config.whois_timeout(), Duration::from_secs(15));
319        assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
320        assert_eq!(config.dns_timeout(), Duration::from_secs(5));
321        assert_eq!(config.http_timeout(), Duration::from_secs(10));
322        assert_eq!(config.ct_timeout(), Duration::from_secs(30));
323    }
324
325    #[test]
326    fn audit_timeout_defaults_and_is_clamped() {
327        let path = std::path::Path::new("~/.seer/config.toml");
328        assert_eq!(
329            SeerConfig::default().audit_timeout(),
330            Duration::from_secs(60)
331        );
332        let config = SeerConfig::parse_or_default("[timeouts]\naudit_secs = 90\n", path);
333        assert_eq!(config.audit_timeout(), Duration::from_secs(90));
334        let config = SeerConfig::parse_or_default("[timeouts]\naudit_secs = 1\n", path);
335        assert_eq!(config.audit_timeout(), Duration::from_secs(10));
336        let config = SeerConfig::parse_or_default("[timeouts]\naudit_secs = 9999\n", path);
337        assert_eq!(config.audit_timeout(), Duration::from_secs(300));
338    }
339
340    #[test]
341    fn ct_timeout_parses_and_is_clamped() {
342        let path = std::path::Path::new("~/.seer/config.toml");
343        let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 45\n", path);
344        assert_eq!(config.ct_timeout(), Duration::from_secs(45));
345        let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 0\n", path);
346        assert_eq!(config.ct_timeout(), Duration::from_secs(1));
347        let config = SeerConfig::parse_or_default("[timeouts]\nct_secs = 9999\n", path);
348        assert_eq!(config.ct_timeout(), Duration::from_secs(MAX_CT_SECS));
349    }
350
351    #[test]
352    fn malformed_toml_falls_back_to_defaults() {
353        let path = std::path::Path::new("~/.seer/config.toml");
354        // Both a syntax error and a type error must yield defaults, not a panic.
355        for content in ["output_format = [not toml", "output_format = 42"] {
356            let config = SeerConfig::parse_or_default(content, path);
357            assert_eq!(config.output_format, "human");
358            assert_eq!(config.bulk.concurrency, 10);
359        }
360    }
361
362    #[test]
363    fn unknown_keys_are_tolerated() {
364        // Forward-compat: a config written by a newer seer (or with a typo'd
365        // extra key) must not hard-fail — no deny_unknown_fields.
366        let path = std::path::Path::new("~/.seer/config.toml");
367        let config =
368            SeerConfig::parse_or_default("output_format = \"json\"\nfuture_option = true\n", path);
369        assert_eq!(config.output_format, "json");
370    }
371
372    #[test]
373    fn valid_content_is_clamped_through_parse_seam() {
374        // parse_or_default must apply the same clamping load() always did.
375        let path = std::path::Path::new("~/.seer/config.toml");
376        let config = SeerConfig::parse_or_default("[bulk]\nconcurrency = 10000\n", path);
377        assert_eq!(config.bulk.concurrency, 50);
378    }
379
380    #[test]
381    fn watch_webhook_url_parses_and_defaults_to_none() {
382        let config: SeerConfig =
383            toml::from_str("[watch]\nwebhook_url = \"https://hooks.example.com/seer\"\n").unwrap();
384        assert_eq!(
385            config.watch.webhook_url.as_deref(),
386            Some("https://hooks.example.com/seer")
387        );
388
389        let config = SeerConfig::default();
390        assert_eq!(config.watch.webhook_url, None);
391    }
392
393    #[test]
394    fn tui_theme_parses_and_defaults_to_frappe() {
395        let config: SeerConfig = toml::from_str("[tui]\ntheme = \"latte\"\n").unwrap();
396        assert_eq!(config.tui.theme, "latte");
397        assert_eq!(config.tui_theme(), "latte");
398
399        let config = SeerConfig::default();
400        assert_eq!(config.tui.theme, "frappe");
401        assert_eq!(config.tui_theme(), "frappe");
402    }
403
404    #[test]
405    fn tui_theme_accessor_is_case_insensitive_and_trims() {
406        for raw in ["Latte", "LATTE", "  latte  "] {
407            let mut config = SeerConfig::default();
408            config.tui.theme = raw.to_string();
409            assert_eq!(config.tui_theme(), "latte", "input: {raw:?}");
410        }
411        for raw in ["Frappe", "frappé", "FRAPPÉ"] {
412            let mut config = SeerConfig::default();
413            config.tui.theme = raw.to_string();
414            assert_eq!(config.tui_theme(), "frappe", "input: {raw:?}");
415        }
416    }
417
418    #[test]
419    fn tui_theme_unknown_name_falls_back_to_frappe() {
420        // Config parsing stays tolerant: an unknown theme loads unchanged but
421        // the accessor clamps it to the default instead of erroring.
422        let config: SeerConfig = toml::from_str("[tui]\ntheme = \"nord\"\n").unwrap();
423        assert_eq!(config.tui.theme, "nord");
424        assert_eq!(config.tui_theme(), "frappe");
425    }
426
427    #[test]
428    fn new_sections_survive_default_toml_roundtrip() {
429        // default_toml must serialize the new [watch]/[tui] tables without
430        // erroring (webhook_url = None is omitted by the toml serializer).
431        let toml_str = SeerConfig::default_toml();
432        let config: SeerConfig = toml::from_str(&toml_str).unwrap();
433        assert_eq!(config.tui.theme, "frappe");
434        assert_eq!(config.watch.webhook_url, None);
435    }
436
437    #[test]
438    fn clamp_bounds_rate_limit_ms() {
439        // An unbounded rate-limit delay (once wired into the bulk executor)
440        // would stall every operation for the configured duration; clamp it.
441        let mut config = SeerConfig::default();
442        config.bulk.rate_limit_ms = 10_000_000;
443        let config = config.clamped();
444        assert!(config.bulk.rate_limit_ms <= 60_000);
445    }
446}