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.
This commit is contained in:
Razvan Dimescu
2026-04-12 03:03:56 +03:00
parent 22bebb85a0
commit f264cea5b4
4 changed files with 185 additions and 7 deletions

View File

@@ -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

View File

@@ -33,6 +33,45 @@ pub struct Config {
pub dot: DotConfig,
#[serde(default)]
pub mobile: MobileConfig,
#[serde(default)]
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)]
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<crate::system_dns::ForwardingRule> {
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<crate::system_dns::ForwardingRule>,
) -> Result<Vec<crate::system_dns::ForwardingRule>> {
let mut merged: Vec<crate::system_dns::ForwardingRule> = config_rules
.iter()
.map(ForwardingRuleConfig::to_runtime_rule)
.collect::<Result<Vec<_>>>()?;
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 {

View File

@@ -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.

View File

@@ -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<String>,
@@ -221,12 +232,8 @@ fn discover_macos() -> SystemDnsInfo {
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
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")]