Skip to content

Commit 97c3620

Browse files
committed
fix(certbot): fall back to the system resolver when authoritative lookups fail
1 parent b775f51 commit 97c3620

1 file changed

Lines changed: 81 additions & 46 deletions

File tree

dstack/certbot/src/acme_client.rs

Lines changed: 81 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use instant_acme::{
1515
use rcgen::{CertificateParams, DistinguishedName, KeyPair};
1616
use serde::{Deserialize, Serialize};
1717
use std::{
18-
collections::BTreeSet,
18+
collections::{BTreeMap, BTreeSet},
1919
net::SocketAddr,
2020
path::{Path, PathBuf},
2121
time::Duration,
@@ -379,18 +379,11 @@ impl AcmeClient {
379379
/// answered from that cache, so the check can never pass no matter how long
380380
/// it waits. Reading from the authoritative servers removes the cache from
381381
/// the path entirely.
382-
async fn authoritative_resolver(&self, challenges: &[Challenge]) -> Result<TokioResolver> {
383-
let domain = challenges
384-
.first()
385-
.map(|c| c.acme_domain.as_str())
386-
.context("no challenge to resolve")?;
382+
async fn authoritative_resolver(&self, domain: &str) -> Result<TokioResolver> {
387383
// The NS records and the nameservers' own addresses are stable, so the
388384
// system resolver -- caching and all -- is the right tool for finding
389385
// them. Only the challenge record itself must dodge the cache.
390-
let bootstrap = TokioResolver::builder_tokio()
391-
.context("failed to read system dns config")?
392-
.build()
393-
.context("failed to build dns resolver")?;
386+
let bootstrap = system_resolver()?;
394387

395388
// `_acme-challenge.<name>` is almost never a zone cut, and neither is
396389
// the name below it: for `_acme-challenge.a.example.com` the NS records
@@ -443,6 +436,28 @@ impl AcmeClient {
443436

444437
debug!("Unsettled challenges: {unsettled_challenges:#?}");
445438

439+
// Resolve each challenge's nameservers once. A SAN list can span zones,
440+
// so this is keyed per challenge rather than one resolver for all of
441+
// them; and with caching disabled there is nothing to gain by repeating
442+
// discovery on every retry.
443+
let mut resolvers = BTreeMap::new();
444+
for challenge in &unsettled_challenges {
445+
if resolvers.contains_key(&challenge.acme_domain) {
446+
continue;
447+
}
448+
let resolver = match self.authoritative_resolver(&challenge.acme_domain).await {
449+
Ok(resolver) => resolver,
450+
Err(err) => {
451+
warn!(
452+
"no authoritative nameserver for {} ({err:#}), using the system resolver",
453+
challenge.acme_domain
454+
);
455+
system_resolver()?
456+
}
457+
};
458+
resolvers.insert(challenge.acme_domain.clone(), resolver);
459+
}
460+
446461
let start_time = std::time::Instant::now();
447462

448463
'outer: loop {
@@ -457,25 +472,11 @@ impl AcmeClient {
457472
break;
458473
}
459474

460-
let dns_resolver = match self.authoritative_resolver(&unsettled_challenges).await {
461-
Ok(resolver) => resolver,
462-
Err(err) => {
463-
// Falling back to the recursive resolver keeps a zone whose
464-
// NS records we cannot read from blocking issuance outright;
465-
// the ACME server has its own DNS view either way.
466-
warn!(
467-
"failed to reach authoritative nameservers ({err:#}), \
468-
falling back to the system resolver"
469-
);
470-
TokioResolver::builder_tokio()
471-
.context("failed to read system dns config")?
472-
.build()
473-
.context("failed to build dns resolver")?
474-
}
475-
};
476-
477475
while let Some(challenge) = unsettled_challenges.pop() {
478476
let expected_txt = &challenge.dns_value;
477+
let dns_resolver = resolvers
478+
.get(&challenge.acme_domain)
479+
.context("no resolver for challenge domain")?;
479480
let settled = match dns_resolver.txt_lookup(&challenge.acme_domain).await {
480481
Ok(record) => record.answers().iter().any(|answer| {
481482
let RData::TXT(txt) = &answer.data else {
@@ -487,10 +488,23 @@ impl AcmeClient {
487488
}),
488489
Err(err) if err.is_no_records_found() => false,
489490
Err(err) => {
490-
bail!(
491-
"failed to lookup dns record {}: {err}",
491+
// Transport failures land here rather than in the arm
492+
// above: `is_no_records_found` covers only
493+
// `NoRecordsFound`, so a timeout or `NoConnections`
494+
// would otherwise abort issuance outright. The
495+
// authoritative servers may simply be unreachable --
496+
// egress to :53 is often closed inside a CVM, and a
497+
// v6-only NS set fails the same way from a v4-only host.
498+
// Drop back to the system resolver for the rest of this
499+
// wait: the ACME server has its own DNS view, and the
500+
// timeout above already proceeds on expiry.
501+
warn!(
502+
"authoritative lookup for {} failed ({err:#}), falling back to the system resolver",
492503
challenge.acme_domain
493504
);
505+
resolvers.insert(challenge.acme_domain.clone(), system_resolver()?);
506+
unsettled_challenges.push(challenge);
507+
continue 'outer;
494508
}
495509
};
496510
if !settled {
@@ -641,10 +655,22 @@ async fn find_error(order: &mut Order) -> Option<Problem> {
641655
None
642656
}
643657

658+
/// The resolver from `/etc/resolv.conf`, used to find nameservers and as the
659+
/// fallback when the authoritative ones cannot be reached.
660+
fn system_resolver() -> Result<TokioResolver> {
661+
TokioResolver::builder_tokio()
662+
.context("failed to read system dns config")?
663+
.build()
664+
.context("failed to build dns resolver")
665+
}
666+
644667
/// The next name up to try when a name carries no NS records.
645668
///
646-
/// Stops before the public suffix: a TLD's nameservers cannot answer for the
647-
/// challenge record, so there is nothing to gain by querying them.
669+
/// Stops at the last two labels. This is a heuristic, not a public-suffix
670+
/// lookup: under a multi-label suffix such as `co.uk` it can stop on the suffix
671+
/// itself and query the registry's nameservers, which answer with a referral
672+
/// rather than the record. That costs one wasted lookup and then falls back,
673+
/// which is why a PSL dependency is not worth carrying here.
648674
fn parent_zone(name: &str) -> Option<String> {
649675
match name.split_once('.') {
650676
Some((_, parent)) if parent.contains('.') => Some(parent.to_string()),
@@ -767,21 +793,6 @@ mod challenge_parsing_tests {
767793
/// challenge object carries no `token`. Deserializing the authorization must
768794
/// still succeed: `challenges` is one array, so a single unparseable entry
769795
/// used to take the usable `dns-01` challenge down with it.
770-
#[test]
771-
fn ns_discovery_walks_up_to_the_zone_cut() {
772-
use crate::acme_client::parent_zone;
773-
// A challenge name is not a zone cut, so the walk has to climb to the
774-
// registrable name that actually carries the NS records.
775-
assert_eq!(parent_zone("06rc0.kvin.wang").as_deref(), Some("kvin.wang"));
776-
assert_eq!(
777-
parent_zone("a.b.example.com").as_deref(),
778-
Some("b.example.com")
779-
);
780-
// ...but never past it: querying a TLD's nameservers is pointless.
781-
assert_eq!(parent_zone("kvin.wang"), None);
782-
assert_eq!(parent_zone("wang"), None);
783-
}
784-
785796
#[test]
786797
fn an_authorization_survives_a_challenge_type_without_a_token() {
787798
// Shape taken from a real acme-staging-v02 authorization response.
@@ -833,3 +844,27 @@ mod challenge_parsing_tests {
833844
assert_eq!(name, "example.com");
834845
}
835846
}
847+
848+
#[cfg(test)]
849+
mod ns_discovery_tests {
850+
use super::parent_zone;
851+
852+
#[test]
853+
fn the_walk_climbs_to_a_name_that_can_carry_ns_records() {
854+
// A challenge name is not a zone cut, so the walk has to climb to the
855+
// name that actually carries the NS records.
856+
assert_eq!(parent_zone("06rc0.kvin.wang").as_deref(), Some("kvin.wang"));
857+
assert_eq!(
858+
parent_zone("a.b.example.com").as_deref(),
859+
Some("b.example.com")
860+
);
861+
// The walk stops at the last two labels. That is a heuristic, not a
862+
// public-suffix lookup: under a multi-label suffix such as `co.uk` it
863+
// can stop on the suffix itself. In practice the registrable name
864+
// carries NS records and the walk breaks a level earlier, and a
865+
// referral answer just falls back, so this is a stopping rule rather
866+
// than a correctness guarantee.
867+
assert_eq!(parent_zone("kvin.wang"), None);
868+
assert_eq!(parent_zone("wang"), None);
869+
}
870+
}

0 commit comments

Comments
 (0)