use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::question::QueryType;
use crate::record::DnsRecord;
use crate::Result;
#[derive(Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub upstream: UpstreamConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub blocking: BlockingConfig,
#[serde(default)]
pub zones: Vec<ZoneRecord>,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub services: Vec<ServiceConfig>,
#[serde(default)]
pub lan: LanConfig,
#[serde(default)]
pub dnssec: DnssecConfig,
#[serde(default)]
pub dot: DotConfig,
#[serde(default)]
pub mobile: MobileConfig,
#[serde(default)]
pub forwarding: Vec<ForwardingRuleConfig>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct ForwardingRuleConfig {
#[serde(deserialize_with = "string_or_vec")]
pub suffix: Vec<String>,
pub upstream: String,
}
impl ForwardingRuleConfig {
fn to_runtime_rules(&self) -> Result<Vec<crate::system_dns::ForwardingRule>> {
let upstream = crate::forward::parse_upstream(&self.upstream, 53)
.map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?;
Ok(self
.suffix
.iter()
.map(|s| crate::system_dns::ForwardingRule::new(s.clone(), upstream.clone()))
.collect())
}
}
pub fn merge_forwarding_rules(
config_rules: &[ForwardingRuleConfig],
discovered: Vec<crate::system_dns::ForwardingRule>,
) -> Result<Vec<crate::system_dns::ForwardingRule>> {
let mut merged: Vec<crate::system_dns::ForwardingRule> = Vec::new();
for rule in config_rules {
merged.extend(rule.to_runtime_rules()?);
}
merged.extend(discovered);
Ok(merged)
}
#[derive(Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_bind_addr")]
pub bind_addr: String,
#[serde(default = "default_api_port")]
pub api_port: u16,
#[serde(default = "default_api_bind_addr")]
pub api_bind_addr: String,
#[serde(default)]
pub data_dir: Option<PathBuf>,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
bind_addr: default_bind_addr(),
api_port: default_api_port(),
api_bind_addr: default_api_bind_addr(),
data_dir: None,
}
}
}
fn default_api_bind_addr() -> String {
"127.0.0.1".to_string()
}
fn default_bind_addr() -> String {
"0.0.0.0:53".to_string()
}
pub const DEFAULT_API_PORT: u16 = 5380;
fn default_api_port() -> u16 {
DEFAULT_API_PORT
}
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum UpstreamMode {
Auto,
#[default]
Forward,
Recursive,
}
impl UpstreamMode {
pub fn as_str(&self) -> &'static str {
match self {
UpstreamMode::Auto => "auto",
UpstreamMode::Forward => "forward",
UpstreamMode::Recursive => "recursive",
}
}
}
#[derive(Deserialize)]
pub struct UpstreamConfig {
#[serde(default)]
pub mode: UpstreamMode,
#[serde(default, deserialize_with = "string_or_vec")]
pub address: Vec<String>,
#[serde(default = "default_upstream_port")]
pub port: u16,
#[serde(default)]
pub fallback: Vec<String>,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_hedge_ms")]
pub hedge_ms: u64,
#[serde(default = "default_root_hints")]
pub root_hints: Vec<String>,
#[serde(default = "default_prime_tlds")]
pub prime_tlds: Vec<String>,
#[serde(default = "default_srtt")]
pub srtt: bool,
}
impl Default for UpstreamConfig {
fn default() -> Self {
UpstreamConfig {
mode: UpstreamMode::default(),
address: Vec::new(),
port: default_upstream_port(),
fallback: Vec::new(),
timeout_ms: default_timeout_ms(),
hedge_ms: default_hedge_ms(),
root_hints: default_root_hints(),
prime_tlds: default_prime_tlds(),
srtt: default_srtt(),
}
}
}
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("string or array of strings")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
Ok(vec![v.to_string()])
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut v = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
v.push(s);
}
Ok(v)
}
}
deserializer.deserialize_any(Visitor)
}
fn default_true() -> bool {
true
}
fn default_srtt() -> bool {
default_true()
}
fn default_prime_tlds() -> Vec<String> {
vec![
"com".into(),
"net".into(),
"org".into(),
"info".into(),
"io".into(),
"dev".into(),
"app".into(),
"xyz".into(),
"me".into(),
"eu".into(),
"uk".into(),
"de".into(),
"fr".into(),
"nl".into(),
"it".into(),
"es".into(),
"pl".into(),
"se".into(),
"no".into(),
"dk".into(),
"fi".into(),
"at".into(),
"be".into(),
"ie".into(),
"pt".into(),
"cz".into(),
"ro".into(),
"gr".into(),
"hu".into(),
"bg".into(),
"hr".into(),
"sk".into(),
"si".into(),
"lt".into(),
"lv".into(),
"ee".into(),
"ch".into(),
"is".into(),
"co".into(),
"br".into(),
"au".into(),
"ca".into(),
"jp".into(),
]
}
fn default_root_hints() -> Vec<String> {
vec![
"198.41.0.4".into(), "199.9.14.201".into(), "192.33.4.12".into(), "199.7.91.13".into(), "192.203.230.10".into(), "192.5.5.241".into(), "192.112.36.4".into(), "198.97.190.53".into(), "192.36.148.17".into(), "192.58.128.30".into(), "193.0.14.129".into(), "199.7.83.42".into(), "202.12.27.33".into(), ]
}
fn default_upstream_port() -> u16 {
53
}
fn default_timeout_ms() -> u64 {
5000
}
fn default_hedge_ms() -> u64 {
10
}
#[derive(Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_max_entries")]
pub max_entries: usize,
#[serde(default = "default_min_ttl")]
pub min_ttl: u32,
#[serde(default = "default_max_ttl")]
pub max_ttl: u32,
#[serde(default)]
pub warm: Vec<String>,
}
impl Default for CacheConfig {
fn default() -> Self {
CacheConfig {
max_entries: default_max_entries(),
min_ttl: default_min_ttl(),
max_ttl: default_max_ttl(),
warm: Vec::new(),
}
}
}
fn default_max_entries() -> usize {
100_000
}
fn default_min_ttl() -> u32 {
60
}
fn default_max_ttl() -> u32 {
86400
}
#[derive(Deserialize)]
pub struct ZoneRecord {
pub domain: String,
pub record_type: String,
pub value: String,
#[serde(default = "default_zone_ttl")]
pub ttl: u32,
}
#[derive(Deserialize)]
pub struct BlockingConfig {
#[serde(default = "default_blocking_enabled")]
pub enabled: bool,
#[serde(default = "default_blocklists")]
pub lists: Vec<String>,
#[serde(default = "default_refresh_hours")]
pub refresh_hours: u64,
#[serde(default)]
pub allowlist: Vec<String>,
}
impl Default for BlockingConfig {
fn default() -> Self {
BlockingConfig {
enabled: default_blocking_enabled(),
lists: default_blocklists(),
refresh_hours: default_refresh_hours(),
allowlist: Vec::new(),
}
}
}
fn default_blocking_enabled() -> bool {
true
}
fn default_blocklists() -> Vec<String> {
vec!["https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt".to_string()]
}
fn default_refresh_hours() -> u64 {
24
}
fn default_zone_ttl() -> u32 {
300
}
#[derive(Deserialize, Clone)]
pub struct ProxyConfig {
#[serde(default = "default_proxy_enabled")]
pub enabled: bool,
#[serde(default = "default_proxy_port")]
pub port: u16,
#[serde(default = "default_proxy_tls_port")]
pub tls_port: u16,
#[serde(default = "default_proxy_tld")]
pub tld: String,
#[serde(default = "default_proxy_bind_addr")]
pub bind_addr: String,
}
impl Default for ProxyConfig {
fn default() -> Self {
ProxyConfig {
enabled: default_proxy_enabled(),
port: default_proxy_port(),
tls_port: default_proxy_tls_port(),
tld: default_proxy_tld(),
bind_addr: default_proxy_bind_addr(),
}
}
}
fn default_proxy_bind_addr() -> String {
"127.0.0.1".to_string()
}
fn default_proxy_enabled() -> bool {
true
}
fn default_proxy_port() -> u16 {
80
}
fn default_proxy_tls_port() -> u16 {
443
}
fn default_proxy_tld() -> String {
"numa".to_string()
}
#[derive(Deserialize, Clone)]
pub struct ServiceConfig {
pub name: String,
pub target_port: u16,
#[serde(default)]
pub routes: Vec<crate::service_store::RouteEntry>,
}
#[derive(Deserialize, Clone)]
pub struct LanConfig {
#[serde(default = "default_lan_enabled")]
pub enabled: bool,
#[serde(default = "default_lan_broadcast_interval")]
pub broadcast_interval_secs: u64,
#[serde(default = "default_lan_peer_timeout")]
pub peer_timeout_secs: u64,
}
impl Default for LanConfig {
fn default() -> Self {
LanConfig {
enabled: default_lan_enabled(),
broadcast_interval_secs: default_lan_broadcast_interval(),
peer_timeout_secs: default_lan_peer_timeout(),
}
}
}
fn default_lan_enabled() -> bool {
false
}
fn default_lan_broadcast_interval() -> u64 {
30
}
fn default_lan_peer_timeout() -> u64 {
90
}
#[derive(Deserialize, Clone, Default)]
pub struct DnssecConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub strict: bool,
}
#[derive(Deserialize, Clone)]
pub struct DotConfig {
#[serde(default = "default_dot_enabled")]
pub enabled: bool,
#[serde(default = "default_dot_port")]
pub port: u16,
#[serde(default = "default_dot_bind_addr")]
pub bind_addr: String,
#[serde(default)]
pub cert_path: Option<PathBuf>,
#[serde(default)]
pub key_path: Option<PathBuf>,
}
impl Default for DotConfig {
fn default() -> Self {
DotConfig {
enabled: default_dot_enabled(),
port: default_dot_port(),
bind_addr: default_dot_bind_addr(),
cert_path: None,
key_path: None,
}
}
}
fn default_dot_enabled() -> bool {
true
}
fn default_dot_port() -> u16 {
853
}
fn default_dot_bind_addr() -> String {
"0.0.0.0".to_string()
}
#[derive(Deserialize, Clone)]
pub struct MobileConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_mobile_port")]
pub port: u16,
#[serde(default = "default_mobile_bind_addr")]
pub bind_addr: String,
}
impl Default for MobileConfig {
fn default() -> Self {
MobileConfig {
enabled: false,
port: default_mobile_port(),
bind_addr: default_mobile_bind_addr(),
}
}
}
fn default_mobile_port() -> u16 {
8765
}
fn default_mobile_bind_addr() -> String {
"0.0.0.0".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lan_disabled_by_default() {
assert!(!LanConfig::default().enabled);
}
#[test]
fn api_binds_localhost_by_default() {
assert_eq!(ServerConfig::default().api_bind_addr, "127.0.0.1");
}
#[test]
fn proxy_binds_localhost_by_default() {
assert_eq!(ProxyConfig::default().bind_addr, "127.0.0.1");
}
#[test]
fn empty_toml_gives_defaults() {
let config: Config = toml::from_str("").unwrap();
assert!(!config.lan.enabled);
assert_eq!(config.server.api_bind_addr, "127.0.0.1");
assert_eq!(config.proxy.bind_addr, "127.0.0.1");
assert_eq!(config.server.api_port, ServerConfig::default().api_port);
}
#[test]
fn lan_enabled_parses() {
let config: Config = toml::from_str("[lan]\nenabled = true").unwrap();
assert!(config.lan.enabled);
}
#[test]
fn custom_bind_addrs_parse() {
let toml = r#"
[server]
api_bind_addr = "0.0.0.0"
[proxy]
bind_addr = "0.0.0.0"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.server.api_bind_addr, "0.0.0.0");
assert_eq!(config.proxy.bind_addr, "0.0.0.0");
}
#[test]
fn service_routes_parse_from_toml() {
let toml = r#"
[[services]]
name = "app"
target_port = 3000
routes = [
{ path = "/api", port = 4000, strip = true },
{ path = "/static", port = 5000 },
]
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.services.len(), 1);
assert_eq!(config.services[0].routes.len(), 2);
assert!(config.services[0].routes[0].strip);
assert!(!config.services[0].routes[1].strip); }
#[test]
fn address_string_parses_to_vec() {
let config: Config = toml::from_str("[upstream]\naddress = \"1.2.3.4\"").unwrap();
assert_eq!(config.upstream.address, vec!["1.2.3.4"]);
}
#[test]
fn address_array_parses() {
let config: Config =
toml::from_str("[upstream]\naddress = [\"1.2.3.4\", \"5.6.7.8:5353\"]").unwrap();
assert_eq!(config.upstream.address, vec!["1.2.3.4", "5.6.7.8:5353"]);
}
#[test]
fn fallback_parses() {
let config: Config =
toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap();
assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]);
}
#[test]
fn empty_address_gives_empty_vec() {
let config: Config = toml::from_str("").unwrap();
assert!(config.upstream.address.is_empty());
assert!(config.upstream.fallback.is_empty());
}
#[test]
fn forwarding_empty_by_default() {
let config: Config = toml::from_str("").unwrap();
assert!(config.forwarding.is_empty());
}
#[test]
fn forwarding_parses_single_rule() {
let toml = r#"
[[forwarding]]
suffix = "home.local"
upstream = "100.90.1.63:5361"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1);
assert_eq!(config.forwarding[0].suffix, &["home.local"]);
assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361");
}
#[test]
fn forwarding_parses_reverse_dns_zone() {
let toml = r#"
[[forwarding]]
suffix = "168.192.in-addr.arpa"
upstream = "100.90.1.63:5361"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1);
assert_eq!(config.forwarding[0].suffix, &["168.192.in-addr.arpa"]);
}
#[test]
fn forwarding_parses_multiple_rules() {
let toml = r#"
[[forwarding]]
suffix = "168.192.in-addr.arpa"
upstream = "100.90.1.63:5361"
[[forwarding]]
suffix = "home.local"
upstream = "10.0.0.1"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 2);
assert_eq!(config.forwarding[1].upstream, "10.0.0.1");
}
#[test]
fn forwarding_parses_suffix_array() {
let toml = r#"
[[forwarding]]
suffix = ["168.192.in-addr.arpa", "onsite"]
upstream = "192.168.88.1"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1);
assert_eq!(
config.forwarding[0].suffix,
&["168.192.in-addr.arpa", "onsite"]
);
}
#[test]
fn forwarding_suffix_array_expands_to_multiple_runtime_rules() {
let rule = ForwardingRuleConfig {
suffix: vec!["168.192.in-addr.arpa".to_string(), "onsite".to_string()],
upstream: "192.168.88.1".to_string(),
};
let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime.len(), 2);
assert_eq!(runtime[0].suffix, "168.192.in-addr.arpa");
assert_eq!(runtime[1].suffix, "onsite");
assert_eq!(runtime[0].upstream, runtime[1].upstream);
}
#[test]
fn forwarding_upstream_with_explicit_port() {
let rule = ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "100.90.1.63:5361".to_string(),
};
let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime.len(), 1);
assert!(matches!(
runtime[0].upstream,
crate::forward::Upstream::Udp(_)
));
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361");
assert_eq!(runtime[0].suffix, "home.local");
}
#[test]
fn forwarding_upstream_defaults_to_port_53() {
let rule = ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "100.90.1.63".to_string(),
};
let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:53");
}
#[test]
fn forwarding_invalid_upstream_returns_error() {
let rule = ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "not-a-valid-host".to_string(),
};
assert!(rule.to_runtime_rules().is_err());
}
#[test]
fn forwarding_upstream_accepts_dot_scheme() {
let rule = ForwardingRuleConfig {
suffix: vec!["google.com".to_string()],
upstream: "tls://9.9.9.9#dns.quad9.net".to_string(),
};
let runtime = rule
.to_runtime_rules()
.expect("tls:// upstream should parse");
assert_eq!(runtime.len(), 1);
assert_eq!(
runtime[0].upstream.to_string(),
"tls://9.9.9.9:853#dns.quad9.net"
);
}
#[test]
fn forwarding_upstream_accepts_doh_scheme() {
let rule = ForwardingRuleConfig {
suffix: vec!["goog".to_string()],
upstream: "https://dns.quad9.net/dns-query".to_string(),
};
let runtime = rule
.to_runtime_rules()
.expect("https:// upstream should parse");
assert_eq!(runtime.len(), 1);
assert_eq!(
runtime[0].upstream.to_string(),
"https://dns.quad9.net/dns-query"
);
}
#[test]
fn forwarding_config_rules_take_precedence_over_discovered() {
let config_rules = vec![ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "10.0.0.1:53".to_string(),
}];
let discovered = vec![crate::system_dns::ForwardingRule::new(
"home.local".to_string(),
crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()),
)];
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged)
.expect("rule should match");
assert_eq!(picked.to_string(), "10.0.0.1:53");
}
#[test]
fn forwarding_merge_preserves_non_overlapping_discovered() {
let config_rules = vec![ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "10.0.0.1:53".to_string(),
}];
let discovered = vec![crate::system_dns::ForwardingRule::new(
"corp.example".to_string(),
crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()),
)];
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
assert_eq!(merged.len(), 2);
let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged)
.expect("discovered rule should still match");
assert_eq!(picked.to_string(), "192.168.1.1:53");
}
#[test]
fn forwarding_merge_suffix_array_expands_to_multiple_rules() {
let config_rules = vec![ForwardingRuleConfig {
suffix: vec!["a.local".to_string(), "b.local".to_string()],
upstream: "10.0.0.1:53".to_string(),
}];
let merged = merge_forwarding_rules(&config_rules, vec![]).unwrap();
assert_eq!(merged.len(), 2);
}
}
pub struct ConfigLoad {
pub config: Config,
pub path: String,
pub found: bool,
}
fn resolve_path(path: &str) -> String {
std::fs::canonicalize(path)
.or_else(|_| std::env::current_dir().map(|cwd| cwd.join(path)))
.unwrap_or_else(|_| Path::new(path).to_path_buf())
.to_string_lossy()
.to_string()
}
pub fn load_config(path: &str) -> Result<ConfigLoad> {
let candidates: Vec<std::path::PathBuf> = {
let p = Path::new(path);
let mut v = vec![p.to_path_buf()];
if p.is_relative() {
let filename = p.file_name().unwrap_or(p.as_os_str());
v.push(crate::config_dir().join(filename));
v.push(crate::data_dir().join(filename));
let suggested = crate::suggested_config_path();
if !v.contains(&suggested) {
v.push(suggested);
}
}
v
};
for candidate in &candidates {
match std::fs::read_to_string(candidate) {
Ok(contents) => {
let resolved = resolve_path(&candidate.to_string_lossy());
let config: Config = toml::from_str(&contents)?;
return Ok(ConfigLoad {
config,
path: resolved,
found: true,
});
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
}
}
let display_path = crate::suggested_config_path().to_string_lossy().to_string();
log::info!("config not found, using defaults (create {})", display_path);
Ok(ConfigLoad {
config: Config::default(),
path: display_path,
found: false,
})
}
pub type ZoneMap = HashMap<String, HashMap<QueryType, Vec<DnsRecord>>>;
pub fn build_zone_map(zones: &[ZoneRecord]) -> Result<ZoneMap> {
let mut map: ZoneMap = HashMap::new();
for zone in zones {
let domain = zone.domain.to_lowercase();
let (qtype, record) = match zone.record_type.to_uppercase().as_str() {
"A" => {
let addr: Ipv4Addr = zone
.value
.parse()
.map_err(|e| format!("invalid A record value '{}': {}", zone.value, e))?;
(
QueryType::A,
DnsRecord::A {
domain: domain.clone(),
addr,
ttl: zone.ttl,
},
)
}
"AAAA" => {
let addr: Ipv6Addr = zone
.value
.parse()
.map_err(|e| format!("invalid AAAA record value '{}': {}", zone.value, e))?;
(
QueryType::AAAA,
DnsRecord::AAAA {
domain: domain.clone(),
addr,
ttl: zone.ttl,
},
)
}
"CNAME" => (
QueryType::CNAME,
DnsRecord::CNAME {
domain: domain.clone(),
host: zone.value.clone(),
ttl: zone.ttl,
},
),
"NS" => (
QueryType::NS,
DnsRecord::NS {
domain: domain.clone(),
host: zone.value.clone(),
ttl: zone.ttl,
},
),
"MX" => {
let parts: Vec<&str> = zone.value.splitn(2, ' ').collect();
if parts.len() != 2 {
return Err(
format!("MX value must be 'priority host', got '{}'", zone.value).into(),
);
}
let priority: u16 = parts[0]
.parse()
.map_err(|e| format!("invalid MX priority '{}': {}", parts[0], e))?;
(
QueryType::MX,
DnsRecord::MX {
domain: domain.clone(),
priority,
host: parts[1].to_string(),
ttl: zone.ttl,
},
)
}
other => {
return Err(format!("unsupported record type '{}'", other).into());
}
};
map.entry(domain)
.or_default()
.entry(qtype)
.or_default()
.push(record);
}
Ok(map)
}