From 37f6e4d443a19d904ff13e703f21bb979d4d1a31 Mon Sep 17 00:00:00 2001 From: Roman Kivalin Date: Mon, 13 Apr 2026 23:14:54 +0200 Subject: [PATCH] Support multiple zones per DNS client with automatic zone matching DNS clients now accept both `zone` (single) and `zones` (list). Zone matching uses longest suffix: `mail.example.com` matches `example.com`, and `host.sub.example.com` prefers `sub.example.com` over `example.com`. Config validation checks that every certificate domain (via its DNS-01 solver) and every DANE name matches at least one zone in the referenced DNS client. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 + examples/config.toml | 7 +- src/config.rs | 183 ++++++++++++++++++++++++++++++++++++++++++- src/dane.rs | 20 +++-- src/main.rs | 4 +- src/solver/dns01.rs | 22 +++++- 6 files changed, 226 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc18d1a..a16b720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features +- Support multiple zones per DNS client (`zones = [...]`), with automatic longest-suffix matching +- Config validation: certificate domains and DANE names are checked against DNS client zones + ### Fixes ### Changes diff --git a/examples/config.toml b/examples/config.toml index 0e7ce5d..3b77e28 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -22,7 +22,10 @@ contact = ["mailto:admin@example.com"] # DNS client connections — referenced by solvers and DANE blocks [dns.main] server = "ns1.example.com" +# Single zone: zone = "example.com" +# Multiple zones on the same server (longest suffix match): +# zones = ["example.com", "example.org"] # tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-key" tsig_key_path = "/etc/certforge/tsig.key" tsig_key_name = "certforge." @@ -30,8 +33,8 @@ tsig_algorithm = "hmac-sha256" port = 53 protocol = "tcp" -# Additional DNS clients for other zones -# [dns.org] +# Additional DNS clients (e.g., different server or TSIG key) +# [dns.other] # server = "ns2.example.org" # zone = "example.org" # tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-org" diff --git a/src/config.rs b/src/config.rs index f0954d1..d58009c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,7 +44,13 @@ fn default_directory_url() -> String { #[derive(Debug, Clone, Deserialize)] pub struct DnsClientConfig { pub server: String, - pub zone: String, + + /// Single zone (convenience shorthand). + #[serde(default)] + pub zone: Option, + /// Multiple zones served by this DNS client. + #[serde(default)] + pub zones: Option>, pub tsig_key_credential: Option, pub tsig_key_path: Option, @@ -58,6 +64,38 @@ pub struct DnsClientConfig { pub protocol: DnsProtocol, } +impl DnsClientConfig { + /// Get all configured zones. + pub fn all_zones(&self) -> Vec<&str> { + let mut result = Vec::new(); + if let Some(zones) = &self.zones { + result.extend(zones.iter().map(|s| s.as_str())); + } + if let Some(zone) = &self.zone + && !result.iter().any(|z| z == &zone.as_str()) { + result.push(zone.as_str()); + } + result + } + + /// Find the best matching zone for a DNS name. + /// Matches if `name == zone` or `name` ends with `.`. + /// Returns the longest (most specific) match. + pub fn find_zone(&self, name: &str) -> Option<&str> { + let name_lower = name.to_ascii_lowercase(); + let name_lower = name_lower.trim_end_matches('.'); + + self.all_zones() + .into_iter() + .filter(|zone| { + let zone_lower = zone.to_ascii_lowercase(); + let zone_lower = zone_lower.trim_end_matches('.'); + name_lower == zone_lower || name_lower.ends_with(&format!(".{zone_lower}")) + }) + .max_by_key(|zone| zone.len()) + } +} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum TsigAlgorithm { @@ -306,6 +344,11 @@ impl Config { "dns.{name}: either tsig_key_credential or tsig_key_path must be set" ))); } + if dns.all_zones().is_empty() { + return Err(Error::Config(format!( + "dns.{name}: either zone or zones must be set" + ))); + } } // Validate solver configs @@ -390,6 +433,18 @@ impl Config { cert.name ))); } + // DNS-01: verify domain matches a zone in the DNS client + if let Some(SolverConfig::Dns01 { dns: dns_name }) = self.solver.get(name) + && let Some(dns_config) = self.dns.get(dns_name.as_str()) { + let challenge_name = format!("_acme-challenge.{domain}"); + if dns_config.find_zone(&challenge_name).is_none() { + return Err(Error::Config(format!( + "certificate {}: domain '{domain}' does not match any zone in dns.{dns_name} ({:?})", + cert.name, + dns_config.all_zones() + ))); + } + } } } @@ -407,6 +462,17 @@ impl Config { cert.name, dane.dns ))); } + // Verify each DANE name matches a zone in the DNS client + if let Some(dns_config) = self.dns.get(&dane.dns) { + for tlsa_name in &dane.names { + if dns_config.find_zone(tlsa_name).is_none() { + return Err(Error::Config(format!( + "certificate {}: DANE name '{tlsa_name}' does not match any zone in dns.{} ({:?})", + cert.name, dane.dns, dns_config.all_zones() + ))); + } + } + } } for hook in &cert.hooks { @@ -684,4 +750,119 @@ solvers = ["dns"] let err = config.validate().unwrap_err(); assert!(err.to_string().contains("solvers length")); } + + #[test] + fn zone_matching() { + let dns = DnsClientConfig { + server: "ns1.example.com".into(), + zone: None, + zones: Some(vec!["example.com".into(), "example.org".into(), "sub.example.com".into()]), + tsig_key_credential: None, + tsig_key_path: Some("/tmp/key".into()), + tsig_key_name: "test.".into(), + tsig_algorithm: TsigAlgorithm::HmacSha256, + port: 53, + protocol: DnsProtocol::Tcp, + }; + + // Basic matches + assert_eq!(dns.find_zone("mail.example.com"), Some("example.com")); + assert_eq!(dns.find_zone("mail.example.org"), Some("example.org")); + assert_eq!(dns.find_zone("example.com"), Some("example.com")); + + // Longest match wins + assert_eq!(dns.find_zone("host.sub.example.com"), Some("sub.example.com")); + + // DANE-style names + assert_eq!(dns.find_zone("_25._tcp.mail.example.com"), Some("example.com")); + assert_eq!(dns.find_zone("_443._tcp.host.sub.example.com"), Some("sub.example.com")); + + // No match + assert_eq!(dns.find_zone("other.net"), None); + + // Case insensitive + assert_eq!(dns.find_zone("Mail.Example.COM"), Some("example.com")); + } + + #[test] + fn multi_zone_config() { + let toml = r#" +[acme] +account_key_path = "/etc/certforge/account.key" +contact = ["mailto:admin@example.com"] + +[dns.main] +server = "ns1.example.com" +zones = ["example.com", "example.org"] +tsig_key_path = "/etc/certforge/tsig.key" +tsig_key_name = "certforge." + +[solver.dns] +type = "dns-01" +dns = "main" + +[[certificate]] +name = "multi" +domains = ["mail.example.com", "mail.example.org"] +key_path = "/etc/certforge/keys/multi.key" +cert_path = "/etc/certforge/certs/multi.pem" +solver = "dns" + + [[certificate.dane]] + dns = "main" + names = ["_25._tcp.mail.example.com", "_25._tcp.mail.example.org"] +"#; + + let config: Config = toml::from_str(toml).unwrap(); + config.validate().unwrap(); + } + + #[test] + fn validation_rejects_domain_without_matching_zone() { + let toml = r#" +[acme] +account_key_path = "/etc/certforge/account.key" +contact = ["mailto:admin@example.com"] + +[dns.main] +server = "ns1.example.com" +zone = "example.com" +tsig_key_path = "/etc/certforge/tsig.key" +tsig_key_name = "certforge." + +[solver.dns] +type = "dns-01" +dns = "main" + +[[certificate]] +name = "bad" +domains = ["mail.other.net"] +key_path = "/etc/certforge/keys/bad.key" +cert_path = "/etc/certforge/certs/bad.pem" +solver = "dns" +"#; + + let config: Config = toml::from_str(toml).unwrap(); + let err = config.validate().unwrap_err(); + assert!(err.to_string().contains("does not match any zone")); + } + + #[test] + fn zone_and_zones_combined() { + let dns = DnsClientConfig { + server: "ns1.example.com".into(), + zone: Some("legacy.com".into()), + zones: Some(vec!["example.com".into()]), + tsig_key_credential: None, + tsig_key_path: Some("/tmp/key".into()), + tsig_key_name: "test.".into(), + tsig_algorithm: TsigAlgorithm::HmacSha256, + port: 53, + protocol: DnsProtocol::Tcp, + }; + + assert_eq!(dns.all_zones(), vec!["example.com", "legacy.com"]); + assert_eq!(dns.find_zone("host.legacy.com"), Some("legacy.com")); + assert_eq!(dns.find_zone("host.example.com"), Some("example.com")); + } } diff --git a/src/dane.rs b/src/dane.rs index 5228704..2d91488 100644 --- a/src/dane.rs +++ b/src/dane.rs @@ -45,6 +45,15 @@ pub fn compute_tlsa_from_key( /// Publish TLSA records for a certificate via DNS updates. /// Zone is taken from the DnsClientConfig. +fn resolve_zone_for_name<'a>(dns_config: &'a DnsClientConfig, name: &str) -> Result<&'a str> { + dns_config.find_zone(name).ok_or_else(|| { + Error::DnsUpdate(format!( + "no matching zone for '{name}' in zones {:?}", + dns_config.all_zones() + )) + }) +} + pub async fn publish_tlsa( updater: &mut DnsUpdater, dns_config: &DnsClientConfig, @@ -52,10 +61,11 @@ pub async fn publish_tlsa( records: &[TlsaRecord], ) -> Result<()> { let record = &records[0]; - let zone = Name::from_ascii(&dns_config.zone) - .map_err(|e| Error::DnsUpdate(format!("invalid zone '{}': {e}", dns_config.zone)))?; for name_str in &dane.names { + let zone_str = resolve_zone_for_name(dns_config, name_str)?; + let zone = Name::from_ascii(zone_str) + .map_err(|e| Error::DnsUpdate(format!("invalid zone '{zone_str}': {e}")))?; let name = Name::from_ascii(name_str) .map_err(|e| Error::DnsUpdate(format!("invalid TLSA name {name_str}: {e}")))?; @@ -74,10 +84,10 @@ pub async fn add_tlsa( dane: &DaneConfig, record: &TlsaRecord, ) -> Result<()> { - let zone = Name::from_ascii(&dns_config.zone) - .map_err(|e| Error::DnsUpdate(format!("invalid zone '{}': {e}", dns_config.zone)))?; - for name_str in &dane.names { + let zone_str = resolve_zone_for_name(dns_config, name_str)?; + let zone = Name::from_ascii(zone_str) + .map_err(|e| Error::DnsUpdate(format!("invalid zone '{zone_str}': {e}")))?; let name = Name::from_ascii(name_str) .map_err(|e| Error::DnsUpdate(format!("invalid TLSA name {name_str}: {e}")))?; diff --git a/src/main.rs b/src/main.rs index 325748c..c55a47e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -235,8 +235,8 @@ fn print_init_commands(config: &config::Config) { for (name, dns) in &config.dns { if let Some(path) = &dns.tsig_key_credential { println!( - "# DNS TSIG key for '{name}' (zone {})\nsystemd-creds encrypt --name={} - {}\n", - dns.zone, + "# DNS TSIG key for '{name}' (zones: {:?})\nsystemd-creds encrypt --name={} - {}\n", + dns.all_zones(), dns.tsig_key_name, path.display() ); diff --git a/src/solver/dns01.rs b/src/solver/dns01.rs index 72b2601..a7d4173 100644 --- a/src/solver/dns01.rs +++ b/src/solver/dns01.rs @@ -15,15 +15,29 @@ impl Dns01Solver { pub fn new(dns_config: DnsClientConfig) -> Self { Self { dns_config } } + + fn resolve_zone(&self, txt_name: &str) -> Result { + self.dns_config + .find_zone(txt_name) + .map(|z| z.to_string()) + .ok_or_else(|| { + Error::DnsUpdate(format!( + "no matching zone for '{txt_name}' in zones {:?}", + self.dns_config.all_zones() + )) + }) + } } #[async_trait::async_trait] impl super::Solver for Dns01Solver { async fn present(&self, challenge: &ChallengeInfo) -> Result<()> { let txt_name = format!("_acme-challenge.{}", challenge.identifier); + let zone_str = self.resolve_zone(&txt_name)?; + tracing::debug!( identifier = %challenge.identifier, - zone = %self.dns_config.zone, + zone = %zone_str, server = %self.dns_config.server, %txt_name, "publishing DNS-01 challenge TXT record" @@ -34,7 +48,7 @@ impl super::Solver for Dns01Solver { let name = Name::from_ascii(&txt_name) .map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?; - let zone = Name::from_ascii(&self.dns_config.zone) + let zone = Name::from_ascii(&zone_str) .map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?; updater.add_txt_record(&zone, &name, &challenge.dns_value, 60).await @@ -42,6 +56,8 @@ impl super::Solver for Dns01Solver { async fn cleanup(&self, challenge: &ChallengeInfo) -> Result<()> { let txt_name = format!("_acme-challenge.{}", challenge.identifier); + let zone_str = self.resolve_zone(&txt_name)?; + tracing::debug!( identifier = %challenge.identifier, %txt_name, @@ -53,7 +69,7 @@ impl super::Solver for Dns01Solver { let name = Name::from_ascii(&txt_name) .map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?; - let zone = Name::from_ascii(&self.dns_config.zone) + let zone = Name::from_ascii(&zone_str) .map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?; updater.delete_txt_record(&zone, &name, &challenge.dns_value).await