@@ -9,12 +9,12 @@ use instant_acme::{
99 Account , AccountCredentials , ChallengeType , Identifier , LetsEncrypt , NewAccount , NewOrder ,
1010 RetryPolicy ,
1111} ;
12- use serde_json ;
12+ use serde :: Deserialize ;
1313use tokio:: {
1414 net:: TcpListener ,
1515 sync:: { mpsc, oneshot} ,
1616} ;
17- use tracing:: { debug, error, info} ;
17+ use tracing:: { debug, error, info, warn } ;
1818
1919use crate :: proto:: AcmeStep ;
2020
@@ -39,6 +39,77 @@ pub struct AcmeCertResult {
3939 pub account_credentials_json : String ,
4040}
4141
42+ /// Minimal subset of the Cloudflare DoH JSON response we care about.
43+ #[ derive( Debug , Deserialize ) ]
44+ struct DohResponse {
45+ /// DNS response code (0 = NOERROR).
46+ #[ serde( rename = "Status" ) ]
47+ status : u32 ,
48+ /// Answer records; may be absent on NXDOMAIN.
49+ #[ serde( rename = "Answer" ) ]
50+ answer : Option < Vec < serde_json:: Value > > ,
51+ }
52+
53+ /// Performs a DNS pre-flight check for `domain` using Cloudflare's DoH endpoint.
54+ ///
55+ /// Returns `Ok(())` if the domain resolves to at least one A or AAAA record.
56+ /// Returns `Err(...)` if the lookup fails or the
57+ /// domain does not resolve.
58+ async fn check_domain_resolves ( domain : & str ) -> anyhow:: Result < ( ) > {
59+ let client = reqwest:: Client :: builder ( )
60+ . timeout ( std:: time:: Duration :: from_secs ( 10 ) )
61+ . build ( )
62+ . context ( "Failed to build HTTP client for DoH pre-flight check" ) ?;
63+
64+ // Try both A and AAAA; succeed as long as either has an answer.
65+ for qtype in & [ "A" , "AAAA" ] {
66+ let url = format ! ( "https://1.1.1.1/dns-query?name={domain}&type={qtype}" ) ;
67+ let response = client
68+ . get ( & url)
69+ . header ( "Accept" , "application/dns-json" )
70+ . send ( )
71+ . await ;
72+
73+ match response {
74+ Ok ( resp) if resp. status ( ) . is_success ( ) => match resp. json :: < DohResponse > ( ) . await {
75+ Ok ( doh) if doh. status == 0 => {
76+ let has_answers = doh. answer . as_ref ( ) . is_some_and ( |a| !a. is_empty ( ) ) ;
77+ if has_answers {
78+ info ! ( "DNS pre-flight: domain '{domain}' resolved ({qtype} record found)" ) ;
79+ return Ok ( ( ) ) ;
80+ }
81+ }
82+ Ok ( doh) => {
83+ debug ! (
84+ "DNS pre-flight: {qtype} lookup for '{domain}' returned status {}" ,
85+ doh. status
86+ ) ;
87+ }
88+ Err ( e) => {
89+ warn ! (
90+ "DNS pre-flight: failed to parse DoH response for '{domain}' ({qtype}): {e}"
91+ ) ;
92+ }
93+ } ,
94+ Ok ( resp) => {
95+ warn ! (
96+ "DNS pre-flight: DoH request for '{domain}' ({qtype}) returned HTTP {}" ,
97+ resp. status( )
98+ ) ;
99+ }
100+ Err ( e) => {
101+ warn ! ( "DNS pre-flight: DoH request for '{domain}' ({qtype}) failed: {e}" ) ;
102+ }
103+ }
104+ }
105+
106+ Err ( anyhow ! (
107+ "Domain '{domain}' does not resolve to any A or AAAA record. \
108+ Make sure your DNS is configured to point '{domain}' to this server's public IP \
109+ address before obtaining a Let's Encrypt certificate."
110+ ) )
111+ }
112+
42113/// Run a full ACME HTTP-01 certificate issuance for the given domain.
43114///
44115/// - If `existing_credentials_json` is non-empty, the ACME account is restored from it.
@@ -51,24 +122,25 @@ pub struct AcmeCertResult {
51122/// the (potentially refreshed) account credentials JSON.
52123pub async fn run_acme_http01 (
53124 domain : String ,
54- use_staging : bool ,
55125 existing_credentials_json : String ,
56126 port80_permit : Option < Port80Permit > ,
57127 progress_tx : mpsc:: UnboundedSender < AcmeStep > ,
58128) -> anyhow:: Result < AcmeCertResult > {
59129 info ! ( "Starting ACME HTTP-01 certificate issuance for domain: {domain}" ) ;
60- let env_label = if use_staging { "staging" } else { "production" } ;
61- info ! ( "Using Let's Encrypt {env_label} environment" ) ;
130+ info ! ( "Using Let's Encrypt production environment" ) ;
131+
132+ // DNS pre-flight: verify the domain resolves before attempting ACME.
133+ let _ = progress_tx. send ( AcmeStep :: CheckingDomain ) ;
134+ info ! ( "DNS pre-flight check for domain: {domain}" ) ;
135+ check_domain_resolves ( & domain) . await ?;
136+
137+ let _ = progress_tx. send ( AcmeStep :: Connecting ) ;
62138
63139 // Restore or create account.
64140 let ( account, credentials) = if existing_credentials_json. is_empty ( ) {
65141 info ! ( "No stored ACME account found; creating a new one with Let's Encrypt" ) ;
66142 let builder = Account :: builder ( ) . context ( "Failed to create ACME account builder" ) ?;
67- let dir_url = if use_staging {
68- LetsEncrypt :: Staging . url ( ) . to_owned ( )
69- } else {
70- LetsEncrypt :: Production . url ( ) . to_owned ( )
71- } ;
143+ let dir_url = LetsEncrypt :: Production . url ( ) . to_owned ( ) ;
72144 info ! ( "Registering account at ACME directory: {dir_url}" ) ;
73145 let ( account, credentials) = builder
74146 . create (
0 commit comments