From 7047767dc225ca56216eb677f4f312582027106b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 06:12:08 +0300 Subject: [PATCH] feat: per-suffix conditional forwarding rules (#82) (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-suffix conditional forwarding rules in numa.toml (#82) Adds a `[[forwarding]]` config section so users can explicitly route domain suffixes to specific upstreams. Config-declared rules take precedence over auto-discovered rules (macOS scutil, Linux search domains) via first-match semantics. Example — the reporter's reverse-DNS case: [[forwarding]] suffix = "168.192.in-addr.arpa" upstream = "100.90.1.63:5361" Bare IPs default to port 53. IPv6 is supported via parse_upstream_addr. ForwardingRule::new() constructor replaces direct struct-literal construction, and make_rule() now delegates to parse_upstream_addr to fix a latent IPv6 parsing bug. * 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. * style: rustfmt * refactor: drop config_count from merge_forwarding_rules return Log config rules directly from config.forwarding before merging, keeping the merge API clean of logging concerns. --- numa.toml | 8 ++ src/config.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +- src/system_dns.rs | 19 +++-- 4 files changed, 212 insertions(+), 7 deletions(-) diff --git a/numa.toml b/numa.toml index 92b5411..3b716e8 100644 --- a/numa.toml +++ b/numa.toml @@ -45,6 +45,14 @@ api_port = 5380 # "co", "br", "au", "ca", "jp", # other major ccTLDs # ] +# [[forwarding]] # per-suffix conditional forwarding rules +# suffix = "168.192.in-addr.arpa" # single suffix → one upstream +# upstream = "100.90.1.63:5361" +# +# [[forwarding]] +# suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream +# upstream = "10.0.0.1" # port 53 default + # [blocking] # enabled = true # set to false to disable ad blocking # refresh_hours = 24 diff --git a/src/config.rs b/src/config.rs index 60b505e..ae9f685 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,39 @@ pub struct Config { pub dot: DotConfig, #[serde(default)] pub mobile: MobileConfig, + #[serde(default)] + pub forwarding: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct ForwardingRuleConfig { + #[serde(deserialize_with = "string_or_vec")] + pub suffix: Vec, + pub upstream: String, +} + +impl ForwardingRuleConfig { + fn to_runtime_rules(&self) -> Result> { + 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, +) -> Result> { + let mut merged: Vec = Vec::new(); + for rule in config_rules { + merged.extend(rule.to_runtime_rules()?); + } + merged.extend(discovered); + Ok(merged) } #[derive(Deserialize)] @@ -585,6 +618,157 @@ mod tests { 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 { diff --git a/src/main.rs b/src/main.rs index 903be9a..7592186 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,7 +210,13 @@ async fn main() -> numa::Result<()> { } service_store.load_persisted(); - let forwarding_rules = system_dns.forwarding_rules; + for fwd in &config.forwarding { + for suffix in &fwd.suffix { + info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream); + } + } + let forwarding_rules = + numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?; // Resolve data_dir from config, falling back to the platform default. // Used for TLS CA storage below and stored on ServerCtx for runtime use. diff --git a/src/system_dns.rs b/src/system_dns.rs index 539f0a1..d560a6e 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -25,6 +25,17 @@ pub struct ForwardingRule { pub upstream: SocketAddr, } +impl ForwardingRule { + pub fn new(suffix: String, upstream: SocketAddr) -> Self { + let dot_suffix = format!(".{}", suffix); + Self { + suffix, + dot_suffix, + upstream, + } + } +} + /// Result of system DNS discovery — default upstream + conditional forwarding rules. pub struct SystemDnsInfo { pub default_upstream: Option, @@ -221,12 +232,8 @@ fn discover_macos() -> SystemDnsInfo { #[cfg(any(target_os = "macos", target_os = "linux"))] fn make_rule(domain: &str, nameserver: &str) -> Option { - let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?; - Some(ForwardingRule { - dot_suffix: format!(".{}", domain), - suffix: domain.to_string(), - upstream: addr, - }) + let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?; + Some(ForwardingRule::new(domain.to_string(), addr)) } #[cfg(target_os = "linux")]