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) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-13 23:14:54 +02:00
co-authored by Claude Opus 4.6
parent 3674a222a6
commit 37f6e4d443
6 changed files with 226 additions and 13 deletions
+3
View File
@@ -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
+5 -2
View File
@@ -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"
+182 -1
View File
@@ -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<String>,
/// Multiple zones served by this DNS client.
#[serde(default)]
pub zones: Option<Vec<String>>,
pub tsig_key_credential: Option<PathBuf>,
pub tsig_key_path: Option<PathBuf>,
@@ -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 `.<zone>`.
/// 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"));
}
}
+15 -5
View File
@@ -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}")))?;
+2 -2
View File
@@ -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()
);
+19 -3
View File
@@ -15,15 +15,29 @@ impl Dns01Solver {
pub fn new(dns_config: DnsClientConfig) -> Self {
Self { dns_config }
}
fn resolve_zone(&self, txt_name: &str) -> Result<String> {
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