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