use crate::output;
use crate::selector;
use crate::services::dns::DnsService;
use clap::{Subcommand, ValueEnum};
use dialoguer::{Input, theme::ColorfulTheme};
use eyre::Result;
#[derive(Clone, ValueEnum)]
pub enum OutputFormat {
Human,
Json,
Tsv,
}
#[derive(Subcommand)]
pub enum DnsCommands {
#[command(alias = "l", about = "List DNS records")]
List {
#[arg(short, long, help = "Filter by subdomain name")]
subdomain: Option<String>,
#[arg(short = 'P', long, help = "Use production API (default: sandbox)")]
production: bool,
},
#[command(alias = "st", about = "Show DNS status and health")]
Status {
#[arg(short = 'P', long, help = "Use production API (default: sandbox)")]
production: bool,
},
#[command(alias = "s", about = "Set an A record for a subdomain")]
Set {
#[arg(short, long, help = "Subdomain name")]
subdomain: Option<String>,
#[arg(short, long, help = "IP address")]
ip: Option<String>,
#[arg(short = 'P', long, help = "Use production API (default: sandbox)")]
production: bool,
},
#[command(alias = "m", about = "Migrate all A records to a new IP")]
Migrate {
#[arg(short, long, help = "New IP address")]
ip: String,
#[arg(short = 'n', long, help = "Dry run (don't actually migrate)")]
dry_run: bool,
#[arg(short = 'P', long, help = "Use production API (default: sandbox)")]
production: bool,
},
#[command(
alias = "sa",
about = "Batch create A records for all app subdomains",
long_about = "Interactively or automatically create DNS A records for all configured \
app subdomains (blocky, calibre, freshrss, etc.) pointing to a selected \
host's IP address."
)]
SetAll {
#[arg(
short = 'H',
long,
value_name = "HOST",
help = "Target host (auberge, auberge-old, vibecoder)"
)]
host: Option<String>,
#[arg(
short,
long,
value_name = "IP",
conflicts_with = "host",
help = "Override IP address"
)]
ip: Option<String>,
#[arg(short = 'n', long, help = "Preview changes without executing")]
dry_run: bool,
#[arg(short = 'y', long, help = "Skip confirmation prompt")]
yes: bool,
#[arg(
short,
long,
help = "Fail if any subdomain env var is missing (non-interactive)"
)]
strict: bool,
#[arg(
short = 'S',
long,
value_name = "NAMES",
value_delimiter = ',',
help = "Only process specific subdomains (comma-separated)"
)]
subdomains: Vec<String>,
#[arg(
long,
value_name = "NAMES",
value_delimiter = ',',
help = "Skip specific subdomains (comma-separated)"
)]
skip: Vec<String>,
#[arg(
short = 'o',
long,
value_enum,
default_value = "human",
help = "Output format"
)]
output: OutputFormat,
#[arg(long, help = "Continue on errors instead of failing fast")]
continue_on_error: bool,
#[arg(short = 'P', long, help = "Use production API (default: sandbox)")]
production: bool,
},
}
pub async fn run_dns_list(subdomain: Option<String>, production: bool) -> Result<()> {
let service = DnsService::new_with_production(Some(production)).await?;
print_mode_banner();
let records = service.list_records().await?;
let filtered: Vec<_> = match &subdomain {
Some(name) => records.iter().filter(|r| r.name == *name).collect(),
None => records.iter().collect(),
};
if filtered.is_empty() {
output::info("No DNS records found");
return Ok(());
}
eprintln!(
"DNS Records for {}\n{:<40} {:<8} {:<24} {:>6}",
service.domain(),
"NAME",
"TYPE",
"CONTENT",
"TTL"
);
eprintln!("{}", "-".repeat(80));
for record in filtered {
let (record_type, content) = format_dns_content(&record.content);
eprintln!(
"{:<40} {:<8} {:<24} {:>6}",
record.name, record_type, content, record.ttl
);
}
Ok(())
}
fn format_dns_content(content: &cloudflare::endpoints::dns::dns::DnsContent) -> (String, String) {
use cloudflare::endpoints::dns::dns::DnsContent;
match content {
DnsContent::A { content } => ("A".to_string(), content.to_string()),
DnsContent::AAAA { content } => ("AAAA".to_string(), content.to_string()),
DnsContent::CNAME { content } => ("CNAME".to_string(), content.clone()),
DnsContent::MX { content, priority } => {
("MX".to_string(), format!("{} ({})", content, priority))
}
DnsContent::TXT { content } => ("TXT".to_string(), content.clone()),
DnsContent::NS { content } => ("NS".to_string(), content.clone()),
DnsContent::SRV { content } => ("SRV".to_string(), content.clone()),
}
}
fn print_mode_banner() {
output::info("CLOUDFLARE DNS");
}
pub async fn run_dns_status(production: bool) -> Result<()> {
let service = DnsService::new_with_production(Some(production)).await?;
print_mode_banner();
let status = service.status().await?;
eprintln!("DNS Status for {}", status.domain);
eprintln!("{}", "-".repeat(40));
eprintln!(
"\nConfigured subdomains: {}",
status.configured_subdomains.join(", ")
);
use cloudflare::endpoints::dns::dns::DnsContent;
let a_records: Vec<_> = status
.active_records
.iter()
.filter(|r| matches!(r.content, DnsContent::A { .. }))
.collect();
eprintln!("\nActive A records: {}", a_records.len());
for record in &a_records {
if let DnsContent::A { content } = record.content {
eprintln!(" {} -> {}", record.name, content);
}
}
if !status.missing_subdomains.is_empty() {
eprintln!(
"\nMissing subdomains: {}",
status.missing_subdomains.join(", ")
);
} else {
eprintln!("\nAll configured subdomains have A records");
}
Ok(())
}
fn resolve_subdomain(subdomain: Option<String>) -> Result<String> {
match subdomain {
Some(s) => Ok(s),
None if selector::has_skim_support() => {
crate::user_config::UserConfig::load()?;
let subdomains = crate::services::dns::discover_subdomains();
let mut items: Vec<String> = subdomains.values().map(|e| e.subdomain.clone()).collect();
if items.is_empty() {
eyre::bail!("No subdomains defined in config");
}
items.sort();
items.dedup();
selector::select(&items, "Select subdomain")
.ok_or_else(|| eyre::eyre!("No subdomain selected"))
}
None => eyre::bail!("Subdomain argument required in non-interactive mode"),
}
}
fn resolve_ip(ip: Option<String>) -> Result<String> {
match ip {
Some(i) => Ok(i),
None if selector::has_skim_support() => {
let value = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("IP address")
.interact_text()?;
let value = value.trim().to_string();
value
.parse::<std::net::Ipv4Addr>()
.map_err(|_| eyre::eyre!("Invalid IPv4 address: {}", value))?;
Ok(value)
}
None => eyre::bail!("IP argument required in non-interactive mode"),
}
}
pub async fn run_dns_set(
subdomain: Option<String>,
ip: Option<String>,
production: bool,
) -> Result<()> {
let subdomain = resolve_subdomain(subdomain)?;
let ip = resolve_ip(ip)?;
let service = DnsService::new_with_production(Some(production)).await?;
print_mode_banner();
output::info(&format!(
"Setting A record: {}.{} -> {}",
subdomain,
service.domain(),
ip
));
service.set_a_record(&subdomain, &ip).await?;
output::success("A record set successfully");
Ok(())
}
pub async fn run_dns_migrate(ip: String, dry_run: bool, production: bool) -> Result<()> {
let service = DnsService::new_with_production(Some(production)).await?;
print_mode_banner();
if dry_run {
eprintln!("[DRY RUN] DNS Migration Preview");
} else {
eprintln!("DNS Migration");
}
eprintln!("{}", "-".repeat(50));
eprintln!(
"{:<14} {:<16} {:^3} {:<16}",
"SUBDOMAIN", "CURRENT", "", "NEW"
);
eprintln!("{}", "-".repeat(50));
let results = service.migrate_all(&ip, dry_run).await?;
for result in &results {
eprintln!(
"{:<14} {:<16} -> {:<16}",
result.subdomain, result.old_ip, result.new_ip
);
}
if dry_run {
eprintln!("\nWould update {} A record(s).", results.len());
} else {
let success_count = results.iter().filter(|r| r.success).count();
eprintln!("\nUpdated {} A record(s).", success_count);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn run_dns_set_all(
host: Option<String>,
ip: Option<String>,
dry_run: bool,
yes: bool,
strict: bool,
subdomains: Vec<String>,
skip: Vec<String>,
_output: OutputFormat,
continue_on_error: bool,
production: bool,
) -> Result<()> {
use crate::services::dns::{SubdomainEntry, discover_subdomains};
use crate::services::inventory::discover_hosts_with_ips;
use std::collections::HashSet;
let service = DnsService::new_with_production(Some(production)).await?;
print_mode_banner();
let target_ip = match (&host, &ip) {
(Some(host_name), None) => {
let hosts = discover_hosts_with_ips(None)?;
hosts
.get(host_name)
.ok_or_else(|| {
eyre::eyre!(
"Host '{}' not found in inventory. Available: {}",
host_name,
hosts.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?
.clone()
}
(None, Some(ip_addr)) => ip_addr.clone(),
(None, None) => {
if !strict {
eyre::bail!("Either --host or --ip must be specified");
} else {
eyre::bail!("Either --host or --ip must be specified in strict mode");
}
}
_ => unreachable!(),
};
let mut discovered = discover_subdomains();
if strict && discovered.is_empty() {
eyre::bail!("No subdomain environment variables found");
}
let skip_set: HashSet<_> = skip.iter().cloned().collect();
let subdomains_to_process: Vec<(String, SubdomainEntry)> = if !subdomains.is_empty() {
subdomains
.into_iter()
.filter(|s| !skip_set.contains(s))
.filter_map(|s| discovered.remove(&s).map(|entry| (s, entry)))
.collect()
} else {
discovered
.into_iter()
.filter(|(k, _)| !skip_set.contains(k))
.collect()
};
if subdomains_to_process.is_empty() {
output::info("No subdomains to process");
return Ok(());
}
if dry_run {
output::info("DRY RUN - Would create the following A records:");
} else {
output::info("Creating the following A records:");
}
for (_, entry) in &subdomains_to_process {
let effective_ip = entry.ip_override.as_deref().unwrap_or(&target_ip);
if entry.ip_override.is_some() {
eprintln!(
" • {}.{} → {} (tailnet)",
entry.subdomain,
service.domain(),
effective_ip
);
} else {
eprintln!(
" • {}.{} → {}",
entry.subdomain,
service.domain(),
effective_ip
);
}
}
if !yes && !dry_run {
eprint!("\nProceed? [y/N]: ");
use std::io::{self, BufRead};
let mut response = String::new();
io::stdin().lock().read_line(&mut response)?;
if !response.trim().eq_ignore_ascii_case("y") {
println!("Operation cancelled");
return Ok(());
}
}
if dry_run {
output::info("DRY RUN - No changes were made");
return Ok(());
}
eprintln!();
let mut succeeded = 0;
let mut failed = 0;
for (idx, (_app_name, entry)) in subdomains_to_process.iter().enumerate() {
let effective_ip = entry.ip_override.as_deref().unwrap_or(&target_ip);
match service.set_a_record(&entry.subdomain, effective_ip).await {
Ok(_) => {
output::success(&format!("Created {}.{}", entry.subdomain, service.domain()));
succeeded += 1;
}
Err(e) => {
eprintln!("Failed {}.{}: {}", entry.subdomain, service.domain(), e);
failed += 1;
if !continue_on_error {
return Err(e);
}
}
}
if idx < subdomains_to_process.len() - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
let has_overrides = subdomains_to_process
.iter()
.any(|(_, e)| e.ip_override.is_some());
if has_overrides {
output::success(&format!(
"Successfully created {}/{} A records (some with tailnet IP overrides)",
succeeded,
subdomains_to_process.len(),
));
} else {
output::success(&format!(
"Successfully created {}/{} A records pointing to {}",
succeeded,
subdomains_to_process.len(),
target_ip
));
}
if failed > 0 {
eprintln!("Failed to create {} records", failed);
std::process::exit(1);
}
Ok(())
}