use std::sync::LazyLock;
use seer_core::bulk::BulkOperation;
use seer_core::colors::CatppuccinExt;
use seer_core::RecordType;
pub const MAX_BULK_DOMAINS: usize = 1000;
pub const BULK_OPS: &[(&str, &str)] = &[
("lookup", "Smart lookup (RDAP first, WHOIS fallback)"),
("status", "Check HTTP, SSL, and domain expiration"),
("dig", "Query DNS records (alias: dns)"),
("avail", "Check domain registration availability"),
("info", "Comprehensive domain info (RDAP + WHOIS merged)"),
("whois", "Query WHOIS information"),
("rdap", "Query RDAP registry data"),
("ssl", "Inspect SSL certificate chain (deep)"),
("prop", "Check DNS propagation (alias: propagation)"),
("posture", "SPF, DMARC, MTA-STS, BIMI, DANE posture"),
("confusables", "Look-alike scan (costly per domain)"),
("caa", "Look up CAA (cert authority) policy"),
];
pub static BULK_OPS_SUMMARY: LazyLock<String> = LazyLock::new(|| {
let names: Vec<&str> = BULK_OPS.iter().map(|(name, _)| *name).collect();
names.join(", ")
});
pub const BULK_INPUT_FORMATS: &str = " Plain text (one domain per line, # for comments):
# My domains to check
example.com
google.com
github.com
CSV (uses first column, skips header if present):
domain,owner,notes
example.com,Alice,Main site
google.com,Bob,Search
github.com,Carol,Code hosting
";
pub fn bulk_operation_for(
op: &str,
domain: String,
record_type: RecordType,
) -> Option<BulkOperation> {
Some(match op {
"lookup" => BulkOperation::Lookup { domain },
"whois" => BulkOperation::Whois { domain },
"rdap" => BulkOperation::Rdap { domain },
"dig" | "dns" => BulkOperation::Dns {
domain,
record_type,
},
"propagation" | "prop" => BulkOperation::Propagation {
domain,
record_type,
},
"status" => BulkOperation::Status { domain },
"avail" => BulkOperation::Avail { domain },
"info" => BulkOperation::Info { domain },
"ssl" => BulkOperation::Ssl { domain },
"posture" => BulkOperation::Posture { domain },
"confusables" => BulkOperation::Confusables { domain },
"caa" => BulkOperation::Caa { domain },
_ => return None,
})
}
pub fn build_bulk_operations(
op: &str,
domains: &[String],
record_type: RecordType,
) -> Result<Vec<BulkOperation>, String> {
if bulk_operation_for(op, String::new(), record_type).is_none() {
return Err(format!(
"Unknown operation: {}. Use: {}",
op, *BULK_OPS_SUMMARY
));
}
Ok(domains
.iter()
.filter_map(|d| bulk_operation_for(op, d.clone(), record_type))
.collect())
}
pub fn parse_bulk_domains(content: &str) -> Result<Vec<String>, String> {
let domains = seer_core::bulk::parse_domains_from_file(content);
if domains.is_empty() {
return Err(
"No valid domains found. Expected format: one domain per line, \
# for comments, or CSV (first column)"
.to_string(),
);
}
if domains.len() > MAX_BULK_DOMAINS {
return Err(format!(
"Bulk input contains {} domains, maximum is {}",
domains.len(),
MAX_BULK_DOMAINS
));
}
Ok(domains)
}
pub async fn run_live_follow(
follower: &seer_core::DnsFollower,
domain: &str,
record_type: RecordType,
nameserver: Option<&str>,
config: seer_core::FollowConfig,
format: seer_core::output::OutputFormat,
) -> seer_core::Result<seer_core::FollowResult> {
use std::io::Write;
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
let sigint = {
let cancel_tx = cancel_tx.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
let _ = cancel_tx.send(true);
})
};
let raw_guard = crate::utils::RawModeGuard::new();
let key_listener =
crate::utils::FollowKeyListener::spawn(cancel_tx.clone(), raw_guard.is_enabled());
let callback: seer_core::dns::FollowProgressCallback = std::sync::Arc::new(move |iteration| {
let formatter = seer_core::output::get_formatter(format);
let output = formatter
.format_follow_iteration(iteration)
.replace('\n', "\r\n");
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(output.as_bytes());
let _ = stdout.write_all(b"\r\n");
let _ = stdout.flush();
});
let result = follower
.follow(
domain,
record_type,
nameserver,
config,
Some(callback),
Some(cancel_rx),
)
.await;
sigint.abort();
if let Some(listener) = key_listener {
listener.stop().await;
}
drop(raw_guard);
result
}
pub fn follow_notes_to_stderr(format: seer_core::output::OutputFormat) -> bool {
format != seer_core::output::OutputFormat::Human
}
pub async fn follow_command(
follower: &seer_core::DnsFollower,
args: crate::dns_args::FollowArgs,
config: &seer_core::SeerConfig,
format: seer_core::output::OutputFormat,
) -> Result<(), String> {
use std::io::Write;
let follow_config = seer_core::FollowConfig::new(args.iterations, args.interval_minutes)
.map_err(|e| e.to_string())?
.with_changes_only(args.changes_only);
let nameserver = args.nameserver.as_deref().or(config.nameserver.as_deref());
let notes_to_stderr = follow_notes_to_stderr(format);
let banner = format!(
"Following {} {} records ({} iterations, {} interval)\r\nPress {} or {} to stop early\r\n\r\n",
args.domain.ctp_green(),
args.record_type.to_string().ctp_yellow(),
args.iterations.to_string().ctp_yellow(),
crate::utils::format_interval(args.interval_minutes),
"Esc".ctp_yellow(),
"Ctrl+C".ctp_yellow()
);
if notes_to_stderr {
eprint!("{}", banner);
let _ = std::io::stderr().flush();
} else {
print!("{}", banner);
let _ = std::io::stdout().flush();
}
let result = run_live_follow(
follower,
&args.domain,
args.record_type,
nameserver,
follow_config,
format,
)
.await
.map_err(|e| e.to_string())?;
if result.interrupted {
let note = "Follow interrupted by user".ctp_yellow();
if notes_to_stderr {
eprintln!("{}", note);
} else {
println!("\n{}", note);
}
}
println!(
"\n{}",
seer_core::output::get_formatter(format).format_follow(&result)
);
Ok(())
}
pub static STORE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn record_lookup_history(
domain: &str,
result: seer_core::LookupResult,
) -> seer_core::Result<()> {
let domain = domain.to_string();
let store = STORE_LOCK.lock().await;
tokio::task::spawn_blocking(move || {
let _store = store;
let mut history = seer_core::LookupHistory::load()?;
history.record(&domain, result);
history.save()
})
.await
.map_err(|e| seer_core::SeerError::ConfigError(format!("history task failed: {e}")))?
}
pub(crate) async fn state_io<T, F>(what: &str, io: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> seer_core::Result<T> + Send + 'static,
{
match tokio::task::spawn_blocking(io).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(format!("Failed to {}: {}", what, e)),
Err(e) => Err(format!("Failed to {}: {}", what, e)),
}
}
pub async fn load_watchlist() -> Result<seer_core::Watchlist, String> {
state_io("load watchlist", seer_core::Watchlist::load).await
}
pub async fn load_history() -> Result<seer_core::LookupHistory, String> {
state_io("load history", seer_core::LookupHistory::load).await
}
pub async fn clear_history() -> Result<(), String> {
let store = STORE_LOCK.lock().await;
state_io("clear history", move || {
let _store = store;
let mut history = seer_core::LookupHistory::load()?;
history.clear();
history.save()
})
.await
}
pub struct DriftOutcome {
pub report: seer_core::DriftReport,
pub had_previous: bool,
}
pub async fn drift_check(
lookup: &seer_core::SmartLookup,
domain: &str,
record: bool,
) -> seer_core::Result<DriftOutcome> {
let result = lookup.lookup(domain).await?;
let domain_key = domain.to_string();
let previous = tokio::task::spawn_blocking(move || -> seer_core::Result<_> {
let history = seer_core::LookupHistory::load()?;
Ok(
seer_core::drift::baseline_snapshot(history.get(&domain_key).iter().map(|e| &e.result))
.cloned(),
)
})
.await
.map_err(|e| seer_core::SeerError::ConfigError(format!("history task failed: {e}")))??;
finish_drift(
domain,
previous,
result,
record,
|domain, result| async move { record_lookup_history(&domain, result).await },
)
.await
}
async fn finish_drift<F, Fut>(
domain: &str,
previous: Option<seer_core::LookupResult>,
result: seer_core::LookupResult,
record: bool,
save: F,
) -> seer_core::Result<DriftOutcome>
where
F: FnOnce(String, seer_core::LookupResult) -> Fut,
Fut: std::future::Future<Output = seer_core::Result<()>>,
{
let report = match &previous {
Some(prev) => seer_core::DriftReport::from_lookups(domain, prev, &result),
None => seer_core::DriftReport::empty(domain),
};
if record {
save(domain.to_string(), result).await?;
}
Ok(DriftOutcome {
report,
had_previous: previous.is_some(),
})
}
pub fn no_baseline_note(domain: &str, record: bool) -> String {
format!(
"no previous snapshot for {} — {}",
domain,
if record {
"recorded a baseline"
} else {
"run with --record to establish a baseline"
}
)
}
pub struct SubdomainBaselineOutcome {
pub result: seer_core::SubdomainResult,
pub report: seer_core::SubdomainBaselineDiff,
}
pub async fn subdomain_baseline_check(
domain: &str,
record: bool,
config: &seer_core::SeerConfig,
) -> seer_core::Result<SubdomainBaselineOutcome> {
let enumerator = seer_core::SubdomainEnumerator::from_config(config);
let result = enumerator.enumerate(domain).await?;
let (result, report) = tokio::task::spawn_blocking(move || -> seer_core::Result<_> {
let mut baselines = seer_core::SubdomainBaselines::load()?;
let report = baselines.diff(&result.domain, &result.subdomains);
if record {
baselines.record(&result);
baselines.save()?;
}
Ok((result, report))
})
.await
.map_err(|e| seer_core::SeerError::ConfigError(format!("baseline task failed: {e}")))??;
Ok(SubdomainBaselineOutcome { result, report })
}
pub fn no_subdomain_baseline_note(domain: &str, record: bool) -> String {
format!(
"no subdomain baseline for {} — {}",
domain,
if record {
"recorded one from this run"
} else {
"run with --record to establish a baseline"
}
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_canonical_op_maps_to_an_operation() {
for (op, _) in BULK_OPS {
assert!(
bulk_operation_for(op, "example.com".to_string(), RecordType::A).is_some(),
"canonical op {op} must be accepted"
);
}
}
#[test]
fn aliases_map_like_their_canonical_names() {
let dig = bulk_operation_for("dig", "a.com".into(), RecordType::MX);
let dns = bulk_operation_for("dns", "a.com".into(), RecordType::MX);
assert!(matches!(dig, Some(BulkOperation::Dns { .. })));
assert!(matches!(dns, Some(BulkOperation::Dns { .. })));
let prop = bulk_operation_for("prop", "a.com".into(), RecordType::A);
let propagation = bulk_operation_for("propagation", "a.com".into(), RecordType::A);
assert!(matches!(prop, Some(BulkOperation::Propagation { .. })));
assert!(matches!(
propagation,
Some(BulkOperation::Propagation { .. })
));
}
#[test]
fn new_ops_map_to_their_variants() {
assert!(matches!(
bulk_operation_for("posture", "a.com".into(), RecordType::A),
Some(BulkOperation::Posture { .. })
));
assert!(matches!(
bulk_operation_for("confusables", "a.com".into(), RecordType::A),
Some(BulkOperation::Confusables { .. })
));
assert!(matches!(
bulk_operation_for("caa", "a.com".into(), RecordType::A),
Some(BulkOperation::Caa { .. })
));
}
#[test]
fn unknown_op_is_rejected_with_op_list() {
assert!(bulk_operation_for("bogus", "a.com".into(), RecordType::A).is_none());
let err = build_bulk_operations("bogus", &["a.com".to_string()], RecordType::A)
.expect_err("unknown op must error");
assert!(err.contains("Unknown operation: bogus"), "got: {err}");
for op in ["posture", "confusables", "caa", "ssl"] {
assert!(err.contains(op), "error should list {op}: {err}");
}
}
#[test]
fn build_bulk_operations_builds_one_per_domain() {
let domains = vec!["a.com".to_string(), "b.com".to_string()];
let ops = build_bulk_operations("caa", &domains, RecordType::A).expect("valid op");
assert_eq!(ops.len(), 2);
assert_eq!(ops[0].domain(), "a.com");
assert_eq!(ops[1].domain(), "b.com");
}
#[test]
fn parse_bulk_domains_rejects_empty_input() {
let err = parse_bulk_domains("# only a comment\n").expect_err("empty must error");
assert!(err.contains("No valid domains"), "got: {err}");
}
#[test]
fn parse_bulk_domains_rejects_oversized_lists() {
let content: String = (0..=MAX_BULK_DOMAINS)
.map(|i| format!("d{i}.com\n"))
.collect();
let err = parse_bulk_domains(&content).expect_err("oversized must error");
assert!(err.contains("maximum is"), "got: {err}");
}
#[test]
fn parse_bulk_domains_accepts_valid_lists() {
let domains = parse_bulk_domains("a.com\n# skip\nb.com\n").expect("valid list");
assert_eq!(domains, vec!["a.com", "b.com"]);
}
fn available(domain: &str) -> seer_core::LookupResult {
seer_core::LookupResult::Available {
data: Box::new(seer_core::AvailabilityResult {
domain: domain.into(),
available: true,
confidence: "high".into(),
method: "rdap".into(),
details: None,
}),
rdap_error: "404".into(),
whois_error: "no match".into(),
whois_data: None,
}
}
#[tokio::test]
async fn drift_record_propagates_a_failed_save() {
let failing = |_: String, _: seer_core::LookupResult| async {
Err(seer_core::SeerError::ConfigError("disk full".into()))
};
let err = finish_drift("a.com", None, available("a.com"), true, failing)
.await
.err()
.expect("a failed save fails the check");
assert!(err.to_string().contains("disk full"), "{err}");
let outcome = finish_drift("a.com", None, available("a.com"), false, failing)
.await
.expect("no save attempted");
assert!(!outcome.had_previous);
let saved = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = saved.clone();
let recording = move |domain: String, _: seer_core::LookupResult| async move {
assert_eq!(domain, "a.com");
flag.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
};
finish_drift("a.com", None, available("a.com"), true, recording)
.await
.expect("saved");
assert!(saved.load(std::sync::atomic::Ordering::SeqCst));
}
#[test]
fn no_baseline_note_reflects_record_flag() {
assert!(no_baseline_note("a.com", true).contains("recorded a baseline"));
assert!(no_baseline_note("a.com", false).contains("--record"));
}
#[test]
fn follow_prose_leaves_stdout_for_machine_formats() {
use seer_core::output::OutputFormat;
assert!(!follow_notes_to_stderr(OutputFormat::Human));
for format in [
OutputFormat::Json,
OutputFormat::Yaml,
OutputFormat::Markdown,
] {
assert!(follow_notes_to_stderr(format), "{format:?}");
}
}
#[test]
fn no_subdomain_baseline_note_reflects_record_flag() {
assert!(no_subdomain_baseline_note("a.com", true).contains("recorded one"));
assert!(no_subdomain_baseline_note("a.com", false).contains("--record"));
}
}