@@ -15,7 +15,7 @@ use instant_acme::{
1515use rcgen:: { CertificateParams , DistinguishedName , KeyPair } ;
1616use serde:: { Deserialize , Serialize } ;
1717use 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.
648674fn 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