use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use hickory_proto::op::{Header, MessageType, OpCode, ResponseCode}; use hickory_proto::rr::rdata::{A, AAAA, CNAME, MX, NS, SOA, SRV, TXT}; use hickory_proto::rr::{LowerName, Name, RData, Record, RecordType}; use hickory_server::authority::MessageResponseBuilder; use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo}; use tracing::{debug, info, warn}; use onis_common::config::DnsConfig; use crate::client::AppviewClient; /// Authoritative DNS request handler backed by the onis appview. /// /// Translates incoming DNS queries into appview resolve calls and converts /// the JSON records back into wire-format DNS responses. pub struct OnisHandler { /// HTTP client for querying the appview resolve API. client: AppviewClient, /// Minimum TTL enforced on all outgoing DNS records. ttl_floor: u32, /// Queries exceeding this duration are logged as slow. slow_query_threshold: Duration, /// TTL used for synthesized SOA records. soa_ttl: u32, /// SOA refresh interval in seconds. soa_refresh: i32, /// SOA retry interval in seconds. soa_retry: i32, /// SOA expire interval in seconds. soa_expire: i32, /// SOA minimum (negative cache) TTL in seconds. soa_minimum: u32, /// SOA MNAME — primary nameserver. soa_mname: Name, /// SOA RNAME — admin contact in DNS format. soa_rname: Name, /// NS records served for all zones. ns_records: Vec, } impl OnisHandler { pub fn new(client: AppviewClient, config: &DnsConfig) -> Self { let ns_records: Vec = config .ns .iter() .filter_map(|ns| Name::from_str(ns).ok()) .collect(); Self { client, ttl_floor: config.ttl_floor, slow_query_threshold: Duration::from_millis(config.slow_query_threshold_ms), soa_ttl: config.soa.ttl, soa_refresh: config.soa.refresh, soa_retry: config.soa.retry, soa_expire: config.soa.expire, soa_minimum: config.soa.minimum, soa_mname: Name::from_str(&config.soa.mname).unwrap_or_else(|_| Name::root()), soa_rname: Name::from_str(&config.soa.rname).unwrap_or_else(|_| Name::root()), ns_records, } } fn enforce_ttl(&self, ttl: u32) -> u32 { ttl.max(self.ttl_floor) } fn name_to_domain(name: &LowerName) -> String { name.to_string().trim_end_matches('.').to_string() } fn map_record_type(rtype: RecordType) -> Option<&'static str> { match rtype { RecordType::A => Some("a"), RecordType::AAAA => Some("aaaa"), RecordType::CNAME => Some("cname"), RecordType::MX => Some("mx"), RecordType::TXT => Some("txt"), RecordType::SRV => Some("srv"), RecordType::SOA => Some("soa"), _ => None, } } fn to_fqdn(name: &str) -> Option { Name::from_str(&format!("{name}.")).ok() } fn now_serial() -> u32 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() as u32 } fn synthesize_soa(&self, zone: &str) -> Record { let name = Self::to_fqdn(zone).unwrap_or_else(Name::root); Record::from_rdata( name, self.enforce_ttl(self.soa_ttl), RData::SOA(SOA::new( self.soa_mname.clone(), self.soa_rname.clone(), Self::now_serial(), self.soa_refresh, self.soa_retry, self.soa_expire, self.soa_minimum, )), ) } fn synthesize_ns(&self, zone: &str) -> Vec { let name = Self::to_fqdn(zone).unwrap_or_else(Name::root); let ttl = self.enforce_ttl(self.soa_ttl); self.ns_records .iter() .map(|ns| { Record::from_rdata(name.clone(), ttl, RData::NS(NS(ns.clone()))) }) .collect() } fn convert_record(&self, value: &serde_json::Value) -> Option { let domain = value.get("domain")?.as_str()?; let name = Self::to_fqdn(domain)?; let ttl_raw = value .get("ttl") .and_then(|v| v.as_u64()) .unwrap_or(self.soa_minimum as u64); let ttl = self.enforce_ttl(ttl_raw as u32); let record = value.get("record")?; let rtype = record.get("$type")?.as_str()?; let rdata: RData = match rtype { "systems.kiri.dns#aRecord" => { let addr: Ipv4Addr = record.get("address")?.as_str()?.parse().ok()?; RData::A(A(addr)) } "systems.kiri.dns#aaaaRecord" => { let addr: Ipv6Addr = record.get("address")?.as_str()?.parse().ok()?; RData::AAAA(AAAA(addr)) } "systems.kiri.dns#cnameRecord" => { let target = record.get("cname")?.as_str()?; RData::CNAME(CNAME(Self::to_fqdn(target)?)) } "systems.kiri.dns#mxRecord" => { let pref = record.get("preference")?.as_u64()? as u16; let exchange = record.get("exchange")?.as_str()?; RData::MX(MX::new( pref, Self::to_fqdn(exchange)?, )) } "systems.kiri.dns#txtRecord" => { let values = record.get("values")?.as_array()?; let strings: Vec = values .iter() .filter_map(|v| v.as_str()) .map(String::from) .collect(); RData::TXT(TXT::new(strings)) } "systems.kiri.dns#srvRecord" => { let priority = record.get("priority")?.as_u64()? as u16; let weight = record.get("weight")?.as_u64()? as u16; let port = record.get("port")?.as_u64()? as u16; let target = record.get("target")?.as_str()?; RData::SRV(SRV::new( priority, weight, port, Self::to_fqdn(target)?, )) } "systems.kiri.dns#soaRecord" => { let mname = record.get("mname")?.as_str()?; let rname = record.get("rname")?.as_str()?; let refresh = record.get("refresh")?.as_u64()? as i32; let retry = record.get("retry")?.as_u64()? as i32; let expire = record.get("expire")?.as_u64()? as i32; let minimum = record.get("minimum")?.as_u64()? as u32; RData::SOA(SOA::new( Self::to_fqdn(mname)?, Self::to_fqdn(rname)?, Self::now_serial(), refresh, retry, expire, minimum, )) } _ => return None, }; Some(Record::from_rdata(name, ttl, rdata)) } /// Get the SOA record for a zone, synthesizing if the user hasn't published one. async fn get_soa(&self, zone: &str) -> Record { if let Ok(resp) = self.client.resolve(zone, Some("soa")).await { for val in &resp.records { if let Some(record) = self.convert_record(val) { return record; } } } self.synthesize_soa(zone) } fn response_header(request: &Request, rcode: ResponseCode) -> Header { let mut header = Header::new(); header.set_id(request.header().id()); header.set_message_type(MessageType::Response); header.set_op_code(OpCode::Query); header.set_authoritative(true); header.set_recursion_desired(request.header().recursion_desired()); header.set_recursion_available(false); header.set_response_code(rcode); header } fn error_response_info(rcode: ResponseCode) -> ResponseInfo { let mut header = Header::new(); header.set_response_code(rcode); header.into() } async fn send_error( request: &Request, response_handle: &mut R, start: &Instant, qtype_str: &str, rcode: ResponseCode, rcode_label: &str, ) -> ResponseInfo { let builder = MessageResponseBuilder::from_message_request(request); let response = builder.error_msg(request.header(), rcode); record_query_metrics(start, qtype_str, rcode_label); response_handle .send_response(response) .await .unwrap_or_else(|e| { warn!(error = %e, rcode = rcode_label, "failed to send error response"); Self::error_response_info(ResponseCode::ServFail) }) } async fn send_dns_response( &self, request: &Request, response_handle: &mut R, start: &Instant, domain: &str, qtype_str: &str, rcode: ResponseCode, rcode_label: &str, answers: &[Record], nameservers: &[Record], authority: &[Record], ) -> ResponseInfo { let header = Self::response_header(request, rcode); let builder = MessageResponseBuilder::from_message_request(request); let response = builder.build( header, answers.iter(), nameservers.iter(), authority.iter(), std::iter::empty(), ); let elapsed = start.elapsed(); info!( domain = %domain, qtype = %qtype_str, rcode = rcode_label, answers = answers.len(), duration_ms = elapsed.as_millis() as u64, src = %request.src(), "query complete" ); if elapsed > self.slow_query_threshold { warn!( domain = %domain, qtype = %qtype_str, duration_ms = elapsed.as_millis() as u64, "slow query" ); } record_query_metrics(start, qtype_str, rcode_label); response_handle .send_response(response) .await .unwrap_or_else(|e| { warn!(error = %e, "failed to send DNS response"); Self::error_response_info(ResponseCode::ServFail) }) } } #[async_trait] impl RequestHandler for OnisHandler { async fn handle_request( &self, request: &Request, mut response_handle: R, ) -> ResponseInfo { let start = Instant::now(); // Only handle standard queries if request.header().message_type() != MessageType::Query || request.header().op_code() != OpCode::Query { return Self::send_error( request, &mut response_handle, &start, "UNKNOWN", ResponseCode::NotImp, "NOTIMP", ).await; } let queries = request.queries(); if queries.is_empty() { return Self::send_error( request, &mut response_handle, &start, "UNKNOWN", ResponseCode::FormErr, "FORMERR", ).await; } let query = &queries[0]; let qname = query.name(); let qtype = query.query_type(); let qtype_str = format!("{qtype:?}"); let domain = Self::name_to_domain(qname); debug!( domain = %domain, qtype = ?qtype, src = %request.src(), "query" ); // Resolve against the appview — single call, appview walks the zone tree let resp = match self.client.resolve(&domain, None).await { Ok(r) => r, Err(e) => { warn!(error = %e, domain = %domain, "appview request failed"); return Self::send_error( request, &mut response_handle, &start, &qtype_str, ResponseCode::ServFail, "SERVFAIL", ).await; } }; // No zone found or zone not verified → REFUSED let zone = match resp.zone { Some(ref z) if resp.verified => z.clone(), _ => { debug!(domain = %domain, "no verified zone, refusing"); return self.send_dns_response( request, &mut response_handle, &start, &domain, &qtype_str, ResponseCode::Refused, "REFUSED", &[], &[], &[], ).await; } }; let soa_record = self.get_soa(&zone).await; let ns_records = self.synthesize_ns(&zone); // Handle NS queries — always return auto-generated NS records if qtype == RecordType::NS { return self.send_dns_response( request, &mut response_handle, &start, &domain, &qtype_str, ResponseCode::NoError, "NOERROR", &ns_records, &[], std::slice::from_ref(&soa_record), ).await; } // Handle SOA queries if qtype == RecordType::SOA { return self.send_dns_response( request, &mut response_handle, &start, &domain, &qtype_str, ResponseCode::NoError, "NOERROR", std::slice::from_ref(&soa_record), &ns_records, &[], ).await; } // Map query type to appview type string let type_str = match Self::map_record_type(qtype) { Some(t) => t, None => { // Unsupported type for a zone we serve → NODATA return self.send_dns_response( request, &mut response_handle, &start, &domain, &qtype_str, ResponseCode::NoError, "NOERROR/NODATA", &[], &[], std::slice::from_ref(&soa_record), ).await; } }; // Query the appview API for the specific record type let typed_resp = match self.client.resolve(&domain, Some(type_str)).await { Ok(r) => r, Err(e) => { warn!(error = %e, domain = %domain, "appview typed request failed"); return Self::send_error( request, &mut response_handle, &start, &qtype_str, ResponseCode::ServFail, "SERVFAIL", ).await; } }; // Convert appview records to DNS records let answers: Vec = typed_resp .records .iter() .filter_map(|v| self.convert_record(v)) .collect(); if answers.is_empty() { let (rcode, label) = if resp.name_exists { (ResponseCode::NoError, "NOERROR/NODATA") } else { (ResponseCode::NXDomain, "NXDOMAIN") }; self.send_dns_response( request, &mut response_handle, &start, &domain, &qtype_str, rcode, label, &[], &[], std::slice::from_ref(&soa_record), ).await } else { self.send_dns_response( request, &mut response_handle, &start, &domain, &qtype_str, ResponseCode::NoError, "NOERROR", &answers, &ns_records, &[], ).await } } } fn record_query_metrics(start: &Instant, qtype: &str, rcode: &str) { metrics::counter!("dns_queries_total", "qtype" => qtype.to_owned(), "rcode" => rcode.to_owned()) .increment(1); metrics::histogram!("dns_query_duration_seconds") .record(start.elapsed().as_secs_f64()); } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; use hickory_proto::rr::{LowerName, Name, RData, RecordType}; use serde_json::json; use crate::client::AppviewClient; use onis_common::config::DnsConfig; use super::OnisHandler; fn test_handler() -> OnisHandler { let client = AppviewClient::new("http://test:3000".to_string()); let config = DnsConfig::default(); OnisHandler::new(client, &config) } #[test] fn enforce_ttl_below_floor() { let handler = test_handler(); assert_eq!(handler.enforce_ttl(10), 60); } #[test] fn enforce_ttl_above_floor() { let handler = test_handler(); assert_eq!(handler.enforce_ttl(300), 300); } #[test] fn enforce_ttl_at_floor() { let handler = test_handler(); assert_eq!(handler.enforce_ttl(60), 60); } #[test] fn enforce_ttl_zero() { let handler = test_handler(); assert_eq!(handler.enforce_ttl(0), 60); } #[test] fn name_to_domain_strips_trailing_dot() { let name = LowerName::from(Name::from_str("example.com.").unwrap()); assert_eq!(OnisHandler::name_to_domain(&name), "example.com"); } #[test] fn name_to_domain_subdomain() { let name = LowerName::from(Name::from_str("sub.example.com.").unwrap()); assert_eq!(OnisHandler::name_to_domain(&name), "sub.example.com"); } #[test] fn name_to_domain_root() { let name = LowerName::from(Name::root()); assert_eq!(OnisHandler::name_to_domain(&name), ""); } #[test] fn map_record_type_supported() { assert_eq!(OnisHandler::map_record_type(RecordType::A), Some("a")); assert_eq!(OnisHandler::map_record_type(RecordType::AAAA), Some("aaaa")); assert_eq!(OnisHandler::map_record_type(RecordType::CNAME), Some("cname")); assert_eq!(OnisHandler::map_record_type(RecordType::MX), Some("mx")); assert_eq!(OnisHandler::map_record_type(RecordType::TXT), Some("txt")); assert_eq!(OnisHandler::map_record_type(RecordType::SRV), Some("srv")); assert_eq!(OnisHandler::map_record_type(RecordType::SOA), Some("soa")); } #[test] fn map_record_type_unsupported_returns_none() { assert_eq!(OnisHandler::map_record_type(RecordType::NS), None); assert_eq!(OnisHandler::map_record_type(RecordType::PTR), None); } #[test] fn convert_a_record() { let handler = test_handler(); let value = json!({ "domain": "example.com", "ttl": 300, "record": { "$type": "systems.kiri.dns#aRecord", "address": "1.2.3.4" } }); let record = handler.convert_record(&value).unwrap(); assert_eq!(record.name(), &Name::from_str("example.com.").unwrap()); assert_eq!(record.ttl(), 300); match record.data() { RData::A(a) => assert_eq!(a.0, Ipv4Addr::new(1, 2, 3, 4)), other => unreachable!("expected A record, got {other:?}"), } } #[test] fn convert_aaaa_record() { let handler = test_handler(); let value = json!({ "domain": "example.com", "ttl": 300, "record": { "$type": "systems.kiri.dns#aaaaRecord", "address": "2001:db8::1" } }); let record = handler.convert_record(&value).unwrap(); match record.data() { RData::AAAA(aaaa) => { assert_eq!(aaaa.0, Ipv6Addr::from_str("2001:db8::1").unwrap()); } other => unreachable!("expected AAAA record, got {other:?}"), } } #[test] fn convert_cname_record() { let handler = test_handler(); let value = json!({ "domain": "www.example.com", "ttl": 300, "record": { "$type": "systems.kiri.dns#cnameRecord", "cname": "example.com" } }); let record = handler.convert_record(&value).unwrap(); assert_eq!(record.name(), &Name::from_str("www.example.com.").unwrap()); match record.data() { RData::CNAME(cname) => { assert_eq!(cname.0, Name::from_str("example.com.").unwrap()); } other => unreachable!("expected CNAME record, got {other:?}"), } } #[test] fn convert_mx_record() { let handler = test_handler(); let value = json!({ "domain": "example.com", "ttl": 300, "record": { "$type": "systems.kiri.dns#mxRecord", "preference": 10, "exchange": "mail.example.com" } }); let record = handler.convert_record(&value).unwrap(); match record.data() { RData::MX(mx) => { assert_eq!(mx.preference(), 10); assert_eq!( mx.exchange(), &Name::from_str("mail.example.com.").unwrap() ); } other => unreachable!("expected MX record, got {other:?}"), } } #[test] fn convert_txt_record() { let handler = test_handler(); let value = json!({ "domain": "example.com", "ttl": 300, "record": { "$type": "systems.kiri.dns#txtRecord", "values": ["v=spf1 include:example.com ~all"] } }); let record = handler.convert_record(&value).unwrap(); match record.data() { RData::TXT(txt) => { let text: String = txt .txt_data() .iter() .map(|d| String::from_utf8_lossy(d).to_string()) .collect::>() .join(""); assert_eq!(text, "v=spf1 include:example.com ~all"); } other => unreachable!("expected TXT record, got {other:?}"), } } #[test] fn convert_srv_record() { let handler = test_handler(); let value = json!({ "domain": "_sip._tcp.example.com", "ttl": 300, "record": { "$type": "systems.kiri.dns#srvRecord", "priority": 10, "weight": 60, "port": 5060, "target": "sip.example.com" } }); let record = handler.convert_record(&value).unwrap(); match record.data() { RData::SRV(srv) => { assert_eq!(srv.priority(), 10); assert_eq!(srv.weight(), 60); assert_eq!(srv.port(), 5060); assert_eq!(srv.target(), &Name::from_str("sip.example.com.").unwrap()); } other => unreachable!("expected SRV record, got {other:?}"), } } #[test] fn convert_soa_record() { let handler = test_handler(); let value = json!({ "domain": "example.com", "ttl": 3600, "record": { "$type": "systems.kiri.dns#soaRecord", "mname": "ns1.example.com", "rname": "admin.example.com", "refresh": 3600, "retry": 900, "expire": 604800, "minimum": 300 } }); let record = handler.convert_record(&value).unwrap(); match record.data() { RData::SOA(soa) => { assert_eq!(soa.mname(), &Name::from_str("ns1.example.com.").unwrap()); assert_eq!(soa.rname(), &Name::from_str("admin.example.com.").unwrap()); assert_eq!(soa.refresh(), 3600); assert_eq!(soa.retry(), 900); assert_eq!(soa.expire(), 604800); assert_eq!(soa.minimum(), 300); } other => unreachable!("expected SOA record, got {other:?}"), } } #[test] fn convert_record_missing_domain_returns_none() { let handler = test_handler(); let value = json!({ "record": { "$type": "systems.kiri.dns#aRecord", "address": "1.2.3.4" } }); assert!(handler.convert_record(&value).is_none()); } #[test] fn convert_record_missing_record_returns_none() { let handler = test_handler(); let value = json!({ "domain": "example.com" }); assert!(handler.convert_record(&value).is_none()); } #[test] fn convert_record_missing_type_returns_none() { let handler = test_handler(); let value = json!({ "domain": "example.com", "record": { "address": "1.2.3.4" } }); assert!(handler.convert_record(&value).is_none()); } #[test] fn convert_record_unknown_type_returns_none() { let handler = test_handler(); let value = json!({ "domain": "example.com", "record": { "$type": "systems.kiri.dns#ptrRecord", "name": "1.2.3.4.in-addr.arpa" } }); assert!(handler.convert_record(&value).is_none()); } #[test] fn convert_record_invalid_ip_returns_none() { let handler = test_handler(); let value = json!({ "domain": "example.com", "record": { "$type": "systems.kiri.dns#aRecord", "address": "not-an-ip" } }); assert!(handler.convert_record(&value).is_none()); } #[test] fn convert_record_ttl_defaults_to_soa_minimum() { let handler = test_handler(); let value = json!({ "domain": "example.com", "record": { "$type": "systems.kiri.dns#aRecord", "address": "1.2.3.4" } }); let record = handler.convert_record(&value).unwrap(); // Default soa_minimum is 300, above the ttl_floor of 60 assert_eq!(record.ttl(), 300); } #[test] fn convert_record_ttl_enforces_floor() { let handler = test_handler(); let value = json!({ "domain": "example.com", "ttl": 10, "record": { "$type": "systems.kiri.dns#aRecord", "address": "1.2.3.4" } }); let record = handler.convert_record(&value).unwrap(); assert_eq!(record.ttl(), 60); } #[test] fn synthesize_soa_uses_config_defaults() { let handler = test_handler(); let record = handler.synthesize_soa("example.com"); assert_eq!(record.name(), &Name::from_str("example.com.").unwrap()); assert_eq!(record.ttl(), 3600); match record.data() { RData::SOA(soa) => { assert_eq!(soa.mname(), &Name::from_str("ns1.kiri.systems.").unwrap()); assert_eq!(soa.rname(), &Name::from_str("admin.kiri.systems.").unwrap()); assert_eq!(soa.refresh(), 3600); assert_eq!(soa.retry(), 900); assert_eq!(soa.expire(), 604800); assert_eq!(soa.minimum(), 300); } other => unreachable!("expected SOA record, got {other:?}"), } } #[test] fn synthesize_ns_returns_configured_nameservers() { let handler = test_handler(); let records = handler.synthesize_ns("example.com"); assert_eq!(records.len(), 2); let names: Vec = records .iter() .map(|r| match r.data() { RData::NS(ns) => ns.0.to_string(), other => unreachable!("expected NS record, got {other:?}"), }) .collect(); assert!(names.contains(&"ns1.kiri.systems.".to_string())); assert!(names.contains(&"ns2.kiri.systems.".to_string())); } #[test] fn synthesize_ns_uses_zone_name() { let handler = test_handler(); let records = handler.synthesize_ns("example.com"); let expected = Name::from_str("example.com.").unwrap(); for record in &records { assert_eq!(record.name(), &expected); } } #[test] fn synthesize_ns_ttl_matches_soa() { let handler = test_handler(); let records = handler.synthesize_ns("example.com"); for record in &records { assert_eq!(record.ttl(), 3600); } } }