1use std::net::{Ipv4Addr, Ipv6Addr};
2use std::str::FromStr;
3use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
4
5use async_trait::async_trait;
6use hickory_proto::op::{Header, MessageType, OpCode, ResponseCode};
7use hickory_proto::rr::rdata::{A, AAAA, CNAME, MX, NS, SOA, SRV, TXT};
8use hickory_proto::rr::{LowerName, Name, RData, Record, RecordType};
9use hickory_server::authority::MessageResponseBuilder;
10use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
11use tracing::{debug, info, warn};
12
13use onis_common::config::DnsConfig;
14
15use crate::client::AppviewClient;
16
17/// Authoritative DNS request handler backed by the onis appview.
18///
19/// Translates incoming DNS queries into appview resolve calls and converts
20/// the JSON records back into wire-format DNS responses.
21pub struct OnisHandler {
22 /// HTTP client for querying the appview resolve API.
23 client: AppviewClient,
24 /// Minimum TTL enforced on all outgoing DNS records.
25 ttl_floor: u32,
26 /// Queries exceeding this duration are logged as slow.
27 slow_query_threshold: Duration,
28 /// TTL used for synthesized SOA records.
29 soa_ttl: u32,
30 /// SOA refresh interval in seconds.
31 soa_refresh: i32,
32 /// SOA retry interval in seconds.
33 soa_retry: i32,
34 /// SOA expire interval in seconds.
35 soa_expire: i32,
36 /// SOA minimum (negative cache) TTL in seconds.
37 soa_minimum: u32,
38 /// SOA MNAME — primary nameserver.
39 soa_mname: Name,
40 /// SOA RNAME — admin contact in DNS format.
41 soa_rname: Name,
42 /// NS records served for all zones.
43 ns_records: Vec<Name>,
44}
45
46impl OnisHandler {
47 pub fn new(client: AppviewClient, config: &DnsConfig) -> Self {
48 let ns_records: Vec<Name> = config
49 .ns
50 .iter()
51 .filter_map(|ns| Name::from_str(ns).ok())
52 .collect();
53
54 Self {
55 client,
56 ttl_floor: config.ttl_floor,
57 slow_query_threshold: Duration::from_millis(config.slow_query_threshold_ms),
58 soa_ttl: config.soa.ttl,
59 soa_refresh: config.soa.refresh,
60 soa_retry: config.soa.retry,
61 soa_expire: config.soa.expire,
62 soa_minimum: config.soa.minimum,
63 soa_mname: Name::from_str(&config.soa.mname).unwrap_or_else(|_| Name::root()),
64 soa_rname: Name::from_str(&config.soa.rname).unwrap_or_else(|_| Name::root()),
65 ns_records,
66 }
67 }
68
69 fn enforce_ttl(&self, ttl: u32) -> u32 {
70 ttl.max(self.ttl_floor)
71 }
72
73 fn name_to_domain(name: &LowerName) -> String {
74 name.to_string().trim_end_matches('.').to_string()
75 }
76
77 fn map_record_type(rtype: RecordType) -> Option<&'static str> {
78 match rtype {
79 RecordType::A => Some("a"),
80 RecordType::AAAA => Some("aaaa"),
81 RecordType::CNAME => Some("cname"),
82 RecordType::MX => Some("mx"),
83 RecordType::TXT => Some("txt"),
84 RecordType::SRV => Some("srv"),
85 RecordType::SOA => Some("soa"),
86 _ => None,
87 }
88 }
89
90 fn to_fqdn(name: &str) -> Option<Name> {
91 Name::from_str(&format!("{name}.")).ok()
92 }
93
94 fn now_serial() -> u32 {
95 SystemTime::now()
96 .duration_since(UNIX_EPOCH)
97 .unwrap_or_default()
98 .as_secs() as u32
99 }
100
101 fn synthesize_soa(&self, zone: &str) -> Record {
102 let name = Self::to_fqdn(zone).unwrap_or_else(Name::root);
103 Record::from_rdata(
104 name,
105 self.enforce_ttl(self.soa_ttl),
106 RData::SOA(SOA::new(
107 self.soa_mname.clone(),
108 self.soa_rname.clone(),
109 Self::now_serial(),
110 self.soa_refresh,
111 self.soa_retry,
112 self.soa_expire,
113 self.soa_minimum,
114 )),
115 )
116 }
117
118 fn synthesize_ns(&self, zone: &str) -> Vec<Record> {
119 let name = Self::to_fqdn(zone).unwrap_or_else(Name::root);
120 let ttl = self.enforce_ttl(self.soa_ttl);
121 self.ns_records
122 .iter()
123 .map(|ns| {
124 Record::from_rdata(name.clone(), ttl, RData::NS(NS(ns.clone())))
125 })
126 .collect()
127 }
128
129 fn convert_record(&self, value: &serde_json::Value) -> Option<Record> {
130 let domain = value.get("domain")?.as_str()?;
131 let name = Self::to_fqdn(domain)?;
132
133 let ttl_raw = value
134 .get("ttl")
135 .and_then(|v| v.as_u64())
136 .unwrap_or(self.soa_minimum as u64);
137 let ttl = self.enforce_ttl(ttl_raw as u32);
138
139 let record = value.get("record")?;
140 let rtype = record.get("$type")?.as_str()?;
141
142 let rdata: RData = match rtype {
143 "systems.kiri.dns#aRecord" => {
144 let addr: Ipv4Addr = record.get("address")?.as_str()?.parse().ok()?;
145 RData::A(A(addr))
146 }
147 "systems.kiri.dns#aaaaRecord" => {
148 let addr: Ipv6Addr = record.get("address")?.as_str()?.parse().ok()?;
149 RData::AAAA(AAAA(addr))
150 }
151 "systems.kiri.dns#cnameRecord" => {
152 let target = record.get("cname")?.as_str()?;
153 RData::CNAME(CNAME(Self::to_fqdn(target)?))
154 }
155 "systems.kiri.dns#mxRecord" => {
156 let pref = record.get("preference")?.as_u64()? as u16;
157 let exchange = record.get("exchange")?.as_str()?;
158 RData::MX(MX::new(
159 pref,
160 Self::to_fqdn(exchange)?,
161 ))
162 }
163 "systems.kiri.dns#txtRecord" => {
164 let values = record.get("values")?.as_array()?;
165 let strings: Vec<String> = values
166 .iter()
167 .filter_map(|v| v.as_str())
168 .map(String::from)
169 .collect();
170 RData::TXT(TXT::new(strings))
171 }
172 "systems.kiri.dns#srvRecord" => {
173 let priority = record.get("priority")?.as_u64()? as u16;
174 let weight = record.get("weight")?.as_u64()? as u16;
175 let port = record.get("port")?.as_u64()? as u16;
176 let target = record.get("target")?.as_str()?;
177 RData::SRV(SRV::new(
178 priority,
179 weight,
180 port,
181 Self::to_fqdn(target)?,
182 ))
183 }
184 "systems.kiri.dns#soaRecord" => {
185 let mname = record.get("mname")?.as_str()?;
186 let rname = record.get("rname")?.as_str()?;
187 let refresh = record.get("refresh")?.as_u64()? as i32;
188 let retry = record.get("retry")?.as_u64()? as i32;
189 let expire = record.get("expire")?.as_u64()? as i32;
190 let minimum = record.get("minimum")?.as_u64()? as u32;
191 RData::SOA(SOA::new(
192 Self::to_fqdn(mname)?,
193 Self::to_fqdn(rname)?,
194 Self::now_serial(),
195 refresh,
196 retry,
197 expire,
198 minimum,
199 ))
200 }
201 _ => return None,
202 };
203
204 Some(Record::from_rdata(name, ttl, rdata))
205 }
206
207 /// Get the SOA record for a zone, synthesizing if the user hasn't published one.
208 async fn get_soa(&self, zone: &str) -> Record {
209 if let Ok(resp) = self.client.resolve(zone, Some("soa")).await {
210 for val in &resp.records {
211 if let Some(record) = self.convert_record(val) {
212 return record;
213 }
214 }
215 }
216 self.synthesize_soa(zone)
217 }
218
219 fn response_header(request: &Request, rcode: ResponseCode) -> Header {
220 let mut header = Header::new();
221 header.set_id(request.header().id());
222 header.set_message_type(MessageType::Response);
223 header.set_op_code(OpCode::Query);
224 header.set_authoritative(true);
225 header.set_recursion_desired(request.header().recursion_desired());
226 header.set_recursion_available(false);
227 header.set_response_code(rcode);
228 header
229 }
230
231 fn error_response_info(rcode: ResponseCode) -> ResponseInfo {
232 let mut header = Header::new();
233 header.set_response_code(rcode);
234 header.into()
235 }
236
237 async fn send_error<R: ResponseHandler>(
238 request: &Request,
239 response_handle: &mut R,
240 start: &Instant,
241 qtype_str: &str,
242 rcode: ResponseCode,
243 rcode_label: &str,
244 ) -> ResponseInfo {
245 let builder = MessageResponseBuilder::from_message_request(request);
246 let response = builder.error_msg(request.header(), rcode);
247 record_query_metrics(start, qtype_str, rcode_label);
248 response_handle
249 .send_response(response)
250 .await
251 .unwrap_or_else(|e| {
252 warn!(error = %e, rcode = rcode_label, "failed to send error response");
253 Self::error_response_info(ResponseCode::ServFail)
254 })
255 }
256
257 async fn send_dns_response<R: ResponseHandler>(
258 &self,
259 request: &Request,
260 response_handle: &mut R,
261 start: &Instant,
262 domain: &str,
263 qtype_str: &str,
264 rcode: ResponseCode,
265 rcode_label: &str,
266 answers: &[Record],
267 nameservers: &[Record],
268 authority: &[Record],
269 ) -> ResponseInfo {
270 let header = Self::response_header(request, rcode);
271 let builder = MessageResponseBuilder::from_message_request(request);
272 let response = builder.build(
273 header,
274 answers.iter(),
275 nameservers.iter(),
276 authority.iter(),
277 std::iter::empty(),
278 );
279
280 let elapsed = start.elapsed();
281 info!(
282 domain = %domain,
283 qtype = %qtype_str,
284 rcode = rcode_label,
285 answers = answers.len(),
286 duration_ms = elapsed.as_millis() as u64,
287 src = %request.src(),
288 "query complete"
289 );
290
291 if elapsed > self.slow_query_threshold {
292 warn!(
293 domain = %domain,
294 qtype = %qtype_str,
295 duration_ms = elapsed.as_millis() as u64,
296 "slow query"
297 );
298 }
299
300 record_query_metrics(start, qtype_str, rcode_label);
301 response_handle
302 .send_response(response)
303 .await
304 .unwrap_or_else(|e| {
305 warn!(error = %e, "failed to send DNS response");
306 Self::error_response_info(ResponseCode::ServFail)
307 })
308 }
309}
310
311#[async_trait]
312impl RequestHandler for OnisHandler {
313 async fn handle_request<R: ResponseHandler>(
314 &self,
315 request: &Request,
316 mut response_handle: R,
317 ) -> ResponseInfo {
318 let start = Instant::now();
319
320 // Only handle standard queries
321 if request.header().message_type() != MessageType::Query
322 || request.header().op_code() != OpCode::Query
323 {
324 return Self::send_error(
325 request, &mut response_handle, &start,
326 "UNKNOWN", ResponseCode::NotImp, "NOTIMP",
327 ).await;
328 }
329
330 let queries = request.queries();
331 if queries.is_empty() {
332 return Self::send_error(
333 request, &mut response_handle, &start,
334 "UNKNOWN", ResponseCode::FormErr, "FORMERR",
335 ).await;
336 }
337
338 let query = &queries[0];
339 let qname = query.name();
340 let qtype = query.query_type();
341 let qtype_str = format!("{qtype:?}");
342 let domain = Self::name_to_domain(qname);
343
344 debug!(
345 domain = %domain,
346 qtype = ?qtype,
347 src = %request.src(),
348 "query"
349 );
350
351 // Resolve against the appview — single call, appview walks the zone tree
352 let resp = match self.client.resolve(&domain, None).await {
353 Ok(r) => r,
354 Err(e) => {
355 warn!(error = %e, domain = %domain, "appview request failed");
356 return Self::send_error(
357 request, &mut response_handle, &start,
358 &qtype_str, ResponseCode::ServFail, "SERVFAIL",
359 ).await;
360 }
361 };
362
363 // No zone found or zone not verified → REFUSED
364 let zone = match resp.zone {
365 Some(ref z) if resp.verified => z.clone(),
366 _ => {
367 debug!(domain = %domain, "no verified zone, refusing");
368 return self.send_dns_response(
369 request, &mut response_handle, &start,
370 &domain, &qtype_str,
371 ResponseCode::Refused, "REFUSED",
372 &[], &[], &[],
373 ).await;
374 }
375 };
376
377 let soa_record = self.get_soa(&zone).await;
378 let ns_records = self.synthesize_ns(&zone);
379
380 // Handle NS queries — always return auto-generated NS records
381 if qtype == RecordType::NS {
382 return self.send_dns_response(
383 request, &mut response_handle, &start,
384 &domain, &qtype_str,
385 ResponseCode::NoError, "NOERROR",
386 &ns_records, &[], std::slice::from_ref(&soa_record),
387 ).await;
388 }
389
390 // Handle SOA queries
391 if qtype == RecordType::SOA {
392 return self.send_dns_response(
393 request, &mut response_handle, &start,
394 &domain, &qtype_str,
395 ResponseCode::NoError, "NOERROR",
396 std::slice::from_ref(&soa_record), &ns_records, &[],
397 ).await;
398 }
399
400 // Map query type to appview type string
401 let type_str = match Self::map_record_type(qtype) {
402 Some(t) => t,
403 None => {
404 // Unsupported type for a zone we serve → NODATA
405 return self.send_dns_response(
406 request, &mut response_handle, &start,
407 &domain, &qtype_str,
408 ResponseCode::NoError, "NOERROR/NODATA",
409 &[], &[], std::slice::from_ref(&soa_record),
410 ).await;
411 }
412 };
413
414 // Query the appview API for the specific record type
415 let typed_resp = match self.client.resolve(&domain, Some(type_str)).await {
416 Ok(r) => r,
417 Err(e) => {
418 warn!(error = %e, domain = %domain, "appview typed request failed");
419 return Self::send_error(
420 request, &mut response_handle, &start,
421 &qtype_str, ResponseCode::ServFail, "SERVFAIL",
422 ).await;
423 }
424 };
425
426 // Convert appview records to DNS records
427 let answers: Vec<Record> = typed_resp
428 .records
429 .iter()
430 .filter_map(|v| self.convert_record(v))
431 .collect();
432
433 if answers.is_empty() {
434 let (rcode, label) = if resp.name_exists {
435 (ResponseCode::NoError, "NOERROR/NODATA")
436 } else {
437 (ResponseCode::NXDomain, "NXDOMAIN")
438 };
439 self.send_dns_response(
440 request, &mut response_handle, &start,
441 &domain, &qtype_str,
442 rcode, label,
443 &[], &[], std::slice::from_ref(&soa_record),
444 ).await
445 } else {
446 self.send_dns_response(
447 request, &mut response_handle, &start,
448 &domain, &qtype_str,
449 ResponseCode::NoError, "NOERROR",
450 &answers, &ns_records, &[],
451 ).await
452 }
453 }
454}
455
456fn record_query_metrics(start: &Instant, qtype: &str, rcode: &str) {
457 metrics::counter!("dns_queries_total", "qtype" => qtype.to_owned(), "rcode" => rcode.to_owned())
458 .increment(1);
459 metrics::histogram!("dns_query_duration_seconds")
460 .record(start.elapsed().as_secs_f64());
461}
462
463#[cfg(test)]
464#[allow(clippy::unwrap_used)]
465mod tests {
466 use std::net::{Ipv4Addr, Ipv6Addr};
467 use std::str::FromStr;
468
469 use hickory_proto::rr::{LowerName, Name, RData, RecordType};
470 use serde_json::json;
471
472 use crate::client::AppviewClient;
473 use onis_common::config::DnsConfig;
474
475 use super::OnisHandler;
476
477 fn test_handler() -> OnisHandler {
478 let client = AppviewClient::new("http://test:3000".to_string());
479 let config = DnsConfig::default();
480 OnisHandler::new(client, &config)
481 }
482
483 #[test]
484 fn enforce_ttl_below_floor() {
485 let handler = test_handler();
486 assert_eq!(handler.enforce_ttl(10), 60);
487 }
488
489 #[test]
490 fn enforce_ttl_above_floor() {
491 let handler = test_handler();
492 assert_eq!(handler.enforce_ttl(300), 300);
493 }
494
495 #[test]
496 fn enforce_ttl_at_floor() {
497 let handler = test_handler();
498 assert_eq!(handler.enforce_ttl(60), 60);
499 }
500
501 #[test]
502 fn enforce_ttl_zero() {
503 let handler = test_handler();
504 assert_eq!(handler.enforce_ttl(0), 60);
505 }
506
507 #[test]
508 fn name_to_domain_strips_trailing_dot() {
509 let name = LowerName::from(Name::from_str("example.com.").unwrap());
510 assert_eq!(OnisHandler::name_to_domain(&name), "example.com");
511 }
512
513 #[test]
514 fn name_to_domain_subdomain() {
515 let name = LowerName::from(Name::from_str("sub.example.com.").unwrap());
516 assert_eq!(OnisHandler::name_to_domain(&name), "sub.example.com");
517 }
518
519 #[test]
520 fn name_to_domain_root() {
521 let name = LowerName::from(Name::root());
522 assert_eq!(OnisHandler::name_to_domain(&name), "");
523 }
524
525 #[test]
526 fn map_record_type_supported() {
527 assert_eq!(OnisHandler::map_record_type(RecordType::A), Some("a"));
528 assert_eq!(OnisHandler::map_record_type(RecordType::AAAA), Some("aaaa"));
529 assert_eq!(OnisHandler::map_record_type(RecordType::CNAME), Some("cname"));
530 assert_eq!(OnisHandler::map_record_type(RecordType::MX), Some("mx"));
531 assert_eq!(OnisHandler::map_record_type(RecordType::TXT), Some("txt"));
532 assert_eq!(OnisHandler::map_record_type(RecordType::SRV), Some("srv"));
533 assert_eq!(OnisHandler::map_record_type(RecordType::SOA), Some("soa"));
534 }
535
536 #[test]
537 fn map_record_type_unsupported_returns_none() {
538 assert_eq!(OnisHandler::map_record_type(RecordType::NS), None);
539 assert_eq!(OnisHandler::map_record_type(RecordType::PTR), None);
540 }
541
542 #[test]
543 fn convert_a_record() {
544 let handler = test_handler();
545 let value = json!({
546 "domain": "example.com",
547 "ttl": 300,
548 "record": {
549 "$type": "systems.kiri.dns#aRecord",
550 "address": "1.2.3.4"
551 }
552 });
553 let record = handler.convert_record(&value).unwrap();
554 assert_eq!(record.name(), &Name::from_str("example.com.").unwrap());
555 assert_eq!(record.ttl(), 300);
556 match record.data() {
557 RData::A(a) => assert_eq!(a.0, Ipv4Addr::new(1, 2, 3, 4)),
558 other => unreachable!("expected A record, got {other:?}"),
559 }
560 }
561
562 #[test]
563 fn convert_aaaa_record() {
564 let handler = test_handler();
565 let value = json!({
566 "domain": "example.com",
567 "ttl": 300,
568 "record": {
569 "$type": "systems.kiri.dns#aaaaRecord",
570 "address": "2001:db8::1"
571 }
572 });
573 let record = handler.convert_record(&value).unwrap();
574 match record.data() {
575 RData::AAAA(aaaa) => {
576 assert_eq!(aaaa.0, Ipv6Addr::from_str("2001:db8::1").unwrap());
577 }
578 other => unreachable!("expected AAAA record, got {other:?}"),
579 }
580 }
581
582 #[test]
583 fn convert_cname_record() {
584 let handler = test_handler();
585 let value = json!({
586 "domain": "www.example.com",
587 "ttl": 300,
588 "record": {
589 "$type": "systems.kiri.dns#cnameRecord",
590 "cname": "example.com"
591 }
592 });
593 let record = handler.convert_record(&value).unwrap();
594 assert_eq!(record.name(), &Name::from_str("www.example.com.").unwrap());
595 match record.data() {
596 RData::CNAME(cname) => {
597 assert_eq!(cname.0, Name::from_str("example.com.").unwrap());
598 }
599 other => unreachable!("expected CNAME record, got {other:?}"),
600 }
601 }
602
603 #[test]
604 fn convert_mx_record() {
605 let handler = test_handler();
606 let value = json!({
607 "domain": "example.com",
608 "ttl": 300,
609 "record": {
610 "$type": "systems.kiri.dns#mxRecord",
611 "preference": 10,
612 "exchange": "mail.example.com"
613 }
614 });
615 let record = handler.convert_record(&value).unwrap();
616 match record.data() {
617 RData::MX(mx) => {
618 assert_eq!(mx.preference(), 10);
619 assert_eq!(
620 mx.exchange(),
621 &Name::from_str("mail.example.com.").unwrap()
622 );
623 }
624 other => unreachable!("expected MX record, got {other:?}"),
625 }
626 }
627
628 #[test]
629 fn convert_txt_record() {
630 let handler = test_handler();
631 let value = json!({
632 "domain": "example.com",
633 "ttl": 300,
634 "record": {
635 "$type": "systems.kiri.dns#txtRecord",
636 "values": ["v=spf1 include:example.com ~all"]
637 }
638 });
639 let record = handler.convert_record(&value).unwrap();
640 match record.data() {
641 RData::TXT(txt) => {
642 let text: String = txt
643 .txt_data()
644 .iter()
645 .map(|d| String::from_utf8_lossy(d).to_string())
646 .collect::<Vec<_>>()
647 .join("");
648 assert_eq!(text, "v=spf1 include:example.com ~all");
649 }
650 other => unreachable!("expected TXT record, got {other:?}"),
651 }
652 }
653
654 #[test]
655 fn convert_srv_record() {
656 let handler = test_handler();
657 let value = json!({
658 "domain": "_sip._tcp.example.com",
659 "ttl": 300,
660 "record": {
661 "$type": "systems.kiri.dns#srvRecord",
662 "priority": 10,
663 "weight": 60,
664 "port": 5060,
665 "target": "sip.example.com"
666 }
667 });
668 let record = handler.convert_record(&value).unwrap();
669 match record.data() {
670 RData::SRV(srv) => {
671 assert_eq!(srv.priority(), 10);
672 assert_eq!(srv.weight(), 60);
673 assert_eq!(srv.port(), 5060);
674 assert_eq!(srv.target(), &Name::from_str("sip.example.com.").unwrap());
675 }
676 other => unreachable!("expected SRV record, got {other:?}"),
677 }
678 }
679
680 #[test]
681 fn convert_soa_record() {
682 let handler = test_handler();
683 let value = json!({
684 "domain": "example.com",
685 "ttl": 3600,
686 "record": {
687 "$type": "systems.kiri.dns#soaRecord",
688 "mname": "ns1.example.com",
689 "rname": "admin.example.com",
690 "refresh": 3600,
691 "retry": 900,
692 "expire": 604800,
693 "minimum": 300
694 }
695 });
696 let record = handler.convert_record(&value).unwrap();
697 match record.data() {
698 RData::SOA(soa) => {
699 assert_eq!(soa.mname(), &Name::from_str("ns1.example.com.").unwrap());
700 assert_eq!(soa.rname(), &Name::from_str("admin.example.com.").unwrap());
701 assert_eq!(soa.refresh(), 3600);
702 assert_eq!(soa.retry(), 900);
703 assert_eq!(soa.expire(), 604800);
704 assert_eq!(soa.minimum(), 300);
705 }
706 other => unreachable!("expected SOA record, got {other:?}"),
707 }
708 }
709
710 #[test]
711 fn convert_record_missing_domain_returns_none() {
712 let handler = test_handler();
713 let value = json!({
714 "record": {
715 "$type": "systems.kiri.dns#aRecord",
716 "address": "1.2.3.4"
717 }
718 });
719 assert!(handler.convert_record(&value).is_none());
720 }
721
722 #[test]
723 fn convert_record_missing_record_returns_none() {
724 let handler = test_handler();
725 let value = json!({
726 "domain": "example.com"
727 });
728 assert!(handler.convert_record(&value).is_none());
729 }
730
731 #[test]
732 fn convert_record_missing_type_returns_none() {
733 let handler = test_handler();
734 let value = json!({
735 "domain": "example.com",
736 "record": {
737 "address": "1.2.3.4"
738 }
739 });
740 assert!(handler.convert_record(&value).is_none());
741 }
742
743 #[test]
744 fn convert_record_unknown_type_returns_none() {
745 let handler = test_handler();
746 let value = json!({
747 "domain": "example.com",
748 "record": {
749 "$type": "systems.kiri.dns#ptrRecord",
750 "name": "1.2.3.4.in-addr.arpa"
751 }
752 });
753 assert!(handler.convert_record(&value).is_none());
754 }
755
756 #[test]
757 fn convert_record_invalid_ip_returns_none() {
758 let handler = test_handler();
759 let value = json!({
760 "domain": "example.com",
761 "record": {
762 "$type": "systems.kiri.dns#aRecord",
763 "address": "not-an-ip"
764 }
765 });
766 assert!(handler.convert_record(&value).is_none());
767 }
768
769 #[test]
770 fn convert_record_ttl_defaults_to_soa_minimum() {
771 let handler = test_handler();
772 let value = json!({
773 "domain": "example.com",
774 "record": {
775 "$type": "systems.kiri.dns#aRecord",
776 "address": "1.2.3.4"
777 }
778 });
779 let record = handler.convert_record(&value).unwrap();
780 // Default soa_minimum is 300, above the ttl_floor of 60
781 assert_eq!(record.ttl(), 300);
782 }
783
784 #[test]
785 fn convert_record_ttl_enforces_floor() {
786 let handler = test_handler();
787 let value = json!({
788 "domain": "example.com",
789 "ttl": 10,
790 "record": {
791 "$type": "systems.kiri.dns#aRecord",
792 "address": "1.2.3.4"
793 }
794 });
795 let record = handler.convert_record(&value).unwrap();
796 assert_eq!(record.ttl(), 60);
797 }
798
799 #[test]
800 fn synthesize_soa_uses_config_defaults() {
801 let handler = test_handler();
802 let record = handler.synthesize_soa("example.com");
803 assert_eq!(record.name(), &Name::from_str("example.com.").unwrap());
804 assert_eq!(record.ttl(), 3600);
805 match record.data() {
806 RData::SOA(soa) => {
807 assert_eq!(soa.mname(), &Name::from_str("ns1.kiri.systems.").unwrap());
808 assert_eq!(soa.rname(), &Name::from_str("admin.kiri.systems.").unwrap());
809 assert_eq!(soa.refresh(), 3600);
810 assert_eq!(soa.retry(), 900);
811 assert_eq!(soa.expire(), 604800);
812 assert_eq!(soa.minimum(), 300);
813 }
814 other => unreachable!("expected SOA record, got {other:?}"),
815 }
816 }
817
818 #[test]
819 fn synthesize_ns_returns_configured_nameservers() {
820 let handler = test_handler();
821 let records = handler.synthesize_ns("example.com");
822 assert_eq!(records.len(), 2);
823 let names: Vec<String> = records
824 .iter()
825 .map(|r| match r.data() {
826 RData::NS(ns) => ns.0.to_string(),
827 other => unreachable!("expected NS record, got {other:?}"),
828 })
829 .collect();
830 assert!(names.contains(&"ns1.kiri.systems.".to_string()));
831 assert!(names.contains(&"ns2.kiri.systems.".to_string()));
832 }
833
834 #[test]
835 fn synthesize_ns_uses_zone_name() {
836 let handler = test_handler();
837 let records = handler.synthesize_ns("example.com");
838 let expected = Name::from_str("example.com.").unwrap();
839 for record in &records {
840 assert_eq!(record.name(), &expected);
841 }
842 }
843
844 #[test]
845 fn synthesize_ns_ttl_matches_soa() {
846 let handler = test_handler();
847 let records = handler.synthesize_ns("example.com");
848 for record in &records {
849 assert_eq!(record.ttl(), 3600);
850 }
851 }
852}