mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-12 16:00:45 +00:00
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:
co-authored by
Claude Opus 4.6
parent
3674a222a6
commit
37f6e4d443
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user