1use clap::ValueEnum;
4use clap::builder::PossibleValue;
5
6use crate::core::dns::records::{
7 DigestType, DsAlgorithm, FwdProtocol, SshfpAlgorithm, SshfpFingerprintType,
8 TlsaCertUsage, TlsaMatchingType, TlsaSelector,
9};
10use crate::core::dns::responses::ListRecordsResponse;
11
12macro_rules! impl_value_enum {
13 ($ty:ty, [$($variant:expr),+ $(,)?]) => {
14 impl ValueEnum for $ty {
15 fn value_variants<'a>() -> &'a [Self]
16 where
17 Self: 'a,
18 {
19 &[$($variant),+]
20 }
21
22 fn to_possible_value(&self) -> Option<PossibleValue> {
23 Some(PossibleValue::new(self.as_str()))
24 }
25 }
26 };
27}
28
29impl_value_enum!(
30 DsAlgorithm,
31 [
32 DsAlgorithm::Rsamd5,
33 DsAlgorithm::Dsa,
34 DsAlgorithm::Rsasha1,
35 DsAlgorithm::DsaNsec3Sha1,
36 DsAlgorithm::Rsasha1Nsec3Sha1,
37 DsAlgorithm::Rsasha256,
38 DsAlgorithm::Rsasha512,
39 DsAlgorithm::EccGost,
40 DsAlgorithm::Ecdsap256sha256,
41 DsAlgorithm::Ecdsap384sha384,
42 DsAlgorithm::Ed25519,
43 DsAlgorithm::Ed448,
44 ]
45);
46
47impl_value_enum!(
48 DigestType,
49 [
50 DigestType::Sha1,
51 DigestType::Sha256,
52 DigestType::GostR341194,
53 DigestType::Sha384,
54 ]
55);
56
57impl_value_enum!(
58 SshfpAlgorithm,
59 [
60 SshfpAlgorithm::Rsa,
61 SshfpAlgorithm::Dsa,
62 SshfpAlgorithm::Ecdsa,
63 SshfpAlgorithm::Ed25519,
64 SshfpAlgorithm::Ed448,
65 ]
66);
67
68impl_value_enum!(
69 SshfpFingerprintType,
70 [SshfpFingerprintType::Sha1, SshfpFingerprintType::Sha256,]
71);
72
73impl_value_enum!(
74 TlsaCertUsage,
75 [
76 TlsaCertUsage::PkixTa,
77 TlsaCertUsage::PkixEe,
78 TlsaCertUsage::DaneTa,
79 TlsaCertUsage::DaneEe,
80 ]
81);
82
83impl_value_enum!(TlsaSelector, [TlsaSelector::Cert, TlsaSelector::Spki]);
84
85impl_value_enum!(
86 TlsaMatchingType,
87 [
88 TlsaMatchingType::Full,
89 TlsaMatchingType::Sha2_256,
90 TlsaMatchingType::Sha2_512,
91 ]
92);
93
94impl_value_enum!(
95 FwdProtocol,
96 [
97 FwdProtocol::Udp,
98 FwdProtocol::Tcp,
99 FwdProtocol::Tls,
100 FwdProtocol::Https,
101 FwdProtocol::Quic,
102 ]
103);
104
105pub fn record_content(record_type: &str, data: &serde_json::Value) -> String {
109 match record_type.to_uppercase().as_str() {
110 "A" | "AAAA" => str_field(data, "ipAddress"),
111 "CNAME" => str_field(data, "cname"),
112 "ANAME" => str_field(data, "aname"),
113 "DNAME" => str_field(data, "dname"),
114 "NS" => str_field(data, "nameServer"),
115 "PTR" => str_field(data, "ptrName"),
116 "TXT" => str_field(data, "text"),
117 "MX" => format!(
118 "{} {}",
119 data.get("preference")
120 .and_then(|v| v.as_u64())
121 .unwrap_or(10),
122 str_field(data, "exchange"),
123 ),
124 "SRV" => format!(
125 "{} {} {} {}",
126 data.get("priority").and_then(|v| v.as_u64()).unwrap_or(0),
127 data.get("weight").and_then(|v| v.as_u64()).unwrap_or(0),
128 data.get("port").and_then(|v| v.as_u64()).unwrap_or(0),
129 str_field(data, "target"),
130 ),
131 "CAA" => format!(
132 "{} {} \"{}\"",
133 data.get("flags").and_then(|v| v.as_u64()).unwrap_or(0),
134 str_field(data, "tag"),
135 str_field(data, "value"),
136 ),
137 "SSHFP" => format!(
138 "{} {} {}",
139 str_field(data, "sshfpAlgorithm"),
140 str_field(data, "sshfpFingerprintType"),
141 str_field(data, "sshfpFingerprint"),
142 ),
143 "TLSA" => format!(
144 "{} {} {} {}",
145 str_field(data, "tlsaCertificateUsage"),
146 str_field(data, "tlsaSelector"),
147 str_field(data, "tlsaMatchingType"),
148 str_field(data, "tlsaCertificateAssociationData"),
149 ),
150 "DS" => format!(
151 "{} {} {} {}",
152 data.get("keyTag").and_then(|v| v.as_u64()).unwrap_or(0),
153 str_field(data, "algorithm"),
154 str_field(data, "digestType"),
155 str_field(data, "digest"),
156 ),
157 "HTTPS" | "SVCB" => format!(
158 "{} {}",
159 data.get("svcPriority")
160 .and_then(|v| v.as_u64())
161 .unwrap_or(1),
162 str_field(data, "svcTargetName"),
163 ),
164 "FWD" => str_field(data, "forwarder"),
165 "NAPTR" => format!(
166 "{} {} \"{}\" \"{}\" \"{}\" {}",
167 data.get("naptrOrder").and_then(|v| v.as_u64()).unwrap_or(0),
168 data.get("naptrPreference")
169 .and_then(|v| v.as_u64())
170 .unwrap_or(0),
171 str_field(data, "naptrFlags"),
172 str_field(data, "naptrServices"),
173 str_field(data, "naptrRegexp"),
174 str_field(data, "naptrReplacement"),
175 ),
176 _ => {
177 if let Some(v) = data.get("value").and_then(|v| v.as_str()) {
179 return v.to_string();
180 }
181 serde_json::to_string(data).unwrap_or_default()
182 }
183 }
184}
185
186fn str_field(data: &serde_json::Value, key: &str) -> String {
187 data.get(key)
188 .and_then(|v| v.as_str())
189 .unwrap_or("")
190 .to_string()
191}
192
193const COL_NAME: &str = "HOST";
196const COL_TYPE: &str = "TYPE";
197const COL_TTL: &str = "TTL";
198const COL_DATA: &str = "DATA";
199
200pub fn print_records_table(response: &ListRecordsResponse) {
205 let total = response.zones.len();
206
207 for (i, zone_records) in response.zones.iter().enumerate() {
208 let zone = &zone_records.zone;
209
210 if zone.disabled {
212 println!("Zone: {} [{}] [disabled]", zone.name, zone.zone_type);
213 } else {
214 println!("Zone: {} [{}]", zone.name, zone.zone_type);
215 }
216
217 if zone_records.records.is_empty() {
218 println!(" (no records)");
219 } else {
220 let name_w = zone_records
222 .records
223 .iter()
224 .map(|r| r.name.len())
225 .max()
226 .unwrap_or(0)
227 .max(COL_NAME.len());
228
229 let type_w = zone_records
230 .records
231 .iter()
232 .map(|r| r.record_type.len())
233 .max()
234 .unwrap_or(0)
235 .max(COL_TYPE.len());
236
237 let ttl_w = zone_records
238 .records
239 .iter()
240 .map(|r| r.ttl.to_string().len())
241 .max()
242 .unwrap_or(0)
243 .max(COL_TTL.len());
244
245 println!();
247 println!(
248 "{:<name_w$} {:<type_w$} {:>ttl_w$} {}",
249 COL_NAME, COL_TYPE, COL_TTL, COL_DATA,
250 );
251 println!("{}", "-".repeat(name_w + type_w + ttl_w + 8));
252
253 for record in &zone_records.records {
255 let content = record_content(&record.record_type, &record.data);
256 let disabled = if record.disabled { " [disabled]" } else { "" };
257
258 println!(
259 "{:<name_w$} {:<type_w$} {:>ttl_w$} {}{}",
260 record.name, record.record_type, record.ttl, content, disabled,
261 );
262 }
263 }
264
265 if i + 1 < total {
267 println!();
268 }
269 }
270}
271
272#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::core::dns::records::RecordSelector;
278 use rstest::rstest;
279 use serde_json::json;
280
281 #[rstest]
282 #[case::a_none(RecordSelector::A { ip: None }, vec![("type", "A")])]
283 #[case::a_some(
284 RecordSelector::A { ip: Some("1.2.3.4".parse().unwrap()) },
285 vec![("type", "A"), ("ipAddress", "1.2.3.4")]
286 )]
287 #[case::aaaa_some(
288 RecordSelector::Aaaa { ip: Some("2001:db8::1".parse().unwrap()) },
289 vec![("type", "AAAA"), ("ipAddress", "2001:db8::1")]
290 )]
291 #[case::mx_some(
292 RecordSelector::Mx { exchange: Some("mail.example.com".into()) },
293 vec![("type", "MX"), ("exchange", "mail.example.com")]
294 )]
295 #[case::txt_some(
296 RecordSelector::Txt { text: Some("v=spf1 ~all".into()) },
297 vec![("type", "TXT"), ("text", "v=spf1 ~all")]
298 )]
299 fn record_selector_to_api_params_matches_expected(
300 #[case] selector: RecordSelector,
301 #[case] expected: Vec<(&'static str, &'static str)>,
302 ) {
303 let actual = selector.to_api_params();
304 let expected: Vec<(&str, String)> = expected
305 .into_iter()
306 .map(|(key, value)| (key, value.to_string()))
307 .collect();
308
309 assert_eq!(actual, expected);
310 }
311
312 #[test]
313 fn a_record_content() {
314 assert_eq!(
315 record_content("A", &json!({"ipAddress": "1.2.3.4"})),
316 "1.2.3.4"
317 );
318 }
319
320 #[test]
321 fn aaaa_record_content() {
322 assert_eq!(
323 record_content("AAAA", &json!({"ipAddress": "2001:db8::1"})),
324 "2001:db8::1"
325 );
326 }
327
328 #[test]
329 fn cname_record_content() {
330 assert_eq!(
331 record_content("CNAME", &json!({"cname": "target.example.com"})),
332 "target.example.com"
333 );
334 }
335
336 #[test]
337 fn mx_record_content_includes_preference() {
338 assert_eq!(
339 record_content(
340 "MX",
341 &json!({"preference": 10, "exchange": "mail.example.com"})
342 ),
343 "10 mail.example.com"
344 );
345 }
346
347 #[test]
348 fn mx_record_content_defaults_preference_to_10() {
349 assert_eq!(
350 record_content("MX", &json!({"exchange": "mail.example.com"})),
351 "10 mail.example.com"
352 );
353 }
354
355 #[test]
356 fn txt_record_content() {
357 assert_eq!(
358 record_content("TXT", &json!({"text": "v=spf1 ~all"})),
359 "v=spf1 ~all"
360 );
361 }
362
363 #[test]
364 fn ns_record_content() {
365 assert_eq!(
366 record_content(
367 "NS",
368 &json!({"nameServer": "ns1.example.com", "glue": null})
369 ),
370 "ns1.example.com"
371 );
372 }
373
374 #[test]
375 fn srv_record_content() {
376 assert_eq!(
377 record_content(
378 "SRV",
379 &json!({"priority": 10, "weight": 20, "port": 5060, "target": "sip.example.com"})
380 ),
381 "10 20 5060 sip.example.com"
382 );
383 }
384
385 #[test]
386 fn caa_record_content() {
387 assert_eq!(
388 record_content(
389 "CAA",
390 &json!({"flags": 0, "tag": "issue", "value": "letsencrypt.org"})
391 ),
392 "0 issue \"letsencrypt.org\""
393 );
394 }
395
396 #[test]
397 fn ds_record_content() {
398 assert_eq!(
399 record_content(
400 "DS",
401 &json!({"keyTag": 12345, "algorithm": "RSASHA256", "digestType": "SHA256", "digest": "abcdef"})
402 ),
403 "12345 RSASHA256 SHA256 abcdef"
404 );
405 }
406
407 #[test]
408 fn fwd_record_content() {
409 assert_eq!(
410 record_content("FWD", &json!({"forwarder": "1.1.1.1"})),
411 "1.1.1.1"
412 );
413 }
414
415 #[test]
416 fn unknown_type_falls_back_to_value_key() {
417 assert_eq!(
418 record_content("CUSTOM", &json!({"value": "some-data"})),
419 "some-data"
420 );
421 }
422
423 #[test]
424 fn unknown_type_falls_back_to_json() {
425 let data = json!({"field": "x"});
426 let result = record_content("MYSTERY", &data);
427 assert!(result.contains("field"));
428 }
429
430 #[test]
431 fn naptr_record_content() {
432 assert_eq!(
433 record_content(
434 "NAPTR",
435 &json!({
436 "naptrOrder": 10,
437 "naptrPreference": 20,
438 "naptrFlags": "U",
439 "naptrServices": "E2U+sip",
440 "naptrRegexp": "!^.*$!sip:info@example.com!",
441 "naptrReplacement": "."
442 })
443 ),
444 "10 20 \"U\" \"E2U+sip\" \"!^.*$!sip:info@example.com!\" ."
445 );
446 }
447
448 #[test]
449 fn record_content_is_case_insensitive() {
450 assert_eq!(
451 record_content("a", &json!({"ipAddress": "1.2.3.4"})),
452 record_content("A", &json!({"ipAddress": "1.2.3.4"}))
453 );
454 }
455}