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 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
280fn 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}