From f264cea5b45e0c7769b19e04e70b2b330634d800 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 03:03:56 +0300 Subject: [PATCH] feat: per-suffix conditional forwarding rules in numa.toml (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- numa.toml | 8 +++ src/config.rs | 156 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 9 ++- src/system_dns.rs | 19 ++++-- 4 files changed, 185 insertions(+), 7 deletions(-) diff --git a/numa.toml b/numa.toml index 92b5411..7100ef2 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" # all PTR lookups for 192.168.x.x +# upstream = "100.90.1.63:5361" # → sent to this upstream +# +# [[forwarding]] +# suffix = "home.local" # all lookups under home.local +# upstream = "10.0.0.1" # → sent to this upstream (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..5327cd8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,45 @@ pub struct Config { pub dot: DotConfig, #[serde(default)] pub mobile: MobileConfig, + #[serde(default)] + pub forwarding: Vec, +} + +/// 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)] +pub struct ForwardingRuleConfig { + pub suffix: String, + pub upstream: String, +} + +impl ForwardingRuleConfig { + /// Parse `upstream` into a `SocketAddr` (port 53 default) and build a + /// runtime `ForwardingRule`. Returns `Err` if the upstream is malformed. + pub fn to_runtime_rule(&self) -> Result { + let addr = crate::forward::parse_upstream_addr(&self.upstream, 53) + .map_err(|e| format!("forwarding rule for '{}': {}", self.suffix, e))?; + Ok(crate::system_dns::ForwardingRule::new( + self.suffix.clone(), + addr, + )) + } +} + +/// Merge config-declared rules with auto-discovered rules. Config rules come +/// 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( + config_rules: &[ForwardingRuleConfig], + discovered: Vec, +) -> Result> { + let mut merged: Vec = config_rules + .iter() + .map(ForwardingRuleConfig::to_runtime_rule) + .collect::>>()?; + merged.extend(discovered); + Ok(merged) } #[derive(Deserialize)] @@ -585,6 +624,123 @@ 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() { + // Reporter's exact case (#82): reverse-DNS zone for 192.168.0.0/16 + 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_upstream_with_explicit_port() { + let rule = ForwardingRuleConfig { + suffix: "home.local".to_string(), + upstream: "100.90.1.63:5361".to_string(), + }; + let runtime = rule.to_runtime_rule().unwrap(); + assert_eq!(runtime.upstream.to_string(), "100.90.1.63:5361"); + assert_eq!(runtime.suffix, "home.local"); + } + + #[test] + fn forwarding_upstream_defaults_to_port_53() { + let rule = ForwardingRuleConfig { + suffix: "home.local".to_string(), + upstream: "100.90.1.63".to_string(), + }; + let runtime = rule.to_runtime_rule().unwrap(); + assert_eq!(runtime.upstream.to_string(), "100.90.1.63:53"); + } + + #[test] + fn forwarding_invalid_upstream_returns_error() { + let rule = ForwardingRuleConfig { + suffix: "home.local".to_string(), + upstream: "not-a-valid-host".to_string(), + }; + assert!(rule.to_runtime_rule().is_err()); + } + + #[test] + 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 { + suffix: "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() { + // A discovered rule for a zone the config doesn't mention must survive. + let config_rules = vec![ForwardingRuleConfig { + suffix: "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); + // Discovered rule still matches its zone + 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"); + } } pub struct ConfigLoad { diff --git a/src/main.rs b/src/main.rs index 903be9a..91089a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,7 +210,14 @@ async fn main() -> numa::Result<()> { } service_store.load_persisted(); - let forwarding_rules = system_dns.forwarding_rules; + let forwarding_rules = + numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?; + for rule in forwarding_rules.iter().take(config.forwarding.len()) { + info!( + "forwarding .{} to {} (config rule)", + rule.suffix, rule.upstream + ); + } // 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")]