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