Skip to content

Commit 92ea2ed

Browse files
authored
fix(certbot): clear stale dns-01 records once per challenge name (#1136)
1 parent 1a90888 commit 92ea2ed

2 files changed

Lines changed: 61 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
- http-client: a caller can bound the response body (`http_request_bounded`, `PrpcClient::with_max_response_bytes`). Nothing is bounded by default — `dstack vmm logs --lines 100000` is a legitimate multi-megabyte fetch — but every client that talks to a guest agent opts in, in the gateway and in the VMM, because a CVM is untrusted and one of them polls on a timer against the whole fleet
3535

3636
### Fixed
37+
- certbot: a certificate covering both a name and its wildcard (`example.com` and `*.example.com`) could never be issued over dns-01. The two authorizations are answered under one `_acme-challenge.example.com`, each with its own TXT value, and the publish step cleared every TXT record at that name before writing its own -- so the second authorization deleted the record answering the first, and the order failed with `Correct value not found for DNS challenge`. Clearing leftovers from an aborted run is now done once per challenge name per issuance, and the records for one name accumulate instead of replacing each other; cleanup afterwards is unchanged, deleting each record this run created by id
3738
- gateway: a node removed via `RemoveNode` silently rejoined the cluster the next time it started, because every node re-registers its own sync address on boot. Once tombstone GC is collecting, that comeback is worse than an annoyance: a stale data directory diverges from every digest, and the divergence repair's full re-exchange resurrects records whose deletes the cluster already collected. Removal now writes a durable marker — a live record, so the GC can never eat it — that every gateway's sync endpoints enforce; a removed node's envelopes are refused until an operator re-admits it with `SetNodeUrl`. The refused node counts HTTP 403 sync rejections (`dstack_gateway_sync_rejected_total`), including removal lockouts and app-identity mismatches. Every gateway also exposes `dstack_gateway_node_last_seen_timestamp_seconds` per known node, so a long-offline gateway is a one-line alert instead of an ack-watermark puzzle
3839
- gateway: deleted KV records left a tombstone that nothing ever collected, so every deregistered CVM stayed on disk for the life of the deployment. Tombstones every peer has acknowledged are now dropped once every `tombstone_gc_writes` replicated writes (default 10000, zero disables); the trigger counts replicated writes rather than reading a clock, so nodes in a cluster collect in the same window without depending on time synchronization. A `SetTombstoneGcConfig` admin RPC stores an operator override in the KV itself, replicating one pace to every node
3940
- gateway: `Admin.RemoveCvm` now reports the outcome of the `inst/` tombstone alone. A failure to delete associated override or telemetry records is logged instead of failing the call, so a removal that did take effect is no longer reported as failed — which also aborted the local routing cleanup that follows it. Re-issuing a removal still sweeps up override and telemetry records orphaned by an earlier partial failure

dstack/certbot/src/acme_client.rs

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -338,11 +338,22 @@ impl AcmeClient {
338338

339339
let acme_domain = challenge_domain(challenge.identifier())?;
340340
let dns_value = challenge.key_authorization().dns_value();
341-
debug!("removing existing TXT record for {acme_domain}");
342-
self.dns01_client
343-
.remove_txt_records(&acme_domain)
344-
.await
345-
.context("failed to remove existing dns record")?;
341+
// Clearing stale records is a per-name preparation step, not a
342+
// per-authorization one. An order for `example.com` and
343+
// `*.example.com` yields two authorizations that are both answered
344+
// under `_acme-challenge.example.com`, each with its own value, and
345+
// both values have to be live at validation time. Purging again for
346+
// the second authorization would delete the record the first one
347+
// just published, so one of the two challenges could never be
348+
// answered and the order failed with "Correct value not found for
349+
// DNS challenge".
350+
if needs_purge(challenges, &acme_domain) {
351+
debug!("removing existing TXT records for {acme_domain}");
352+
self.dns01_client
353+
.remove_txt_records(&acme_domain)
354+
.await
355+
.context("failed to remove existing dns record")?;
356+
}
346357
debug!(
347358
"creating TXT record for {acme_domain} with TTL {}s",
348359
self.dns_txt_ttl
@@ -649,6 +660,17 @@ impl AcmeClient {
649660
}
650661
}
651662

663+
/// Whether the stale TXT records under `acme_domain` still have to be cleared.
664+
///
665+
/// The purge runs once per challenge name per issuance: the records this run
666+
/// has already published live under the names in `published`, and clearing
667+
/// those would take an answered challenge back down.
668+
fn needs_purge(published: &[Challenge], acme_domain: &str) -> bool {
669+
!published
670+
.iter()
671+
.any(|challenge| challenge.acme_domain == acme_domain)
672+
}
673+
652674
/// The name of the TXT record that answers a dns-01 challenge for `identifier`.
653675
///
654676
/// The record always lives under the bare name: a wildcard authorization for
@@ -922,3 +944,36 @@ mod challenge_domain_tests {
922944
assert!(challenge_domain(&ip.authorized(false)).is_err());
923945
}
924946
}
947+
948+
#[cfg(test)]
949+
mod purge_tests {
950+
use super::{needs_purge, Challenge};
951+
952+
fn challenge(acme_domain: &str, dns_value: &str) -> Challenge {
953+
Challenge {
954+
id: format!("rec-{dns_value}"),
955+
acme_domain: acme_domain.to_string(),
956+
dns_value: dns_value.to_string(),
957+
}
958+
}
959+
960+
/// A base name and its wildcard are two authorizations answered under one
961+
/// `_acme-challenge.<name>`. The first one clears whatever an aborted run
962+
/// left behind; the second must publish alongside it instead of wiping it.
963+
#[test]
964+
fn a_name_is_cleared_once_per_issuance() {
965+
let mut published = vec![];
966+
assert!(needs_purge(&published, "_acme-challenge.example.com"));
967+
968+
published.push(challenge("_acme-challenge.example.com", "value-for-base"));
969+
assert!(!needs_purge(&published, "_acme-challenge.example.com"));
970+
}
971+
972+
/// A SAN list can span zones, and clearing one challenge name says nothing
973+
/// about the others.
974+
#[test]
975+
fn an_untouched_name_is_still_cleared() {
976+
let published = vec![challenge("_acme-challenge.example.com", "value-for-base")];
977+
assert!(needs_purge(&published, "_acme-challenge.example.org"));
978+
}
979+
}

0 commit comments

Comments
 (0)