auth dns over atproto
at main 852 lines 29 kB view raw
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}