From a752055542d6ae4fde27b102d0a06290ba4f843d Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 02:50:00 -0700 Subject: [PATCH 01/37] feat(certbot): add the dns-persist-01 validation method dns-01 rewrites the zone on every order, so certbot holds a DNS provider credential for the life of the deployment. dns-persist-01 (draft-ietf-acme-dns-persist-01) proves control with a `_validation-persist` TXT record published once, out of band: the record names the CA and the ACME account, nothing about it changes between orders, and certbot only ever reads DNS. `ValidationMethod` replaces the `Dns01Client` field on `AcmeClient`, so the dns-01-only state -- the provider client and the TXT TTL -- lives in the one variant that has any use for it, and dns-persist-01 cannot be constructed holding a credential it would never call. `check_dns` widens from an exact match on the key authorization to matching whatever the challenge expects, and the cleanup pass skips records certbot did not create: a persistent record is the operator's and outlives every order. The self-check stays advisory for both methods. Our resolver is not the CA's, and under dns-persist-01 our expectation can be stricter than the CA's -- the challenge's `issuer-domain-names` are not exposed by instant-acme -- so a record we cannot see is named in a warning and the order proceeds. CAA content now names the challenge in use, because a record pinned to `validationmethods=dns-01` refuses every dns-persist-01 order. The dns-01 string is unchanged byte for byte, pinned by a test, so records published by earlier releases keep matching. `required_dns_records` renders the whole one-time setup -- validation record plus CAA -- from an account URI rather than a live client, so a caller holding only stored credentials can render it without a round trip to the CA. The record grammar is an RFC 8659 issue-value, and the parser mirrors what CAs run down to the parts that reject rather than ignore (trailing semicolon, repeated tag, whitespace in a value), so certbot never renders a record the CA would refuse or accepts one it would. The gateway keeps its dns-01 behaviour; the call sites move to the new constructor unchanged. --- dstack/certbot/src/acme_client.rs | 659 ++++++++++++++++++---- dstack/certbot/src/bot.rs | 110 ++-- dstack/certbot/src/dns_persist.rs | 399 +++++++++++++ dstack/certbot/src/lib.rs | 7 +- dstack/gateway/src/distributed_certbot.rs | 58 +- 5 files changed, 1050 insertions(+), 183 deletions(-) create mode 100644 dstack/certbot/src/dns_persist.rs diff --git a/dstack/certbot/src/acme_client.rs b/dstack/certbot/src/acme_client.rs index 6288303b9..3cc73ab3d 100644 --- a/dstack/certbot/src/acme_client.rs +++ b/dstack/certbot/src/acme_client.rs @@ -16,6 +16,7 @@ use rcgen::{CertificateParams, DistinguishedName, KeyPair}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, BTreeSet}, + fmt, net::SocketAddr, path::{Path, PathBuf}, time::Duration, @@ -25,23 +26,245 @@ use tracing::{debug, error, info, warn}; use x509_parser::prelude::{GeneralName, Pem}; use super::dns01_client::{Dns01Api, Dns01Client}; +use super::dns_persist::{self, AuthorizationRecord}; use super::http_client::ReqwestHttpClient; +/// The ACME challenge used to prove control of the certificate's domains. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChallengeKind { + /// RFC 8555 `dns-01`. certbot answers every order by writing a TXT record + /// through the DNS provider API, so it needs a credential with write access + /// to the zone. + #[default] + #[serde(rename = "dns-01")] + Dns01, + /// draft-ietf-acme-dns-persist-01 `dns-persist-01`. Control is proven by a + /// `_validation-persist` record published once, out of band; certbot needs no + /// DNS credential and the zone can be hosted anywhere. + /// + /// Experimental — see `docs/certbot-dns-persist-01.md`. + #[serde(rename = "dns-persist-01")] + DnsPersist01, +} + +/// How the client proves control of a domain to the ACME server. +/// +/// The two methods differ in who writes DNS. `dns-01` needs certbot to hold a +/// provider credential with write access to the zone for the lifetime of the +/// deployment; `dns-persist-01` moves that to a one-time record the operator +/// publishes by hand, after which certbot only ever reads DNS. +#[derive(Debug)] +pub enum ValidationMethod { + /// RFC 8555 `dns-01`: certbot publishes a fresh `_acme-challenge` TXT record + /// through the provider API for every order and removes it afterwards. + Dns01 { + /// Provider client with write access to the zone. + client: Dns01Client, + /// TTL of the published records, in seconds (1 = auto, min 60 on Cloudflare). + txt_ttl: u32, + }, + /// draft-ietf-acme-dns-persist-01 `dns-persist-01`: control is proven by a + /// `_validation-persist` TXT record naming the CA and this ACME account, + /// published once and left in place. certbot needs no provider credential, + /// and the zone can be hosted anywhere. + /// + /// Experimental: the draft is still changing and Let's Encrypt serves this + /// challenge on staging only. See `docs/certbot-dns-persist-01.md`. + DnsPersist01 { + /// Issuer Domain Name to name in the record and in CAA records. Must be + /// one of the `issuer-domain-names` the CA sends in the challenge — + /// `letsencrypt.org` for Let's Encrypt. + issuer_domain_name: String, + }, +} + +impl ValidationMethod { + /// Which challenge this method answers. + fn kind(&self) -> ChallengeKind { + match self { + Self::Dns01 { .. } => ChallengeKind::Dns01, + Self::DnsPersist01 { .. } => ChallengeKind::DnsPersist01, + } + } + + /// The challenge type to look for in an authorization. + fn challenge_type(&self) -> ChallengeType { + match self { + Self::Dns01 { .. } => ChallengeType::Dns01, + // instant-acme has no variant for the draft challenge, so it lands in + // `Unknown`. Matching on the wire string is what selects it. + Self::DnsPersist01 { .. } => ChallengeType::Unknown(DNS_PERSIST_01.to_string()), + } + } + + /// Issuer Domain Name to write into CAA records. + fn issuer_domain_name(&self) -> &str { + match self { + // Unconfigurable for dns-01, as it has always been: existing + // deployments have CAA records published under this name. + Self::Dns01 { .. } => dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME, + Self::DnsPersist01 { issuer_domain_name } => issuer_domain_name, + } + } + + /// The provider client, or an error naming why there isn't one. + fn dns01_client(&self) -> Result<&Dns01Client> { + match self { + Self::Dns01 { client, .. } => Ok(client), + Self::DnsPersist01 { .. } => bail!( + "dns-persist-01 holds no DNS provider credential, so certbot cannot write \ + records; publish them out of band (see the `dns-records` command)" + ), + } + } +} + +/// Wire name of the draft challenge, as it appears in the authorization. +const DNS_PERSIST_01: &str = "dns-persist-01"; + +/// A DNS record that has to exist before the CA will issue. +/// +/// Rendered as a zone-file line so it can be pasted into any provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequiredRecord { + /// FQDN the record lives at. + pub name: String, + /// Record type, e.g. `TXT` or `CAA`. + pub record_type: String, + /// Record value, including any CAA flags and tag. + pub content: String, +} + +impl fmt::Display for RequiredRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}. IN {} {}", self.name, self.record_type, self.content) + } +} + +/// The CAA `issue`/`issuewild` value that pins issuance to one account. +/// +/// `validationmethods` names the challenge actually in use: a record left +/// pinned to `dns-01` after switching to `dns-persist-01` refuses every order, +/// and vice versa. +fn caa_content(challenge: ChallengeKind, issuer_domain_name: &str, account_uri: &str) -> String { + let method = match challenge { + ChallengeKind::Dns01 => "dns-01", + ChallengeKind::DnsPersist01 => DNS_PERSIST_01, + }; + format!("{issuer_domain_name};validationmethods={method};accounturi={account_uri}") +} + +/// Every DNS record that has to exist for the CA to issue for `domains`. +/// +/// Under `dns-01` certbot writes these itself and the list is informational. +/// Under `dns-persist-01` it holds no credential and cannot write anything, so +/// this list *is* the one-time setup an operator has to publish by hand. +/// +/// Takes the account URI rather than a client so that callers holding only the +/// stored credentials — an admin listing, say — can render the records without +/// a round trip to the CA. +pub fn required_dns_records( + challenge: ChallengeKind, + issuer_domain_name: &str, + account_uri: &str, + domains: &[String], +) -> Vec { + let caa_content = caa_content(challenge, issuer_domain_name, account_uri); + let mut records = Vec::new(); + for base_name in base_names(domains) { + if challenge == ChallengeKind::DnsPersist01 { + // One record covers the base name and, with the wildcard policy, + // `*.`; only ask for the policy when a wildcard is + // actually requested, so the record grants no more than needed. + let record = AuthorizationRecord { + issuer_domain_name: issuer_domain_name.to_string(), + account_uri: account_uri.to_string(), + wildcard: domains + .iter() + .any(|name| name.strip_prefix("*.") == Some(base_name)), + }; + records.push(RequiredRecord { + name: dns_persist::validation_domain(base_name), + record_type: "TXT".to_string(), + content: format!("\"{}\"", record.rdata()), + }); + } + for tag in ["issue", "issuewild"] { + records.push(RequiredRecord { + name: base_name.to_string(), + record_type: "CAA".to_string(), + content: format!("0 {tag} \"{caa_content}\""), + }); + } + } + records +} + /// A AcmeClient instance. pub struct AcmeClient { account: Account, credentials: Credentials, - dns01_client: Dns01Client, + validation: ValidationMethod, max_dns_wait: Duration, - /// TTL for DNS TXT records used in ACME challenges (in seconds). - dns_txt_ttl: u32, } +/// One pending authorization and the DNS record that answers it. #[derive(Debug, Clone)] struct Challenge { - id: String, + /// Provider-assigned record id, used by the cleanup pass after the order + /// settles. `None` when certbot did not create the record and must not + /// delete it — `dns-persist-01` records belong to the operator. + id: Option, + /// FQDN the TXT record lives at. acme_domain: String, - dns_value: String, + /// What a TXT record there has to say for the CA to accept the challenge. + expected: Expected, +} + +/// The condition a challenge's TXT records have to meet. +#[derive(Debug, Clone)] +enum Expected { + /// `dns-01`: the key authorization digest, matched verbatim. + KeyAuthorization(String), + /// `dns-persist-01`: an issue-value naming our issuer and account. + Authorization(AuthorizationRecord), +} + +impl Expected { + /// Whether the TXT records currently published at the challenge domain answer + /// the challenge. + fn satisfied_by(&self, published: &[String], now: u64) -> bool { + match self { + Self::KeyAuthorization(value) => published.iter().any(|txt| txt == value), + Self::Authorization(record) => record.satisfied_by_any(published, now), + } + } +} + +impl Challenge { + /// A line naming what is missing at the challenge domain, phrased so an + /// operator can act on it: under `dns-persist-01` it is the exact record to + /// publish, under `dns-01` it is the value certbot just wrote. + fn unsettled_hint(&self) -> String { + format!( + "no TXT record at {} matches the expected value: {}", + self.acme_domain, self.expected + ) + } +} + +impl fmt::Display for Expected { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::KeyAuthorization(value) => f.write_str(value), + Self::Authorization(record) => write!(f, "{}", record.rdata()), + } + } +} + +/// Current UNIX time, for comparing against a record's `persistUntil`. +fn now_secs() -> u64 { + time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64 } #[derive(Serialize, Deserialize)] @@ -59,16 +282,26 @@ pub(crate) fn acme_matches(encoded_credentials: &str, acme_url: &str) -> bool { credentials.acme_url == acme_url } +/// The names CAA and `dns-persist-01` records are published under, deduplicated. +/// +/// A wildcard request is authorized from its base name, so `example.com` and +/// `*.example.com` share one set of records. +fn base_names(domains: &[String]) -> BTreeSet<&str> { + domains + .iter() + .map(|name| name.strip_prefix("*.").unwrap_or(name)) + .collect() +} + fn caa_tag(content: &str) -> Option<&str> { content.split_whitespace().nth(1) } impl AcmeClient { pub async fn load( - dns01_client: Dns01Client, + validation: ValidationMethod, encoded_credentials: &str, max_dns_wait: Duration, - dns_txt_ttl: u32, ) -> Result { let credentials: Credentials = serde_json::from_str(encoded_credentials)?; let http_client = Box::new(ReqwestHttpClient::new()?); @@ -78,19 +311,17 @@ impl AcmeClient { let credentials: Credentials = serde_json::from_str(encoded_credentials)?; Ok(Self { account, - dns01_client, + validation, credentials, max_dns_wait, - dns_txt_ttl, }) } /// Create a new account. pub async fn new_account( acme_url: &str, - dns01_client: Dns01Client, + validation: ValidationMethod, max_dns_wait: Duration, - dns_txt_ttl: u32, ) -> Result { let http_client = Box::new(ReqwestHttpClient::new()?); let (account, credentials) = Account::builder_with_http(http_client) @@ -112,10 +343,9 @@ impl AcmeClient { }; Ok(Self { account, - dns01_client, + validation, credentials, max_dns_wait, - dns_txt_ttl, }) } @@ -129,27 +359,46 @@ impl AcmeClient { &self.credentials.account_id } + /// The CAA `issue`/`issuewild` value that pins issuance to this account. + fn caa_content(&self) -> String { + caa_content( + self.validation.kind(), + self.validation.issuer_domain_name(), + self.account_id(), + ) + } + + /// Every DNS record that has to exist for the CA to issue for `domains`. + /// + /// See [`required_dns_records`], which this fills in from the live account. + pub fn required_dns_records(&self, domains: &[String]) -> Vec { + required_dns_records( + self.validation.kind(), + self.validation.issuer_domain_name(), + self.account_id(), + domains, + ) + } + pub async fn set_caa_records(&self, domains: &[String]) -> Result<()> { - let account_id = self.account_id(); - let content = format!("letsencrypt.org;validationmethods=dns-01;accounturi={account_id}"); - let base_names = domains - .iter() - .map(|name| name.strip_prefix("*.").unwrap_or(name)) - .collect::>(); + let dns01_client = self.validation.dns01_client().context( + "cannot set CAA records without DNS write access; publish the records \ + printed by `certbot dns-records` instead", + )?; + let content = self.caa_content(); + let base_names = base_names(domains); for base_name in base_names { // 1. Set ";" to guard timing gap between the operations. debug!("setting guard CAA records for {base_name}"); - let guard0 = self - .dns01_client + let guard0 = dns01_client .add_caa_record(base_name, 0, "issue", ";") .await?; - let guard1 = self - .dns01_client + let guard1 = dns01_client .add_caa_record(base_name, 0, "issuewild", ";") .await?; // 2. Remove the existing constraints - for record in self.dns01_client.get_records(base_name).await? { + for record in dns01_client.get_records(base_name).await? { if record.id == guard0 || record.id == guard1 { continue; } @@ -161,22 +410,22 @@ impl AcmeClient { "removing existing issuer CAA record {} {}", record.name, record.content ); - self.dns01_client.remove_record(&record.id).await?; + dns01_client.remove_record(&record.id).await?; } } // 3. Set the new constraints debug!("setting CAA records for {base_name}, 0 issue \"{content}\""); - self.dns01_client + dns01_client .add_caa_record(base_name, 0, "issue", &content) .await?; debug!("setting CAA records for {base_name}, 0 issuewild \"{content}\""); - self.dns01_client + dns01_client .add_caa_record(base_name, 0, "issuewild", &content) .await?; debug!("removing guard CAA records for {base_name}"); // 4. Remove the guards - self.dns01_client.remove_record(&guard0).await?; - self.dns01_client.remove_record(&guard1).await?; + dns01_client.remove_record(&guard0).await?; + dns01_client.remove_record(&guard1).await?; } Ok(()) } @@ -190,10 +439,18 @@ impl AcmeClient { let result = self .request_new_certificate_inner(key, domains, &mut challenges) .await; - for challenge in &challenges { - debug!("removing dns record {}", challenge.id); - if let Err(err) = self.dns01_client.remove_record(&challenge.id).await { - error!("failed to remove dns record {}: {err}", challenge.id); + // Only records certbot created are cleaned up. A dns-persist-01 record + // is the operator's and outlives every order, so it carries no id. + for id in challenges + .iter() + .filter_map(|challenge| challenge.id.as_ref()) + { + let Ok(dns01_client) = self.validation.dns01_client() else { + break; + }; + debug!("removing dns record {id}"); + if let Err(err) = dns01_client.remove_record(id).await { + error!("failed to remove dns record {id}: {err}"); } } result @@ -347,6 +604,7 @@ impl AcmeClient { impl AcmeClient { async fn authorize(&self, order: &mut Order, challenges: &mut Vec) -> Result<()> { + let challenge_type = self.validation.challenge_type(); let mut authorizations = order.authorizations(); while let Some(authz) = authorizations.next().await { let mut authz = authz.context("failed to get authorizations")?; @@ -356,42 +614,61 @@ impl AcmeClient { _ => bail!("unsupported authorization status: {:?}", authz.status), } + // Read before taking the challenge handle, which borrows the + // authorization for the rest of the iteration. + let wildcard = authz.wildcard; let challenge = authz - .challenge(ChallengeType::Dns01) - .context("no dns01 challenge found")?; - - let acme_domain = challenge_domain(challenge.identifier())?; - let dns_value = challenge.key_authorization().dns_value(); - // Clearing stale records is a per-name preparation step, not a - // per-authorization one. An order for `example.com` and - // `*.example.com` yields two authorizations that are both answered - // under `_acme-challenge.example.com`, each with its own value, and - // both values have to be live at validation time. Purging again for - // the second authorization would delete the record the first one - // just published, so one of the two challenges could never be - // answered and the order failed with "Correct value not found for - // DNS challenge". - if needs_purge(challenges, &acme_domain) { - debug!("removing existing TXT records for {acme_domain}"); - self.dns01_client - .remove_txt_records(&acme_domain) - .await - .context("failed to remove existing dns record")?; - } - debug!( - "creating TXT record for {acme_domain} with TTL {}s", - self.dns_txt_ttl - ); - let id = self - .dns01_client - .add_txt_record(&acme_domain, &dns_value, self.dns_txt_ttl) - .await - .context("failed to create dns record")?; - challenges.push(Challenge { - id, - acme_domain, - dns_value, - }); + .challenge(challenge_type.clone()) + .with_context(|| format!("no {challenge_type:?} challenge found"))?; + + let acme_domain = challenge_domain(self.validation.kind(), challenge.identifier())?; + let challenge = match &self.validation { + ValidationMethod::Dns01 { + client, txt_ttl, .. + } => { + let dns_value = challenge.key_authorization().dns_value(); + // Clearing stale records is a per-name preparation step, not a + // per-authorization one. An order for `example.com` and + // `*.example.com` yields two authorizations that are both answered + // under `_acme-challenge.example.com`, each with its own value, and + // both values have to be live at validation time. Purging again for + // the second authorization would delete the record the first one + // just published, so one of the two challenges could never be + // answered and the order failed with "Correct value not found for + // DNS challenge". + if needs_purge(challenges, &acme_domain) { + debug!("removing existing TXT records for {acme_domain}"); + client + .remove_txt_records(&acme_domain) + .await + .context("failed to remove existing dns record")?; + } + debug!("creating TXT record for {acme_domain} with TTL {txt_ttl}s"); + let id = client + .add_txt_record(&acme_domain, &dns_value, *txt_ttl) + .await + .context("failed to create dns record")?; + Challenge { + id: Some(id), + acme_domain, + expected: Expected::KeyAuthorization(dns_value), + } + } + // Nothing to publish: the record is already in the zone, or the + // order is about to fail and say so. There is likewise nothing + // to purge -- the record is the operator's, and one persistent + // record answers every authorization under the name. + ValidationMethod::DnsPersist01 { issuer_domain_name } => Challenge { + id: None, + acme_domain, + expected: Expected::Authorization(AuthorizationRecord { + issuer_domain_name: issuer_domain_name.clone(), + account_uri: self.account_id().to_string(), + wildcard, + }), + }, + }; + challenges.push(challenge); } Ok(()) } @@ -416,10 +693,10 @@ impl AcmeClient { // the name below it: for `_acme-challenge.a.example.com` the NS records // usually live on `example.com`. Querying the full name returns NODATA, // so walk up a label at a time until a name actually carries NS records. - let mut candidate = domain - .strip_prefix("_acme-challenge.") - .unwrap_or(domain) - .to_string(); + // The leading label is dropped for any challenge prefix -- `_acme-challenge` + // for dns-01, `_validation-persist` for dns-persist-01 -- since an + // underscore label never carries NS records. + let mut candidate = strip_challenge_label(domain).to_string(); let mut addrs = Vec::new(); let mut zone = candidate.clone(); loop { @@ -452,7 +729,13 @@ impl AcmeClient { resolver_for(&addrs) } - /// Self check the TXT records for the given challenges. + /// Wait until every challenge's records are visible to us. + /// + /// Advisory, not a gate: our resolver is not the CA's, and under + /// `dns-persist-01` our expectation can even be stricter than the CA's -- + /// the `issuer-domain-names` it would accept are not exposed by + /// instant-acme. A record we never see is reported and the order proceeds, + /// letting the CA decide. async fn check_dns(&self, challenges: &[Challenge]) -> Result<()> { let mut delay = Duration::from_millis(250); let mut tries = 1u8; @@ -500,24 +783,26 @@ impl AcmeClient { "DNS propagation timeout after {elapsed:?}, max wait time is {max:?}. proceeding anyway as ACME server may have different DNS view", max = self.max_dns_wait ); + for challenge in &unsettled_challenges { + warn!("{}", challenge.unsettled_hint()); + } break; } while let Some(challenge) = unsettled_challenges.pop() { - let expected_txt = &challenge.dns_value; let dns_resolver = resolvers .get(&challenge.acme_domain) .context("no resolver for challenge domain")?; - let settled = match dns_resolver.txt_lookup(&challenge.acme_domain).await { - Ok(record) => record.answers().iter().any(|answer| { - let RData::TXT(txt) = &answer.data else { - return false; - }; - let actual_txt = txt.to_string(); - debug!("Expected challenge: {expected_txt}, actual: {actual_txt}"); - actual_txt == *expected_txt - }), - Err(err) if err.is_no_records_found() => false, + let published = match dns_resolver.txt_lookup(&challenge.acme_domain).await { + Ok(records) => records + .answers() + .iter() + .filter_map(|answer| match &answer.data { + RData::TXT(txt) => Some(txt.to_string()), + _ => None, + }) + .collect::>(), + Err(err) if err.is_no_records_found() => Vec::new(), Err(err) if !fell_back.contains(&challenge.acme_domain) => { // Transport failures land here rather than in the arm // above: `is_no_records_found` covers only @@ -548,10 +833,14 @@ impl AcmeClient { domain = &challenge.acme_domain, "dns lookup failed: {err:#}" ); - false + Vec::new() } }; - if !settled { + debug!( + "Expected challenge: {}, actual: {published:?}", + challenge.expected + ); + if !challenge.expected.satisfied_by(&published, now_secs()) { delay = Duration::from_secs(32).min(delay * 2); tries += 1; debug!( @@ -570,13 +859,14 @@ impl AcmeClient { Ok(()) } - /// Tell the ACME server every pending dns-01 challenge is answerable. + /// Tell the ACME server every pending challenge is answerable. /// - /// The TXT records are published first and verified for propagation, so this - /// is a second pass over the same authorizations: 0.8 dropped - /// `Order::set_challenge_ready(url)`, and `ChallengeHandle::set_ready()` - /// borrows the order, so the handle cannot be held across the DNS wait. + /// The DNS records are published and checked first, so this is a second pass + /// over the same authorizations: 0.8 dropped `Order::set_challenge_ready(url)`, + /// and `ChallengeHandle::set_ready()` borrows the order, so the handle cannot + /// be held across the DNS wait. async fn set_challenges_ready(&self, order: &mut Order) -> Result<()> { + let challenge_type = self.validation.challenge_type(); let mut authorizations = order.authorizations(); while let Some(authz) = authorizations.next().await { let mut authz = authz.context("failed to get authorizations")?; @@ -584,8 +874,8 @@ impl AcmeClient { continue; } let mut challenge = authz - .challenge(ChallengeType::Dns01) - .context("no dns01 challenge found")?; + .challenge(challenge_type.clone()) + .with_context(|| format!("no {challenge_type:?} challenge found"))?; debug!("setting challenge ready for {}", challenge.url); challenge .set_ready() @@ -695,17 +985,36 @@ fn needs_purge(published: &[Challenge], acme_domain: &str) -> bool { .any(|challenge| challenge.acme_domain == acme_domain) } -/// The name of the TXT record that answers a dns-01 challenge for `identifier`. +/// The name of the TXT record that answers `challenge`'s validation for `identifier`. /// /// The record always lives under the bare name: a wildcard authorization for -/// `*.example.com` is answered at `_acme-challenge.example.com`. `AuthorizedIdentifier` +/// `*.example.com` is answered at `_acme-challenge.example.com`, and at +/// `_validation-persist.example.com` under dns-persist-01. `AuthorizedIdentifier` /// renders the wildcard prefix in its `Display`, so formatting it directly would publish /// the record at `_acme-challenge.*.example.com` and fail every wildcard issuance. -fn challenge_domain(identifier: &AuthorizedIdentifier<'_>) -> Result { +fn challenge_domain( + challenge: ChallengeKind, + identifier: &AuthorizedIdentifier<'_>, +) -> Result { let Identifier::Dns(name) = identifier.identifier else { bail!("unsupported identifier type in authorization: {identifier}"); }; - Ok(format!("_acme-challenge.{name}")) + Ok(match challenge { + ChallengeKind::Dns01 => format!("_acme-challenge.{name}"), + ChallengeKind::DnsPersist01 => dns_persist::validation_domain(name), + }) +} + +/// Drop a leading underscore label, so the zone walk starts at a real name. +/// +/// Challenge records live under a reserved label -- `_acme-challenge` for +/// dns-01, `_validation-persist` for dns-persist-01 -- which never carries NS +/// records, so querying it only costs a round trip. +fn strip_challenge_label(domain: &str) -> &str { + match domain.starts_with('_') { + true => domain.split_once('.').map_or(domain, |(_, rest)| rest), + false => domain, + } } async fn find_error(order: &mut Order) -> Result> { @@ -952,6 +1261,23 @@ mod challenge_parsing_tests { mod ns_discovery_tests { use super::parent_zone; + #[test] + fn a_challenge_label_is_dropped_before_the_walk_starts() { + use super::strip_challenge_label; + + // Both challenge methods put their record under a reserved underscore + // label, and neither label can carry NS records. + assert_eq!( + strip_challenge_label("_acme-challenge.example.com"), + "example.com" + ); + assert_eq!( + strip_challenge_label("_validation-persist.example.com"), + "example.com" + ); + assert_eq!(strip_challenge_label("example.com"), "example.com"); + } + #[test] fn the_walk_climbs_to_a_name_that_can_carry_ns_records() { // A challenge name is not a zone cut, so the walk has to climb to the @@ -974,43 +1300,156 @@ mod ns_discovery_tests { #[cfg(test)] mod challenge_domain_tests { - use super::challenge_domain; + use super::{challenge_domain, ChallengeKind}; use instant_acme::Identifier; /// A wildcard order authorizes the bare name with `wildcard: true`, and the TXT record /// answering it must be published under that bare name. Formatting the identifier /// through its `Display` instead would ask for `_acme-challenge.*.example.com`. + /// dns-persist-01 answers the same authorization under its own label, and the + /// wildcard prefix must not leak into that name either. #[test] fn the_challenge_domain_never_carries_a_wildcard_prefix() { let dns = Identifier::Dns("example.com".to_string()); + for wildcard in [false, true] { + assert_eq!( + challenge_domain(ChallengeKind::Dns01, &dns.authorized(wildcard)).unwrap(), + "_acme-challenge.example.com", + "wildcard={wildcard}" + ); + assert_eq!( + challenge_domain(ChallengeKind::DnsPersist01, &dns.authorized(wildcard)).unwrap(), + "_validation-persist.example.com", + "wildcard={wildcard}" + ); + } + } + + /// Both methods are only defined for DNS identifiers; anything else is a bug in the + /// order we built, so it must be an error rather than a nonsensical record name. + #[test] + fn a_non_dns_identifier_is_rejected() { + let ip = Identifier::Ip("192.0.2.1".parse().unwrap()); + assert!(challenge_domain(ChallengeKind::Dns01, &ip.authorized(false)).is_err()); + assert!(challenge_domain(ChallengeKind::DnsPersist01, &ip.authorized(false)).is_err()); + } +} + +#[cfg(test)] +mod required_record_tests { + use super::*; + + const ACCOUNT: &str = "https://acme-v02.api.letsencrypt.org/acme/acct/1234567890"; + + fn records(challenge: ChallengeKind, domains: &[&str]) -> Vec { + required_dns_records( + challenge, + dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME, + ACCOUNT, + &domains.iter().map(|d| d.to_string()).collect::>(), + ) + .iter() + .map(ToString::to_string) + .collect() + } + + #[test] + fn dns01_caa_content_is_byte_identical_to_the_pinned_format() { + // CAA values published by earlier releases have to keep matching, or + // issuance stops the moment this string drifts. assert_eq!( - challenge_domain(&dns.authorized(false)).unwrap(), - "_acme-challenge.example.com" + caa_content( + ChallengeKind::Dns01, + dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME, + ACCOUNT + ), + format!("letsencrypt.org;validationmethods=dns-01;accounturi={ACCOUNT}") ); + } + + #[test] + fn caa_content_names_the_challenge_in_use() { + // A CAA record still pinned to dns-01 refuses every dns-persist-01 order. + assert!(caa_content( + ChallengeKind::DnsPersist01, + dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME, + ACCOUNT + ) + .contains("validationmethods=dns-persist-01")); + } + + #[test] + fn dns01_needs_no_validation_record() { + // certbot writes the `_acme-challenge` record itself, per order. assert_eq!( - challenge_domain(&dns.authorized(true)).unwrap(), - "_acme-challenge.example.com" + records(ChallengeKind::Dns01, &["*.example.com"]), + vec![ + format!( + "example.com. IN CAA 0 issue \"letsencrypt.org;validationmethods=dns-01;accounturi={ACCOUNT}\"" + ), + format!( + "example.com. IN CAA 0 issuewild \"letsencrypt.org;validationmethods=dns-01;accounturi={ACCOUNT}\"" + ), + ] ); } - /// dns-01 is only defined for DNS identifiers; anything else is a bug in the order we - /// built, so it must be an error rather than a nonsensical record name. #[test] - fn a_non_dns_identifier_is_rejected() { - let ip = Identifier::Ip("192.0.2.1".parse().unwrap()); - assert!(challenge_domain(&ip.authorized(false)).is_err()); + fn dns_persist_asks_for_the_wildcard_policy_only_when_a_wildcard_is_ordered() { + let plain = records(ChallengeKind::DnsPersist01, &["example.com"]); + assert_eq!( + plain[0], + format!( + "_validation-persist.example.com. IN TXT \"letsencrypt.org; accounturi={ACCOUNT}\"" + ) + ); + + let wildcard = records(ChallengeKind::DnsPersist01, &["*.example.com"]); + assert_eq!( + wildcard[0], + format!( + "_validation-persist.example.com. IN TXT \"letsencrypt.org; accounturi={ACCOUNT}; policy=wildcard\"" + ) + ); + } + + #[test] + fn a_name_and_its_wildcard_share_one_validation_record() { + // Both identifiers authorize from the same base name, so asking the + // operator for two records would be asking for one too many. + let records = records( + ChallengeKind::DnsPersist01, + &["example.com", "*.example.com"], + ); + assert_eq!( + records.iter().filter(|r| r.contains(" TXT ")).count(), + 1, + "{records:#?}" + ); + assert!(records[0].contains("policy=wildcard"), "{records:#?}"); + } + + #[test] + fn each_base_name_gets_its_own_records() { + let records = records( + ChallengeKind::DnsPersist01, + &["*.a.example.com", "*.b.example.com"], + ); + assert_eq!(records.len(), 6, "{records:#?}"); + assert!(records[0].starts_with("_validation-persist.a.example.com.")); + assert!(records[3].starts_with("_validation-persist.b.example.com.")); } } #[cfg(test)] mod purge_tests { - use super::{needs_purge, Challenge}; + use super::{needs_purge, Challenge, Expected}; fn challenge(acme_domain: &str, dns_value: &str) -> Challenge { Challenge { - id: format!("rec-{dns_value}"), + id: Some(format!("rec-{dns_value}")), acme_domain: acme_domain.to_string(), - dns_value: dns_value.to_string(), + expected: Expected::KeyAuthorization(dns_value.to_string()), } } diff --git a/dstack/certbot/src/bot.rs b/dstack/certbot/src/bot.rs index 0daf499e3..30718d02d 100644 --- a/dstack/certbot/src/bot.rs +++ b/dstack/certbot/src/bot.rs @@ -9,12 +9,13 @@ use std::{ time::Duration, }; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use fs_err as fs; use tokio::time::sleep; -use tracing::{error, info}; +use tracing::{error, info, warn}; -use crate::acme_client::{acme_matches, read_pem}; +use crate::acme_client::{acme_matches, read_pem, ChallengeKind, RequiredRecord, ValidationMethod}; +use crate::dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME; use super::{AcmeClient, Dns01Client}; @@ -27,6 +28,15 @@ pub struct CertBotConfig { auto_set_caa: bool, credentials_file: PathBuf, auto_create_account: bool, + /// ACME challenge used to prove control of the domains. + #[builder(default)] + challenge: ChallengeKind, + /// Issuer Domain Name naming the CA in `dns-persist-01` and CAA records. + /// + /// Must be one of the `issuer-domain-names` the CA sends in the challenge. + #[builder(default = LETS_ENCRYPT_ISSUER_DOMAIN_NAME.to_string())] + issuer_domain_name: String, + /// Cloudflare API token. Unused, and warned about, under `dns-persist-01`. cf_api_token: String, cf_api_url: Option, cert_file: PathBuf, @@ -57,17 +67,12 @@ pub struct CertBot { async fn create_new_account( config: &CertBotConfig, - dns01_client: Dns01Client, + validation: ValidationMethod, ) -> Result { info!("creating new ACME account"); - let client = AcmeClient::new_account( - &config.acme_url, - dns01_client, - config.max_dns_wait, - config.dns_txt_ttl, - ) - .await - .context("failed to create new account")?; + let client = AcmeClient::new_account(&config.acme_url, validation, config.max_dns_wait) + .await + .context("failed to create new account")?; let credentials = client .dump_credentials() .context("failed to dump credentials")?; @@ -87,39 +92,20 @@ async fn create_new_account( impl CertBot { /// Build a new `CertBot` from a `CertBotConfig`. pub async fn build(config: CertBotConfig) -> Result { - let base_domain = config - .cert_subject_alt_names - .first() - .context("cert_subject_alt_names is empty")? - .trim() - .trim_start_matches("*.") - .trim_end_matches('.') - .to_string(); - let dns01_client = Dns01Client::new_cloudflare( - base_domain, - config.cf_api_token.clone(), - config.cf_api_url.clone(), - ) - .await?; + let validation = build_validation_method(&config).await?; let acme_client = match fs::read_to_string(&config.credentials_file) { Ok(credentials) => { if acme_matches(&credentials, &config.acme_url) { - AcmeClient::load( - dns01_client, - &credentials, - config.max_dns_wait, - config.dns_txt_ttl, - ) - .await? + AcmeClient::load(validation, &credentials, config.max_dns_wait).await? } else { - create_new_account(&config, dns01_client).await? + create_new_account(&config, validation).await? } } Err(e) if e.kind() == ErrorKind::NotFound => { if !config.auto_create_account { return Err(e).context("credentials file not found"); } - create_new_account(&config, dns01_client).await? + create_new_account(&config, validation).await? } Err(e) => { return Err(e).context("failed to read credentials file"); @@ -262,6 +248,60 @@ impl CertBot { .set_caa_records(&self.config.cert_subject_alt_names) .await } + + /// The DNS records that have to exist for the configured domains. + pub fn required_dns_records(&self) -> Vec { + self.acme_client + .required_dns_records(&self.config.cert_subject_alt_names) + } +} + +/// Resolve the configured challenge into a live validation method. +/// +/// `dns-01` resolves the Cloudflare zone here, which is an authenticated call, +/// so a bad credential fails at startup rather than at the first renewal. +/// `dns-persist-01` talks to no provider at all. +async fn build_validation_method(config: &CertBotConfig) -> Result { + match config.challenge { + ChallengeKind::Dns01 => { + let base_domain = config + .cert_subject_alt_names + .first() + .context("cert_subject_alt_names is empty")? + .trim() + .trim_start_matches("*.") + .trim_end_matches('.') + .to_string(); + let client = Dns01Client::new_cloudflare( + base_domain, + config.cf_api_token.clone(), + config.cf_api_url.clone(), + ) + .await?; + Ok(ValidationMethod::Dns01 { + client, + txt_ttl: config.dns_txt_ttl, + }) + } + ChallengeKind::DnsPersist01 => { + // Refuse rather than silently skip: `auto_set_caa` promises the CAA + // records are kept in sync, and without DNS write access nothing here + // can keep that promise. `certbot dns-records` prints what to publish. + if config.auto_set_caa { + bail!( + "auto_set_caa is not supported with dns-persist-01, which has no DNS \ + write access; set auto_set_caa = false and publish the records from \ + `certbot dns-records` by hand" + ); + } + if !config.cf_api_token.is_empty() { + warn!("ignoring cf_api_token: dns-persist-01 needs no DNS provider credential"); + } + Ok(ValidationMethod::DnsPersist01 { + issuer_domain_name: config.issuer_domain_name.clone(), + }) + } + } } pub fn read_pubkey(cert_pem: &str) -> Result> { diff --git a/dstack/certbot/src/dns_persist.rs b/dstack/certbot/src/dns_persist.rs new file mode 100644 index 000000000..4712a4901 --- /dev/null +++ b/dstack/certbot/src/dns_persist.rs @@ -0,0 +1,399 @@ +// SPDX-FileCopyrightText: © 2026 Phala Network +// +// SPDX-License-Identifier: Apache-2.0 + +//! The persistent DNS authorization record used by the `dns-persist-01` challenge. +//! +//! `dns-persist-01` (draft-ietf-acme-dns-persist-01) replaces the per-order +//! `_acme-challenge` TXT record of `dns-01` with a single record that stays in the +//! zone: `_validation-persist.`, naming the CA and the ACME account allowed +//! to issue for that name. The account key proves who is asking; the record proves +//! the zone owner agreed. Nothing about it changes between orders, so a client that +//! uses this method never needs write access to the zone. +//! +//! This module owns the record's syntax: rendering the line an operator has to +//! publish, and deciding whether what is currently published would satisfy the CA. +//! The RDATA is an RFC 8659 `issue-value` — the same grammar as a CAA `issue` +//! record — so the parser here mirrors the one CAs run (see `va/dns_persist.go` in +//! Boulder), including the parts that reject rather than ignore: a trailing +//! semicolon, a repeated tag, whitespace inside a value. + +use std::fmt; + +use anyhow::{bail, Context, Result}; + +/// Label prepended to the name being validated to form the validation domain name. +/// +/// draft-ietf-acme-dns-persist-01, section 4. +const VALIDATION_LABEL: &str = "_validation-persist"; + +/// Issuer Domain Name for Let's Encrypt, matching the `caaIdentities` it advertises. +/// +/// A CA lists the names it answers to in the challenge object's +/// `issuer-domain-names`; a record naming anything else is ignored by that CA. +pub const LETS_ENCRYPT_ISSUER_DOMAIN_NAME: &str = "letsencrypt.org"; + +/// The `policy` value that widens a record to cover wildcards. +const POLICY_WILDCARD: &str = "wildcard"; + +/// The validation domain name for `name`, where the CA looks for the TXT record. +/// +/// A wildcard request is authorized by the record on its base name: ACME strips the +/// `*.` before creating the authorization, so `*.example.com` and `example.com` +/// share `_validation-persist.example.com` and are told apart by `policy=wildcard`. +pub fn validation_domain(name: &str) -> String { + let base = name.strip_prefix("*.").unwrap_or(name); + format!("{VALIDATION_LABEL}.{base}") +} + +/// The record an operator has to publish for one name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationRecord { + /// Issuer Domain Name of the CA this record authorizes. + pub issuer_domain_name: String, + /// URI of the ACME account allowed to issue, compared byte for byte by the CA. + pub account_uri: String, + /// Whether the record also covers `*.`. + pub wildcard: bool, +} + +impl AuthorizationRecord { + /// The TXT RDATA to publish, as a single string. + /// + /// Rendered without a trailing semicolon: CAs read the RDATA as an RFC 8659 + /// `issue-value`, where a trailing semicolon is an empty parameter and makes + /// the whole record malformed. + pub fn rdata(&self) -> String { + let mut rdata = format!( + "{}; accounturi={}", + self.issuer_domain_name, self.account_uri + ); + if self.wildcard { + rdata.push_str("; policy=wildcard"); + } + rdata + } + + /// Whether `rdata` currently published at the validation domain satisfies this + /// record's requirement, as of `now` (UNIX seconds). + /// + /// Follows the CA's own filter: a record naming a different issuer is not a + /// failure, it belongs to another CA and is skipped. Only a record that names + /// our issuer is held to the account, policy and lifetime checks. + fn satisfied_by(&self, rdata: &str, now: u64) -> bool { + let Ok(parsed) = IssueValue::parse(rdata) else { + return false; + }; + if parsed.issuer_domain_name != self.issuer_domain_name { + return false; + } + if parsed.account_uri != self.account_uri { + return false; + } + if parsed.persist_until.is_some_and(|until| now > until) { + return false; + } + // A record without `policy=wildcard` authorizes the exact name only, so it + // cannot stand in for a wildcard request. The reverse is fine: a wildcard + // record also covers the name itself. + !self.wildcard || parsed.policy.as_deref().is_some_and(is_wildcard_policy) + } + + /// Whether any of the TXT records at the validation domain satisfies this one. + /// + /// Several records may sit at the same label, one per CA or per account, and + /// the CA accepts the name if any single record passes. + pub fn satisfied_by_any(&self, published: &[String], now: u64) -> bool { + published.iter().any(|rdata| self.satisfied_by(rdata, now)) + } +} + +impl fmt::Display for AuthorizationRecord { + /// Renders the full zone-file line, ready to paste into a DNS provider. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "IN TXT \"{}\"", self.rdata()) + } +} + +/// A parsed RFC 8659 `issue-value`, the grammar shared with CAA `issue` records. +#[derive(Debug, PartialEq, Eq)] +struct IssueValue { + issuer_domain_name: String, + account_uri: String, + policy: Option, + /// UNIX timestamp after which the CA stops accepting the record. + persist_until: Option, +} + +impl IssueValue { + fn parse(rdata: &str) -> Result { + let mut parts = rdata.split(';'); + let issuer_domain_name = trim_wsp(parts.next().unwrap_or_default()); + if issuer_domain_name.is_empty() { + bail!("missing issuer domain name"); + } + + let mut account_uri = None; + let mut policy = None; + let mut persist_until = None; + let mut seen = Vec::new(); + for part in parts { + let part = trim_wsp(part); + // An empty parameter means a doubled or trailing semicolon. CAs treat + // that as malformed rather than skipping it, so neither do we. + if part.is_empty() { + bail!("empty parameter or trailing semicolon"); + } + let (tag, value) = part.split_once('=').context("parameter is not tag=value")?; + // RFC 8659 matches tags case-insensitively; values are not folded. + let tag = tag.to_lowercase(); + if !value.bytes().all(is_value_byte) { + bail!("parameter {tag} has a value with a forbidden character"); + } + if seen.contains(&tag) { + bail!("duplicate parameter {tag}"); + } + seen.push(tag.clone()); + match tag.as_str() { + "accounturi" => account_uri = Some(value.to_string()), + "policy" => policy = Some(value.to_string()), + "persistuntil" => { + persist_until = Some( + value + .parse::() + .context("persistUntil is not a base-10 timestamp")?, + ) + } + // The draft requires unrecognized tags to be ignored, so that + // later revisions can add parameters without invalidating records. + _ => {} + } + } + + Ok(Self { + issuer_domain_name: issuer_domain_name.to_string(), + account_uri: account_uri.context("missing mandatory accounturi parameter")?, + policy, + persist_until, + }) + } +} + +fn is_wildcard_policy(policy: &str) -> bool { + policy.eq_ignore_ascii_case(POLICY_WILDCARD) +} + +/// Trim the whitespace RFC 8659 allows around the issuer name and each parameter. +fn trim_wsp(part: &str) -> &str { + part.trim_matches([' ', '\t']) +} + +/// Whether a byte may appear in a parameter value. +/// +/// RFC 8659 allows printable ASCII except `;`, which excludes whitespace: a value +/// containing a space is a malformed record, not a value with a space in it. +fn is_value_byte(byte: u8) -> bool { + matches!(byte, 0x21..=0x3a | 0x3c..=0x7e) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An arbitrary "now" that precedes every `persistUntil` used below. + const NOW: u64 = 1_700_000_000; + const ACCOUNT: &str = "https://acme-v02.api.letsencrypt.org/acme/acct/1234567890"; + + fn record(wildcard: bool) -> AuthorizationRecord { + AuthorizationRecord { + issuer_domain_name: LETS_ENCRYPT_ISSUER_DOMAIN_NAME.to_string(), + account_uri: ACCOUNT.to_string(), + wildcard, + } + } + + /// Whether `published` satisfies a request for `name`, wildcard or not. + fn accepts(wildcard: bool, published: &str) -> bool { + record(wildcard).satisfied_by(published, NOW) + } + + #[test] + fn validation_domain_prepends_the_label() { + assert_eq!( + validation_domain("example.com"), + "_validation-persist.example.com" + ); + } + + #[test] + fn validation_domain_strips_the_wildcard_prefix() { + // The CA looks up the base name for a wildcard authorization, so a record + // at `_validation-persist.*.example.com` would never be read. + assert_eq!( + validation_domain("*.example.com"), + "_validation-persist.example.com" + ); + } + + #[test] + fn rdata_has_no_trailing_semicolon() { + assert_eq!( + record(false).rdata(), + format!("letsencrypt.org; accounturi={ACCOUNT}") + ); + } + + #[test] + fn rdata_carries_the_wildcard_policy() { + assert_eq!( + record(true).rdata(), + format!("letsencrypt.org; accounturi={ACCOUNT}; policy=wildcard") + ); + } + + #[test] + fn display_renders_a_zone_file_line() { + assert_eq!( + record(false).to_string(), + format!("IN TXT \"letsencrypt.org; accounturi={ACCOUNT}\"") + ); + } + + #[test] + fn rendered_record_parses_back() { + for wildcard in [false, true] { + assert!( + accepts(wildcard, &record(wildcard).rdata()), + "wildcard={wildcard}" + ); + } + } + + #[test] + fn accepts_the_draft_example_layout() { + // draft-ietf-acme-dns-persist-01, figure 2, plus the whitespace RFC 8659 + // allows around the issuer name and each parameter. + let record = AuthorizationRecord { + issuer_domain_name: "authority.example".to_string(), + account_uri: "https://ca.example/acct/123".to_string(), + wildcard: false, + }; + for published in [ + "authority.example; accounturi=https://ca.example/acct/123", + "authority.example;accounturi=https://ca.example/acct/123", + "\tauthority.example ;\taccounturi=https://ca.example/acct/123 ", + ] { + assert!(record.satisfied_by(published, NOW), "{published:?}"); + } + } + + #[test] + fn matches_tags_case_insensitively() { + assert!(accepts( + true, + &format!("letsencrypt.org; AccountURI={ACCOUNT}; Policy=WILDCARD") + )); + } + + #[test] + fn rejects_a_different_account_uri() { + assert!(!accepts( + false, + "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/9" + )); + } + + #[test] + fn compares_the_account_uri_without_case_folding() { + // The draft pins Simple String Comparison, so an upper-cased URI is a + // different account as far as the CA is concerned. + assert!(!accepts( + false, + &format!("letsencrypt.org; accounturi={}", ACCOUNT.to_uppercase()) + )); + } + + #[test] + fn ignores_a_record_naming_another_issuer() { + assert!(!accepts( + false, + &format!("otherca.example; accounturi={ACCOUNT}") + )); + } + + #[test] + fn rejects_a_plain_record_for_a_wildcard_request() { + let published = record(false).rdata(); + assert!(accepts(false, &published)); + assert!(!accepts(true, &published)); + } + + #[test] + fn accepts_a_wildcard_record_for_a_plain_request() { + assert!(accepts(false, &record(true).rdata())); + } + + #[test] + fn rejects_a_trailing_semicolon() { + // A CA reads the empty tail as an empty parameter and fails the whole + // record, so a record rendered with one would be silently unusable. + assert!(!accepts( + false, + &format!("letsencrypt.org; accounturi={ACCOUNT};") + )); + } + + #[test] + fn rejects_a_duplicate_parameter() { + assert!(!accepts( + false, + &format!("letsencrypt.org; accounturi={ACCOUNT}; accounturi={ACCOUNT}") + )); + } + + #[test] + fn rejects_whitespace_inside_a_value() { + assert!(!accepts( + false, + "letsencrypt.org; accounturi=https://ca.example/acct/1 2" + )); + } + + #[test] + fn rejects_a_record_without_accounturi() { + assert!(!accepts(false, "letsencrypt.org; policy=wildcard")); + } + + #[test] + fn ignores_unrecognized_parameters() { + assert!(accepts( + true, + &format!("letsencrypt.org; accounturi={ACCOUNT}; policy=wildcard; futuretag=whatever") + )); + } + + #[test] + fn rejects_an_expired_persist_until() { + let published = format!("letsencrypt.org; accounturi={ACCOUNT}; persistUntil={NOW}"); + assert!(record(false).satisfied_by(&published, NOW)); + assert!(!record(false).satisfied_by(&published, NOW + 1)); + } + + #[test] + fn rejects_a_malformed_persist_until() { + assert!(!accepts( + false, + &format!("letsencrypt.org; accounturi={ACCOUNT}; persistUntil=tomorrow") + )); + } + + #[test] + fn accepts_any_one_of_the_published_records() { + let published = vec![ + "otherca.example; accounturi=https://other.example/acct/1".to_string(), + record(true).rdata(), + ]; + assert!(record(true).satisfied_by_any(&published, NOW)); + assert!(!record(true).satisfied_by_any(&published[..1], NOW)); + } +} diff --git a/dstack/certbot/src/lib.rs b/dstack/certbot/src/lib.rs index df71b9935..7d70f1f07 100644 --- a/dstack/certbot/src/lib.rs +++ b/dstack/certbot/src/lib.rs @@ -11,18 +11,23 @@ //! //! - Automatic certificate issuance and renewal //! - DNS-01 challenge support (currently implemented for Cloudflare) +//! - DNS-PERSIST-01 challenge support, which needs no DNS provider credential //! - Easy integration with existing Rust applications //! //! For more detailed information on the available methods and their usage, please refer //! to the documentation of individual structs and functions. -pub use acme_client::AcmeClient; +pub use acme_client::{ + required_dns_records, AcmeClient, ChallengeKind, RequiredRecord, ValidationMethod, +}; pub use bot::{read_pubkey, CertBot, CertBotConfig}; pub use dns01_client::Dns01Client; +pub use dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME; pub use workdir::WorkDir; mod acme_client; mod bot; mod dns01_client; +mod dns_persist; mod http_client; mod workdir; diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index 288b3403e..0b713c3fd 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context, Result}; -use certbot::{AcmeClient, Dns01Client}; +use certbot::{AcmeClient, Dns01Client, ValidationMethod}; use dstack_guest_agent_rpc::v0::RawQuoteArgs; use ra_tls::attestation::QuoteContentType; use ra_tls::rcgen::KeyPair; @@ -111,13 +111,17 @@ impl DistributedCertBot { Ok(()) } - async fn dns_client(&self, domain: &str, dns_cred: &DnsCredential) -> Result { - match &dns_cred.provider { + async fn dns_client(&self, domain: &str, dns_cred: &DnsCredential) -> Result { + let client = match &dns_cred.provider { DnsProvider::Cloudflare { api_token, api_url } => { Dns01Client::new_cloudflare(domain.to_string(), api_token.clone(), api_url.clone()) - .await + .await? } - } + }; + Ok(ValidationMethod::Dns01 { + client, + txt_ttl: dns_cred.dns_txt_ttl, + }) } /// Rotate the shared ACME account without interrupting certificate serving. @@ -179,14 +183,9 @@ impl DistributedCertBot { bail!("no ZT-Domain configured for ACME credential rotation"); }; - let client = AcmeClient::new_account( - acme_url, - first_client, - first_cred.max_dns_wait, - first_cred.dns_txt_ttl, - ) - .await - .context("failed to create replacement ACME account")?; + let client = AcmeClient::new_account(acme_url, first_client, first_cred.max_dns_wait) + .await + .context("failed to create replacement ACME account")?; let credentials = client .dump_credentials() .context("failed to encode replacement ACME credentials")?; @@ -221,14 +220,9 @@ impl DistributedCertBot { ); for (domain, dns_cred, dns_client) in prepared { let result = async { - let client = AcmeClient::load( - dns_client, - &credentials, - dns_cred.max_dns_wait, - dns_cred.dns_txt_ttl, - ) - .await - .context("failed to prepare ACME client")?; + let client = AcmeClient::load(dns_client, &credentials, dns_cred.max_dns_wait) + .await + .context("failed to prepare ACME client")?; client .set_caa_records(std::slice::from_ref(domain)) .await @@ -689,14 +683,9 @@ impl DistributedCertBot { ); } let dns01_client = self.dns_client(domain, dns_cred).await?; - let client = AcmeClient::load( - dns01_client, - &creds.acme_credentials, - dns_cred.max_dns_wait, - dns_cred.dns_txt_ttl, - ) - .await - .context("failed to load ACME client from KvStore credentials")?; + let client = AcmeClient::load(dns01_client, &creds.acme_credentials, dns_cred.max_dns_wait) + .await + .context("failed to load ACME client from KvStore credentials")?; Ok(Some(client)) } @@ -722,14 +711,9 @@ impl DistributedCertBot { info!("creating new global ACME account at {acme_url}"); let dns01_client = self.dns_client(domain, dns_cred).await?; - let client = AcmeClient::new_account( - acme_url, - dns01_client, - dns_cred.max_dns_wait, - dns_cred.dns_txt_ttl, - ) - .await - .context("failed to create new ACME account")?; + let client = AcmeClient::new_account(acme_url, dns01_client, dns_cred.max_dns_wait) + .await + .context("failed to create new ACME account")?; let creds_json = client .dump_credentials() From 72bbcae411067920e3b366dbed08dc94353c95a5 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 02:50:00 -0700 Subject: [PATCH 02/37] feat(certbot): print the one-time dns-persist-01 records from the CLI Under dns-persist-01 certbot cannot write the records it needs, so the records are the setup. `certbot dns-records` prints them as zone-file lines for the configured domains, ready to paste into any provider, once `certbot init` has registered the account they name. `challenge` and `issuer_domain_name` join certbot.toml, and `cf_api_token` becomes optional -- a token left configured alongside dns-persist-01 is warned about rather than silently ignored. `auto_set_caa` is refused outright with dns-persist-01: it promises certbot keeps CAA in sync, and without write access nothing can keep that promise. --- dstack/certbot/cli/src/main.rs | 47 ++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/dstack/certbot/cli/src/main.rs b/dstack/certbot/cli/src/main.rs index 68348b5f9..f72838466 100644 --- a/dstack/certbot/cli/src/main.rs +++ b/dstack/certbot/cli/src/main.rs @@ -6,7 +6,7 @@ use std::{path::PathBuf, time::Duration}; use anyhow::{Context, Result}; -use certbot::{CertBotConfig, WorkDir}; +use certbot::{CertBotConfig, ChallengeKind, WorkDir, LETS_ENCRYPT_ISSUER_DOMAIN_NAME}; use clap::Parser; use documented::DocumentedFields; use fs_err as fs; @@ -40,6 +40,15 @@ enum Command { #[arg(short, long, default_value = "certbot.toml")] config: PathBuf, }, + /// Print the DNS records the configured domains need + /// + /// With `challenge = "dns-persist-01"` these are not written by certbot and + /// have to be published once, by hand, before the first issuance. + DnsRecords { + /// Path to the configuration file + #[arg(short, long, default_value = "certbot.toml")] + config: PathBuf, + }, /// Generate configuration template Cfg { /// Write to file @@ -60,7 +69,23 @@ struct Config { workdir: PathBuf, /// ACME server URL acme_url: String, - /// Cloudflare API token + /// ACME challenge used to prove control of the domains + /// + /// "dns-01" (default) writes a TXT record per order through the Cloudflare + /// API and needs cf_api_token. + /// + /// "dns-persist-01" proves control with a _validation-persist TXT record + /// published once, by hand: no API token, and the zone can be hosted + /// anywhere. Run `certbot init` then `certbot dns-records` to get the + /// records to publish. Experimental: the draft is still changing and + /// Let's Encrypt serves this challenge on staging only. + #[serde(default)] + challenge: ChallengeKind, + /// Issuer Domain Name naming the CA in dns-persist-01 and CAA records + #[serde(default = "default_issuer_domain_name")] + issuer_domain_name: String, + /// Cloudflare API token (unused with dns-persist-01) + #[serde(default)] cf_api_token: String, /// Optional Cloudflare-compatible API base URL #[serde(default)] @@ -90,6 +115,8 @@ impl Default for Config { Self { workdir: ".".into(), acme_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(), + challenge: ChallengeKind::default(), + issuer_domain_name: default_issuer_domain_name(), cf_api_token: "".into(), cf_api_url: None, dns_txt_ttl: default_dns_txt_ttl(), @@ -108,6 +135,10 @@ const fn default_dns_txt_ttl() -> u32 { 60 } +fn default_issuer_domain_name() -> String { + LETS_ENCRYPT_ISSUER_DOMAIN_NAME.to_string() +} + impl Config { fn to_commented_toml(&self) -> Result { let mut doc = to_document(self)?; @@ -152,6 +183,8 @@ fn load_config(config: &PathBuf) -> Result { .key_file(workdir.key_path()) .auto_create_account(true) .cert_subject_alt_names(config.domains) + .challenge(config.challenge) + .issuer_domain_name(config.issuer_domain_name) .cf_api_token(config.cf_api_token) .maybe_cf_api_url(config.cf_api_url) .dns_txt_ttl(config.dns_txt_ttl) @@ -231,6 +264,16 @@ async fn main() -> Result<()> { .context("Failed to build bot")?; bot.set_caa().await?; } + Command::DnsRecords { config } => { + let bot_config = load_config(&config).context("Failed to load configuration")?; + let bot = bot_config + .build_bot() + .await + .context("Failed to build bot")?; + for record in bot.required_dns_records() { + println!("{record}"); + } + } Command::Cfg { write_to } => { let toml_str = Config::default().to_commented_toml()?; match write_to { From b6834d401f1158cb08969c0532dffcd6bdd94048 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 02:50:46 -0700 Subject: [PATCH 03/37] feat(gateway): let a ZT domain validate with dns-persist-01 A gateway CVM running dns-01 holds a Cloudflare token with write access to the operator's whole zone. Attestation covers what the CVM runs, not what becomes of a secret it holds, so that token is the widest credential in the deployment and it exists only to write one TXT record per order. dns-persist-01 removes it: control comes from a `_validation-persist` record the operator publishes once, and the CVM never gets DNS write access at all. `ZtDomainConfig.challenge` picks the method per domain and defaults to dns-01, so records written before the field existed decode as the method those deployments were using -- pinned by a test over both the named and the legacy positional msgpack encodings. Such a domain needs no DNS credential, and `validation_for` never looks one up for it. `GetZtDomain` and `ListZtDomains` return the records to publish in `required_dns_records`, rendered from the stored account URI with no ACME round trip so the listing endpoints stay cheap; it comes back empty rather than failing when no account exists yet. Two operations cannot be self-service for such a domain, and say so rather than failing silently: - `SetCaa` skips it and logs the records instead. There is nothing to reconcile without write access, and one such domain must not make the RPC unusable for the dns-01 domains beside it; the summary reports how many were left to the operator. - `RotateAcmeCredentials` moves the cluster to a new account while every `_validation-persist` record still names the old one, so orders for those domains fail until the operator republishes. The response now carries the new records in `required_dns_records`, rendered after the switch so they name the account the cluster actually moved to, and `domains_updated` counts only the domains whose CAA was re-pinned. --- dstack/gateway/rpc/proto/gateway_rpc.proto | 16 ++ dstack/gateway/src/admin_service.rs | 43 +++- dstack/gateway/src/distributed_certbot.rs | 278 +++++++++++++++++---- dstack/gateway/src/kv/mod.rs | 54 ++++ dstack/gateway/src/main_service.rs | 4 +- 5 files changed, 334 insertions(+), 61 deletions(-) diff --git a/dstack/gateway/rpc/proto/gateway_rpc.proto b/dstack/gateway/rpc/proto/gateway_rpc.proto index 4935974e5..7700e808b 100644 --- a/dstack/gateway/rpc/proto/gateway_rpc.proto +++ b/dstack/gateway/rpc/proto/gateway_rpc.proto @@ -210,6 +210,10 @@ message RotateAcmeCredentialsResponse { string account_uri = 1; // Number of ZT domains whose CAA records were updated for the new account. uint32 domains_updated = 2; + // Zone-file lines an operator must publish by hand, for domains the gateway + // cannot write (dns-persist-01). Non-empty means issuance for those domains + // stays broken until they name the new account. + repeated string required_dns_records = 3; } // Get HostInfo for associated instance id. @@ -742,6 +746,14 @@ message ZtDomainConfig { optional uint32 node = 4; // Priority for default base_domain selection (higher = preferred) int32 priority = 5; + // ACME challenge proving control of this domain: "dns-01" (default) or + // "dns-persist-01". Empty means "dns-01". + // + // "dns-persist-01" needs no DNS credential: control comes from a + // _validation-persist TXT record the operator publishes once, so the gateway + // CVM never holds write access to the zone. Experimental — the draft is still + // changing and Let's Encrypt serves it on staging only. + string challenge = 6; } // ZT-Domain information (config + certificate status) @@ -750,6 +762,10 @@ message ZtDomainInfo { ZtDomainConfig config = 1; // Certificate status ZtDomainCertStatus cert_status = 2; + // Zone-file lines the domain's DNS must contain. Under "dns-01" the gateway + // writes these itself and they are informational; under "dns-persist-01" they + // are the one-time setup an operator has to publish. + repeated string required_dns_records = 3; } // ZT-Domain certificate status diff --git a/dstack/gateway/src/admin_service.rs b/dstack/gateway/src/admin_service.rs index b9f5e6955..87227fbfd 100644 --- a/dstack/gateway/src/admin_service.rs +++ b/dstack/gateway/src/admin_service.rs @@ -6,6 +6,7 @@ use std::sync::atomic::Ordering; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{bail, ensure, Context, Result}; +use certbot::ChallengeKind; use dstack_gateway_rpc::{ admin_server::{AdminRpc, AdminServer}, CertAttestationInfo, CertbotConfigResponse, ClearInstancePortPolicyRequest, @@ -110,10 +111,11 @@ impl AdminRpc for AdminRpcHandler { } async fn rotate_acme_credentials(self) -> Result { - let (account_uri, domains_updated) = self.state.rotate_acme_credentials().await?; + let outcome = self.state.rotate_acme_credentials().await?; Ok(RotateAcmeCredentialsResponse { - account_uri, - domains_updated: domains_updated.try_into().unwrap_or(u32::MAX), + account_uri: outcome.account_uri, + domains_updated: outcome.domains_updated.try_into().unwrap_or(u32::MAX), + required_dns_records: outcome.required_dns_records, }) } @@ -554,11 +556,15 @@ impl AdminRpc for AdminRpcHandler { async fn list_zt_domains(self) -> Result { let kv_store = self.state.kv_store(); let cert_resolver = &self.state.cert_resolver; + let certbot = &self.state.certbot; let domains = kv_store .list_zt_domain_configs() .into_iter() - .map(|config| zt_domain_to_proto(config, kv_store, cert_resolver)) + .map(|config| { + let records = certbot.required_dns_records(&config); + zt_domain_to_proto(config, kv_store, cert_resolver, records) + }) .collect(); Ok(ListZtDomainsResponse { domains }) @@ -573,7 +579,8 @@ impl AdminRpc for AdminRpcHandler { .get_zt_domain_config(&domain) .context("ZT-Domain config not found")?; - Ok(zt_domain_to_proto(config, kv_store, cert_resolver)) + let records = self.state.certbot.required_dns_records(&config); + Ok(zt_domain_to_proto(config, kv_store, cert_resolver, records)) } async fn add_zt_domain(self, request: ProtoZtDomainConfig) -> Result { @@ -591,7 +598,8 @@ impl AdminRpc for AdminRpcHandler { kv_store.save_zt_domain_config(&config)?; info!("Added ZT-Domain config: {}", config.domain); - Ok(zt_domain_to_proto(config, kv_store, cert_resolver)) + let records = self.state.certbot.required_dns_records(&config); + Ok(zt_domain_to_proto(config, kv_store, cert_resolver, records)) } async fn update_zt_domain(self, request: ProtoZtDomainConfig) -> Result { @@ -608,7 +616,8 @@ impl AdminRpc for AdminRpcHandler { kv_store.save_zt_domain_config(&config)?; info!("Updated ZT-Domain config: {}", config.domain); - Ok(zt_domain_to_proto(config, kv_store, cert_resolver)) + let records = self.state.certbot.required_dns_records(&config); + Ok(zt_domain_to_proto(config, kv_store, cert_resolver, records)) } async fn delete_zt_domain(self, request: DeleteZtDomainRequest) -> Result<()> { @@ -976,20 +985,33 @@ fn proto_to_zt_domain_config( bail!("port must be between 1 and 65535"); } + // Empty means the historical default: every ZT domain predates the choice. + let challenge = match proto.challenge.as_str() { + "" | "dns-01" => ChallengeKind::Dns01, + "dns-persist-01" => ChallengeKind::DnsPersist01, + other => bail!("unsupported challenge {other:?}, expected dns-01 or dns-persist-01"), + }; + Ok(ZtDomainConfig { domain, dns_cred_id, port: proto.port.try_into().context("port out of range")?, node: proto.node, priority: proto.priority, + challenge, }) } /// Convert internal ZtDomainConfig to proto ZtDomainInfo (with cert status) +/// +/// `required_dns_records` is best effort: rendering it needs the ACME account +/// URI, and a domain whose ACME client cannot be built yet still has to be +/// listable. It comes back empty in that case rather than failing the call. fn zt_domain_to_proto( config: ZtDomainConfig, kv_store: &crate::kv::KvStore, cert_resolver: &crate::cert_store::CertResolver, + required_dns_records: Vec, ) -> ZtDomainInfo { // Get certificate data for status let cert_data = kv_store.get_cert_data(&config.domain); @@ -1003,6 +1025,11 @@ fn zt_domain_to_proto( loaded_in_memory, }); + let challenge = match config.challenge { + ChallengeKind::Dns01 => "dns-01", + ChallengeKind::DnsPersist01 => "dns-persist-01", + }; + ZtDomainInfo { config: Some(ProtoZtDomainConfig { domain: config.domain, @@ -1010,8 +1037,10 @@ fn zt_domain_to_proto( port: config.port.into(), node: config.node, priority: config.priority, + challenge: challenge.to_string(), }), cert_status, + required_dns_records, } } diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index 0b713c3fd..af1d0d88a 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -11,7 +11,9 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context, Result}; -use certbot::{AcmeClient, Dns01Client, ValidationMethod}; +use certbot::{ + AcmeClient, ChallengeKind, Dns01Client, ValidationMethod, LETS_ENCRYPT_ISSUER_DOMAIN_NAME, +}; use dstack_guest_agent_rpc::v0::RawQuoteArgs; use ra_tls::attestation::QuoteContentType; use ra_tls::rcgen::KeyPair; @@ -33,6 +35,42 @@ const ROTATION_LOCK_TIMEOUT_SECS: u64 = 600; /// Default ACME URL (Let's Encrypt production) const DEFAULT_ACME_URL: &str = "https://acme-v02.api.letsencrypt.org/directory"; +/// How long a `dns-persist-01` domain waits for its record to become visible. +/// +/// A `dns-01` domain reads this from its DNS credential, and a `dns-persist-01` +/// domain has none. It stays a constant rather than a config knob because the +/// wait is advisory: certbot polls its own resolver and starts the order either +/// way, so the value cannot decide whether issuance succeeds. Matches the +/// default `max_dns_wait` of a DNS credential. +const DNS_PERSIST_MAX_DNS_WAIT: Duration = Duration::from_secs(300); + +/// What an ACME credential rotation left behind. +#[derive(Debug)] +pub struct RotationOutcome { + /// URI of the new ACME account. + pub account_uri: String, + /// Domains whose CAA records were re-pinned to the new account. + pub domains_updated: usize, + /// Records an operator has to publish by hand, for domains the gateway + /// cannot write. Non-empty means issuance for those domains is broken until + /// they are published. + pub required_dns_records: Vec, +} + +/// How one ZT domain answers ACME challenges. +struct DomainValidation { + method: ValidationMethod, + /// How long to wait for the challenge records to become visible. + max_dns_wait: Duration, +} + +impl DomainValidation { + /// Whether the gateway can write this domain's DNS records itself. + fn writes_dns(&self) -> bool { + matches!(self.method, ValidationMethod::Dns01 { .. }) + } +} + /// Multi-domain certificate manager pub struct DistributedCertBot { kv_store: Arc, @@ -111,19 +149,101 @@ impl DistributedCertBot { Ok(()) } - async fn dns_client(&self, domain: &str, dns_cred: &DnsCredential) -> Result { - let client = match &dns_cred.provider { + async fn dns_client(&self, domain: &str, dns_cred: &DnsCredential) -> Result { + match &dns_cred.provider { DnsProvider::Cloudflare { api_token, api_url } => { Dns01Client::new_cloudflare(domain.to_string(), api_token.clone(), api_url.clone()) - .await? + .await } - }; - Ok(ValidationMethod::Dns01 { - client, - txt_ttl: dns_cred.dns_txt_ttl, + } + } + + /// Build the ACME validation method for one ZT domain. + /// + /// `dns-01` resolves the zone through the provider API, so a missing or + /// broken credential surfaces here rather than at the first order. + /// `dns-persist-01` reads no credential at all: the gateway CVM holds no + /// DNS write access for that domain, which is the point of the method. + async fn validation_for( + &self, + domain: &str, + config: &ZtDomainConfig, + ) -> Result { + match config.challenge { + ChallengeKind::Dns01 => { + let dns_cred = dns_credential_for(&self.kv_store, config)?; + let client = self.dns_client(domain, &dns_cred).await?; + Ok(DomainValidation { + method: ValidationMethod::Dns01 { + client, + txt_ttl: dns_cred.dns_txt_ttl, + }, + max_dns_wait: dns_cred.max_dns_wait, + }) + } + ChallengeKind::DnsPersist01 => Ok(DomainValidation { + method: ValidationMethod::DnsPersist01 { + issuer_domain_name: self.issuer_domain_name()?, + }, + max_dns_wait: DNS_PERSIST_MAX_DNS_WAIT, + }), + } + } + + /// Issuer Domain Name to name in `dns-persist-01` and CAA records. + fn issuer_domain_name(&self) -> Result { + let configured = self.config()?.issuer_domain_name; + Ok(match configured.is_empty() { + true => LETS_ENCRYPT_ISSUER_DOMAIN_NAME.to_string(), + false => configured, }) } + /// The DNS records a ZT domain's zone has to contain, as zone-file lines. + /// + /// Rendered from the stored account URI with no ACME round trip, so the + /// admin listing endpoints stay cheap. Empty until an ACME account exists — + /// every record names one — and empty rather than an error when the stored + /// credentials cannot be read, since listing a domain must not depend on + /// them. + pub fn required_dns_records(&self, config: &ZtDomainConfig) -> Vec { + let Ok(issuer_domain_name) = self.issuer_domain_name() else { + return Vec::new(); + }; + let Some(account_uri) = self + .kv_store + .get_acme_credentials() + .ok() + .flatten() + .and_then(|creds| extract_account_uri(&creds.acme_credentials)) + else { + return Vec::new(); + }; + // The gateway only ever orders `*.{domain}`, so that is the whole set of + // identifiers the CA will look records up for. + certbot::required_dns_records( + config.challenge, + &issuer_domain_name, + &account_uri, + &[format!("*.{}", config.domain)], + ) + .iter() + .map(ToString::to_string) + .collect() + } + + /// Log the records an operator has to publish for a domain, and return them. + fn report_manual_records(&self, config: &ZtDomainConfig) -> Vec { + let records = self.required_dns_records(config); + for record in &records { + warn!( + "cert[{}]: publish this record by hand: {record}", + config.domain + ); + } + records + } + /// Rotate the shared ACME account without interrupting certificate serving. /// /// The sequence is: validate every domain's DNS credential, create the @@ -146,7 +266,7 @@ impl DistributedCertBot { /// /// Runs under the shared ACME lock, so a concurrent reconciliation or /// first-use registration on any node is refused for the duration. - pub async fn rotate_acme_credentials(&self) -> Result<(String, usize)> { + pub async fn rotate_acme_credentials(&self) -> Result { let rotation_lock = self.acquire_acme_lock("rotate ACME credentials")?; let result = self.do_rotate_acme_credentials().await; if let Err(err) = self.release_rotation_lock(&rotation_lock) { @@ -155,7 +275,7 @@ impl DistributedCertBot { result } - async fn do_rotate_acme_credentials(&self) -> Result<(String, usize)> { + async fn do_rotate_acme_credentials(&self) -> Result { let configs = self.kv_store.list_zt_domain_configs(); let certbot_config = self.config()?; let acme_url = if certbot_config.acme_url.is_empty() { @@ -167,25 +287,30 @@ impl DistributedCertBot { // Validate every domain's DNS credential up front: constructing a DNS // client resolves the zone through an authenticated API call, so a // misconfigured domain aborts the rotation here with no side effects - // and no ACME account consumed. + // and no ACME account consumed. A dns-persist-01 domain has no + // credential to check and is prepared without touching any provider. let mut prepared = Vec::with_capacity(configs.len()); for config in &configs { - let dns_cred = dns_credential_for(&self.kv_store, config)?; - let dns_client = self - .dns_client(&config.domain, &dns_cred) + let validation = self + .validation_for(&config.domain, config) .await .with_context(|| format!("DNS credential check failed for {}", config.domain))?; - prepared.push((&config.domain, dns_cred, dns_client)); + prepared.push((config, validation)); } let total = prepared.len(); let mut prepared = prepared.into_iter(); - let Some((first_domain, first_cred, first_client)) = prepared.next() else { + let Some((first_config, first_validation)) = prepared.next() else { bail!("no ZT-Domain configured for ACME credential rotation"); }; + let first_writes_dns = first_validation.writes_dns(); - let client = AcmeClient::new_account(acme_url, first_client, first_cred.max_dns_wait) - .await - .context("failed to create replacement ACME account")?; + let client = AcmeClient::new_account( + acme_url, + first_validation.method, + first_validation.max_dns_wait, + ) + .await + .context("failed to create replacement ACME account")?; let credentials = client .dump_credentials() .context("failed to encode replacement ACME credentials")?; @@ -202,36 +327,59 @@ impl DistributedCertBot { // Re-pin every domain's CAA to the new account, best effort across all // domains: one failing domain must not block re-pinning the rest. The // first domain reuses the registration client, which is already bound - // to its DNS client and the new credentials. + // to its validation method and the new credentials. + // + // A dns-persist-01 domain has nothing to re-pin from here, and rotation + // is not transparent for it either: its `_validation-persist` record + // still names the *old* account, so orders for that domain fail until + // the operator republishes. Those records are reported below. let mut failed = Vec::new(); - let mut record = |domain: &String, result: Result<()>| match result { + let mut manual_domains = 0usize; + let mut repin = |domain: &str, result: Result<()>| match result { Ok(()) => info!("cert[{domain}]: CAA re-pinned to {account_uri}"), Err(err) => { error!("cert[{domain}]: failed to re-pin CAA: {err:?}"); - failed.push(domain.clone()); + failed.push(domain.to_string()); } }; - record( - first_domain, - client - .set_caa_records(std::slice::from_ref(first_domain)) - .await - .context("failed to update CAA records"), - ); - for (domain, dns_cred, dns_client) in prepared { - let result = async { - let client = AcmeClient::load(dns_client, &credentials, dns_cred.max_dns_wait) + if first_writes_dns { + repin( + &first_config.domain, + client + .set_caa_records(std::slice::from_ref(&first_config.domain)) .await - .context("failed to prepare ACME client")?; + .context("failed to update CAA records"), + ); + } else { + manual_domains += 1; + } + for (config, validation) in prepared { + if !validation.writes_dns() { + manual_domains += 1; + continue; + } + let result = async { + let client = + AcmeClient::load(validation.method, &credentials, validation.max_dns_wait) + .await + .context("failed to prepare ACME client")?; client - .set_caa_records(std::slice::from_ref(domain)) + .set_caa_records(std::slice::from_ref(&config.domain)) .await .context("failed to update CAA records") } .await; - record(domain, result); + repin(&config.domain, result); } + // Rendered after the new credentials are published, so the records name + // the account the cluster has actually switched to. + let manual = configs + .iter() + .filter(|config| config.challenge != ChallengeKind::Dns01) + .flat_map(|config| self.report_manual_records(config)) + .collect::>(); + // Attest the new account only after CAA re-pinning: attestation does // not gate issuance, so its agent round trips must not widen the // window where the published account and the CAA records disagree. @@ -250,7 +398,17 @@ impl DistributedCertBot { failed.join(", ") ); } - Ok((account_uri, total)) + if manual_domains > 0 { + warn!( + "{manual_domains}/{total} domains use dns-persist-01: issuance for them stays \ + broken until the records above name the new account {account_uri}" + ); + } + Ok(RotationOutcome { + account_uri, + domains_updated: total - manual_domains, + required_dns_records: manual, + }) } /// Get the current certbot configuration from KV store. @@ -370,8 +528,17 @@ impl DistributedCertBot { async fn do_set_caa_all(&self, configs: Vec) -> Result<()> { let total = configs.len(); let mut failed = Vec::new(); + let mut skipped = 0usize; for config in configs { let domain = config.domain.clone(); + // A dns-persist-01 domain has no DNS write access to reconcile with. + // Log the records the operator owns instead of failing the call, so + // one such domain does not make SetCaa unusable for the rest. + if config.challenge != ChallengeKind::Dns01 { + self.report_manual_records(&config); + skipped += 1; + continue; + } match self.set_caa(&domain, &config).await { Ok(()) => info!("cert[{domain}]: CAA records reconciled"), Err(err) => { @@ -389,7 +556,10 @@ impl DistributedCertBot { failed.join(", ") ); } - info!("CAA records reconciled for {total} domains"); + info!( + "CAA records reconciled for {} domains ({skipped} on dns-persist-01 left to the operator)", + total - skipped + ); Ok(()) } @@ -407,10 +577,9 @@ impl DistributedCertBot { // the lock may call [`Self::acquire_acme_lock`], and keeping the // registering variant out of this path is what makes that structural // rather than a rule to remember. - let dns_cred = dns_credential_for(&self.kv_store, config)?; let acme_url = self.acme_url()?; let acme_client = self - .load_stored_acme_client(domain, &dns_cred, &acme_url) + .load_stored_acme_client(domain, config, &acme_url) .await .context("failed to initialize ACME client")? .context("no shared ACME account is registered for this cluster")?; @@ -621,12 +790,10 @@ impl DistributedCertBot { domain: &str, config: &ZtDomainConfig, ) -> Result { - // Get DNS credential (from config or default) - let dns_cred = dns_credential_for(&self.kv_store, config)?; let acme_url = self.acme_url()?; if let Some(client) = self - .load_stored_acme_client(domain, &dns_cred, &acme_url) + .load_stored_acme_client(domain, config, &acme_url) .await? { info!("loaded global ACME account credentials from KvStore"); @@ -644,7 +811,7 @@ impl DistributedCertBot { // lock and look again before spending a registration. let rotation_lock = self.acquire_acme_lock("register the shared ACME account")?; let client = self - .register_or_adopt_account(domain, &dns_cred, &acme_url) + .register_or_adopt_account(domain, config, &acme_url) .await; if let Err(err) = self.release_rotation_lock(&rotation_lock) { error!("failed to release ACME rotation lock: {err:?}"); @@ -664,7 +831,7 @@ impl DistributedCertBot { async fn load_stored_acme_client( &self, domain: &str, - dns_cred: &DnsCredential, + config: &ZtDomainConfig, acme_url: &str, ) -> Result> { let Some(creds) = self @@ -682,10 +849,14 @@ impl DistributedCertBot { call RotateAcmeCredentials to switch directories" ); } - let dns01_client = self.dns_client(domain, dns_cred).await?; - let client = AcmeClient::load(dns01_client, &creds.acme_credentials, dns_cred.max_dns_wait) - .await - .context("failed to load ACME client from KvStore credentials")?; + let validation = self.validation_for(domain, config).await?; + let client = AcmeClient::load( + validation.method, + &creds.acme_credentials, + validation.max_dns_wait, + ) + .await + .context("failed to load ACME client from KvStore credentials")?; Ok(Some(client)) } @@ -698,11 +869,11 @@ impl DistributedCertBot { async fn register_or_adopt_account( &self, domain: &str, - dns_cred: &DnsCredential, + config: &ZtDomainConfig, acme_url: &str, ) -> Result { if let Some(client) = self - .load_stored_acme_client(domain, dns_cred, acme_url) + .load_stored_acme_client(domain, config, acme_url) .await? { info!("adopted the ACME account registered while this node waited for the lock"); @@ -710,8 +881,8 @@ impl DistributedCertBot { } info!("creating new global ACME account at {acme_url}"); - let dns01_client = self.dns_client(domain, dns_cred).await?; - let client = AcmeClient::new_account(acme_url, dns01_client, dns_cred.max_dns_wait) + let validation = self.validation_for(domain, config).await?; + let client = AcmeClient::new_account(acme_url, validation.method, validation.max_dns_wait) .await .context("failed to create new ACME account")?; @@ -951,6 +1122,7 @@ mod tests { port: 443, node: None, priority: 0, + challenge: ChallengeKind::Dns01, } } @@ -1152,7 +1324,7 @@ mod tests { let err = match certbot .register_or_adopt_account( "app.example.com", - &unreachable_dns_credential(), + &test_zt_domain_config(), DEFAULT_ACME_URL, ) .await diff --git a/dstack/gateway/src/kv/mod.rs b/dstack/gateway/src/kv/mod.rs index 62bdc3870..251927ab5 100644 --- a/dstack/gateway/src/kv/mod.rs +++ b/dstack/gateway/src/kv/mod.rs @@ -57,6 +57,7 @@ use std::{ }; use anyhow::{Context, Result}; +use certbot::ChallengeKind; use crate::models::InstanceInfo; use crate::time::{encode_ts, now_secs}; @@ -378,6 +379,12 @@ pub struct ZtDomainConfig { /// The domain with highest priority is returned as the default base_domain in APIs #[serde(default)] pub priority: i32, + /// ACME challenge used to prove control of this domain. + /// + /// Records written before dns-persist-01 support have no such field and + /// decode as `dns-01`, which is the only method those deployments had. + #[serde(default)] + pub challenge: ChallengeKind, } /// Global certbot configuration (stored in KV, synced across nodes) @@ -394,6 +401,13 @@ pub struct GlobalCertbotConfig { pub renew_timeout: Duration, /// ACME server URL (None means use default Let's Encrypt production) pub acme_url: String, + /// Issuer Domain Name naming the CA in dns-persist-01 and CAA records. + /// + /// Empty means Let's Encrypt. Only read by domains using `dns-persist-01`: + /// the record has to name a CA the challenge lists in `issuer-domain-names`, + /// so a private or staging ACME server needs its own value here. + #[serde(default)] + pub issuer_domain_name: String, } impl Default for GlobalCertbotConfig { @@ -403,6 +417,7 @@ impl Default for GlobalCertbotConfig { renew_before_expiration: Duration::from_secs(30 * 86400), // 30 days renew_timeout: Duration::from_secs(300), // 5 minutes acme_url: Default::default(), // default Let's Encrypt + issuer_domain_name: Default::default(), // default Let's Encrypt } } } @@ -2524,6 +2539,43 @@ mod value_encoding_tests { } } + /// A ZT domain stored before `dns-persist-01` existed has no `challenge` + /// field, and must keep decoding as the method it was actually using. + #[test] + fn a_zt_domain_without_a_challenge_field_decodes_as_dns01() { + /// The shape `ZtDomainConfig` had before the field was added. + #[derive(Serialize)] + struct LegacyZtDomainConfig { + domain: String, + dns_cred_id: Option, + port: u16, + node: Option, + priority: i32, + } + + let legacy = LegacyZtDomainConfig { + domain: "app.example.com".to_string(), + dns_cred_id: Some("cred-1".to_string()), + port: 443, + node: None, + priority: 7, + }; + + for (label, encoded) in [ + ("named", encode(&legacy).expect("named encode")), + ( + "legacy positional", + rmp_serde::encode::to_vec(&legacy).expect("positional encode"), + ), + ] { + let decoded: ZtDomainConfig = + decode(&encoded).unwrap_or_else(|err| panic!("{label} decode failed: {err}")); + assert_eq!(decoded.domain, "app.example.com", "{label}"); + assert_eq!(decoded.priority, 7, "{label}"); + assert_eq!(decoded.challenge, ChallengeKind::Dns01, "{label}"); + } + } + /// The configured window has to reach the store, not just the config file. /// /// An fsync per write runs under the store lock, so it bounds how fast this @@ -3413,6 +3465,7 @@ mod corruption_tests { port: 443, node: None, priority: 0, + challenge: Default::default(), }) .expect("save should succeed"); // Same record filed under another domain's key: honouring the value @@ -3427,6 +3480,7 @@ mod corruption_tests { port: 443, node: None, priority: 100, + challenge: Default::default(), }, true, ) diff --git a/dstack/gateway/src/main_service.rs b/dstack/gateway/src/main_service.rs index 427405c59..678117b91 100644 --- a/dstack/gateway/src/main_service.rs +++ b/dstack/gateway/src/main_service.rs @@ -619,7 +619,9 @@ impl Proxy { } } - pub(crate) async fn rotate_acme_credentials(&self) -> Result<(String, usize)> { + pub(crate) async fn rotate_acme_credentials( + &self, + ) -> Result { self.certbot.rotate_acme_credentials().await } From c883548ed812c074a70043f82c8030bb332a4073 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 02:50:46 -0700 Subject: [PATCH 04/37] docs: document dns-persist-01 certificate issuance --- CHANGELOG.md | 1 + docs/certbot-dns-persist-01.md | 166 +++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 docs/certbot-dns-persist-01.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4636d26da..042d32100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - guest-agent: v1 `Attest` accepts `include_boottime_gpu_evidence` and returns the boot-time GPU attestation evidence in `AttestResponse.boottime_gpu_evidence`, so a verifier fetches the attestation and the GPU evidence in one round trip. It arrives as a `GpuEvidenceBundle` list -- the same shape `AttestGpu` returns, so a consumer writes one bundle parser and dispatches on `format`: `nvidia-nvattest-boottime-json-v1` is the record written at boot, `nvidia-nvattest-collect-evidence-json-v1` is collected on demand against a caller's nonce, and a verifier for one does not appraise the other. Absence is the empty list. The bundle's `evidence` is the nvattest output byte for byte as read from disk, because the only thing binding it to the boot is sha256 over precisely those bytes against the measured `gpu-attestation` event. `Attest` is also v1's sole CVM attestation entry point: the `VersionedAttestation` it returns already carries the TDX quote and event log, and unlike `GetQuote` it answers on every supported platform - sdk: `AppCompose` in the Go SDK gained `init_script`, `storage_fs`, `swap_size`, `event_log_version`, `port_policy` and `verity_volumes`, and `Requirements` gained `gpu_policy` in the Go and Python SDKs - shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796) +- certbot/gateway: opt-in `dns-persist-01` certificate validation ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist-01)), which issues without a DNS provider credential at all. `dns-01` needs write access to the zone on every order, so a gateway CVM holds a Cloudflare API token for the life of the deployment -- a token that rewrites the whole zone, not just `_acme-challenge`. Under `dns-persist-01` the zone owner publishes one `_validation-persist.` TXT record naming the CA and the ACME account; nothing about it changes between orders, so certbot only ever reads DNS and the zone can be hosted anywhere, with no provider integration. Set `challenge = "dns-persist-01"` in `certbot.toml`, or `challenge` on a gateway ZT domain; the default stays `dns-01` and existing deployments are untouched. `certbot dns-records` prints the records to publish, `GetZtDomain`/`ListZtDomains` return them in `required_dns_records`, and the gateway logs them wherever it would otherwise have written DNS. CAA records carry `validationmethods=dns-persist-01` to match, so switching methods means republishing both. Two gateway operations change shape for such a domain: `SetCaa` skips it, having nothing to reconcile without write access, and `RotateAcmeCredentials` leaves it broken until the operator republishes -- the record still names the old account -- so the response now returns the new records in `required_dns_records`. **Experimental**: the draft is still changing, and Let's Encrypt serves the challenge on staging only pending an open working-group issue. Documented in `docs/certbot-dns-persist-01.md` - gateway: `Admin.Status` reports `health_gating`, so an operator can see whether this node's health polling is switched on. With it off, instances that opted in sit at `unknown` forever and are all in rotation, which is otherwise indistinguishable on the dashboard from being held out pending a first answer - gateway: `Admin.SetInstanceReady` takes a CVM instance out of its app's load-balancing rotation without stopping it; instance-id routing stays open so the instance can still be investigated, and the setting survives re-registration - gateway: operator-set per-instance overrides now live under their own KV keys — `admin//ready` and `admin//port_policy` — instead of inside the instance record, so a CVM re-registration can no longer drop them and setting one cannot discard a peer's unsynced change to the other. An override left in an instance record by an earlier build is moved across on load diff --git a/docs/certbot-dns-persist-01.md b/docs/certbot-dns-persist-01.md new file mode 100644 index 000000000..371ac1b2b --- /dev/null +++ b/docs/certbot-dns-persist-01.md @@ -0,0 +1,166 @@ +# Certificate issuance without a DNS credential (`dns-persist-01`) + +`dns-01` asks certbot to write a fresh `_acme-challenge` TXT record for every +order, so whatever runs certbot holds a DNS API token with write access to the +zone, forever. In dstack that token lives inside the gateway CVM. Attestation +covers what the CVM is running, but a token is a token: anything that gets hold +of it can rewrite the zone, including records that have nothing to do with +certificates. + +`dns-persist-01` moves the proof out of the issuance loop. The zone owner +publishes one record naming the CA and the ACME account allowed to issue: + +```dns +_validation-persist.example.com. IN TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890" +``` + +The account key proves who is asking, the record proves the zone owner agreed, +and neither changes between orders. certbot reads DNS and never writes it, so +the CVM holds no DNS credential and the zone can be hosted anywhere — no +Cloudflare account, no API token, no provider integration. + +> **Experimental.** `dns-persist-01` is specified in +> [draft-ietf-acme-dns-persist-01][draft], which is still changing: an open +> working-group issue may add a client-key-derived value to the record, and +> Let's Encrypt has said it will not deploy the challenge to production until +> that is resolved. It is live on Let's Encrypt **staging** and in +> [Pebble][pebble]. Treat the record format as unstable, and expect to +> republish when the draft settles. + +[draft]: https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist-01 +[pebble]: https://github.com/letsencrypt/pebble + +## What the record means + +| Part | Effect | +| --- | --- | +| `letsencrypt.org` | Issuer Domain Name. A CA ignores records naming a different issuer, so one label can hold records for several CAs. | +| `accounturi=` | The ACME account authorized to issue. Compared byte for byte — no case folding, no URI normalization. | +| `policy=wildcard` | Extends the record to `*.example.com`. Without it the CA authorizes `example.com` alone and refuses wildcard orders. | +| `persistUntil=` | Optional UNIX timestamp after which the CA stops accepting the record. | + +A wildcard order authorizes from its base name — `*.example.com` is validated +against `_validation-persist.example.com`, not +`_validation-persist.*.example.com` — so one record covers a name and its +wildcard. + +Two things about the syntax bite in practice, because a CA rejects the whole +record rather than ignoring the offending part: **no trailing semicolon**, and +**no whitespace inside a value**. certbot renders records that satisfy both; +copy them verbatim rather than retyping. + +The scope stops at the names above. Let's Encrypt does not walk up the tree, so +a record on `example.com` does not authorize `sub.example.com` — give each base +name its own record. + +## Standalone certbot + +`certbot` never writes DNS in this mode, so setup is: create the account, read +the records off it, publish them, then issue. + +```toml +# certbot.toml +workdir = "/var/lib/certbot" +acme_url = "https://acme-staging-v02.api.letsencrypt.org/directory" +challenge = "dns-persist-01" +issuer_domain_name = "letsencrypt.org" +# auto_set_caa promises certbot keeps CAA in sync, which it cannot do without +# write access. Leave it off and publish the CAA records below by hand. +auto_set_caa = false +domains = ["example.com", "*.example.com"] +renew_interval = 3600 +renew_days_before = 10 +renew_timeout = 120 +max_dns_wait = 300 +``` + +`cf_api_token` is unused and can be left out; certbot warns if one is set. + +```console +$ certbot init -c certbot.toml +INFO certbot::bot: creating new ACME account +INFO certbot::bot: created new ACME account: https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890 + +$ certbot dns-records -c certbot.toml +_validation-persist.example.com. IN TXT "letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890; policy=wildcard" +example.com. IN CAA 0 issue "letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890" +example.com. IN CAA 0 issuewild "letsencrypt.org;validationmethods=dns-persist-01;accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890" +``` + +Publish all three, wait for them to propagate, then issue: + +```console +$ certbot renew --once -c certbot.toml +INFO certbot::acme_client: requesting new certificates for example.com, *.example.com +INFO certbot::bot: created new certificate +``` + +Renewals need nothing further. The record stays, and `certbot renew` reuses it +for every order. + +The CAA records are optional but recommended — they stop any other account, at +Let's Encrypt or elsewhere, from being issued for your name. Note the +`validationmethods=dns-persist-01` in them: a CAA record left pinned to +`dns-01` refuses every `dns-persist-01` order, so switching methods means +updating CAA and the validation record together. + +### When issuance fails + +certbot checks its own resolver before starting an order, and says exactly what +it expected to find: + +``` +WARN certbot::acme_client: no TXT record at _validation-persist.example.com matches the expected value: letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890; policy=wildcard +Error: order is invalid: API error: No valid TXT record found for DNS-PERSIST-01 challenge (urn:ietf:params:acme:error:unauthorized) +``` + +The check is advisory and never blocks an order: certbot's resolver is not the +CA's, and its expectation can be stricter than what the CA would accept. A +warning with a successful issuance underneath it is a resolver difference, not +a problem. + +If the CA rejects the order, compare the published record against +`certbot dns-records` character by character. The usual causes are a stale +`accounturi` after the account was recreated, a missing `policy=wildcard` on a +wildcard order, and an issuer domain name that does not match the CA. + +## dstack-gateway + +A ZT domain picks its method with the `challenge` field, which defaults to +`dns-01` — existing domains are unaffected: + +```json +{ "domain": "app.example.com", "port": 443, "challenge": "dns-persist-01" } +``` + +Such a domain needs no `dns_cred_id`, and the gateway CVM never receives a DNS +credential for it. `GetZtDomain` and `ListZtDomains` return the records to +publish in `required_dns_records`, and the gateway logs them whenever it cannot +write DNS itself: + +``` +WARN cert[app.example.com]: publish this record by hand: _validation-persist.app.example.com. IN TXT "letsencrypt.org; accounturi=...; policy=wildcard" +``` + +The gateway issues for `*.{domain}` only, so each domain needs one validation +record with `policy=wildcard`. + +For a non-production ACME server, set `issuer_domain_name` in the global +certbot config to whatever that server puts in `issuer-domain-names` — +`pebble.letsencrypt.org` for Pebble. Empty means `letsencrypt.org`. + +Two operations behave differently on these domains: + +- **`SetCaa`** skips them. There is nothing to reconcile without write access; + the records are logged instead, and the summary reports how many were left to + the operator. +- **`RotateAcmeCredentials`** is not self-service. Rotation moves the cluster + to a new ACME account, and every `_validation-persist` record still names the + old one, so orders for those domains fail until the operator republishes. The + response returns the new records in `required_dns_records` and the gateway + logs them; publish before the next renewal comes due. + +## Related + +- [dstack-gateway](dstack-gateway.md) — gateway architecture and TLS termination +- [deployment.md](deployment.md#4-zero-trust-https-optional) — the `dns-01` setup this replaces From 23f2fe318e856857cb7af7a0f028a9e5b426657c Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 07:20:54 -0700 Subject: [PATCH 05/37] docs: quote the real dns-persist-01 rejection from staging --- docs/certbot-dns-persist-01.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/certbot-dns-persist-01.md b/docs/certbot-dns-persist-01.md index 371ac1b2b..c6102b8a7 100644 --- a/docs/certbot-dns-persist-01.md +++ b/docs/certbot-dns-persist-01.md @@ -111,13 +111,14 @@ it expected to find: ``` WARN certbot::acme_client: no TXT record at _validation-persist.example.com matches the expected value: letsencrypt.org; accounturi=https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890; policy=wildcard -Error: order is invalid: API error: No valid TXT record found for DNS-PERSIST-01 challenge (urn:ietf:params:acme:error:unauthorized) +Error: order is invalid: API error: Checking DNS-PERSIST-01 challenge TXT record with issuer-domain-name "letsencrypt.org": accounturi mismatch: expected "https://acme-staging-v02.api.letsencrypt.org/acme/acct/1234567890", got "https://acme-staging-v02.api.letsencrypt.org/acme/acct/9876543210" (urn:ietf:params:acme:error:unauthorized) ``` The check is advisory and never blocks an order: certbot's resolver is not the CA's, and its expectation can be stricter than what the CA would accept. A warning with a successful issuance underneath it is a resolver difference, not -a problem. +a problem. A record that genuinely does not match costs the full `max_dns_wait` +before the order is sent, because the check waits out its budget first. If the CA rejects the order, compare the published record against `certbot dns-records` character by character. The usual causes are a stale From 87486307907fd9be24f1bbdf9415e07f59c02747 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 07:35:22 -0700 Subject: [PATCH 06/37] fix(certbot): read a dns-persist-01 record the way the CA reads it --- dstack/certbot/src/dns_persist.rs | 104 +++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/dstack/certbot/src/dns_persist.rs b/dstack/certbot/src/dns_persist.rs index 4712a4901..987548220 100644 --- a/dstack/certbot/src/dns_persist.rs +++ b/dstack/certbot/src/dns_persist.rs @@ -84,7 +84,9 @@ impl AuthorizationRecord { let Ok(parsed) = IssueValue::parse(rdata) else { return false; }; - if parsed.issuer_domain_name != self.issuer_domain_name { + if normalized_issuer(&parsed.issuer_domain_name) + != normalized_issuer(&self.issuer_domain_name) + { return false; } if parsed.account_uri != self.account_uri { @@ -145,17 +147,40 @@ impl IssueValue { bail!("empty parameter or trailing semicolon"); } let (tag, value) = part.split_once('=').context("parameter is not tag=value")?; - // RFC 8659 matches tags case-insensitively; values are not folded. - let tag = tag.to_lowercase(); + // RFC 8659's grammar is `parameter = tag *WSP "=" *WSP value`, so the + // separator may be padded on either side and each half is trimmed on + // its own. Trimming only the whole parameter would read the tag of + // `accounturi = ` as `"accounturi "` and report the mandatory + // parameter missing on a record the CA accepts. + let tag = trim_wsp(tag).to_lowercase(); + let value = trim_wsp(value); + if tag.is_empty() { + bail!("parameter has an empty tag"); + } if !value.bytes().all(is_value_byte) { bail!("parameter {tag} has a value with a forbidden character"); } - if seen.contains(&tag) { - bail!("duplicate parameter {tag}"); + match tag.as_str() { + "accounturi" | "policy" | "persistuntil" => { + // Only recognized tags are held to uniqueness: the draft has + // the CA ignore unknown tags outright, so a record repeating + // one is still a record the CA issues from. + if seen.contains(&tag) { + bail!("duplicate parameter {tag}"); + } + seen.push(tag.clone()); + } + // The draft requires unrecognized tags to be ignored, so that + // later revisions can add parameters without invalidating records. + _ => continue, } - seen.push(tag.clone()); match tag.as_str() { - "accounturi" => account_uri = Some(value.to_string()), + "accounturi" => { + if value.is_empty() { + bail!("empty value for the mandatory accounturi parameter"); + } + account_uri = Some(value.to_string()); + } "policy" => policy = Some(value.to_string()), "persistuntil" => { persist_until = Some( @@ -164,8 +189,6 @@ impl IssueValue { .context("persistUntil is not a base-10 timestamp")?, ) } - // The draft requires unrecognized tags to be ignored, so that - // later revisions can add parameters without invalidating records. _ => {} } } @@ -179,6 +202,19 @@ impl IssueValue { } } +/// An Issuer Domain Name folded the way the CA folds it before comparing. +/// +/// Boulder normalizes both sides (lowercase, then drop the root dot) before +/// deciding whether a record is one of its own, so `LetsEncrypt.ORG.` names the +/// same CA as `letsencrypt.org`. Comparing raw bytes here would treat a record +/// the CA honours as belonging to someone else and warn about a missing record +/// through every issuance. IDNA folding is left out: this compares against a +/// name dstack configures, and an operator writing a non-ASCII issuer name has +/// a mismatch a self-check cannot paper over. +fn normalized_issuer(name: &str) -> String { + name.trim_end_matches('.').to_lowercase() +} + fn is_wildcard_policy(policy: &str) -> bool { policy.eq_ignore_ascii_case(POLICY_WILDCARD) } @@ -217,6 +253,56 @@ mod tests { record(wildcard).satisfied_by(published, NOW) } + /// RFC 8659 spells the parameter grammar `tag *WSP "=" *WSP value`, and + /// Boulder trims each half separately. A record padded around the separator + /// is one the CA issues from, so the self-check has to read it the same way. + #[test] + fn whitespace_around_the_separator_is_not_part_of_the_tag_or_value() { + assert!(accepts( + false, + &format!("letsencrypt.org; accounturi = {ACCOUNT}") + )); + assert!(accepts( + true, + &format!("letsencrypt.org;\taccounturi\t=\t{ACCOUNT}; policy\t=\twildcard") + )); + } + + /// The draft has the CA ignore unrecognized tags outright, so repeating one + /// cannot invalidate a record. Uniqueness is only enforced where the CA + /// enforces it. + #[test] + fn a_repeated_unknown_tag_is_ignored_rather_than_fatal() { + assert!(accepts( + false, + &format!("letsencrypt.org; accounturi={ACCOUNT}; futuretag=a; futuretag=b") + )); + assert!(!accepts( + false, + &format!("letsencrypt.org; accounturi={ACCOUNT}; accounturi={ACCOUNT}") + )); + } + + /// Boulder folds case and drops the root dot before deciding whether a + /// record names it, so the same record must not read as another CA's here. + #[test] + fn the_issuer_name_is_compared_the_way_the_ca_compares_it() { + assert!(accepts( + false, + &format!("LetsEncrypt.ORG.; accounturi={ACCOUNT}") + )); + assert!(!accepts( + false, + &format!("other-ca.example; accounturi={ACCOUNT}") + )); + } + + /// `accounturi` is mandatory, and an empty value does not supply it. + #[test] + fn an_empty_account_uri_is_not_an_account_uri() { + assert!(!accepts(false, "letsencrypt.org; accounturi=")); + } + #[test] fn validation_domain_prepends_the_label() { assert_eq!( From 108f500b2c848af5bc25318c3349b838e0bba743 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 07:35:26 -0700 Subject: [PATCH 07/37] fix(certbot): name the configured CA in dns-01 CAA records too --- dstack/certbot/src/acme_client.rs | 15 ++++++++++++--- dstack/certbot/src/bot.rs | 1 + dstack/gateway/src/distributed_certbot.rs | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/dstack/certbot/src/acme_client.rs b/dstack/certbot/src/acme_client.rs index 3cc73ab3d..17bed930b 100644 --- a/dstack/certbot/src/acme_client.rs +++ b/dstack/certbot/src/acme_client.rs @@ -62,6 +62,8 @@ pub enum ValidationMethod { client: Dns01Client, /// TTL of the published records, in seconds (1 = auto, min 60 on Cloudflare). txt_ttl: u32, + /// Issuer Domain Name to name in the CAA records certbot publishes. + issuer_domain_name: String, }, /// draft-ietf-acme-dns-persist-01 `dns-persist-01`: control is proven by a /// `_validation-persist` TXT record naming the CA and this ACME account, @@ -98,11 +100,18 @@ impl ValidationMethod { } /// Issuer Domain Name to write into CAA records. + /// + /// Both methods read it from configuration, whose default is + /// `letsencrypt.org` -- the name existing deployments already have published + /// -- so an untouched configuration writes what it wrote before. A CAA + /// record naming a CA other than the one at `acme_url` forbids the very + /// issuance it is published to enable, and that is not a dns-01/ + /// dns-persist-01 distinction. fn issuer_domain_name(&self) -> &str { match self { - // Unconfigurable for dns-01, as it has always been: existing - // deployments have CAA records published under this name. - Self::Dns01 { .. } => dns_persist::LETS_ENCRYPT_ISSUER_DOMAIN_NAME, + Self::Dns01 { + issuer_domain_name, .. + } => issuer_domain_name, Self::DnsPersist01 { issuer_domain_name } => issuer_domain_name, } } diff --git a/dstack/certbot/src/bot.rs b/dstack/certbot/src/bot.rs index 30718d02d..886cf926e 100644 --- a/dstack/certbot/src/bot.rs +++ b/dstack/certbot/src/bot.rs @@ -281,6 +281,7 @@ async fn build_validation_method(config: &CertBotConfig) -> Result { diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index af1d0d88a..7c252c4d5 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -177,6 +177,7 @@ impl DistributedCertBot { method: ValidationMethod::Dns01 { client, txt_ttl: dns_cred.dns_txt_ttl, + issuer_domain_name: self.issuer_domain_name()?, }, max_dns_wait: dns_cred.max_dns_wait, }) @@ -190,7 +191,8 @@ impl DistributedCertBot { } } - /// Issuer Domain Name to name in `dns-persist-01` and CAA records. + /// Issuer Domain Name to name in `dns-persist-01` records and in the CAA + /// records written for either challenge. fn issuer_domain_name(&self) -> Result { let configured = self.config()?.issuer_domain_name; Ok(match configured.is_empty() { From e49b1ba36bb79100ed1559c7c1ee120d0faae413 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 07:35:26 -0700 Subject: [PATCH 08/37] fix(gateway): fit the dns-persist-01 dns wait inside the order timeout --- dstack/gateway/src/distributed_certbot.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dstack/gateway/src/distributed_certbot.rs b/dstack/gateway/src/distributed_certbot.rs index 7c252c4d5..5cca88690 100644 --- a/dstack/gateway/src/distributed_certbot.rs +++ b/dstack/gateway/src/distributed_certbot.rs @@ -40,9 +40,16 @@ const DEFAULT_ACME_URL: &str = "https://acme-v02.api.letsencrypt.org/directory"; /// A `dns-01` domain reads this from its DNS credential, and a `dns-persist-01` /// domain has none. It stays a constant rather than a config knob because the /// wait is advisory: certbot polls its own resolver and starts the order either -/// way, so the value cannot decide whether issuance succeeds. Matches the -/// default `max_dns_wait` of a DNS credential. -const DNS_PERSIST_MAX_DNS_WAIT: Duration = Duration::from_secs(300); +/// way, so the value cannot decide whether issuance succeeds. +/// +/// It has to stay comfortably under `renew_timeout` (300s by default), which +/// wraps the whole order, for that to be true. The wait is measured from after +/// the order and its authorizations are fetched, so a value equal to the outer +/// timeout means the outer one always fires first: the order is aborted with +/// "certificate request timed out" instead of proceeding to the CA, and the +/// warning naming the record it could not see -- the first thing an operator +/// is told to look at -- is never logged. +const DNS_PERSIST_MAX_DNS_WAIT: Duration = Duration::from_secs(120); /// What an ACME credential rotation left behind. #[derive(Debug)] From da71eb224294b4785cf1c6c47b7edda8c86d1bcd Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 07:35:37 -0700 Subject: [PATCH 09/37] feat(gateway): make the issuer domain name settable --- docs/certbot-dns-persist-01.md | 13 ++++++++--- dstack/gateway/rpc/proto/gateway_rpc.proto | 7 ++++++ dstack/gateway/src/admin_service.rs | 25 ++++++++++++++++++++++ dstack/gateway/templates/dashboard.html | 10 +++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/certbot-dns-persist-01.md b/docs/certbot-dns-persist-01.md index c6102b8a7..7ca6ab3a1 100644 --- a/docs/certbot-dns-persist-01.md +++ b/docs/certbot-dns-persist-01.md @@ -146,9 +146,16 @@ WARN cert[app.example.com]: publish this record by hand: _validation-persist.app The gateway issues for `*.{domain}` only, so each domain needs one validation record with `policy=wildcard`. -For a non-production ACME server, set `issuer_domain_name` in the global -certbot config to whatever that server puts in `issuer-domain-names` — -`pebble.letsencrypt.org` for Pebble. Empty means `letsencrypt.org`. +A ZT domain's challenge is chosen when it is added and carried forward by every +edit, in the dashboard's ZT-Domain form as well as over the API. + +For a non-production ACME server, set `issuer_domain_name` in the global certbot +config — `Admin.SetCertbotConfig`, or the field of that name in the dashboard's +Certbot Configuration — to whatever that server puts in `issuer-domain-names`, +`pebble.letsencrypt.org` for Pebble. Empty means `letsencrypt.org`. It also +names the CA in the CAA records certbot writes for `dns-01` domains, so one +setting covers both challenges rather than pinning CAA to Let's Encrypt while +orders go elsewhere. Two operations behave differently on these domains: diff --git a/dstack/gateway/rpc/proto/gateway_rpc.proto b/dstack/gateway/rpc/proto/gateway_rpc.proto index 7700e808b..eed45978d 100644 --- a/dstack/gateway/rpc/proto/gateway_rpc.proto +++ b/dstack/gateway/rpc/proto/gateway_rpc.proto @@ -860,6 +860,9 @@ message CertbotConfigResponse { uint64 renew_timeout_secs = 3; // ACME server URL (empty means default Let's Encrypt production) string acme_url = 4; + // Issuer Domain Name naming the CA in dns-persist-01 and CAA records + // (empty means letsencrypt.org) + string issuer_domain_name = 5; } // Set certbot configuration request @@ -872,6 +875,10 @@ message SetCertbotConfigRequest { optional uint64 renew_timeout_secs = 3; // ACME server URL (empty means use default Let's Encrypt production) optional string acme_url = 4; + // Issuer Domain Name naming the CA in dns-persist-01 and CAA records + // (empty means letsencrypt.org). Must be one of the issuer-domain-names the + // ACME server at acme_url advertises, or every dns-persist-01 order fails. + optional string issuer_domain_name = 5; } // ==================== Tombstone GC Configuration Messages ==================== diff --git a/dstack/gateway/src/admin_service.rs b/dstack/gateway/src/admin_service.rs index 87227fbfd..2cb1cb36d 100644 --- a/dstack/gateway/src/admin_service.rs +++ b/dstack/gateway/src/admin_service.rs @@ -717,6 +717,7 @@ impl AdminRpc for AdminRpcHandler { renew_before_expiration_secs: config.renew_before_expiration.as_secs(), renew_timeout_secs: config.renew_timeout.as_secs(), acme_url: config.acme_url, + issuer_domain_name: config.issuer_domain_name, }) } @@ -1094,6 +1095,9 @@ fn merge_certbot_config( if let Some(url) = request.acme_url { config.acme_url = url; } + if let Some(name) = request.issuer_domain_name { + config.issuer_domain_name = name; + } Ok(config) } @@ -1148,11 +1152,32 @@ mod certbot_config_tests { renew_before_expiration_secs: Some(86400), renew_timeout_secs: Some(30), acme_url: Some("https://acme-staging.example/directory".to_string()), + issuer_domain_name: None, }, ) .expect("a complete request replaces the record"); assert_eq!(merged.renew_interval, Duration::from_secs(60)); assert_eq!(merged.acme_url, "https://acme-staging.example/directory"); + // Not required to repair the record: empty is a meaningful value that + // means Let's Encrypt, so it needs no operator decision. + assert_eq!(merged.issuer_domain_name, ""); + } + + /// The name the CA is known by has to be settable, or a deployment pointed + /// at a non-Let's-Encrypt ACME server publishes records naming the wrong CA + /// with no way to correct them. + #[test] + fn the_issuer_domain_name_round_trips() { + let merged = merge_certbot_config( + Ok(stored()), + SetCertbotConfigRequest { + issuer_domain_name: Some("pebble.letsencrypt.org".to_string()), + ..Default::default() + }, + ) + .expect("a partial update keeps the rest"); + assert_eq!(merged.issuer_domain_name, "pebble.letsencrypt.org"); + assert_eq!(merged.acme_url, stored().acme_url); } } diff --git a/dstack/gateway/templates/dashboard.html b/dstack/gateway/templates/dashboard.html index 7d62d69e0..bc40c17f3 100644 --- a/dstack/gateway/templates/dashboard.html +++ b/dstack/gateway/templates/dashboard.html @@ -549,6 +549,13 @@

