Skip to content

Commit e0f20d0

Browse files
committed
verify domain before acme
1 parent 6ea9d7f commit e0f20d0

6 files changed

Lines changed: 241 additions & 32 deletions

File tree

Cargo.lock

Lines changed: 147 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ rustls = { version = "0.23", default-features = false, features = [
5959
"aws-lc-rs",
6060
] }
6161
instant-acme = { version = "0.8", features = ["hyper-rustls", "aws-lc-rs"] }
62+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json"] }
6263

6364
[build-dependencies]
6465
tonic-prost-build = "0.14"

src/acme.rs

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
1313
use tokio::{
1414
net::TcpListener,
1515
sync::{mpsc, oneshot},
1616
};
17-
use tracing::{debug, error, info};
17+
use tracing::{debug, error, info, warn};
1818

1919
use 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.
52123
pub 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

Comments
 (0)