Files
numa/src/config.rs
Razvan Dimescu 043a7e1ba5 feat: raise cache default to 100K entries, evict stalest instead of dropping
The 10K cap was too conservative — the blocklist alone holds 400K domains.
At ~100 bytes per wire entry, 100K entries is ~10MB.

When the cache is full and evict_expired doesn't free enough slots,
evict_stalest removes the entry with the least remaining TTL instead of
silently discarding the new insert.
2026-04-12 19:23:28 +03:00

927 lines
27 KiB
Rust

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 addr = crate::forward::parse_upstream_addr(&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(), addr))
.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,
/// Where numa writes TLS material (CA, leaf certs, regenerated state).
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
#[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![
// gTLDs
"com".into(),
"net".into(),
"org".into(),
"info".into(),
"io".into(),
"dev".into(),
"app".into(),
"xyz".into(),
"me".into(),
// EU + European ccTLDs
"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(),
// Other major ccTLDs
"co".into(),
"br".into(),
"au".into(),
"ca".into(),
"jp".into(),
]
}
fn default_root_hints() -> Vec<String> {
vec![
"198.41.0.4".into(), // a.root-servers.net
"199.9.14.201".into(), // b.root-servers.net
"192.33.4.12".into(), // c.root-servers.net
"199.7.91.13".into(), // d.root-servers.net
"192.203.230.10".into(), // e.root-servers.net
"192.5.5.241".into(), // f.root-servers.net
"192.112.36.4".into(), // g.root-servers.net
"198.97.190.53".into(), // h.root-servers.net
"192.36.148.17".into(), // i.root-servers.net
"192.58.128.30".into(), // j.root-servers.net
"193.0.14.129".into(), // k.root-servers.net
"199.7.83.42".into(), // l.root-servers.net
"202.12.27.33".into(), // m.root-servers.net
]
}
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,
/// Path to TLS certificate (PEM). If None, uses self-signed CA.
#[serde(default)]
pub cert_path: Option<PathBuf>,
/// Path to TLS private key (PEM). If None, uses self-signed CA.
#[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()
}
/// Configuration for the mobile API — a persistent HTTP listener that
/// serves a read-only subset of routes (`/health`, `/ca.pem`,
/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for
/// consumption by the iOS/Android companion apps.
///
/// Unlike the main API (port 5380, localhost-only by default, supports
/// state-mutating routes), the mobile API is safe to expose on the LAN
/// because every route is idempotent and read-only.
#[derive(Deserialize, Clone)]
pub struct MobileConfig {
/// If true, spawn the mobile API listener at startup. **Default false.**
/// Opt-in because the listener binds to the LAN by default and exposes
/// a few read-only endpoints to any device on the same network (`/health`,
/// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are
/// cryptographically sensitive (the CA private key is never served),
/// but users should enable this explicitly rather than have a new
/// LAN-reachable port appear after an upgrade.
#[serde(default)]
pub enabled: bool,
/// Port for the mobile API. Default 8765.
#[serde(default = "default_mobile_port")]
pub port: u16,
/// Bind address for the mobile API. Default "0.0.0.0" (all interfaces)
/// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict
/// to localhost — useful if you're running behind another front-end.
#[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); // default false
}
#[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());
}
// ── issue #82: [[forwarding]] config section ────────────────────────
#[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_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_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(),
"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(),
"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 {
// canonicalize gives the real absolute path for existing files;
// for non-existent files, build an absolute path manually
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> {
// Try the given path first, then well-known locations (for service mode where cwd is /)
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));
// Interactive root and sudo'd users: always consult the XDG path
// so `touch ~/.config/numa/numa.toml` works regardless of whether
// config_dir() routed to FHS (issue #81).
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)
}