diff --git a/Cargo.lock b/Cargo.lock index 450a3618..930a610f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1280,7 +1280,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "coordinator" -version = "0.15.4" +version = "0.16.7" dependencies = [ "async-nats", "futures", @@ -1937,9 +1937,7 @@ dependencies = [ [[package]] name = "dns-update" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8414d37d45aad309d6644add18d9023c591622e565f0f1946753feebed762bfe" +version = "0.5.0" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -6035,7 +6033,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "registry" -version = "0.15.4" +version = "0.16.7" dependencies = [ "ahash", "hashify", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 7e34869c..c19c8443 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -20,7 +20,7 @@ mail-parser = { version = "0.11", features = ["full_encoding"] } mail-builder = { version = "0.4" } mail-auth = { version = "0.9", features = ["generate"] } smtp-proto = { version = "0.2", features = ["rkyv"] } -dns-update = { version = "0.4.0" } +dns-update = { path = "/Users/me/code/dns-update" } calcard = { version = "0.3", features = ["rkyv"] } ahash = { version = "0.8.2", features = ["serde"] } parking_lot = "0.12.1" diff --git a/crates/common/src/network/acme/order.rs b/crates/common/src/network/acme/order.rs index 4edf668f..60395c4a 100644 --- a/crates/common/src/network/acme/order.rs +++ b/crates/common/src/network/acme/order.rs @@ -267,17 +267,22 @@ impl AcmeRequestBuilder { .or_else(|| psl::domain_str(domain)) .unwrap_or(domain); + let proof = self.dns_proof(challenge)?; + let challenge_name = format!("_acme-challenge.{}", domain); dns_parameters .updater - .create( + .set_rrset( zone, - &format!("_acme-challenge.{}", domain), - DnsRecord::TXT(self.dns_proof(challenge)?), - true, - true, + &challenge_name, + dns_update::DnsRecordType::TXT, + vec![DnsRecord::TXT(proof.clone())], ) .await .map_err(AcmeError::Dns)?; + dns_parameters + .updater + .wait_for_txt_propagation(&challenge_name, zone, &proof) + .await; } ChallengeType::DnsPersist01 => {} ChallengeType::Unknown => unreachable!(), diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index eadc5b51..0790ce8f 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -133,7 +133,7 @@ impl Server { } } DnsRecordType::AutoConfig => { - let pacc_digest = Sha256::digest(&self.get_pacc_for_fomain(domain_name).await?); + let pacc_digest = Sha256::digest(&self.get_pacc_for_domain(domain_name).await?); let pacc_digest_encoded = general_purpose::STANDARD.encode(pacc_digest); records.push(NamedDnsRecord { @@ -379,7 +379,7 @@ impl Server { .map(|records| BindSerializer::serialize(&records)) } - pub async fn get_pacc_for_fomain(&self, domain_name: &str) -> trc::Result { + pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result { self.get_directory_for_domain(domain_name) .await .caused_by(trc::location!()) diff --git a/crates/common/src/network/dns/update.rs b/crates/common/src/network/dns/update.rs index d0185465..36226ba6 100644 --- a/crates/common/src/network/dns/update.rs +++ b/crates/common/src/network/dns/update.rs @@ -1114,125 +1114,95 @@ impl DnsUpdater { } } - pub async fn create( + pub async fn set_rrset( &self, origin: &str, name: &str, - record: DnsRecord, - verify: bool, - delete_before_create: bool, - ) -> Result { - // First try deleting the record - if delete_before_create - && let Err(err) = self.updater.delete(name, origin, record.as_type()).await - { - // Errors are expected if the record does not exist - trc::event!( - Dns(DnsEvent::RecordDeletionFailed), - Hostname = name.to_string(), - Reason = err.to_string(), - Details = origin.to_string(), - Type = record.as_type().as_str(), - ); - } + record_type: DnsRecordType, + records: Vec, + ) -> Result<(), String> { + let record_values = records + .iter() + .map(|r| trc::Value::String(r.to_string().into())) + .collect::>(); - // Create the record if let Err(err) = self .updater - .create(name, record.clone(), self.ttl.as_secs() as u32, origin) + .set_rrset( + name, + record_type, + self.ttl.as_secs() as u32, + records, + origin, + ) .await { - return Err(format!("Failed to create DNS record: {}", err)); + return Err(format!("Failed to set DNS RRSet: {}", err)); } trc::event!( Dns(DnsEvent::RecordCreated), Hostname = name.to_string(), Details = origin.to_string(), - Type = record.as_type().as_str(), - Value = record.to_string(), + Type = record_type.as_str(), + Value = record_values, ); - - if verify && let DnsRecord::TXT(txt_record) = &record { - #[cfg(feature = "test_mode")] - if matches!( - self.updater, - dns_update::DnsUpdater::Pebble(_) | dns_update::DnsUpdater::InMemory(_) - ) { - return Ok(true); - } - - // Wait for changes to propagate - if let Some(initial_wait) = self.propagation_delay { - tokio::time::sleep(initial_wait).await; - } - let wait_until = Instant::now() + self.propagation_timeout; - let mut did_propagate = false; - while Instant::now() < wait_until { - match self.core.smtp.resolvers.dns.txt_raw_lookup(&name).await { - Ok(result) => { - let result = std::str::from_utf8(&result).unwrap_or_default(); - if result.contains(txt_record) { - did_propagate = true; - break; - } else { - trc::event!( - Dns(DnsEvent::RecordNotPropagated), - Hostname = name.to_string(), - Details = origin.to_string(), - Result = result.to_string(), - Type = record.as_type().as_str(), - Value = record.to_string(), - ); - } - } - Err(err) => { - trc::event!( - Dns(DnsEvent::RecordLookupFailed), - Hostname = name.to_string(), - Details = origin.to_string(), - Reason = err.to_string(), - Type = record.as_type().as_str(), - Value = record.to_string(), - ); - } - } - - tokio::time::sleep(self.polling_interval).await; - } - - if did_propagate { - trc::event!( - Dns(DnsEvent::RecordPropagated), - Hostname = name.to_string(), - Details = origin.to_string(), - Type = record.as_type().as_str(), - Value = record.to_string(), - ); - } else { - trc::event!( - Dns(DnsEvent::RecordPropagationTimeout), - Hostname = name.to_string(), - Details = origin.to_string(), - Type = record.as_type().as_str(), - Value = record.to_string(), - ); - } - - Ok(did_propagate) - } else { - Ok(true) - } + Ok(()) } - pub async fn delete( + pub async fn add_to_rrset( &self, origin: &str, name: &str, record_type: DnsRecordType, + records: Vec, ) -> Result<(), String> { - // First try deleting the record - match self.updater.delete(name, origin, record_type).await { + let record_values = records + .iter() + .map(|r| trc::Value::String(r.to_string().into())) + .collect::>(); + + if let Err(err) = self + .updater + .add_to_rrset( + name, + record_type, + self.ttl.as_secs() as u32, + records, + origin, + ) + .await + { + return Err(format!("Failed to add to DNS RRSet: {}", err)); + } + + trc::event!( + Dns(DnsEvent::RecordCreated), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = record_type.as_str(), + Value = record_values, + ); + Ok(()) + } + + pub async fn remove_from_rrset( + &self, + origin: &str, + name: &str, + record_type: DnsRecordType, + records: Vec, + ) -> Result<(), String> { + let record_values = records + .iter() + .map(|r| trc::Value::String(r.to_string().into())) + .collect::>(); + + match self + .updater + .remove_from_rrset(name, record_type, records, origin) + .await + { Ok(_) => Ok(()), Err(err) => { trc::event!( @@ -1241,11 +1211,79 @@ impl DnsUpdater { Reason = err.to_string(), Details = origin.to_string(), Type = record_type.as_str(), + Value = record_values, ); Err(err.to_string()) } } } + + pub async fn wait_for_txt_propagation(&self, name: &str, origin: &str, expected: &str) -> bool { + #[cfg(feature = "test_mode")] + if matches!( + self.updater, + dns_update::DnsUpdater::Pebble(_) | dns_update::DnsUpdater::InMemory(_) + ) { + return true; + } + + if let Some(initial_wait) = self.propagation_delay { + tokio::time::sleep(initial_wait).await; + } + let wait_until = Instant::now() + self.propagation_timeout; + let mut did_propagate = false; + while Instant::now() < wait_until { + match self.core.smtp.resolvers.dns.txt_raw_lookup(&name).await { + Ok(result) => { + let result = std::str::from_utf8(&result).unwrap_or_default(); + if result.contains(expected) { + did_propagate = true; + break; + } else { + trc::event!( + Dns(DnsEvent::RecordNotPropagated), + Hostname = name.to_string(), + Details = origin.to_string(), + Result = result.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } + } + Err(err) => { + trc::event!( + Dns(DnsEvent::RecordLookupFailed), + Hostname = name.to_string(), + Details = origin.to_string(), + Reason = err.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } + } + + tokio::time::sleep(self.polling_interval).await; + } + + if did_propagate { + trc::event!( + Dns(DnsEvent::RecordPropagated), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } else { + trc::event!( + Dns(DnsEvent::RecordPropagationTimeout), + Hostname = name.to_string(), + Details = origin.to_string(), + Type = DnsRecordType::TXT.as_str(), + Value = expected.to_string(), + ); + } + did_propagate + } } impl Server { diff --git a/crates/coordinator/Cargo.toml b/crates/coordinator/Cargo.toml index 6cab04de..485568be 100644 --- a/crates/coordinator/Cargo.toml +++ b/crates/coordinator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coordinator" -version = "0.15.4" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index ff35e905..b405b0f8 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -311,7 +311,7 @@ impl ParseHttp for Server { .await?; return Ok(Resource::new( "application/json", - self.get_pacc_for_fomain( + self.get_pacc_for_domain( req.headers() .get(header::HOST) .and_then(|h| h.to_str().ok()) diff --git a/crates/jmap/src/mailbox/query.rs b/crates/jmap/src/mailbox/query.rs index 0af6b77a..ce624d17 100644 --- a/crates/jmap/src/mailbox/query.rs +++ b/crates/jmap/src/mailbox/query.rs @@ -59,7 +59,7 @@ impl MailboxQuery for Server { )); } MailboxFilter::Name(name) => { - #[cfg(feature = "test_mode")] + #[cfg(any(feature = "dev_mode", feature = "test_mode"))] { // Used for concurrent requests tests if name == "__sleep" { diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 188b913c..312aab7a 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "registry" -version = "0.15.4" +version = "0.16.7" edition = "2024" [dependencies] diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index b8f964ec..59a1e981 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -34,7 +34,7 @@ sha2 = "0.11" reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]} base64 = "0.22" compact_str = "0.9.0" -dns-update = { version = "0.4.0" } +dns-update = { path = "/Users/me/code/dns-update" } [dev-dependencies] diff --git a/crates/services/src/task_manager/dkim.rs b/crates/services/src/task_manager/dkim.rs index b6dc60bf..d88e75d0 100644 --- a/crates/services/src/task_manager/dkim.rs +++ b/crates/services/src/task_manager/dkim.rs @@ -191,10 +191,25 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R // Publish key if let Some((updater, origin)) = &dns_updater { let record = generate_dkim_dns_record(&signature, &domain.name).await?; - let signature_transition = if updater - .create(origin, &record.name, record.record, true, true) + let dns_update::DnsRecord::TXT(txt_value) = &record.record else { + return Ok(TaskResult::permanent( + "DKIM record must be a TXT record".to_string(), + )); + }; + let propagation_target = txt_value.clone(); + let published = updater + .set_rrset( + origin, + &record.name, + dns_update::DnsRecordType::TXT, + vec![record.record.clone()], + ) .await - .is_ok_and(|did_propagate| did_propagate) + .is_ok(); + let signature_transition = if published + && updater + .wait_for_txt_propagation(&record.name, origin, &propagation_target) + .await { trc::event!( Dkim(DkimEvent::SignaturePublished), @@ -243,10 +258,27 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R for signature in publish_signatures { let record = generate_dkim_dns_record(&signature.object, &domain.name).await?; if let Some((updater, origin)) = &dns_updater { - match updater - .create(origin, &record.name, record.record, true, true) - .await - { + let dns_update::DnsRecord::TXT(txt_value) = &record.record else { + return Ok(TaskResult::permanent( + "DKIM record must be a TXT record".to_string(), + )); + }; + let propagation_target = txt_value.clone(); + let publish_result = updater + .set_rrset( + origin, + &record.name, + dns_update::DnsRecordType::TXT, + vec![record.record.clone()], + ) + .await; + let propagation_result = match &publish_result { + Ok(_) => Ok(updater + .wait_for_txt_propagation(&record.name, origin, &propagation_target) + .await), + Err(err) => Err(err.clone()), + }; + match propagation_result { Ok(true) => { let signature_transition = UTCDateTime::from_timestamp((now + dkim.rotate_after.as_secs()) as i64); @@ -354,7 +386,12 @@ async fn dkim_management(server: &Server, task: &TaskDomainManagement) -> trc::R let record = generate_dkim_dns_record_name(&signature.object, &domain.name); if let Some((updater, origin)) = &dns_updater { match updater - .delete(origin, &record, dns_update::DnsRecordType::TXT) + .set_rrset( + origin, + &record, + dns_update::DnsRecordType::TXT, + Vec::new(), + ) .await { Ok(_) => { diff --git a/crates/services/src/task_manager/dns.rs b/crates/services/src/task_manager/dns.rs index f0d5166a..1a2093ba 100644 --- a/crates/services/src/task_manager/dns.rs +++ b/crates/services/src/task_manager/dns.rs @@ -6,11 +6,11 @@ use crate::task_manager::TaskResult; use common::Server; +use dns_update::{DnsRecord, DnsRecordType}; use registry::schema::structs::{ DnsManagement, Domain, Task, TaskDnsManagement, TaskDomainManagement, TaskStatus, }; -use std::fmt::Write; -use store::ahash::AHashSet; +use std::{collections::HashMap, fmt::Write}; pub(crate) trait DnsManagementTask: Sync + Send { fn dns_management(&self, task: &TaskDnsManagement) -> impl Future + Send; @@ -60,20 +60,19 @@ async fn dns_management(server: &Server, task: &TaskDnsManagement) -> trc::Resul .build_dns_records(task.domain_id, &domain, task.update_records.as_slice()) .await?; - // Delete any previous records - let delete_records = records - .iter() - .map(|record| (&record.name, record.record.as_type())) - .collect::>(); - for (name, record_type) in delete_records { - let _ = dns_updater.delete(origin, name, record_type).await; + // Group records by (name, type) so each RRSet is published in one call. + let mut by_owner: HashMap<(String, DnsRecordType), Vec> = HashMap::new(); + for record in records { + by_owner + .entry((record.name, record.record.as_type())) + .or_default() + .push(record.record); } - // Add new records let mut errors = String::new(); - for record in records { + for ((name, record_type), recs) in by_owner { if let Err(err) = dns_updater - .create(origin, &record.name, record.record, false, false) + .set_rrset(origin, &name, record_type, recs) .await { if !errors.is_empty() { @@ -81,8 +80,10 @@ async fn dns_management(server: &Server, task: &TaskDnsManagement) -> trc::Resul } let _ = write!( &mut errors, - "Failed to create DNS record for {}: {}", - record.name, err + "Failed to set DNS RRSet for {}/{}: {}", + name, + record_type.as_str(), + err ); } } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 6ff161e7..d89d90bd 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -81,7 +81,7 @@ rkyv = { version = "0.8.10", features = ["little_endian"] } compact_str = "0.9.0" quick-xml = "0.39" jmap-tools = { version = "0.1" } -dns-update = { version = "0.4.0", features = ["test_provider"] } +dns-update = { path = "/Users/me/code/dns-update", features = ["test_provider"] } x509-parser = "0.18" [target.'cfg(not(target_env = "msvc"))'.dependencies]