mod bulk;
mod clipboard;
mod dig_args;
mod display;
mod dns_args;
mod manage;
mod ops;
mod payload;
mod query;
mod repl;
mod tui;
mod utils;
use std::process::ExitCode;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use payload::Payload;
use query::Query;
use seer_core::colors::CatppuccinExt;
use seer_core::output::OutputFormat;
use utils::machine_error;
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
enum FailOn {
Warning,
Critical,
}
fn dig_usage() -> String {
format!(
"seer dig [OPTIONS] {}\n seer dig [OPTIONS] -x <IP>",
dig_args::USAGE
)
}
fn dig_long_help() -> String {
format!("{}\nRecord types: {}\n", DIG_EXAMPLES, *VALID_RECORD_TYPES)
}
const DIG_EXAMPLES: &str = r#"Examples:
seer dig example.com # A records (the default type)
seer dig example.com A AAAA MX # several types, queried concurrently
seer dig @1.1.1.1 example.com MX # ask one nameserver (also -s 1.1.1.1)
seer dig example.com HTTPS +short # values only, one per line
seer dig -x 8.8.8.8 # reverse lookup (PTR)
seer dig www.example.com +trace # walk down from the root servers
seer dig _sip._tcp.example.com SRV # SRV names take _service._proto.name
seer dig example.com '*' # ANY: the common types, fanned out
seer --format json dig example.com # the result object (an array for several types)
seer --fields status,answers.name dig example.com
"#;
fn compare_usage() -> String {
format!("seer compare {}", dns_args::COMPARE_USAGE)
}
fn follow_usage() -> String {
format!("seer follow [OPTIONS] {}", dns_args::FOLLOW_USAGE)
}
fn parse_output_format(value: &str) -> Result<OutputFormat, String> {
value.parse()
}
#[derive(Parser)]
#[command(name = "seer")]
#[command(about = "Domain name helper - WHOIS, RDAP, DIG, and propagation checking")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(short, long, value_parser = parse_output_format)]
format: Option<OutputFormat>,
#[arg(short, long)]
quiet: bool,
#[arg(long, value_delimiter = ',')]
fields: Option<Vec<String>>,
}
#[derive(Subcommand)]
enum Commands {
Lookup {
domain: String,
},
Info {
domain: String,
},
Whois {
domain: String,
},
Rdap {
query: String,
},
#[command(override_usage = dig_usage(), after_long_help = dig_long_help())]
Dig {
#[arg(value_name = "ARGS")]
args: Vec<String>,
#[arg(short, long)]
server: Option<String>,
#[arg(long)]
short: bool,
#[arg(long)]
trace: bool,
#[arg(short = 'x', long, value_name = "IP")]
reverse: Option<String>,
},
Prop {
domain: String,
#[arg(default_value = "A")]
record_type: String,
},
#[command(after_long_help = bulk::long_help())]
Bulk {
#[arg(
value_name = "OPERATION",
help = format!("Operation type: {}", *ops::BULK_OPS_SUMMARY)
)]
operation: String,
#[arg(value_name = "FILE")]
file: String,
#[arg(value_name = "TYPE", default_value = "A")]
record_type: String,
#[arg(short, long, value_name = "OUTPUT")]
output: Option<String>,
#[arg(long, value_enum)]
progress: Option<bulk::ProgressMode>,
},
Status {
domain: String,
},
#[command(override_usage = follow_usage())]
Follow {
#[arg(value_name = "ARGS")]
args: Vec<String>,
#[arg(short, long)]
server: Option<String>,
#[arg(long)]
changes_only: bool,
},
Reverse {
ip: String,
},
Avail {
domain: String,
},
Dnssec {
domain: String,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
Config {
#[arg(long)]
init: bool,
},
GenerateKey {
#[arg(long)]
export: bool,
#[arg(long, default_value_t = 32, value_parser = clap::value_parser!(u16).range(1..=4096))]
bytes: u16,
},
Ssl {
domain: String,
},
Tld {
tld: String,
},
#[command(override_usage = compare_usage())]
Compare {
#[arg(value_name = "ARGS")]
args: Vec<String>,
},
Subdomains {
domain: String,
#[arg(long)]
resolve: bool,
#[arg(long, conflicts_with = "resolve")]
diff: bool,
#[arg(long, conflicts_with = "resolve")]
record: bool,
},
Diff {
domain_a: String,
domain_b: String,
},
Watch {
action: Option<String>,
#[arg(value_name = "DOMAIN")]
domains: Vec<String>,
#[arg(long, value_enum, default_value_t = FailOn::Critical)]
fail_on: FailOn,
#[arg(long, value_name = "URL")]
webhook: Option<String>,
},
History {
domain: Option<String>,
#[arg(long, conflicts_with = "domain")]
clear: bool,
},
Drift {
domain: String,
#[arg(long)]
record: bool,
},
Caa {
domain: String,
},
Posture {
domain: String,
},
Headers {
domain: String,
},
Takeover {
domain: String,
#[arg(long = "host", value_name = "HOST")]
hosts: Vec<String>,
},
Confusables {
domain: String,
},
Doctor,
Delegation {
domain: String,
},
#[command(hide = true)]
Mangen {
dir: std::path::PathBuf,
},
Tui {
domain: Option<String>,
},
}
#[tokio::main]
async fn main() -> ExitCode {
utils::install_raw_mode_panic_hook();
let _log_guard = seer_core::logging::init_logging_with_writer(
"seer",
"error",
display::ProgressWriterFactory::new(),
);
let cli = Cli::parse();
let config = seer_core::SeerConfig::load();
let (format, result) = match cli.command {
Some(command) => {
let format = cli
.format
.unwrap_or_else(|| config_output_format(&config.output_format));
let output = Output::new(format, cli.quiet, cli.fields);
(format, execute_command(command, &output, &config).await)
}
None => {
let format = cli.format.unwrap_or_default();
let result = async {
let mut repl = repl::Repl::new()?;
if let Some(format) = cli.format {
repl.set_output_format(format);
}
repl.run().await
}
.await
.map(|()| 0)
.map_err(Failure::from);
(format, result)
}
};
match result {
Ok(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
Err(failure) => {
print_error(format, &failure.0);
ExitCode::FAILURE
}
}
}
#[derive(Debug)]
struct Failure(String);
impl<E: std::fmt::Display> From<E> for Failure {
fn from(e: E) -> Self {
Self(e.to_string())
}
}
pub(crate) fn config_output_format(configured: &str) -> OutputFormat {
parse_config_format(configured).unwrap_or_else(|warning| {
eprintln!("{} {}", "Warning:".ctp_yellow(), warning);
OutputFormat::default()
})
}
fn parse_config_format(configured: &str) -> Result<OutputFormat, String> {
if configured.trim().is_empty() {
return Ok(OutputFormat::default());
}
configured
.parse()
.map_err(|e| format!("config file output_format: {e} (using human)"))
}
struct Output {
format: OutputFormat,
quiet: bool,
fields: Option<Vec<String>>,
}
impl Output {
fn new(format: OutputFormat, quiet: bool, fields: Option<Vec<String>>) -> Self {
Self {
format,
quiet: quiet || fields.is_some(),
fields,
}
}
fn show_payload(&self, payload: &Payload) {
if self.quiet {
handle_quiet_output(payload, &self.fields);
} else {
println!("{}", payload::serialize(payload, self.format));
}
}
fn show_listing(&self, listing: &manage::Listing) {
if self.quiet {
handle_quiet_output(&listing.data, &self.fields);
} else {
println!("{}", listing.render(self.format));
}
}
}
fn resolve_field_path<'a>(
value: &'a serde_json::Value,
parts: &[&str],
out: &mut Vec<&'a serde_json::Value>,
) {
use serde_json::Value;
let Some((part, rest)) = parts.split_first() else {
out.push(value);
return;
};
match value {
Value::Object(map) => match map.get(*part) {
Some(next) => resolve_field_path(next, rest, out),
None => out.push(&Value::Null),
},
Value::Array(items) => match part.parse::<usize>() {
Ok(index) => match items.get(index) {
Some(next) => resolve_field_path(next, rest, out),
None => out.push(&Value::Null),
},
Err(_) => {
for item in items {
resolve_field_path(item, parts, out);
}
}
},
_ => out.push(&Value::Null),
}
}
fn extract_field_lines(value: &serde_json::Value, fields: &[String]) -> Vec<String> {
if let serde_json::Value::Array(items) = value {
let indexes_the_list = fields.iter().any(|field| {
field
.split('.')
.next()
.is_some_and(|first| first.parse::<usize>().is_ok())
});
if !indexes_the_list {
return items
.iter()
.flat_map(|item| extract_field_lines(item, fields))
.collect();
}
}
let mut lines = Vec::new();
for field in fields {
let parts: Vec<&str> = field.split('.').collect();
let mut matches = Vec::new();
resolve_field_path(value, &parts, &mut matches);
lines.extend(matches.into_iter().map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}));
}
lines
}
fn handle_quiet_output<T: serde::Serialize>(value: &T, fields: &Option<Vec<String>>) {
if let Some(ref fields) = fields {
let json_value = serde_json::to_value(value).unwrap_or_default();
for line in extract_field_lines(&json_value, fields) {
println!("{}", line);
}
} else {
let json = serde_json::to_string(value).unwrap_or_default();
println!("{}", json);
}
}
fn print_error(format: OutputFormat, msg: &str) {
match machine_error(format, msg) {
Some(structured) => eprintln!("{}", structured),
None => eprintln!("{} {}", "Error:".ctp_red(), msg),
}
}
pub(crate) static VALID_RECORD_TYPES: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| seer_core::RecordType::ALL_NAMES.join(", "));
pub(crate) fn try_parse_record_type(s: &str) -> Result<seer_core::RecordType, String> {
s.parse::<seer_core::RecordType>()
.map_err(|e| format!("{} (valid types: {})", e, *VALID_RECORD_TYPES))
}
async fn execute_command(
command: Commands,
output: &Output,
config: &seer_core::SeerConfig,
) -> Result<i32, Failure> {
let format = output.format;
let query = match command {
Commands::Lookup { domain } => Query::Lookup(domain),
Commands::Info { domain } => Query::Info(domain),
Commands::Whois { domain } => Query::Whois(domain),
Commands::Rdap { query } => Query::Rdap(query),
Commands::Dig {
args,
server,
short,
trace,
reverse,
} => {
let flags = dig_args::DigFlags {
server,
short,
trace,
reverse,
};
dig_query(&args, flags, output.quiet, output.fields.is_some())?
}
Commands::Prop {
domain,
record_type,
} => Query::Prop {
domain,
record_type: try_parse_record_type(&record_type)?,
},
Commands::Status { domain } => Query::Status(domain),
Commands::Reverse { ip } => Query::Reverse(ip),
Commands::Avail { domain } => Query::Avail(domain),
Commands::Dnssec { domain } => Query::Dnssec(domain),
Commands::Ssl { domain } => Query::Ssl(domain),
Commands::Tld { tld } => Query::Tld(tld),
Commands::Compare { args } => dns_args::parse_compare(&args)?.into_query(),
Commands::Subdomains {
domain,
resolve,
diff,
record,
} => Query::Subdomains {
domain,
resolve,
diff,
record,
},
Commands::Diff { domain_a, domain_b } => Query::Diff(domain_a, domain_b),
Commands::Drift { domain, record } => Query::Drift { domain, record },
Commands::Caa { domain } => Query::Caa(domain),
Commands::Posture { domain } => Query::Posture(domain),
Commands::Headers { domain } => Query::Headers(domain),
Commands::Takeover { domain, hosts } => Query::Takeover { domain, hosts },
Commands::Confusables { domain } => Query::Confusables(domain),
Commands::Doctor => Query::Doctor,
Commands::Delegation { domain } => Query::Delegation(domain),
Commands::Bulk {
operation,
file,
record_type,
output: csv,
progress,
} => {
let request = bulk::BulkRequest {
operation,
input: file,
record_type: try_parse_record_type(&record_type)?,
output: csv,
progress,
};
return Ok(bulk::run_bulk(request, format, config).await?.exit_code());
}
Commands::Follow {
args,
server,
changes_only,
} => {
let flags = dns_args::FollowFlags {
server,
changes_only,
};
let args = dns_args::parse_follow(&args, flags)?;
let follower = seer_core::DnsFollower::from_config(config);
ops::follow_command(&follower, args, config, format).await?;
return Ok(0);
}
Commands::Watch {
action,
domains,
fail_on,
webhook,
} => return watch_command(action, &domains, fail_on, webhook, output, config).await,
Commands::History { domain, clear } => {
output.show_listing(&manage::history(domain.as_deref(), clear, "seer lookup").await?);
return Ok(0);
}
Commands::Config { init } => {
let listing = if init {
manage::config_init()?
} else {
manage::config_show(config)
};
output.show_listing(&listing);
return Ok(0);
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "seer", &mut std::io::stdout());
return Ok(0);
}
Commands::GenerateKey { export, bytes } => {
use base64::Engine;
let mut buf = vec![0u8; usize::from(bytes)];
getrandom::fill(&mut buf).map_err(|e| format!("OS RNG unavailable: {e}"))?;
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf);
if export {
println!("export SEER_API_KEY={}", token);
} else {
println!("{}", token);
}
return Ok(0);
}
Commands::Mangen { dir } => {
std::fs::create_dir_all(&dir)?;
clap_mangen::generate_to(Cli::command(), &dir)?;
println!(
"Man pages written to: {}",
dir.display().to_string().ctp_green()
);
return Ok(0);
}
Commands::Tui { domain } => {
tui::run(domain).await?;
return Ok(0);
}
};
let spin = cli_spinner(&query);
let clients = query::Clients::from_config(config);
let outcome = query::run(query, &clients, config, spin).await?;
outcome.present(format, |payload| output.show_payload(payload));
Ok(outcome_exit_code(&outcome))
}
async fn watch_command(
action: Option<String>,
domains: &[String],
fail_on: FailOn,
webhook: Option<String>,
output: &Output,
config: &seer_core::SeerConfig,
) -> Result<i32, Failure> {
if let Some(action) = action {
output.show_listing(&manage::watch_edit(&action, domains, "seer watch").await?);
return Ok(0);
}
let report = match manage::watch_check(config, "seer watch").await? {
manage::WatchCheck::Empty(listing) => {
output.show_listing(&listing);
return Ok(0);
}
manage::WatchCheck::Report(report) => report,
};
output.show_payload(&Payload::Watch(report.clone()));
if let Some(url) = webhook.as_deref().or(config.watch.webhook_url.as_deref()) {
let client = seer_core::webhook::WebhookClient::from_config(config);
if let Err(e) = client.post_json(url, &report).await {
eprintln!("{} webhook delivery failed: {}", "Warning:".ctp_yellow(), e);
}
}
let failed = match fail_on {
FailOn::Critical => report.critical > 0,
FailOn::Warning => report.warnings > 0 || report.critical > 0,
};
Ok(i32::from(failed))
}
fn cli_spinner(query: &Query) -> bool {
matches!(
query,
Query::Lookup(_)
| Query::Info(_)
| Query::Subdomains { .. }
| Query::Drift { .. }
| Query::Headers(_)
| Query::Takeover { .. }
| Query::Confusables(_)
| Query::Diff(..)
| Query::Doctor
| Query::Delegation(_)
| Query::Trace { .. }
)
}
fn dig_query(
args: &[String],
flags: dig_args::DigFlags,
quiet: bool,
fields: bool,
) -> Result<Query, String> {
let dig = dig_args::parse(args, flags)?;
check_short_output(dig.short, quiet, fields)?;
Ok(dig.into_query())
}
fn check_short_output(short: bool, quiet: bool, fields: bool) -> Result<(), String> {
if short && (quiet || fields) {
return Err(
"+short/--short prints bare values, so it cannot be combined with -q/--fields \
(which select from the JSON result)"
.to_string(),
);
}
Ok(())
}
fn outcome_exit_code(outcome: &query::Outcome) -> i32 {
if outcome.failed() {
1
} else {
exit_code(&outcome.payload)
}
}
fn exit_code(payload: &Payload) -> i32 {
let failed = match payload {
Payload::Status(s) => {
s.http_status.is_none_or(|code| !(200..300).contains(&code))
|| s.certificate
.as_ref()
.is_some_and(|c| !c.is_valid || c.days_until_expiry < 30)
|| s.domain_expiration
.as_ref()
.is_some_and(|d| d.days_until_expiry < 30)
}
Payload::Avail(a) => !a.available,
Payload::Dnssec(r) => r.status != "signed",
Payload::Compare(c) => !c.matches,
Payload::Drift(d) => d.has_drift(),
Payload::Takeover(t) => t.has_findings(),
Payload::SubdomainBaselineDiff(d) => d.has_new_names(),
Payload::Doctor(r) => r.overall == seer_core::doctor::CheckStatus::Fail,
Payload::Delegation(d) => !d.in_sync || !d.lame.is_empty(),
Payload::Dig(_) | Payload::DigMany(_) | Payload::Trace(_) => false,
_ => false,
};
i32::from(failed)
}
pub(crate) fn render_doctor_report(
report: &seer_core::doctor::DoctorReport,
format: OutputFormat,
) -> String {
use colored::Colorize as _;
use seer_core::doctor::CheckStatus;
match format {
OutputFormat::Json => serde_json::to_string_pretty(report)
.unwrap_or_else(|e| format!("{{\"error\":\"{}\"}}", e)),
OutputFormat::Yaml => seer_core::output::YamlFormatter::new().to_yaml_value(report),
OutputFormat::Markdown => {
let mut out = String::from(
"# Doctor Report\n\n| Check | Status | Latency | Detail |\n|---|---|---|---|\n",
);
for check in &report.checks {
let latency = check
.latency_ms
.map(|ms| format!("{}ms", ms))
.unwrap_or_else(|| "—".to_string());
let detail = check.detail.replace('|', "\\|");
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
check.name, check.status, latency, detail
));
}
out.push_str(&format!("\n**Overall:** {}\n", report.overall));
out
}
OutputFormat::Human => {
let status_str = |s: CheckStatus| match s {
CheckStatus::Pass => "PASS".ctp_green().bold().to_string(),
CheckStatus::Warn => "WARN".ctp_yellow().bold().to_string(),
CheckStatus::Fail => "FAIL".ctp_red().bold().to_string(),
};
let mut out = format!("{}\n\n", "Doctor Report".sky().bold());
for check in &report.checks {
let latency = check
.latency_ms
.map(|ms| format!(" ({}ms)", ms))
.unwrap_or_default();
out.push_str(&format!(
" {} {:<16} {}{}\n",
status_str(check.status),
check.name,
check.detail,
latency
));
}
out.push_str(&format!("\nOverall: {}", status_str(report.overall)));
out
}
}
}
#[cfg(test)]
mod compare_follow_cli_tests {
use super::{dns_args, Cli, Commands, Query};
use clap::Parser;
use seer_core::RecordType;
fn compare(argv: &[&str]) -> Result<Query, String> {
let cli = Cli::try_parse_from(["seer", "compare"].iter().chain(argv).copied())
.map_err(|e| e.to_string())?;
let Some(Commands::Compare { args }) = cli.command else {
panic!("expected Compare command");
};
dns_args::parse_compare(&args).map(dns_args::CompareArgs::into_query)
}
#[test]
fn compare_accepts_the_positional_and_at_forms() {
for (argv, want) in [
(&["example.com", "8.8.8.8", "1.1.1.1"][..], RecordType::A),
(&["example.com", "8.8.8.8", "1.1.1.1", "MX"], RecordType::MX),
(
&["example.com", "MX", "@8.8.8.8", "@1.1.1.1"],
RecordType::MX,
),
] {
let Ok(Query::Compare {
domain,
record_type,
server_a,
server_b,
}) = compare(argv)
else {
panic!("{argv:?} should parse");
};
assert_eq!(domain, "example.com");
assert_eq!(record_type, want, "{argv:?}");
assert_eq!(
(server_a.as_str(), server_b.as_str()),
("8.8.8.8", "1.1.1.1")
);
}
let err = compare(&["example.com", "8.8.8.8"])
.err()
.expect("one server");
assert!(err.contains("two nameservers"), "{err}");
}
#[test]
fn follow_merges_clap_flags_into_the_grammar() {
let cli = Cli::try_parse_from([
"seer",
"follow",
"example.com",
"5",
"0.5",
"MX",
"-s",
"1.1.1.1",
"--changes-only",
])
.expect("follow parses");
let Some(Commands::Follow {
args,
server,
changes_only,
}) = cli.command
else {
panic!("expected Follow command");
};
let got = dns_args::parse_follow(
&args,
dns_args::FollowFlags {
server,
changes_only,
},
)
.expect("valid");
assert_eq!(got.domain, "example.com");
assert_eq!((got.iterations, got.interval_minutes), (5, 0.5));
assert_eq!(got.record_type, RecordType::MX);
assert_eq!(got.nameserver.as_deref(), Some("1.1.1.1"));
assert!(got.changes_only);
}
}
#[cfg(test)]
mod dig_cli_tests {
use super::{dig_query, Cli, Commands, Query};
use clap::Parser;
use seer_core::RecordType;
fn query(argv: &[&str]) -> Result<Query, String> {
let cli = Cli::try_parse_from(std::iter::once("seer").chain(argv.iter().copied()))
.map_err(|e| e.to_string())?;
let Some(Commands::Dig {
args,
server,
short,
trace,
reverse,
}) = cli.command
else {
panic!("expected Dig command");
};
let flags = super::dig_args::DigFlags {
server,
short,
trace,
reverse,
};
dig_query(&args, flags, cli.quiet, cli.fields.is_some())
}
#[test]
fn positional_tokens_and_flags_interleave() {
for argv in [
&["dig", "@1.1.1.1", "example.com", "A", "MX", "+short"][..],
&["dig", "example.com", "--short", "A", "-s", "1.1.1.1", "MX"],
&[
"dig",
"-s",
"@1.1.1.1",
"A",
"example.com",
"MX",
"A",
"--short",
],
] {
let Ok(Query::Dig {
name,
types,
server,
short,
}) = query(argv)
else {
panic!("{argv:?} should be a dig query");
};
assert_eq!(name, "example.com", "{argv:?}");
assert_eq!(types, vec![RecordType::A, RecordType::MX], "{argv:?}");
assert_eq!(server.as_deref(), Some("1.1.1.1"), "{argv:?}");
assert!(short, "{argv:?}");
}
}
#[test]
fn defaults_reverse_and_trace() {
assert!(matches!(
query(&["dig", "example.com"]),
Ok(Query::Dig { ref name, ref types, server: None, short: false })
if name == "example.com" && types == &[RecordType::A]
));
assert!(matches!(
query(&["dig", "-x", "2001:db8::1"]),
Ok(Query::Dig { ref name, ref types, .. })
if name == "2001:db8::1" && types == &[RecordType::PTR]
));
assert!(matches!(
query(&["dig", "--reverse", "192.0.2.1", "+short"]),
Ok(Query::Dig { short: true, .. })
));
for argv in [
&["dig", "--trace", "www.example.com", "AAAA"][..],
&["dig", "www.example.com", "AAAA", "+trace"],
] {
assert!(
matches!(
query(argv),
Ok(Query::Trace { ref name, record_type: RecordType::AAAA, short: false })
if name == "www.example.com"
),
"{argv:?}"
);
}
assert!(matches!(
query(&["dig", "example.com", "--trace", "--short"]),
Ok(Query::Trace {
record_type: RecordType::A,
short: true,
..
})
));
}
#[test]
fn flag_conflicts_are_usage_errors() {
for (argv, problem) in [
(&["dig"][..], "no name to query"),
(
&["dig", "--trace", "-s", "8.8.8.8", "example.com"],
"root servers",
),
(
&["dig", "--trace", "example.com", "A", "MX"],
"one record type",
),
(
&["dig", "-s", "8.8.8.8", "@1.1.1.1", "example.com"],
"one nameserver",
),
(&["dig", "-x", "8.8.8.8", "example.com"], "one name"),
(&["dig", "-x", "not-an-ip"], "-x needs an IP address"),
(&["dig", "example.com", "+tcp"], "unknown dig option '+tcp'"),
] {
let err = query(argv).err().expect("must be rejected");
assert!(err.contains(problem), "{argv:?}: {err}");
}
}
#[test]
fn short_conflicts_with_quiet_and_fields_but_not_format() {
for argv in [
&["-q", "dig", "example.com", "+short"][..],
&["--fields", "status", "dig", "example.com", "--short"],
&["-q", "--fields", "status", "dig", "example.com", "+short"],
] {
let err = query(argv).err().expect("must be rejected");
assert!(err.contains("-q/--fields"), "{argv:?}: {err}");
}
assert!(query(&["--format", "json", "dig", "example.com", "+short"]).is_ok());
assert!(query(&["-q", "--fields", "status", "dig", "example.com"]).is_ok());
}
}
#[cfg(test)]
mod feature_suite_cli_tests {
use super::{Cli, Commands};
use clap::Parser;
#[test]
fn doctor_parses_with_no_args() {
let cli = Cli::try_parse_from(["seer", "doctor"]).expect("doctor should parse");
assert!(matches!(cli.command, Some(Commands::Doctor)));
}
#[test]
fn delegation_requires_a_domain() {
assert!(Cli::try_parse_from(["seer", "delegation"]).is_err());
let cli = Cli::try_parse_from(["seer", "delegation", "example.com"])
.expect("delegation should parse");
let Some(Commands::Delegation { domain }) = cli.command else {
panic!("expected Delegation command");
};
assert_eq!(domain, "example.com");
}
#[test]
fn headers_requires_a_domain() {
assert!(Cli::try_parse_from(["seer", "headers"]).is_err());
let cli =
Cli::try_parse_from(["seer", "headers", "example.com"]).expect("headers should parse");
let Some(Commands::Headers { domain }) = cli.command else {
panic!("expected Headers command");
};
assert_eq!(domain, "example.com");
}
#[test]
fn takeover_requires_a_domain_and_defaults_to_ct_enumeration() {
assert!(Cli::try_parse_from(["seer", "takeover"]).is_err());
let cli = Cli::try_parse_from(["seer", "takeover", "example.com"])
.expect("takeover should parse");
let Some(Commands::Takeover { domain, hosts }) = cli.command else {
panic!("expected Takeover command");
};
assert_eq!(domain, "example.com");
assert!(hosts.is_empty());
}
#[test]
fn takeover_host_flag_is_repeatable() {
let cli = Cli::try_parse_from([
"seer",
"takeover",
"example.com",
"--host",
"a.example.com",
"--host",
"b.example.com",
])
.expect("takeover --host should parse");
let Some(Commands::Takeover { hosts, .. }) = cli.command else {
panic!("expected Takeover command");
};
assert_eq!(hosts, vec!["a.example.com", "b.example.com"]);
}
#[test]
fn watch_webhook_flag_parses_and_defaults_to_none() {
let cli = Cli::try_parse_from([
"seer",
"watch",
"--webhook",
"https://hooks.example.com/seer",
])
.expect("watch --webhook should parse");
let Some(Commands::Watch { webhook, .. }) = cli.command else {
panic!("expected Watch command");
};
assert_eq!(webhook.as_deref(), Some("https://hooks.example.com/seer"));
let cli = Cli::try_parse_from(["seer", "watch"]).expect("bare watch should parse");
let Some(Commands::Watch { webhook, .. }) = cli.command else {
panic!("expected Watch command");
};
assert_eq!(webhook, None);
}
#[test]
fn mangen_parses_and_is_hidden_from_help() {
let cli = Cli::try_parse_from(["seer", "mangen", "/tmp/man"]).expect("mangen parses");
let Some(Commands::Mangen { dir }) = cli.command else {
panic!("expected Mangen command");
};
assert_eq!(dir, std::path::PathBuf::from("/tmp/man"));
use clap::CommandFactory;
let mut help = Vec::new();
Cli::command()
.write_long_help(&mut help)
.expect("help renders");
let help = String::from_utf8(help).expect("utf8 help");
assert!(
!help.contains("mangen"),
"mangen should be hidden from help"
);
}
#[test]
fn mangen_generates_root_and_subcommand_pages() {
use clap::CommandFactory;
let dir = std::env::temp_dir().join(format!(
"seer-mangen-test-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("create tempdir");
clap_mangen::generate_to(Cli::command(), &dir).expect("man generation succeeds");
assert!(dir.join("seer.1").is_file(), "root page seer.1 missing");
assert!(
dir.join("seer-lookup.1").is_file(),
"subcommand page seer-lookup.1 missing"
);
assert!(
dir.join("seer-doctor.1").is_file(),
"subcommand page seer-doctor.1 missing"
);
assert!(
!dir.join("seer-mangen.1").exists(),
"hidden mangen must not get a man page"
);
std::fs::remove_dir_all(&dir).expect("cleanup tempdir");
}
}
#[cfg(test)]
mod doctor_render_tests {
use super::render_doctor_report;
use seer_core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
use seer_core::output::OutputFormat;
fn sample_report() -> DoctorReport {
DoctorReport::from_checks(vec![
DoctorCheck {
name: "config".into(),
status: CheckStatus::Pass,
detail: "using built-in defaults".into(),
latency_ms: None,
},
DoctorCheck {
name: "dns".into(),
status: CheckStatus::Fail,
detail: "query timed out | resolver unreachable".into(),
latency_ms: Some(5000),
},
])
}
#[test]
fn human_output_lists_checks_and_overall() {
let out = render_doctor_report(&sample_report(), OutputFormat::Human);
assert!(out.contains("config"));
assert!(out.contains("PASS"));
assert!(out.contains("FAIL"));
assert!(out.contains("5000ms"));
assert!(out.contains("Overall:"));
}
#[test]
fn json_output_round_trips_through_serde() {
let out = render_doctor_report(&sample_report(), OutputFormat::Json);
let parsed: DoctorReport = serde_json::from_str(&out).expect("valid JSON report");
assert_eq!(parsed.overall, CheckStatus::Fail);
assert_eq!(parsed.checks.len(), 2);
}
#[test]
fn markdown_output_is_a_table_with_escaped_pipes() {
let out = render_doctor_report(&sample_report(), OutputFormat::Markdown);
assert!(out.starts_with("# Doctor Report"));
assert!(out.contains("| config | PASS |"));
assert!(out.contains("timed out \\| resolver"));
assert!(out.contains("**Overall:** FAIL"));
}
#[test]
fn yaml_output_is_non_empty() {
let out = render_doctor_report(&sample_report(), OutputFormat::Yaml);
assert!(out.contains("overall"));
}
}
#[cfg(test)]
mod record_type_parse_tests {
use super::try_parse_record_type;
#[test]
fn parses_valid_types_case_insensitively() {
assert_eq!(
try_parse_record_type("mx").unwrap(),
seer_core::RecordType::MX
);
assert_eq!(
try_parse_record_type("AAAA").unwrap(),
seer_core::RecordType::AAAA
);
}
#[test]
fn invalid_type_error_names_input_and_lists_valid_types() {
let err = try_parse_record_type("MXX").unwrap_err();
assert!(err.contains("MXX"), "error should name the input: {err}");
assert!(
err.contains("AAAA") && err.contains("SSHFP"),
"error should list valid types: {err}"
);
}
#[test]
fn valid_types_list_stays_parseable() {
for name in super::VALID_RECORD_TYPES.split(", ") {
assert!(
try_parse_record_type(name).is_ok(),
"VALID_RECORD_TYPES lists {name}, which no longer parses"
);
}
}
}
#[cfg(test)]
mod exit_code_tests {
use super::{exit_code, Payload};
use chrono::TimeZone;
fn dnssec(status: &str) -> Payload {
Payload::Dnssec(Box::new(seer_core::DnssecReport {
domain: "example.com".into(),
enabled: status != "unsigned",
has_ds_records: false,
has_dnskey_records: false,
ds_records: vec![],
dnskey_records: vec![],
issues: vec![],
status: status.into(),
chain_valid: status == "signed",
authentication_tier: seer_core::dns::AuthenticationTier::DigestOnly,
}))
}
#[test]
fn only_a_signed_zone_passes_dnssec() {
assert_eq!(exit_code(&dnssec("signed")), 0);
for status in ["unsigned", "partial", "misconfigured", "secure"] {
assert_eq!(exit_code(&dnssec(status)), 1, "{status} must fail");
}
}
fn status(http: Option<u16>, cert_days: Option<i64>, expiry_days: Option<i64>) -> Payload {
let at = chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let mut s = seer_core::StatusResponse::new("example.com".into());
s.http_status = http;
s.certificate = cert_days.map(|days| seer_core::CertificateInfo {
issuer: "CA".into(),
subject: "example.com".into(),
valid_from: at,
valid_until: at,
days_until_expiry: days,
is_valid: days > 0,
hostname_verified: true,
});
s.domain_expiration = expiry_days.map(|days| seer_core::DomainExpiration {
expiration_date: at,
days_until_expiry: days,
registrar: None,
});
Payload::Status(Box::new(s))
}
#[test]
fn status_fails_on_non_2xx_or_expiry_within_30_days() {
assert_eq!(exit_code(&status(Some(200), Some(90), Some(365))), 0);
assert_eq!(exit_code(&status(Some(204), None, None)), 0);
assert_eq!(exit_code(&status(None, Some(90), Some(365))), 1);
assert_eq!(exit_code(&status(Some(503), Some(90), Some(365))), 1);
assert_eq!(exit_code(&status(Some(200), Some(29), Some(365))), 1);
assert_eq!(exit_code(&status(Some(200), Some(-1), Some(365))), 1);
assert_eq!(exit_code(&status(Some(200), Some(90), Some(29))), 1);
}
#[test]
fn avail_compare_drift_and_takeover_fail_on_their_finding() {
let avail = |available| {
Payload::Avail(Box::new(seer_core::AvailabilityResult {
domain: "example.com".into(),
available,
confidence: "high".into(),
method: "rdap".into(),
details: None,
}))
};
assert_eq!(exit_code(&avail(true)), 0);
assert_eq!(exit_code(&avail(false)), 1);
let compare = |matches| {
let side = |ns: &str| seer_core::dns::ServerResult {
nameserver: ns.into(),
status: Some(seer_core::DnsStatus::NoError),
cname_chain: vec![],
records: vec![],
error: None,
};
Payload::Compare(Box::new(seer_core::DnsComparison {
domain: "example.com".into(),
record_type: seer_core::RecordType::A,
server_a: side("8.8.8.8"),
server_b: side("1.1.1.1"),
matches,
only_in_a: vec![],
only_in_b: vec![],
common: vec![],
}))
};
assert_eq!(exit_code(&compare(true)), 0);
assert_eq!(exit_code(&compare(false)), 1);
let mut drift = seer_core::DriftReport::empty("example.com");
assert_eq!(exit_code(&Payload::Drift(Box::new(drift.clone()))), 0);
drift.changes.push(seer_core::FieldChange {
field: "registrar".into(),
old: Some("A".into()),
new: Some("B".into()),
});
assert_eq!(exit_code(&Payload::Drift(Box::new(drift))), 1);
let takeover = |vulnerable, potential| {
Payload::Takeover(Box::new(seer_core::TakeoverReport {
domain: "example.com".into(),
hosts_checked: 3,
hosts_skipped: 0,
vulnerable,
potential,
inconclusive: 0,
findings: vec![],
notes: vec![],
}))
};
assert_eq!(exit_code(&takeover(0, 0)), 0);
assert_eq!(exit_code(&takeover(1, 0)), 1);
assert_eq!(exit_code(&takeover(0, 1)), 1);
}
#[test]
fn subdomain_diff_fails_only_on_added_names() {
let diff = |added: &[&str], removed: &[&str], baseline_missing| {
Payload::SubdomainBaselineDiff(Box::new(seer_core::SubdomainBaselineDiff {
domain: "example.com".into(),
baseline_recorded_at: None,
added: added.iter().map(|s| s.to_string()).collect(),
removed: removed.iter().map(|s| s.to_string()).collect(),
unchanged_count: 1,
baseline_missing,
baseline_truncated: false,
}))
};
assert_eq!(exit_code(&diff(&[], &[], true)), 0, "first run");
assert_eq!(exit_code(&diff(&[], &["old.example.com"], false)), 0);
assert_eq!(exit_code(&diff(&["new.example.com"], &[], false)), 1);
}
#[test]
fn doctor_fails_only_on_fail_and_delegation_on_drift_or_lameness() {
use seer_core::doctor::{CheckStatus, DoctorCheck, DoctorReport};
let doctor = |status| {
Payload::Doctor(Box::new(DoctorReport::from_checks(vec![DoctorCheck {
name: "dns".into(),
status,
detail: String::new(),
latency_ms: None,
}])))
};
assert_eq!(exit_code(&doctor(CheckStatus::Pass)), 0);
assert_eq!(exit_code(&doctor(CheckStatus::Warn)), 0);
assert_eq!(exit_code(&doctor(CheckStatus::Fail)), 1);
let delegation = |in_sync, lame: Vec<seer_core::dns::LameNs>| {
Payload::Delegation(Box::new(seer_core::dns::DelegationReport {
domain: "example.com".into(),
parent_zone: "com".into(),
parent_server_queried: vec![],
delegated_ns: vec![],
zone_ns: vec![],
in_sync,
missing_from_zone: vec![],
missing_from_parent: vec![],
lame,
warnings: vec![],
}))
};
let lame = || {
vec![seer_core::dns::LameNs {
host: "ns1.example.com".into(),
reason: "REFUSED".into(),
}]
};
assert_eq!(exit_code(&delegation(true, vec![])), 0);
assert_eq!(exit_code(&delegation(false, vec![])), 1);
assert_eq!(exit_code(&delegation(true, lame())), 1);
}
#[test]
fn informational_results_exit_zero() {
assert_eq!(exit_code(&Payload::Reverse(vec![])), 0);
}
#[test]
fn dig_and_trace_are_not_check_style() {
use crate::payload::fixtures;
use seer_core::{DnsStatus, RecordType};
for status in [
DnsStatus::NoError,
DnsStatus::NxDomain,
DnsStatus::ServFail,
DnsStatus::Refused,
] {
let result = fixtures::dig_status(RecordType::A, status);
assert_eq!(exit_code(&Payload::Dig(Box::new(result.clone()))), 0);
assert_eq!(exit_code(&Payload::DigMany(vec![result])), 0);
}
let stopped = fixtures::trace(vec![], Some("every server of seer.test. failed"));
assert_eq!(exit_code(&Payload::Trace(Box::new(stopped))), 0);
let answered = fixtures::trace(vec![fixtures::a("www.seer.test", "192.0.2.7")], None);
assert_eq!(exit_code(&Payload::Trace(Box::new(answered))), 0);
}
#[test]
fn a_stopped_trace_exits_one_only_when_short() {
use crate::payload::fixtures;
use crate::query::trace_outcome;
let stopped = || fixtures::trace(vec![], Some("every server of seer.test. failed"));
assert_eq!(super::outcome_exit_code(&trace_outcome(stopped(), true)), 1);
assert_eq!(
super::outcome_exit_code(&trace_outcome(stopped(), false)),
0
);
let answered = fixtures::trace(vec![fixtures::a("www.seer.test", "192.0.2.7")], None);
assert_eq!(super::outcome_exit_code(&trace_outcome(answered, true)), 0);
}
#[test]
fn a_partly_failed_dig_exits_one() {
use crate::payload::fixtures;
use seer_core::{DnsStatus, RecordType, SeerError};
let outcome = crate::query::dig_outcome(vec![
(
RecordType::A,
Ok(fixtures::dig_status(RecordType::A, DnsStatus::NxDomain)),
),
(
RecordType::MX,
Err(SeerError::DnsError("MX lookup failed: timed out".into())),
),
])
.expect("one type answered");
assert_eq!(super::outcome_exit_code(&outcome), 1);
let outcome = crate::query::dig_outcome(vec![(
RecordType::A,
Ok(fixtures::dig_status(RecordType::A, DnsStatus::ServFail)),
)])
.expect("answered");
assert_eq!(super::outcome_exit_code(&outcome), 0);
}
}
#[cfg(test)]
mod quiet_fields_tests {
use super::extract_field_lines;
use serde_json::json;
fn fields(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
#[test]
fn field_path_applies_to_each_array_element() {
let records = json!([
{"name": "example.com", "ttl": 300},
{"name": "www.example.com", "ttl": 60}
]);
assert_eq!(
extract_field_lines(&records, &fields(&["name"])),
vec!["example.com", "www.example.com"]
);
}
#[test]
fn numeric_segments_index_arrays() {
let value = json!({"records": [{"data": {"address": "1.2.3.4"}}, {"data": {"address": "5.6.7.8"}}]});
assert_eq!(
extract_field_lines(&value, &fields(&["records.1.data.address"])),
vec!["5.6.7.8"]
);
assert_eq!(
extract_field_lines(&json!(["a", "b"]), &fields(&["0"])),
vec!["a"]
);
assert_eq!(
extract_field_lines(&json!(["a"]), &fields(&["5"])),
vec![""]
);
}
#[test]
fn dig_fields_select_from_the_result_object() {
use crate::payload::{fixtures, Payload};
let result = fixtures::dig(
seer_core::RecordType::A,
vec![
fixtures::cname("www.seer.test", "edge.cdn.test."),
fixtures::a("edge.cdn.test", "192.0.2.7"),
],
);
let value = serde_json::to_value(Payload::Dig(Box::new(result))).unwrap();
assert_eq!(
extract_field_lines(
&value,
&fields(&["status", "flags", "answers.name", "wildcard"])
),
vec![
"NOERROR",
r#"["qr","rd","ra"]"#,
"www.seer.test",
"edge.cdn.test",
""
]
);
assert_eq!(
extract_field_lines(&value, &fields(&["answers.1.data.value.address"])),
vec!["192.0.2.7"]
);
}
#[test]
fn list_roots_extract_fields_element_by_element() {
use crate::payload::{fixtures, Payload};
use seer_core::{DnsStatus, RecordType};
let many = Payload::DigMany(vec![
fixtures::dig(
RecordType::A,
vec![fixtures::a("www.seer.test", "192.0.2.7")],
),
fixtures::dig_status(RecordType::AAAA, DnsStatus::NoError),
fixtures::dig(
RecordType::MX,
vec![fixtures::mx("www.seer.test", "mail.seer.test.")],
),
]);
let value = serde_json::to_value(many).unwrap();
assert_eq!(
extract_field_lines(&value, &fields(&["record_type", "status"])),
vec!["A", "NOERROR", "AAAA", "NOERROR", "MX", "NOERROR"]
);
assert_eq!(
extract_field_lines(&value, &fields(&["2.record_type", "0.status"])),
vec!["MX", "NOERROR"]
);
assert_eq!(
extract_field_lines(&value, &fields(&["record_type"])),
vec!["A", "AAAA", "MX"]
);
}
#[test]
fn object_paths_and_missing_fields_are_unchanged() {
let value = json!({"certificate": {"issuer": "Test CA", "days": 30}});
assert_eq!(
extract_field_lines(
&value,
&fields(&["certificate.issuer", "certificate.days", "nope"])
),
vec!["Test CA", "30", ""]
);
}
}
#[cfg(test)]
mod global_option_tests {
use super::{parse_config_format, Cli, Commands, Output};
use clap::Parser;
use seer_core::output::OutputFormat;
#[test]
fn a_mistyped_format_flag_is_a_usage_error() {
let err = Cli::try_parse_from(["seer", "--format", "jsno", "lookup", "example.com"])
.err()
.expect("must be rejected");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
assert!(
err.to_string().contains("human, json, yaml, markdown"),
"{err}"
);
for (flag, want) in [
("json", OutputFormat::Json),
("YML", OutputFormat::Yaml),
("md", OutputFormat::Markdown),
("text", OutputFormat::Human),
] {
let cli = Cli::try_parse_from(["seer", "-f", flag, "doctor"]).expect("valid");
assert_eq!(cli.format, Some(want), "{flag}");
}
}
#[test]
fn the_config_format_falls_back_with_a_warning() {
assert_eq!(parse_config_format("yaml"), Ok(OutputFormat::Yaml));
assert_eq!(parse_config_format(""), Ok(OutputFormat::Human));
let warning = parse_config_format("jsno").expect_err("warns");
assert!(
warning.contains("jsno") && warning.contains("using human"),
"{warning}"
);
}
#[test]
fn fields_imply_quiet() {
let fields = Some(vec!["status".to_string()]);
assert!(Output::new(OutputFormat::Human, false, fields).quiet);
assert!(Output::new(OutputFormat::Human, true, None).quiet);
assert!(!Output::new(OutputFormat::Human, false, None).quiet);
}
#[test]
fn generate_key_bytes_are_bounded_by_clap() {
for bytes in ["0", "4097"] {
let err = Cli::try_parse_from(["seer", "generate-key", "--bytes", bytes])
.err()
.expect("out of range");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
let cli = Cli::try_parse_from(["seer", "generate-key", "--bytes", "4096"]).expect("max");
assert!(matches!(
cli.command,
Some(Commands::GenerateKey { bytes: 4096, .. })
));
}
#[test]
fn history_clear_conflicts_with_a_domain() {
assert!(Cli::try_parse_from(["seer", "history", "example.com", "--clear"]).is_err());
assert!(Cli::try_parse_from(["seer", "history", "--clear"]).is_ok());
}
#[test]
fn watch_add_takes_several_domains() {
let cli = Cli::try_parse_from(["seer", "watch", "add", "a.com", "b.com"]).expect("valid");
let Some(Commands::Watch {
action, domains, ..
}) = cli.command
else {
panic!("expected Watch command");
};
assert_eq!(action.as_deref(), Some("add"));
assert_eq!(domains, vec!["a.com", "b.com"]);
}
#[tokio::test]
async fn command_errors_return_instead_of_exiting() {
let config = seer_core::SeerConfig::default();
let output = Output::new(OutputFormat::Json, false, None);
for argv in [
&["seer", "compare", "example.com"][..],
&["seer", "prop", "example.com", "MXX"],
&["seer", "dig", "mx", "NS"],
&["seer", "follow", "example.com", "1", "2", "3"],
] {
let cli = Cli::try_parse_from(argv).expect("clap accepts it");
let command = cli.command.expect("a command");
let failure = super::execute_command(command, &output, &config)
.await
.expect_err("usage error");
assert!(!failure.0.is_empty(), "{argv:?}");
}
}
}