From b4b939c78bce38a8781c202be2eb10c798a6b68e Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 14 Apr 2026 09:22:24 +0300 Subject: [PATCH] fix: accept tls:// and https:// in [[forwarding]] upstreams Config-level forwarding rules were parsed with the UDP-only `parse_upstream_addr` helper, silently rejecting the DoT/DoH schemes that the rest of the forwarding pipeline already supports. Widen `ForwardingRule.upstream` from `SocketAddr` to `Upstream` so config rules reuse the same parser as `[upstream].address` and `fallback`. Demote `parse_upstream_addr` to `pub(crate)` to prevent the same mistake recurring. Closes #100. --- numa.toml | 8 ++++++++ src/config.rs | 44 ++++++++++++++++++++++++++++++++++++++++---- src/ctx.rs | 12 +++++++----- src/forward.rs | 11 ++++++++++- src/system_dns.rs | 15 ++++++++++----- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/numa.toml b/numa.toml index 1ea3341..4edee81 100644 --- a/numa.toml +++ b/numa.toml @@ -58,6 +58,14 @@ api_port = 5380 # [[forwarding]] # suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream # upstream = "10.0.0.1" # port 53 default +# +# [[forwarding]] # DoT upstream: tls://IP[:port]#hostname +# suffix = ["google.com", "goog"] # hostname is the TLS SNI / cert name +# upstream = "tls://9.9.9.9#dns.quad9.net" # port 853 default +# +# [[forwarding]] # DoH upstream: full https:// URL +# suffix = "example.corp" +# upstream = "https://dns.quad9.net/dns-query" # [blocking] # enabled = true # set to false to disable ad blocking diff --git a/src/config.rs b/src/config.rs index 237f3bd..4d22956 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,12 +46,12 @@ pub struct ForwardingRuleConfig { impl ForwardingRuleConfig { fn to_runtime_rules(&self) -> Result> { - let addr = crate::forward::parse_upstream_addr(&self.upstream, 53) + let upstream = crate::forward::parse_upstream(&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)) + .map(|s| crate::system_dns::ForwardingRule::new(s.clone(), upstream.clone())) .collect()) } } @@ -710,6 +710,10 @@ mod tests { }; let runtime = rule.to_runtime_rules().unwrap(); assert_eq!(runtime.len(), 1); + assert!(matches!( + runtime[0].upstream, + crate::forward::Upstream::Udp(_) + )); assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361"); assert_eq!(runtime[0].suffix, "home.local"); } @@ -733,6 +737,38 @@ mod tests { assert!(rule.to_runtime_rules().is_err()); } + #[test] + fn forwarding_upstream_accepts_dot_scheme() { + let rule = ForwardingRuleConfig { + suffix: vec!["google.com".to_string()], + upstream: "tls://9.9.9.9#dns.quad9.net".to_string(), + }; + let runtime = rule + .to_runtime_rules() + .expect("tls:// upstream should parse"); + assert_eq!(runtime.len(), 1); + assert_eq!( + runtime[0].upstream.to_string(), + "tls://9.9.9.9:853#dns.quad9.net" + ); + } + + #[test] + fn forwarding_upstream_accepts_doh_scheme() { + let rule = ForwardingRuleConfig { + suffix: vec!["goog".to_string()], + upstream: "https://dns.quad9.net/dns-query".to_string(), + }; + let runtime = rule + .to_runtime_rules() + .expect("https:// upstream should parse"); + assert_eq!(runtime.len(), 1); + assert_eq!( + runtime[0].upstream.to_string(), + "https://dns.quad9.net/dns-query" + ); + } + #[test] fn forwarding_config_rules_take_precedence_over_discovered() { let config_rules = vec![ForwardingRuleConfig { @@ -741,7 +777,7 @@ mod tests { }]; let discovered = vec![crate::system_dns::ForwardingRule::new( "home.local".to_string(), - "192.168.1.1:53".parse().unwrap(), + crate::forward::Upstream::Udp("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) @@ -757,7 +793,7 @@ mod tests { }]; let discovered = vec![crate::system_dns::ForwardingRule::new( "corp.example".to_string(), - "192.168.1.1:53".parse().unwrap(), + crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()), )]; let merged = merge_forwarding_rules(&config_rules, discovered).unwrap(); assert_eq!(merged.len(), 2); diff --git a/src/ctx.rs b/src/ctx.rs index 2812bed..222e407 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -190,13 +190,12 @@ pub async fn resolve_query( resp.header.authed_data = true; } (resp, QueryPath::Cached, cached_dnssec) - } else if let Some(fwd_addr) = + } else if let Some(upstream) = crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { // Conditional forwarding takes priority over recursive mode // (e.g. Tailscale .ts.net, VPC private zones) - let upstream = Upstream::Udp(fwd_addr); - match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await { + match forward_and_cache(raw_wire, upstream, ctx, &qname, qtype).await { Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate), Err(e) => { error!( @@ -1083,7 +1082,7 @@ mod tests { let mut ctx = crate::testutil::test_ctx().await; ctx.forwarding_rules = vec![ForwardingRule::new( "168.192.in-addr.arpa".to_string(), - upstream_addr, + Upstream::Udp(upstream_addr), )]; let ctx = Arc::new(ctx); @@ -1236,7 +1235,10 @@ mod tests { let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await; let mut ctx = crate::testutil::test_ctx().await; - ctx.forwarding_rules = vec![ForwardingRule::new("corp".to_string(), upstream_addr)]; + ctx.forwarding_rules = vec![ForwardingRule::new( + "corp".to_string(), + Upstream::Udp(upstream_addr), + )]; let ctx = Arc::new(ctx); let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await; diff --git a/src/forward.rs b/src/forward.rs index e13e360..7c7a53a 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -36,6 +36,12 @@ impl PartialEq for Upstream { } } +impl fmt::Debug for Upstream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + impl fmt::Display for Upstream { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -49,7 +55,10 @@ impl fmt::Display for Upstream { } } -pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result { +pub(crate) fn parse_upstream_addr( + s: &str, + default_port: u16, +) -> std::result::Result { // Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353" if let Ok(addr) = s.parse::() { return Ok(addr); diff --git a/src/system_dns.rs b/src/system_dns.rs index d560a6e..96ae372 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -2,6 +2,8 @@ use std::net::SocketAddr; use log::info; +use crate::forward::Upstream; + fn print_recursive_hint() { let is_recursive = crate::config::load_config("numa.toml") .map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive) @@ -22,11 +24,11 @@ fn is_loopback_or_stub(addr: &str) -> bool { pub struct ForwardingRule { pub suffix: String, dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching - pub upstream: SocketAddr, + pub upstream: Upstream, } impl ForwardingRule { - pub fn new(suffix: String, upstream: SocketAddr) -> Self { + pub fn new(suffix: String, upstream: Upstream) -> Self { let dot_suffix = format!(".{}", suffix); Self { suffix, @@ -233,7 +235,7 @@ fn discover_macos() -> SystemDnsInfo { #[cfg(any(target_os = "macos", target_os = "linux"))] fn make_rule(domain: &str, nameserver: &str) -> Option { let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?; - Some(ForwardingRule::new(domain.to_string(), addr)) + Some(ForwardingRule::new(domain.to_string(), Upstream::Udp(addr))) } #[cfg(target_os = "linux")] @@ -822,10 +824,13 @@ fn uninstall_windows() -> Result<(), String> { /// Find the upstream for a domain by checking forwarding rules. /// Returns None if no rule matches (use default upstream). /// Zero-allocation on the hot path — dot_suffix is pre-computed. -pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option { +pub fn match_forwarding_rule<'a>( + domain: &str, + rules: &'a [ForwardingRule], +) -> Option<&'a Upstream> { for rule in rules { if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) { - return Some(rule.upstream); + return Some(&rule.upstream); } } None