From f264cea5b45e0c7769b19e04e70b2b330634d800 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 03:03:56 +0300 Subject: [PATCH 1/4] 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")] -- 2.34.1 From a757b98744720bf07a53f988556f5a3d928a41bf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 05:29:49 +0300 Subject: [PATCH 2/4] 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. --- numa.toml | 8 ++-- src/config.rs | 114 +++++++++++++++++++++++++++++++++----------------- src/main.rs | 4 +- 3 files changed, 81 insertions(+), 45 deletions(-) diff --git a/numa.toml b/numa.toml index 7100ef2..3b716e8 100644 --- a/numa.toml +++ b/numa.toml @@ -46,12 +46,12 @@ api_port = 5380 # ] # [[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 +# suffix = "168.192.in-addr.arpa" # single suffix → one upstream +# upstream = "100.90.1.63:5361" # # [[forwarding]] -# suffix = "home.local" # all lookups under home.local -# upstream = "10.0.0.1" # → sent to this upstream (port 53 default) +# 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 diff --git a/src/config.rs b/src/config.rs index 5327cd8..e4389f1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,41 +37,38 @@ pub struct Config { 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, + #[serde(deserialize_with = "string_or_vec")] + pub suffix: Vec, 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 { + fn to_runtime_rules(&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, - )) + .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()) } } /// 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::>>()?; +) -> Result<(Vec, usize)> { + let mut merged: Vec = Vec::new(); + for rule in config_rules { + merged.extend(rule.to_runtime_rules()?); + } + let config_count = merged.len(); merged.extend(discovered); - Ok(merged) + Ok((merged, config_count)) } #[derive(Deserialize)] @@ -642,13 +639,12 @@ mod tests { "#; 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].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" @@ -656,7 +652,7 @@ mod tests { "#; 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"); + assert_eq!(config.forwarding[0].suffix, &["168.192.in-addr.arpa"]); } #[test] @@ -675,49 +671,80 @@ mod tests { 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: "home.local".to_string(), + suffix: vec!["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"); + 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: "home.local".to_string(), + suffix: vec!["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"); + 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: "home.local".to_string(), + suffix: vec!["home.local".to_string()], upstream: "not-a-valid-host".to_string(), }; - assert!(rule.to_runtime_rule().is_err()); + assert!(rule.to_runtime_rules().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(), + 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 (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) .expect("rule should match"); assert_eq!(picked.to_string(), "10.0.0.1:53"); @@ -725,22 +752,31 @@ mod tests { #[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(), + 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(); + 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"); } + + #[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 { diff --git a/src/main.rs b/src/main.rs index 91089a5..265fc37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,9 +210,9 @@ async fn main() -> numa::Result<()> { } service_store.load_persisted(); - let forwarding_rules = + let (forwarding_rules, config_count) = 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!( "forwarding .{} to {} (config rule)", rule.suffix, rule.upstream -- 2.34.1 From 5043d4612ec9b951f6f791dc711cf61692eacfcf Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 05:29:59 +0300 Subject: [PATCH 3/4] style: rustfmt --- src/config.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index e4389f1..04a6796 100644 --- a/src/config.rs +++ b/src/config.rs @@ -689,10 +689,7 @@ mod tests { #[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(), - ], + 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(); -- 2.34.1 From 8d70d0df8a6f0039624a4a3b75693c43cf49d295 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 12 Apr 2026 05:34:55 +0300 Subject: [PATCH 4/4] 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. --- src/config.rs | 17 ++++++----------- src/main.rs | 13 ++++++------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/config.rs b/src/config.rs index 04a6796..ae9f685 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,19 +56,16 @@ impl ForwardingRuleConfig { } } -/// Merge config-declared rules with auto-discovered rules. Config rules come -/// first so `match_forwarding_rule`'s first-match semantics gives them precedence. pub fn merge_forwarding_rules( config_rules: &[ForwardingRuleConfig], discovered: Vec, -) -> Result<(Vec, usize)> { +) -> Result> { let mut merged: Vec = Vec::new(); for rule in config_rules { merged.extend(rule.to_runtime_rules()?); } - let config_count = merged.len(); merged.extend(discovered); - Ok((merged, config_count)) + Ok(merged) } #[derive(Deserialize)] @@ -740,8 +737,7 @@ mod tests { "home.local".to_string(), "192.168.1.1:53".parse().unwrap(), )]; - let (merged, count) = merge_forwarding_rules(&config_rules, discovered).unwrap(); - assert_eq!(count, 1); + 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"); @@ -757,7 +753,7 @@ mod tests { "corp.example".to_string(), "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); let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged) .expect("discovered rule should still match"); @@ -765,13 +761,12 @@ mod tests { } #[test] - fn forwarding_merge_suffix_array_expands_config_count() { + 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, count) = merge_forwarding_rules(&config_rules, vec![]).unwrap(); - assert_eq!(count, 2); + let merged = merge_forwarding_rules(&config_rules, vec![]).unwrap(); assert_eq!(merged.len(), 2); } } diff --git a/src/main.rs b/src/main.rs index 265fc37..7592186 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,14 +210,13 @@ async fn main() -> numa::Result<()> { } service_store.load_persisted(); - let (forwarding_rules, config_count) = - numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?; - for rule in forwarding_rules.iter().take(config_count) { - info!( - "forwarding .{} to {} (config rule)", - rule.suffix, rule.upstream - ); + 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. -- 2.34.1