Skip to main content

seer_core/
config.rs

1//! Configuration file support for Seer.
2//!
3//! Loads settings from `~/.seer/config.toml` with environment variable overrides.
4
5use std::path::PathBuf;
6use std::time::Duration;
7
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11/// Seer configuration loaded from `~/.seer/config.toml`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct SeerConfig {
15    /// Default output format ("human", "json", "yaml")
16    pub output_format: String,
17    /// Default DNS nameserver spec. Accepts a bare IP/hostname with an
18    /// optional port (UDP, e.g. `"8.8.8.8"`, `"dns.google"`,
19    /// `"[2001:4860:4860::8888]:53"`), `tls://host[:port]` for DNS over TLS
20    /// (default port 853, e.g. `"tls://1.1.1.1"`), or
21    /// `https://host[:port][/path]` for DNS over HTTPS (default port 443,
22    /// default path `/dns-query`, e.g. `"https://cloudflare-dns.com/dns-query"`).
23    /// Parsed by [`crate::dns::NameserverSpec`]; invalid specs surface as an
24    /// error on first use rather than at load time.
25    pub nameserver: Option<String>,
26    /// Timeout settings
27    pub timeouts: TimeoutConfig,
28    /// Bulk operation settings
29    pub bulk: BulkConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct TimeoutConfig {
35    /// WHOIS query timeout in seconds
36    pub whois_secs: u64,
37    /// RDAP query timeout in seconds
38    pub rdap_secs: u64,
39    /// DNS query timeout in seconds
40    pub dns_secs: u64,
41    /// HTTP/SSL check timeout in seconds
42    pub http_secs: u64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct BulkConfig {
48    /// Default concurrency for bulk operations
49    pub concurrency: usize,
50    /// Rate limit delay in milliseconds between operations
51    pub rate_limit_ms: u64,
52}
53
54impl Default for SeerConfig {
55    fn default() -> Self {
56        Self {
57            output_format: "human".to_string(),
58            nameserver: None,
59            timeouts: TimeoutConfig::default(),
60            bulk: BulkConfig::default(),
61        }
62    }
63}
64
65impl Default for TimeoutConfig {
66    fn default() -> Self {
67        Self {
68            whois_secs: 15,
69            // Matches the RDAP client's own DEFAULT_TIMEOUT (15s) and the
70            // documented value; the client uses 15s, so the default must too.
71            rdap_secs: 15,
72            dns_secs: 5,
73            http_secs: 10,
74        }
75    }
76}
77
78impl Default for BulkConfig {
79    fn default() -> Self {
80        Self {
81            concurrency: 10,
82            rate_limit_ms: 100,
83        }
84    }
85}
86
87impl SeerConfig {
88    /// Returns the path to the config file (`~/.seer/config.toml`).
89    pub fn config_path() -> Option<PathBuf> {
90        dirs::home_dir().map(|home| home.join(".seer").join("config.toml"))
91    }
92
93    /// Loads config from `~/.seer/config.toml`, falling back to defaults if not found.
94    pub fn load() -> Self {
95        let Some(path) = Self::config_path() else {
96            return Self::default();
97        };
98
99        if !path.exists() {
100            return Self::default();
101        }
102
103        match std::fs::read_to_string(&path) {
104            Ok(content) => match toml::from_str::<SeerConfig>(&content) {
105                Ok(config) => {
106                    debug!(?path, "Loaded config");
107                    config.clamped()
108                }
109                Err(e) => {
110                    tracing::warn!(?path, error = %e, "Failed to parse config, using defaults");
111                    Self::default()
112                }
113            },
114            Err(e) => {
115                debug!(?path, error = %e, "Could not read config, using defaults");
116                Self::default()
117            }
118        }
119    }
120
121    /// Clamp loaded values into sane ranges. Without this, a user
122    /// (accidentally or maliciously) setting `bulk.concurrency = 0` would
123    /// hand `Semaphore::new(0)` to every bulk operation and block forever;
124    /// `bulk.concurrency = 10000` would spawn thousands of concurrent
125    /// connections. Timeouts of `0` would error every network call
126    /// immediately. The bounds are per-protocol (a config file can't bypass
127    /// them): concurrency 1–50; whois/rdap timeouts 1–300s; dns 1–60s;
128    /// http 1–120s.
129    fn clamped(mut self) -> Self {
130        self.bulk.concurrency = self.bulk.concurrency.clamp(1, 50);
131        // A multi-day per-operation delay would stall bulk runs indefinitely;
132        // cap at 60s (0 is valid — no inter-operation delay).
133        self.bulk.rate_limit_ms = self.bulk.rate_limit_ms.min(60_000);
134        self.timeouts.whois_secs = self.timeouts.whois_secs.clamp(1, 300);
135        self.timeouts.rdap_secs = self.timeouts.rdap_secs.clamp(1, 300);
136        self.timeouts.dns_secs = self.timeouts.dns_secs.clamp(1, 60);
137        self.timeouts.http_secs = self.timeouts.http_secs.clamp(1, 120);
138        self
139    }
140
141    /// Returns the WHOIS timeout as a Duration.
142    pub fn whois_timeout(&self) -> Duration {
143        Duration::from_secs(self.timeouts.whois_secs)
144    }
145
146    /// Returns the RDAP timeout as a Duration.
147    pub fn rdap_timeout(&self) -> Duration {
148        Duration::from_secs(self.timeouts.rdap_secs)
149    }
150
151    /// Returns the DNS timeout as a Duration.
152    pub fn dns_timeout(&self) -> Duration {
153        Duration::from_secs(self.timeouts.dns_secs)
154    }
155
156    /// Returns the HTTP timeout as a Duration.
157    pub fn http_timeout(&self) -> Duration {
158        Duration::from_secs(self.timeouts.http_secs)
159    }
160
161    /// Returns the inter-operation bulk rate-limit delay as a Duration.
162    pub fn bulk_rate_limit(&self) -> Duration {
163        Duration::from_millis(self.bulk.rate_limit_ms)
164    }
165
166    /// Generates a default config file content as TOML.
167    pub fn default_toml() -> String {
168        toml::to_string_pretty(&Self::default()).unwrap_or_else(|_| String::new())
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_default_config() {
178        let config = SeerConfig::default();
179        assert_eq!(config.output_format, "human");
180        assert_eq!(config.timeouts.whois_secs, 15);
181        assert_eq!(config.timeouts.rdap_secs, 15);
182        assert_eq!(config.timeouts.dns_secs, 5);
183        assert_eq!(config.bulk.concurrency, 10);
184    }
185
186    #[test]
187    fn test_parse_config_toml() {
188        let toml_str = r#"
189output_format = "json"
190nameserver = "1.1.1.1"
191
192[timeouts]
193whois_secs = 20
194rdap_secs = 45
195
196[bulk]
197concurrency = 20
198"#;
199        let config: SeerConfig = toml::from_str(toml_str).unwrap();
200        assert_eq!(config.output_format, "json");
201        assert_eq!(config.nameserver, Some("1.1.1.1".to_string()));
202        assert_eq!(config.timeouts.whois_secs, 20);
203        assert_eq!(config.timeouts.rdap_secs, 45);
204        assert_eq!(config.timeouts.dns_secs, 5); // default
205        assert_eq!(config.bulk.concurrency, 20);
206    }
207
208    #[test]
209    fn nameserver_accepts_dot_doh_specs_opaquely() {
210        // The nameserver key is an opaque spec string: DoT/DoH forms load
211        // unchanged and are validated by NameserverSpec::parse on first use,
212        // not at config-load time.
213        let config: SeerConfig = toml::from_str(r#"nameserver = "tls://1.1.1.1""#).unwrap();
214        assert_eq!(config.nameserver.as_deref(), Some("tls://1.1.1.1"));
215    }
216
217    #[test]
218    fn test_default_toml_roundtrip() {
219        let toml_str = SeerConfig::default_toml();
220        let config: SeerConfig = toml::from_str(&toml_str).unwrap();
221        assert_eq!(config.output_format, "human");
222    }
223
224    #[test]
225    fn test_timeout_durations() {
226        let config = SeerConfig::default();
227        assert_eq!(config.whois_timeout(), Duration::from_secs(15));
228        assert_eq!(config.rdap_timeout(), Duration::from_secs(15));
229        assert_eq!(config.dns_timeout(), Duration::from_secs(5));
230        assert_eq!(config.http_timeout(), Duration::from_secs(10));
231    }
232
233    #[test]
234    fn clamp_bounds_rate_limit_ms() {
235        // An unbounded rate-limit delay (once wired into the bulk executor)
236        // would stall every operation for the configured duration; clamp it.
237        let mut config = SeerConfig::default();
238        config.bulk.rate_limit_ms = 10_000_000;
239        let config = config.clamped();
240        assert!(config.bulk.rate_limit_ms <= 60_000);
241    }
242}