Certbot Configuration

ACME server URL (empty = Let's Encrypt production) + + Issuer Domain Name + + + + Name of the CA in dns-persist-01 and CAA records; must be one the ACME server advertises + Renewal Interval @@ -952,6 +959,7 @@

Add ZT-Domain

}); const data = await response.json(); document.getElementById('certbot-acme-url').value = data.acme_url || ''; + document.getElementById('certbot-issuer-domain-name').value = data.issuer_domain_name || ''; document.getElementById('certbot-renew-interval').value = data.renew_interval_secs || 43200; document.getElementById('certbot-renew-before').value = data.renew_before_expiration_secs || 2592000; document.getElementById('certbot-renew-timeout').value = data.renew_timeout_secs || 300; @@ -962,6 +970,7 @@

Add ZT-Domain

async function saveCertbotConfig() { const acmeUrl = document.getElementById('certbot-acme-url').value.trim(); + const issuerDomainName = document.getElementById('certbot-issuer-domain-name').value.trim(); const renewInterval = parseInt(document.getElementById('certbot-renew-interval').value) || undefined; const renewBefore = parseInt(document.getElementById('certbot-renew-before').value) || undefined; const renewTimeout = parseInt(document.getElementById('certbot-renew-timeout').value) || undefined; @@ -969,6 +978,7 @@

Add ZT-Domain

try { const body = {}; if (acmeUrl !== undefined) body.acme_url = acmeUrl; + if (issuerDomainName !== undefined) body.issuer_domain_name = issuerDomainName; if (renewInterval !== undefined) body.renew_interval_secs = renewInterval; if (renewBefore !== undefined) body.renew_before_expiration_secs = renewBefore; if (renewTimeout !== undefined) body.renew_timeout_secs = renewTimeout; From 45cd81b69b4e86b201bf5dbb5b3ad6c435ae104e Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 25 Aug 2026 07:35:37 -0700 Subject: [PATCH 10/37] fix(gateway): keep a ZT domain's challenge across a dashboard edit --- dstack/gateway/templates/dashboard.html | 34 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/dstack/gateway/templates/dashboard.html b/dstack/gateway/templates/dashboard.html index bc40c17f3..605f4455d 100644 --- a/dstack/gateway/templates/dashboard.html +++ b/dstack/gateway/templates/dashboard.html @@ -767,6 +767,13 @@

Edit ZT-Domain

+ + + + +