Skip to main content

dnslib/cli/
runner.rs

1use serde_json::Value;
2
3use crate::{
4    cli::{AllowedCmd, BlockedCmd, CacheCmd, Command, RecordCmd, ZoneCmd, records},
5    control_plane::config::DnsServerConfig,
6    core::{
7        dns::{
8            access_lists, cache, logs, logs::LogsOptions, records as dns_records,
9            service::DnsService, settings, stats, zones,
10        },
11        error::{Error, Result},
12    },
13    vendors::runtime::VendorClient,
14};
15
16#[allow(clippy::too_many_arguments)]
17pub async fn run_record_list_across_servers(
18    selected: &[&DnsServerConfig],
19    domain: Option<&str>,
20    zone: Option<&str>,
21    all_subdomains: bool,
22    use_local_ip: bool,
23    json: bool,
24) -> Result<()> {
25    let mut json_zones = Vec::new();
26    let mut printed_servers = 0usize;
27
28    for server in selected {
29        let client = VendorClient::from_server(server)?;
30        let response = dns_records::query::list_records_for_query(
31            &client,
32            domain,
33            zone,
34            all_subdomains,
35            use_local_ip,
36        )
37        .await?;
38
39        if json {
40            for mut zone_records in response.zones {
41                if zone_records.zone.id.is_none() {
42                    zone_records.zone.id = Some(zone_records.zone.name.clone());
43                }
44                json_zones.push(serde_json::json!({
45                    "serverName": server.id,
46                    "serverId": server.id,
47                    "vendor": format!("{:?}", server.vendor),
48                    "zone": zone_records.zone,
49                    "records": zone_records.records,
50                }));
51            }
52        } else if !response.zones.is_empty() {
53            if printed_servers > 0 {
54                println!();
55            }
56            println!("=== Server: {} ({:?}) ===", server.id, server.vendor);
57            records::print_records_table(&response);
58            printed_servers += 1;
59        }
60    }
61
62    if json {
63        let pretty = serde_json::to_string_pretty(&json_zones).map_err(|error| {
64            Error::parse(format!("could not serialise record list response: {error}"))
65        })?;
66        println!("{pretty}");
67    }
68
69    Ok(())
70}
71
72#[tracing::instrument(skip(client, command), fields(command = tracing::field::Empty))]
73pub async fn run<C: DnsService>(client: &C, command: Command) -> Result<()> {
74    let cmd_name = match &command {
75        Command::Zone(z) => match z {
76            ZoneCmd::List { .. } => "zone list",
77            ZoneCmd::Create { .. } => "zone create",
78            ZoneCmd::Delete { .. } => "zone delete",
79            ZoneCmd::Enable { .. } => "zone enable",
80            ZoneCmd::Disable { .. } => "zone disable",
81            ZoneCmd::Import { .. } => "zone import",
82            ZoneCmd::Export { .. } => "zone export",
83            ZoneCmd::Transfer { .. } => "zone transfer",
84        },
85        Command::Record(r) => match r {
86            RecordCmd::List { .. } => "record list",
87            RecordCmd::Add { .. } => "record add",
88            RecordCmd::Delete { .. } => "record delete",
89        },
90        Command::Cache(c) => match c {
91            CacheCmd::List { .. } => "cache list",
92            CacheCmd::Delete { .. } => "cache delete",
93            CacheCmd::Flush => "cache flush",
94        },
95        Command::Stats { .. } => "stats",
96        Command::Blocked(b) => match b {
97            BlockedCmd::List => "blocked list",
98            BlockedCmd::Add { .. } => "blocked add",
99            BlockedCmd::Delete { .. } => "blocked delete",
100        },
101        Command::Allowed(a) => match a {
102            AllowedCmd::List => "allowed list",
103            AllowedCmd::Add { .. } => "allowed add",
104            AllowedCmd::Delete { .. } => "allowed delete",
105        },
106        Command::Settings { .. } => "settings",
107        Command::Logs { .. } => "logs",
108        Command::Mcp
109        | Command::Config(_)
110        | Command::Completions { .. }
111        | Command::ServerIds
112        | Command::Sync { .. }
113        | Command::Query(_) => {
114            unreachable!()
115        }
116    };
117    tracing::Span::current().record("command", cmd_name);
118    tracing::info!(command = cmd_name, "running CLI command");
119    // Record list has its own output format logic — handle it before the
120    // generic JSON path.
121    if let Command::Record(RecordCmd::List {
122        domain,
123        zone,
124        all_subdomains,
125        use_local_ip,
126        json,
127        servers: _,
128    }) = command
129    {
130        let response = dns_records::query::list_records_for_query(
131            client,
132            domain.as_deref(),
133            zone.as_deref(),
134            all_subdomains,
135            use_local_ip,
136        )
137        .await?;
138
139        if json {
140            let value = serde_json::to_value(&response).map_err(|e| Error::parse(e.to_string()))?;
141            print_result(&value)?;
142        } else {
143            records::print_records_table(&response);
144        }
145        return Ok(());
146    }
147
148    if let Command::Zone(ZoneCmd::Export { zone, output }) = command {
149        let zone_text = zones::export_zone_file(client, &zone).await?;
150        if let Some(path) = output {
151            std::fs::write(&path, &zone_text)
152                .map_err(|e| Error::io(format!("writing zone file '{}'", path.display()), e))?;
153        } else {
154            print!("{zone_text}");
155        }
156        return Ok(());
157    }
158
159    let result = match command {
160        Command::Mcp => unreachable!("handled in main"),
161        Command::Config(_) => unreachable!("handled in main"),
162        Command::Sync { .. } => unreachable!("handled in main"),
163        Command::Query(_) => unreachable!("handled in main"),
164        Command::Record(RecordCmd::List { .. }) => unreachable!("handled above"),
165
166        Command::Zone(cmd) => match cmd {
167            ZoneCmd::List { page, per_page } => zones::list_zones(client, page, per_page).await?,
168            ZoneCmd::Create { zone, r#type } => zones::create_zone(client, &zone, &r#type).await?,
169            ZoneCmd::Delete { zone } => zones::delete_zone(client, &zone).await?,
170            ZoneCmd::Enable { zone } => zones::enable_zone(client, &zone).await?,
171            ZoneCmd::Disable { zone } => zones::disable_zone(client, &zone).await?,
172            ZoneCmd::Export { .. } => unreachable!("handled above"),
173            ZoneCmd::Transfer { .. } => unreachable!("handled in main"),
174            ZoneCmd::Import {
175                zone,
176                file,
177                options,
178            } => {
179                let file_name = file
180                    .file_name()
181                    .map(|n| n.to_string_lossy().into_owned())
182                    .unwrap_or_else(|| "zone.txt".into());
183                let file_bytes = std::fs::read(&file)
184                    .map_err(|e| Error::io(format!("reading zone file '{}'", file.display()), e))?;
185                zones::import_zone_file(
186                    client,
187                    &zone,
188                    file_name,
189                    file_bytes,
190                    options.overwrite,
191                    options.overwrite_zone,
192                    options.overwrite_soa_serial,
193                )
194                .await?
195            }
196        },
197
198        Command::Record(cmd) => match cmd {
199            RecordCmd::List { .. } => unreachable!("handled above"),
200            RecordCmd::Add {
201                zone,
202                domain,
203                ttl,
204                record,
205            } => dns_records::create_record(client, &zone, &domain, ttl, &record).await?,
206            RecordCmd::Delete {
207                zone,
208                domain,
209                record,
210            } => {
211                let type_params = record.to_api_params();
212                dns_records::delete_record(client, &zone, &domain, &type_params).await?
213            }
214        },
215
216        Command::Cache(cmd) => match cmd {
217            CacheCmd::List { domain } => cache::list_cache(client, &domain).await?,
218            CacheCmd::Delete { domain } => cache::delete_cache_zone(client, &domain).await?,
219            CacheCmd::Flush => cache::flush_cache(client).await?,
220        },
221
222        Command::Stats { r#type } => stats::get_stats(client, &r#type).await?,
223
224        Command::Blocked(cmd) => match cmd {
225            BlockedCmd::List => access_lists::list_blocked(client).await?,
226            BlockedCmd::Add { domain } => access_lists::add_blocked(client, &domain).await?,
227            BlockedCmd::Delete { domain } => access_lists::delete_blocked(client, &domain).await?,
228        },
229
230        Command::Allowed(cmd) => match cmd {
231            AllowedCmd::List => access_lists::list_allowed(client).await?,
232            AllowedCmd::Add { domain } => access_lists::add_allowed(client, &domain).await?,
233            AllowedCmd::Delete { domain } => access_lists::delete_allowed(client, &domain).await?,
234        },
235
236        Command::Settings { show_secrets } => {
237            if show_secrets {
238                settings::get_settings_unredacted(client).await?
239            } else {
240                settings::get_settings(client).await?
241            }
242        }
243
244        Command::Logs {
245            lines,
246            start,
247            end,
248            level,
249        } => {
250            let lines_vec = logs::get_logs(
251                client,
252                LogsOptions {
253                    lines: Some(lines),
254                    start: start.map(|s| resolve_time(&s)),
255                    end: end.map(|s| resolve_time(&s)),
256                    level,
257                },
258            )
259            .await?;
260            serde_json::to_value(lines_vec).map_err(|e| Error::parse(e.to_string()))?
261        }
262
263        Command::Completions { .. } | Command::ServerIds => {
264            unreachable!("handled in main")
265        }
266    };
267
268    print_result(&result)?;
269    Ok(())
270}
271
272fn print_result(value: &Value) -> Result<()> {
273    let display = value.get("response").unwrap_or(value);
274    let out = serde_json::to_string_pretty(display)
275        .map_err(|e| Error::parse(format!("could not serialise response: {e}")))?;
276    println!("{out}");
277    Ok(())
278}
279
280/// Resolve a time argument to an ISO 8601 datetime string.
281///
282/// Accepts three forms:
283/// 1. Relative duration (`10m`, `2h`, `1d`, `30s`) — subtracted from now
284/// 2. Time of day (`HH:MM` or `HH:MM:SS`) — resolved to the most recent past occurrence
285/// 3. Any other string — returned unchanged (assumed ISO 8601)
286fn resolve_time(s: &str) -> String {
287    if let Some(offset_secs) = parse_relative_duration(s) {
288        let now = now_unix_secs();
289        return unix_to_iso8601(now.saturating_sub(offset_secs));
290    }
291    if let Some(day_secs) = parse_time_of_day(s) {
292        let now = now_unix_secs();
293        let today_midnight = now - (now % 86400);
294        let candidate = today_midnight + day_secs;
295        let target = if candidate > now {
296            candidate.saturating_sub(86400)
297        } else {
298            candidate
299        };
300        return unix_to_iso8601(target);
301    }
302    s.to_string()
303}
304
305fn parse_relative_duration(s: &str) -> Option<u64> {
306    let (num_str, unit) = s.split_at(s.len().checked_sub(1)?);
307    let n: u64 = num_str.parse().ok()?;
308    match unit {
309        "s" => Some(n),
310        "m" => Some(n * 60),
311        "h" => Some(n * 3600),
312        "d" => Some(n * 86400),
313        _ => None,
314    }
315}
316
317fn parse_time_of_day(s: &str) -> Option<u64> {
318    let parts: Vec<&str> = s.split(':').collect();
319    if parts.len() < 2 || parts.len() > 3 {
320        return None;
321    }
322    let h: u64 = parts[0].parse().ok()?;
323    let m: u64 = parts[1].parse().ok()?;
324    let sec: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
325    if h >= 24 || m >= 60 || sec >= 60 {
326        return None;
327    }
328    Some(h * 3600 + m * 60 + sec)
329}
330
331fn now_unix_secs() -> u64 {
332    std::time::SystemTime::now()
333        .duration_since(std::time::UNIX_EPOCH)
334        .unwrap_or_default()
335        .as_secs()
336}
337
338fn unix_to_iso8601(secs: u64) -> String {
339    let (year, month, day) = days_to_ymd(secs / 86400);
340    let t = secs % 86400;
341    let h = t / 3600;
342    let m = (t % 3600) / 60;
343    let s = t % 60;
344    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}")
345}
346
347fn days_to_ymd(mut days: u64) -> (u32, u8, u8) {
348    let mut year = 1970u32;
349    loop {
350        let dy = if is_leap(year) { 366 } else { 365 };
351        if days < dy {
352            break;
353        }
354        days -= dy;
355        year += 1;
356    }
357    let month_lens = [
358        31u8,
359        if is_leap(year) { 29 } else { 28 },
360        31,
361        30,
362        31,
363        30,
364        31,
365        31,
366        30,
367        31,
368        30,
369        31,
370    ];
371    let mut month = 1u8;
372    for &ml in &month_lens {
373        if days < ml as u64 {
374            break;
375        }
376        days -= ml as u64;
377        month += 1;
378    }
379    (year, month, days as u8 + 1)
380}
381
382fn is_leap(year: u32) -> bool {
383    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
384}