feat: accept suffix as string or array in [[forwarding]] rules

Reuses existing string_or_vec deserializer so users can write:
  suffix = ["168.192.in-addr.arpa", "onsite"]
instead of repeating [[forwarding]] blocks per suffix.
This commit is contained in:
Razvan Dimescu
2026-04-12 05:29:49 +03:00
parent f264cea5b4
commit a757b98744
3 changed files with 81 additions and 45 deletions

View File

@@ -46,12 +46,12 @@ api_port = 5380
# ] # ]
# [[forwarding]] # per-suffix conditional forwarding rules # [[forwarding]] # per-suffix conditional forwarding rules
# suffix = "168.192.in-addr.arpa" # all PTR lookups for 192.168.x.x # suffix = "168.192.in-addr.arpa" # single suffix → one upstream
# upstream = "100.90.1.63:5361" # → sent to this upstream # upstream = "100.90.1.63:5361"
# #
# [[forwarding]] # [[forwarding]]
# suffix = "home.local" # all lookups under home.local # suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream
# upstream = "10.0.0.1" # → sent to this upstream (port 53 default) # upstream = "10.0.0.1" # port 53 default
# [blocking] # [blocking]
# enabled = true # set to false to disable ad blocking # enabled = true # set to false to disable ad blocking

View File

