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