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            unreachable!()
114        }
115    };
116    tracing::Span::current().record("command", cmd_name);
117    tracing::info!(command = cmd_name, "running CLI command");
118    // Record list has its own output format logic — handle it before the
119    // generic JSON path.
120    if let Command::Record(RecordCmd::List {
121        domain,
122        zone,
123        all_subdomains,
124        use_local_ip,
125        json,
126        servers: _,
127    }) = command
128    {
129        let response = dns_records::query::list_records_for_query(
130            client,
131            domain.as_deref(),
132            zone.as_deref(),
133            all_subdomains,
134            use_local_ip,
135        )
136        .await?;
137
138        if json {
139            let value = serde_json::to_value(&response).map_err(|e| Error::parse(e.to_string()))?;
140            print_result(&value)?;
141        } else {
142            records::print_records_table(&response);
143        }
144        return Ok(());
145    }
146
147    if let Command::Zone(ZoneCmd::Export { zone, output }) = command {
148        let zone_text = zones::export_zone_file(client, &zone).await?;
149        if let Some(path) = output {
150            std::fs::write(&path, &zone_text)
151                .map_err(|e| Error::io(format!("writing zone file '{}'", path.display()), e))?;
152        } else {
153            print!("{zone_text}");
154        }
155        return Ok(());
156    }
157
158    let result = match command {
159        Command::Mcp => unreachable!("handled in main"),
160        Command::Config(_) => unreachable!("handled in main"),
161        Command::Sync { .. } => unreachable!("handled in main"),
162        Command::Record(RecordCmd::List { .. }) => unreachable!("handled above"),
163
164        Command::Zone(cmd) => match cmd {
165            ZoneCmd::List { page, per_page } => zones::list_zones(client, page, per_page).await?,
166            ZoneCmd::Create { zone, r#type } => zones::create_zone(client, &zone, &r#type).await?,
167            ZoneCmd::Delete { zone } => zones::delete_zone(client, &zone).await?,
168            ZoneCmd::Enable { zone } => zones::enable_zone(client, &zone).await?,
169            ZoneCmd::Disable { zone } => zones::disable_zone(client, &zone).await?,
170            ZoneCmd::Export { .. } => unreachable!("handled above"),
171            ZoneCmd::Transfer { .. } => unreachable!("handled in main"),
172            ZoneCmd::Import {
173                zone,
174                file,
175                options,
176            } => {
177                let file_name = file
178                    .file_name()
179                    .map(|n| n.to_string_lossy().into_owned())
180                    .unwrap_or_else(|| "zone.txt".into());
181                let file_bytes = std::fs::read(&file)
182                    .map_err(|e| Error::io(format!("reading zone file '{}'", file.display()), e))?;
183                zones::import_zone_file(
184                    client,
185                    &zone,
186                    file_name,
187                    file_bytes,
188                    options.overwrite,
189                    options.overwrite_zone,
190                    options.overwrite_soa_serial,
191                )
192                .await?
193            }
194        },
195
196        Command::Record(cmd) => match cmd {
197            RecordCmd::List { .. } => unreachable!("handled above"),
198            RecordCmd::Add {
199                zone,
200                domain,
201                ttl,
202                record,
203            } => {
204                dns_records::create_record(client, &zone, &domain, ttl, &record).await?
205            }
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 { lines, start, end, level } => {
245            let lines_vec = logs::get_logs(client, LogsOptions {
246                lines,
247                start: start.map(|s| resolve_time(&s)),
248                end:   end.map(|s| resolve_time(&s)),
249                level,
250            })
251            .await?;
252            serde_json::to_value(lines_vec).map_err(|e| Error::parse(e.to_string()))?
253        }
254
255        Command::Completions { .. } | Command::ServerIds => {
256            unreachable!("handled in main")
257        }
258    };
259
260    print_result(&result)?;
261    Ok(())
262}
263
264fn print_result(value: &Value) -> Result<()> {
265    let display = value.get("response").unwrap_or(value);
266    let out = serde_json::to_string_pretty(display)
267        .map_err(|e| Error::parse(format!("could not serialise response: {e}")))?;
268    println!("{out}");
269    Ok(())
270}
271
272/// Resolve a time argument to an ISO 8601 datetime string.
273///
274/// Accepts three forms:
275/// 1. Relative duration (`10m`, `2h`, `1d`, `30s`) — subtracted from now
276/// 2. Time of day (`HH:MM` or `HH:MM:SS`) — resolved to the most recent past occurrence
277/// 3. Any other string — returned unchanged (assumed ISO 8601)
278fn resolve_time(s: &str) -> String {
279    if let Some(offset_secs) = parse_relative_duration(s) {
280        let now = now_unix_secs();
281        return unix_to_iso8601(now.saturating_sub(offset_secs));
282    }
283    if let Some(day_secs) = parse_time_of_day(s) {
284        let now = now_unix_secs();
285        let today_midnight = now - (now % 86400);
286        let candidate = today_midnight + day_secs;
287        let target = if candidate > now { candidate.saturating_sub(86400) } else { candidate };
288        return unix_to_iso8601(target);
289    }
290    s.to_string()
291}
292
293fn parse_relative_duration(s: &str) -> Option<u64> {
294    let (num_str, unit) = s.split_at(s.len().checked_sub(1)?);
295    let n: u64 = num_str.parse().ok()?;
296    match unit {
297        "s" => Some(n),
298        "m" => Some(n * 60),
299        "h" => Some(n * 3600),
300        "d" => Some(n * 86400),
301        _ => None,
302    }
303}
304
305fn parse_time_of_day(s: &str) -> Option<u64> {
306    let parts: Vec<&str> = s.split(':').collect();
307    if parts.len() < 2 || parts.len() > 3 { return None; }
308    let h: u64 = parts[0].parse().ok()?;
309    let m: u64 = parts[1].parse().ok()?;
310    let sec: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
311    if h >= 24 || m >= 60 || sec >= 60 { return None; }
312    Some(h * 3600 + m * 60 + sec)
313}
314
315fn now_unix_secs() -> u64 {
316    std::time::SystemTime::now()
317        .duration_since(std::time::UNIX_EPOCH)
318        .unwrap_or_default()
319        .as_secs()
320}
321
322fn unix_to_iso8601(secs: u64) -> String {
323    let (year, month, day) = days_to_ymd(secs / 86400);
324    let t = secs % 86400;
325    let h = t / 3600;
326    let m = (t % 3600) / 60;
327    let s = t % 60;
328    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}")
329}
330
331fn days_to_ymd(mut days: u64) -> (u32, u8, u8) {
332    let mut year = 1970u32;
333    loop {
334        let dy = if is_leap(year) { 366 } else { 365 };
335        if days < dy { break; }
336        days -= dy;
337        year += 1;
338    }
339    let month_lens = [31u8, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
340    let mut month = 1u8;
341    for &ml in &month_lens {
342        if days < ml as u64 { break; }
343        days -= ml as u64;
344        month += 1;
345    }
346    (year, month, days as u8 + 1)
347}
348
349fn is_leap(year: u32) -> bool {
350    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
351}