@@ -37,41 +37,38 @@ pub struct Config {
pub forwarding: Vec<ForwardingRuleConfig>, pub forwarding: Vec<ForwardingRuleConfig>,
} }
/// User-declared conditional forwarding rule from `[[forwarding]]` in numa.toml.
/// Takes precedence over auto-discovered rules (macOS scutil, Linux search domains)
/// so explicit user intent wins over heuristics — issue #82.
#[derive(Deserialize, Clone, Debug)] #[derive(Deserialize, Clone, Debug)]
pub struct ForwardingRuleConfig { pub struct ForwardingRuleConfig {
pub suffix: String, #[serde(deserialize_with = "string_or_vec")]
pub suffix: Vec<String>,
pub upstream: String, pub upstream: String,
} }
impl ForwardingRuleConfig { impl ForwardingRuleConfig {
/// Parse `upstream` into a `SocketAddr` (port 53 default) and build a fn to_runtime_rules(&self) -> Result<Vec<crate::system_dns::ForwardingRule>> {
/// runtime `ForwardingRule`. Returns `Err` if the upstream is malformed.
pub fn to_runtime_rule(&self) -> Result<crate::system_dns::ForwardingRule> {
let addr = crate::forward::parse_upstream_addr(&self.upstream, 53) let addr = crate::forward::parse_upstream_addr(&self.upstream, 53)
.map_err(|e| format!("forwarding rule for '{}': {}", self.suffix, e))?; .map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?;
Ok(crate::system_dns::ForwardingRule::new( Ok(self
self.suffix.clone(), .suffix
addr, .iter()
)) .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), addr))
.collect())
} }
} }
/// Merge config-declared rules with auto-discovered rules. Config rules come /// Merge config-declared rules with auto-discovered rules. Config rules come
/// first so `match_forwarding_rule`'s first-match semantics gives them precedence. /// first so `match_forwarding_rule`'s first-match semantics gives them precedence.
/// Returns `Err` if any config rule has an invalid upstream.
pub fn merge_forwarding_rules( pub fn merge_forwarding_rules(
config_rules: &[ForwardingRuleConfig], config_rules: &[ForwardingRuleConfig],
discovered: Vec<crate::system_dns::ForwardingRule>, discovered: Vec<crate::system_dns::ForwardingRule>,
) -> Result<Vec<crate::system_dns::ForwardingRule>> { ) -> Result<(Vec<crate::system_dns::ForwardingRule>, usize)> {
let mut merged: Vec<crate::system_dns::ForwardingRule> = config_rules let mut merged: Vec<crate::system_dns::ForwardingRule> = Vec::new();
.iter() for rule in config_rules {
.map(ForwardingRuleConfig::to_runtime_rule) merged.extend(rule.to_runtime_rules()?);
.collect::<Result<Vec<_>>>()?; }
let config_count = merged.len();
merged.extend(discovered); merged.extend(discovered);
Ok(merged) Ok((merged, config_count))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -642,13 +639,12 @@ mod tests {
"#; "#;
let config: Config = toml::from_str(toml).unwrap(); let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1); assert_eq!(config.forwarding.len(), 1);
assert_eq!(config.forwarding[0].suffix, "home.local"); assert_eq!(config.forwarding[0].suffix, &["home.local"]);
assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361"); assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361");
} }
#[test] #[test]
fn forwarding_parses_reverse_dns_zone() { fn forwarding_parses_reverse_dns_zone() {
// Reporter's exact case (#82): reverse-DNS zone for 192.168.0.0/16
let toml = r#" let toml = r#"
[[forwarding]] [[forwarding]]
suffix = "168.192.in-addr.arpa" suffix = "168.192.in-addr.arpa"
@@ -656,7 +652,7 @@ mod tests {
"#; "#;
let config: Config = toml::from_str(toml).unwrap(); let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1); assert_eq!(config.forwarding.len(), 1);
assert_eq!(config.forwarding[0].suffix, "168.192.in-addr.arpa"); assert_eq!(config.forwarding[0].suffix, &["168.192.in-addr.arpa"]);
} }
#[test] #[test]
@@ -675,49 +671,80 @@ mod tests {
assert_eq!(config.forwarding[1].upstream, "10.0.0.1"); 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] #[test]
fn forwarding_upstream_with_explicit_port() { fn forwarding_upstream_with_explicit_port() {
let rule = ForwardingRuleConfig { let rule = ForwardingRuleConfig {
suffix: "home.local".to_string(), suffix: vec!["home.local".to_string()],
upstream: "100.90.1.63:5361".to_string(), upstream: "100.90.1.63:5361".to_string(),
}; };
let runtime = rule.to_runtime_rule().unwrap(); let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime.upstream.to_string(), "100.90.1.63:5361"); assert_eq!(runtime.len(), 1);
assert_eq!(runtime.suffix, "home.local"); assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361");
assert_eq!(runtime[0].suffix, "home.local");
} }
#[test] #[test]
fn forwarding_upstream_defaults_to_port_53() { fn forwarding_upstream_defaults_to_port_53() {
let rule = ForwardingRuleConfig { let rule = ForwardingRuleConfig {
suffix: "home.local".to_string(), suffix: vec!["home.local".to_string()],
upstream: "100.90.1.63".to_string(), upstream: "100.90.1.63".to_string(),
}; };
let runtime = rule.to_runtime_rule().unwrap(); let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime.upstream.to_string(), "100.90.1.63:53"); assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:53");
} }
#[test] #[test]
fn forwarding_invalid_upstream_returns_error() { fn forwarding_invalid_upstream_returns_error() {
let rule = ForwardingRuleConfig { let rule = ForwardingRuleConfig {
suffix: "home.local".to_string(), suffix: vec!["home.local".to_string()],
upstream: "not-a-valid-host".to_string(), upstream: "not-a-valid-host".to_string(),
}; };
assert!(rule.to_runtime_rule().is_err()); assert!(rule.to_runtime_rules().is_err());
} }
#[test] #[test]
fn forwarding_config_rules_take_precedence_over_discovered() { fn forwarding_config_rules_take_precedence_over_discovered() {
// Config and auto-discovery both claim "home.local"; config must win
// because match_forwarding_rule uses first-match semantics.
let config_rules = vec![ForwardingRuleConfig { let config_rules = vec![ForwardingRuleConfig {
suffix: "home.local".to_string(), suffix: vec!["home.local".to_string()],
upstream: "10.0.0.1:53".to_string(), upstream: "10.0.0.1:53".to_string(),
}]; }];
let discovered = vec![crate::system_dns::ForwardingRule::new( let discovered = vec![crate::system_dns::ForwardingRule::new(
"home.local".to_string(), "home.local".to_string(),
"192.168.1.1:53".parse().unwrap(), "192.168.1.1:53".parse().unwrap(),
)]; )];
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); let (merged, count) = merge_forwarding_rules(&config_rules, discovered).unwrap();
assert_eq!(count, 1);
let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged) let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged)
.expect("rule should match"); .expect("rule should match");
assert_eq!(picked.to_string(), "10.0.0.1:53"); assert_eq!(picked.to_string(), "10.0.0.1:53");
@@ -725,22 +752,31 @@ mod tests {
#[test] #[test]
fn forwarding_merge_preserves_non_overlapping_discovered() { fn forwarding_merge_preserves_non_overlapping_discovered() {
// A discovered rule for a zone the config doesn't mention must survive.
let config_rules = vec![ForwardingRuleConfig { let config_rules = vec![ForwardingRuleConfig {
suffix: "home.local".to_string(), suffix: vec!["home.local".to_string()],
upstream: "10.0.0.1:53".to_string(), upstream: "10.0.0.1:53".to_string(),
}]; }];
let discovered = vec![crate::system_dns::ForwardingRule::new( let discovered = vec![crate::system_dns::ForwardingRule::new(
"corp.example".to_string(), "corp.example".to_string(),
"192.168.1.1:53".parse().unwrap(), "192.168.1.1:53".parse().unwrap(),
)]; )];
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); let (merged, _) = merge_forwarding_rules(&config_rules, discovered).unwrap();
assert_eq!(merged.len(), 2); assert_eq!(merged.len(), 2);
// Discovered rule still matches its zone
let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged) let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged)
.expect("discovered rule should still match"); .expect("discovered rule should still match");
assert_eq!(picked.to_string(), "192.168.1.1:53"); assert_eq!(picked.to_string(), "192.168.1.1:53");
} }
#[test]
fn forwarding_merge_suffix_array_expands_config_count() {
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, count) = merge_forwarding_rules(&config_rules, vec![]).unwrap();
assert_eq!(count, 2);
assert_eq!(merged.len(), 2);
}
} }
pub struct ConfigLoad { pub struct ConfigLoad {

View File

@@ -210,9 +210,9 @@ async fn main() -> numa::Result<()> {
} }
service_store.load_persisted(); service_store.load_persisted();
let forwarding_rules = let (forwarding_rules, config_count) =
numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?; numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?;
for rule in forwarding_rules.iter().take(config.forwarding.len()) { for rule in forwarding_rules.iter().take(config_count) {
info!( info!(
"forwarding .{} to {} (config rule)", "forwarding .{} to {} (config rule)",
rule.suffix, rule.upstream rule.suffix, rule.upstream