Skip to main content

dnslib/core/dns/
responses.rs

1//! Typed response structs for Technitium API responses.
2//!
3//! `RecordData` in types.rs covers records you can *add or delete*.
4//! `ReadOnlyRecordData` here covers records that are server-managed and
5//! only ever appear in list_records responses — never in add/delete calls.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::core::dns::records::{DsAlgorithm, RecordData};
11use crate::core::error::{Error, Result};
12
13// ─── Read-only DNSSEC record data ─────────────────────────────────────────────
14
15/// DNSKEY — public key record, managed by Technitium's DNSSEC key lifecycle.
16/// Created when you publish a private key; retired via the DNSSEC key API.
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "camelCase")]
19pub struct DnskeyData {
20    pub flags: u16,
21    /// Always 3 per RFC 4034
22    pub protocol: u8,
23    pub algorithm: DsAlgorithm,
24    /// Base64-encoded public key
25    pub public_key: String,
26    pub computed_key_tag: u16,
27    /// Active | Ready | Generated | Retired
28    pub dns_key_state: Option<String>,
29    pub is_ksk: Option<bool>,
30}
31
32/// RRSIG — signature over a record set, generated automatically on every write.
33/// Technitium refreshes these before expiry; you cannot add or remove them directly.
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "camelCase")]
36pub struct RrsigData {
37    pub type_covered: String,
38    pub algorithm: DsAlgorithm,
39    pub labels: u8,
40    pub original_ttl: u32,
41    /// ISO 8601 datetime
42    pub signature_expiration: String,
43    /// ISO 8601 datetime
44    pub signature_inception: String,
45    pub key_tag: u16,
46    pub signer_name: String,
47    /// Base64-encoded signature
48    pub signature: String,
49}
50
51/// NSEC — proof of non-existence (ordered linked list of zone names).
52/// Generated by the signing engine; not manually manageable.
53#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
54#[serde(rename_all = "camelCase")]
55pub struct NsecData {
56    pub next_domain_name: String,
57    /// Record types present at this name, e.g. ["A", "RRSIG", "NSEC"]
58    pub types: Vec<String>,
59}
60
61/// NSEC3 — hashed proof of non-existence.
62/// Generated by the signing engine; not manually manageable.
63#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
64#[serde(rename_all = "camelCase")]
65pub struct Nsec3Data {
66    pub hash_algorithm: String,
67    pub flags: u8,
68    pub iterations: u16,
69    /// Hex-encoded salt
70    pub salt: String,
71    pub next_hashed_owner_name: String,
72    pub types: Vec<String>,
73}
74
75/// Record types that appear in list_records responses but cannot be
76/// added or deleted via the record API.
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78#[serde(tag = "type", rename_all = "UPPERCASE")]
79pub enum ReadOnlyRecordData {
80    Dnskey(DnskeyData),
81    Rrsig(RrsigData),
82    Nsec(NsecData),
83    Nsec3(Nsec3Data),
84}
85
86// ─── Unified record data ──────────────────────────────────────────────────────
87
88/// Any record that can appear in a list_records response — either a writable
89/// record (add/delete supported) or a read-only server-managed DNSSEC record.
90#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
91#[serde(untagged)]
92pub enum AnyRecordData {
93    Writable(RecordData),
94    ReadOnly(ReadOnlyRecordData),
95}
96
97// ─── Zone record entry ────────────────────────────────────────────────────────
98
99/// A single DNS record as returned by the list_records API.
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "camelCase")]
102pub struct ZoneRecord {
103    pub name: String,
104    #[serde(rename = "type")]
105    pub record_type: String,
106    pub ttl: u32,
107    #[serde(default)]
108    pub disabled: bool,
109    #[serde(default)]
110    pub comments: String,
111    #[serde(default)]
112    pub expiry_ttl: u64,
113    #[serde(rename = "rData")]
114    pub data: serde_json::Value,
115    /// Parsed typed form — None if the type is unrecognised
116    #[serde(skip)]
117    pub parsed: Option<AnyRecordData>,
118}
119
120impl ZoneRecord {
121    /// Typed record data for this record. Uses the pre-parsed value when one is
122    /// present, otherwise parses on demand from `record_type` + `data` — so it
123    /// works regardless of which vendor produced the record.
124    pub fn typed(&self) -> Option<AnyRecordData> {
125        if let Some(parsed) = &self.parsed {
126            return Some(parsed.clone());
127        }
128        parse_record_data(&self.record_type, &self.data)
129    }
130}
131
132// ─── List records response ────────────────────────────────────────────────────
133
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "camelCase")]
136pub struct ZoneInfo {
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub id: Option<String>,
139    pub name: String,
140    #[serde(rename = "type")]
141    pub zone_type: String,
142    #[serde(default)]
143    pub disabled: bool,
144    pub dnssec_status: Option<String>,
145}
146
147/// Records for a single DNS zone.
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149pub struct ZoneRecords {
150    pub zone: ZoneInfo,
151    pub records: Vec<ZoneRecord>,
152}
153
154/// Response from list_records — may contain one zone or many (e.g. Pangolin "list all").
155///
156/// **Serialization shape:**
157/// - Single zone → `{"zone": {...}, "records": [...]}` (flat, matches the historical shape)
158/// - Multiple zones → `{"zones": [{"zone": {...}, "records": [...]}, ...]}`
159#[derive(Debug, Clone)]
160pub struct ListRecordsResponse {
161    pub zones: Vec<ZoneRecords>,
162}
163
164impl serde::Serialize for ListRecordsResponse {
165    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
166        use serde::ser::SerializeMap;
167        match self.zones.as_slice() {
168            [single] => {
169                let mut map = s.serialize_map(Some(2))?;
170                map.serialize_entry("zone", &single.zone)?;
171                map.serialize_entry("records", &single.records)?;
172                map.end()
173            }
174            _ => {
175                let mut map = s.serialize_map(Some(1))?;
176                map.serialize_entry("zones", &self.zones)?;
177                map.end()
178            }
179        }
180    }
181}
182
183impl<'de> serde::Deserialize<'de> for ListRecordsResponse {
184    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
185        #[derive(Deserialize)]
186        #[serde(untagged)]
187        enum Repr {
188            Multi {
189                zones: Vec<ZoneRecords>,
190            },
191            Single {
192                zone: ZoneInfo,
193                records: Vec<ZoneRecord>,
194            },
195        }
196        match Repr::deserialize(d)? {
197            Repr::Multi { zones } => Ok(Self { zones }),
198            Repr::Single { zone, records } => Ok(Self::single(zone, records)),
199        }
200    }
201}
202
203impl ListRecordsResponse {
204    /// Convenience constructor for the common single-zone case.
205    pub fn single(zone: ZoneInfo, records: Vec<ZoneRecord>) -> Self {
206        Self {
207            zones: vec![ZoneRecords { zone, records }],
208        }
209    }
210
211    /// Parse the raw Technitium API JSON into a typed response, populating
212    /// `parsed` on each record where the type is recognised.
213    pub fn from_value(value: &serde_json::Value) -> Result<Self> {
214        let response = value
215            .get("response")
216            .ok_or_else(|| Error::parse("list_records response missing 'response' key"))?;
217
218        let mut zone: ZoneInfo = serde_json::from_value(
219            response
220                .get("zone")
221                .ok_or_else(|| Error::parse("list_records response missing 'response.zone'"))?
222                .clone(),
223        )
224        .map_err(|e| Error::parse(format!("could not deserialize zone info: {e}")))?;
225        if zone.id.is_none() {
226            zone.id = Some(zone.name.clone());
227        }
228
229        let raw_records = response
230            .get("records")
231            .and_then(|r| r.as_array())
232            .ok_or_else(|| {
233                Error::parse("list_records response missing 'response.records' array")
234            })?;
235
236        let records = raw_records
237            .iter()
238            .filter_map(|r| {
239                let mut record: ZoneRecord = serde_json::from_value(r.clone()).ok()?;
240                record.parsed = parse_record_data(&record.record_type, &record.data);
241                Some(record)
242            })
243            .collect();
244
245        Ok(Self::single(zone, records))
246    }
247}
248
249fn parse_record_data(record_type: &str, rdata: &serde_json::Value) -> Option<AnyRecordData> {
250    // Reconstruct the tagged value that serde expects for RecordData / ReadOnlyRecordData
251    let mut tagged = rdata.clone();
252    if let Some(obj) = tagged.as_object_mut() {
253        obj.insert(
254            "type".into(),
255            serde_json::Value::String(record_type.to_uppercase()),
256        );
257    }
258
259    // Try writable first, then read-only
260    if let Ok(w) = serde_json::from_value::<RecordData>(tagged.clone()) {
261        return Some(AnyRecordData::Writable(w));
262    }
263    if let Ok(ro) = serde_json::from_value::<ReadOnlyRecordData>(tagged) {
264        return Some(AnyRecordData::ReadOnly(ro));
265    }
266    None
267}
268
269// ─── Tests ────────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use rstest::{fixture, rstest};
275    use serde_json::json;
276
277    // ── Fixtures ──────────────────────────────────────────────────────────────
278
279    #[fixture]
280    fn zone_json() -> serde_json::Value {
281        json!({ "name": "example.com", "type": "Primary", "disabled": false })
282    }
283
284    #[fixture]
285    fn a_record_json() -> serde_json::Value {
286        json!({
287            "name": "www",
288            "type": "A",
289            "ttl": 3600,
290            "disabled": false,
291            "comments": "",
292            "rData": { "ipAddress": "1.2.3.4" }
293        })
294    }
295
296    #[fixture]
297    fn rrsig_record_json() -> serde_json::Value {
298        json!({
299            "name": "@",
300            "type": "RRSIG",
301            "ttl": 86400,
302            "disabled": false,
303            "comments": "",
304            "rData": {
305                "typeCovered": "A",
306                "algorithm": "ECDSAP256SHA256",
307                "labels": 2,
308                "originalTtl": 3600,
309                "signatureExpiration": "20261231000000",
310                "signatureInception": "20260101000000",
311                "keyTag": 12345,
312                "signerName": "example.com",
313                "signature": "abc123=="
314            }
315        })
316    }
317
318    #[fixture]
319    fn dnskey_record_json() -> serde_json::Value {
320        json!({
321            "name": "@",
322            "type": "DNSKEY",
323            "ttl": 86400,
324            "disabled": false,
325            "comments": "",
326            "rData": {
327                "flags": 257,
328                "protocol": 3,
329                "algorithm": "ECDSAP256SHA256",
330                "publicKey": "base64key==",
331                "computedKeyTag": 12345,
332                "dnsKeyState": "Active",
333                "isKsk": true
334            }
335        })
336    }
337
338    fn wrap_response(
339        zone: serde_json::Value,
340        records: Vec<serde_json::Value>,
341    ) -> serde_json::Value {
342        json!({ "status": "ok", "response": { "zone": zone, "records": records } })
343    }
344
345    // ── from_value — happy paths ──────────────────────────────────────────────
346
347    #[rstest]
348    fn parses_zone_info(zone_json: serde_json::Value) {
349        let resp = wrap_response(zone_json, vec![]);
350        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
351        assert_eq!(result.zones.len(), 1);
352        assert_eq!(result.zones[0].zone.name, "example.com");
353        assert_eq!(result.zones[0].zone.zone_type, "Primary");
354        assert!(!result.zones[0].zone.disabled);
355    }
356
357    #[rstest]
358    fn empty_records_list(zone_json: serde_json::Value) {
359        let resp = wrap_response(zone_json, vec![]);
360        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
361        assert!(result.zones[0].records.is_empty());
362    }
363
364    #[rstest]
365    fn a_record_parsed_as_writable(zone_json: serde_json::Value, a_record_json: serde_json::Value) {
366        let resp = wrap_response(zone_json, vec![a_record_json]);
367        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
368
369        let records = &result.zones[0].records;
370        assert_eq!(records.len(), 1);
371        let record = &records[0];
372        assert_eq!(record.record_type, "A");
373        assert_eq!(record.ttl, 3600);
374        assert_eq!(record.name, "www");
375
376        match &record.parsed {
377            Some(AnyRecordData::Writable(RecordData::A { ip })) => {
378                assert_eq!(ip.to_string(), "1.2.3.4");
379            }
380            other => panic!("expected Writable(A), got {other:?}"),
381        }
382    }
383
384    #[rstest]
385    fn rrsig_parsed_as_read_only(
386        zone_json: serde_json::Value,
387        rrsig_record_json: serde_json::Value,
388    ) {
389        let resp = wrap_response(zone_json, vec![rrsig_record_json]);
390        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
391
392        match &result.zones[0].records[0].parsed {
393            Some(AnyRecordData::ReadOnly(ReadOnlyRecordData::Rrsig(data))) => {
394                assert_eq!(data.type_covered, "A");
395                assert_eq!(data.key_tag, 12345);
396                assert_eq!(data.signer_name, "example.com");
397            }
398            other => panic!("expected ReadOnly(Rrsig), got {other:?}"),
399        }
400    }
401
402    #[rstest]
403    fn dnskey_parsed_as_read_only(
404        zone_json: serde_json::Value,
405        dnskey_record_json: serde_json::Value,
406    ) {
407        let resp = wrap_response(zone_json, vec![dnskey_record_json]);
408        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
409
410        match &result.zones[0].records[0].parsed {
411            Some(AnyRecordData::ReadOnly(ReadOnlyRecordData::Dnskey(data))) => {
412                assert_eq!(data.flags, 257);
413                assert_eq!(data.computed_key_tag, 12345);
414                assert_eq!(data.dns_key_state.as_deref(), Some("Active"));
415                assert_eq!(data.is_ksk, Some(true));
416            }
417            other => panic!("expected ReadOnly(Dnskey), got {other:?}"),
418        }
419    }
420
421    #[rstest]
422    fn unknown_type_produces_none_parsed(zone_json: serde_json::Value) {
423        let record = json!({
424            "name": "weird",
425            "type": "NEWTYPE99",
426            "ttl": 300,
427            "rData": { "someField": "someValue" }
428        });
429        let resp = wrap_response(zone_json, vec![record]);
430        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
431        assert!(
432            result.zones[0].records[0].parsed.is_none(),
433            "unknown type should produce None"
434        );
435    }
436
437    #[rstest]
438    fn mixed_records_parse_correctly(
439        zone_json: serde_json::Value,
440        a_record_json: serde_json::Value,
441        rrsig_record_json: serde_json::Value,
442    ) {
443        let unknown = json!({ "name": "x", "type": "MYSTERY", "ttl": 60, "rData": {} });
444        let resp = wrap_response(zone_json, vec![a_record_json, rrsig_record_json, unknown]);
445        let result = ListRecordsResponse::from_value(&resp).expect("should parse");
446
447        let records = &result.zones[0].records;
448        assert_eq!(records.len(), 3);
449        assert!(matches!(
450            records[0].parsed,
451            Some(AnyRecordData::Writable(_))
452        ));
453        assert!(matches!(
454            records[1].parsed,
455            Some(AnyRecordData::ReadOnly(_))
456        ));
457        assert!(records[2].parsed.is_none());
458    }
459
460    // ── from_value — error paths ──────────────────────────────────────────────
461
462    #[rstest]
463    fn missing_response_key_returns_parse_error() {
464        let bad = json!({ "status": "ok" });
465        let err = ListRecordsResponse::from_value(&bad).unwrap_err();
466        assert!(
467            matches!(err, crate::core::error::Error::Parse { ref context } if context.contains("'response'"))
468        );
469    }
470
471    #[rstest]
472    fn missing_zone_key_returns_parse_error() {
473        let bad = json!({ "status": "ok", "response": { "records": [] } });
474        let err = ListRecordsResponse::from_value(&bad).unwrap_err();
475        assert!(
476            matches!(err, crate::core::error::Error::Parse { ref context } if context.contains("zone"))
477        );
478    }
479
480    #[rstest]
481    fn missing_records_key_returns_parse_error(zone_json: serde_json::Value) {
482        let bad = json!({ "status": "ok", "response": { "zone": zone_json } });
483        let err = ListRecordsResponse::from_value(&bad).unwrap_err();
484        assert!(
485            matches!(err, crate::core::error::Error::Parse { ref context } if context.contains("records"))
486        );
487    }
488
489    #[rstest]
490    #[case(json!({}))]
491    #[case(json!(null))]
492    #[case(json!([]))]
493    fn empty_or_null_json_returns_parse_error(#[case] input: serde_json::Value) {
494        assert!(ListRecordsResponse::from_value(&input).is_err());
495    }
496
497    #[rstest]
498    fn skips_malformed_records_rather_than_failing(
499        zone_json: serde_json::Value,
500        a_record_json: serde_json::Value,
501    ) {
502        let bad_record = json!({ "name": "bad", "ttl": 300, "rData": {} });
503        let resp = wrap_response(zone_json, vec![bad_record, a_record_json]);
504        let result = ListRecordsResponse::from_value(&resp).expect("should parse overall response");
505        let records = &result.zones[0].records;
506        assert_eq!(records.len(), 1);
507        assert_eq!(records[0].record_type, "A");
508    }
509
510    // ── ZoneRecord fields ─────────────────────────────────────────────────────
511
512    #[rstest]
513    fn record_disabled_defaults_to_false(zone_json: serde_json::Value) {
514        let record = json!({
515            "name": "test", "type": "A", "ttl": 300,
516            "rData": { "ipAddress": "10.0.0.1" }
517        });
518        let resp = wrap_response(zone_json, vec![record]);
519        let result = ListRecordsResponse::from_value(&resp).unwrap();
520        assert!(!result.zones[0].records[0].disabled);
521    }
522
523    #[rstest]
524    fn record_comments_defaults_to_empty(zone_json: serde_json::Value) {
525        let record = json!({
526            "name": "test", "type": "A", "ttl": 300,
527            "rData": { "ipAddress": "10.0.0.1" }
528        });
529        let resp = wrap_response(zone_json, vec![record]);
530        let result = ListRecordsResponse::from_value(&resp).unwrap();
531        assert_eq!(result.zones[0].records[0].comments, "");
532    }
533
534    // ── ListRecordsResponse::single ───────────────────────────────────────────
535
536    #[rstest]
537    fn single_wraps_zone_and_records_in_one_entry(zone_json: serde_json::Value) {
538        let zone: ZoneInfo = serde_json::from_value(zone_json).unwrap();
539        let result = ListRecordsResponse::single(zone, vec![]);
540        assert_eq!(result.zones.len(), 1);
541        assert_eq!(result.zones[0].zone.name, "example.com");
542        assert!(result.zones[0].records.is_empty());
543    }
544
545    // ── Serialization shape ───────────────────────────────────────────────────
546
547    fn make_zone(name: &str) -> ZoneInfo {
548        ZoneInfo {
549            id: None,
550            name: name.to_string(),
551            zone_type: "Primary".to_string(),
552            disabled: false,
553            dnssec_status: None,
554        }
555    }
556
557    #[test]
558    fn single_zone_serializes_flat() {
559        let resp = ListRecordsResponse::single(make_zone("example.com"), vec![]);
560        let v = serde_json::to_value(&resp).unwrap();
561        assert!(v.get("zone").is_some(), "should have top-level 'zone'");
562        assert!(
563            v.get("records").is_some(),
564            "should have top-level 'records'"
565        );
566        assert!(v.get("zones").is_none(), "should NOT have 'zones' wrapper");
567        assert_eq!(v["zone"]["name"], "example.com");
568    }
569
570    #[test]
571    fn multi_zone_serializes_with_zones_array() {
572        let resp = ListRecordsResponse {
573            zones: vec![
574                ZoneRecords {
575                    zone: make_zone("a.example.com"),
576                    records: vec![],
577                },
578                ZoneRecords {
579                    zone: make_zone("b.example.com"),
580                    records: vec![],
581                },
582            ],
583        };
584        let v = serde_json::to_value(&resp).unwrap();
585        assert!(v.get("zones").is_some(), "should have 'zones' array");
586        assert!(v.get("zone").is_none(), "should NOT have top-level 'zone'");
587        assert_eq!(v["zones"].as_array().unwrap().len(), 2);
588    }
589
590    #[test]
591    fn single_zone_round_trips_through_serde() {
592        let original = ListRecordsResponse::single(make_zone("example.com"), vec![]);
593        let json = serde_json::to_value(&original).unwrap();
594        let restored: ListRecordsResponse = serde_json::from_value(json).unwrap();
595        assert_eq!(restored.zones.len(), 1);
596        assert_eq!(restored.zones[0].zone.name, "example.com");
597    }
598
599    #[test]
600    fn multi_zone_round_trips_through_serde() {
601        let original = ListRecordsResponse {
602            zones: vec![
603                ZoneRecords {
604                    zone: make_zone("a.example.com"),
605                    records: vec![],
606                },
607                ZoneRecords {
608                    zone: make_zone("b.example.com"),
609                    records: vec![],
610                },
611            ],
612        };
613        let json = serde_json::to_value(&original).unwrap();
614        let restored: ListRecordsResponse = serde_json::from_value(json).unwrap();
615        assert_eq!(restored.zones.len(), 2);
616        assert_eq!(restored.zones[0].zone.name, "a.example.com");
617        assert_eq!(restored.zones[1].zone.name, "b.example.com");
618    }
619}