Skip to content

Commit 617e8a9

Browse files
committed
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.
1 parent eec801b commit 617e8a9

5 files changed

Lines changed: 325 additions & 54 deletions

File tree

dstack/gateway/rpc/proto/gateway_rpc.proto

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ message RotateAcmeCredentialsResponse {
210210
string account_uri = 1;
211211
// Number of ZT domains whose CAA records were updated for the new account.
212212
uint32 domains_updated = 2;
213+
// Zone-file lines an operator must publish by hand, for domains the gateway
214+
// cannot write (dns-persist-01). Non-empty means issuance for those domains
215+
// stays broken until they name the new account.
216+
repeated string required_dns_records = 3;
213217
}
214218

215219
// Get HostInfo for associated instance id.
@@ -742,6 +746,14 @@ message ZtDomainConfig {
742746
optional uint32 node = 4;
743747
// Priority for default base_domain selection (higher = preferred)
744748
int32 priority = 5;
749+
// ACME challenge proving control of this domain: "dns-01" (default) or
750+
// "dns-persist-01". Empty means "dns-01".
751+
//
752+
// "dns-persist-01" needs no DNS credential: control comes from a
753+
// _validation-persist TXT record the operator publishes once, so the gateway
754+
// CVM never holds write access to the zone. Experimental — the draft is still
755+
// changing and Let's Encrypt serves it on staging only.
756+
string challenge = 6;
745757
}
746758

747759
// ZT-Domain information (config + certificate status)
@@ -750,6 +762,10 @@ message ZtDomainInfo {
750762
ZtDomainConfig config = 1;
751763
// Certificate status
752764
ZtDomainCertStatus cert_status = 2;
765+
// Zone-file lines the domain's DNS must contain. Under "dns-01" the gateway
766+
// writes these itself and they are informational; under "dns-persist-01" they
767+
// are the one-time setup an operator has to publish.
768+
repeated string required_dns_records = 3;
753769
}
754770

755771
// ZT-Domain certificate status

dstack/gateway/src/admin_service.rs

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::sync::atomic::Ordering;
66
use std::time::{Duration, SystemTime, UNIX_EPOCH};
77

88
use anyhow::{bail, ensure, Context, Result};
9+
use certbot::ChallengeKind;
910
use dstack_gateway_rpc::{
1011
admin_server::{AdminRpc, AdminServer},
1112
CertAttestationInfo, CertbotConfigResponse, ClearInstancePortPolicyRequest,
@@ -110,10 +111,11 @@ impl AdminRpc for AdminRpcHandler {
110111
}
111112

112113
async fn rotate_acme_credentials(self) -> Result<RotateAcmeCredentialsResponse> {
113-
let (account_uri, domains_updated) = self.state.rotate_acme_credentials().await?;
114+
let outcome = self.state.rotate_acme_credentials().await?;
114115
Ok(RotateAcmeCredentialsResponse {
115-
account_uri,
116-
domains_updated: domains_updated.try_into().unwrap_or(u32::MAX),
116+
account_uri: outcome.account_uri,
117+
domains_updated: outcome.domains_updated.try_into().unwrap_or(u32::MAX),
118+
required_dns_records: outcome.required_dns_records,
117119
})
118120
}
119121

@@ -554,11 +556,15 @@ impl AdminRpc for AdminRpcHandler {
554556
async fn list_zt_domains(self) -> Result<ListZtDomainsResponse> {
555557
let kv_store = self.state.kv_store();
556558
let cert_resolver = &self.state.cert_resolver;
559+
let certbot = &self.state.certbot;
557560

558561
let domains = kv_store
559562
.list_zt_domain_configs()
560563
.into_iter()
561-
.map(|config| zt_domain_to_proto(config, kv_store, cert_resolver))
564+
.map(|config| {
565+
let records = certbot.required_dns_records(&config);
566+
zt_domain_to_proto(config, kv_store, cert_resolver, records)
567+
})
562568
.collect();
563569

564570
Ok(ListZtDomainsResponse { domains })
@@ -573,7 +579,8 @@ impl AdminRpc for AdminRpcHandler {
573579
.get_zt_domain_config(&domain)
574580
.context("ZT-Domain config not found")?;
575581

576-
Ok(zt_domain_to_proto(config, kv_store, cert_resolver))
582+
let records = self.state.certbot.required_dns_records(&config);
583+
Ok(zt_domain_to_proto(config, kv_store, cert_resolver, records))
577584
}
578585

579586
async fn add_zt_domain(self, request: ProtoZtDomainConfig) -> Result<ZtDomainInfo> {
@@ -591,7 +598,8 @@ impl AdminRpc for AdminRpcHandler {
591598
kv_store.save_zt_domain_config(&config)?;
592599
info!("Added ZT-Domain config: {}", config.domain);
593600

594-
Ok(zt_domain_to_proto(config, kv_store, cert_resolver))
601+
let records = self.state.certbot.required_dns_records(&config);
602+
Ok(zt_domain_to_proto(config, kv_store, cert_resolver, records))
595603
}
596604

597605
async fn update_zt_domain(self, request: ProtoZtDomainConfig) -> Result<ZtDomainInfo> {
@@ -608,7 +616,8 @@ impl AdminRpc for AdminRpcHandler {
608616
kv_store.save_zt_domain_config(&config)?;
609617
info!("Updated ZT-Domain config: {}", config.domain);
610618

611-
Ok(zt_domain_to_proto(config, kv_store, cert_resolver))
619+
let records = self.state.certbot.required_dns_records(&config);
620+
Ok(zt_domain_to_proto(config, kv_store, cert_resolver, records))
612621
}
613622

614623
async fn delete_zt_domain(self, request: DeleteZtDomainRequest) -> Result<()> {
@@ -976,20 +985,33 @@ fn proto_to_zt_domain_config(
976985
bail!("port must be between 1 and 65535");
977986
}
978987

988+
// Empty means the historical default: every ZT domain predates the choice.
989+
let challenge = match proto.challenge.as_str() {
990+
"" | "dns-01" => ChallengeKind::Dns01,
991+
"dns-persist-01" => ChallengeKind::DnsPersist01,
992+
other => bail!("unsupported challenge {other:?}, expected dns-01 or dns-persist-01"),
993+
};
994+
979995
Ok(ZtDomainConfig {
980996
domain,
981997
dns_cred_id,
982998
port: proto.port.try_into().context("port out of range")?,
983999
node: proto.node,
9841000
priority: proto.priority,
1001+
challenge,
9851002
})
9861003
}
9871004

9881005
/// Convert internal ZtDomainConfig to proto ZtDomainInfo (with cert status)
1006+
///
1007+
/// `required_dns_records` is best effort: rendering it needs the ACME account
1008+
/// URI, and a domain whose ACME client cannot be built yet still has to be
1009+
/// listable. It comes back empty in that case rather than failing the call.
9891010
fn zt_domain_to_proto(
9901011
config: ZtDomainConfig,
9911012
kv_store: &crate::kv::KvStore,
9921013
cert_resolver: &crate::cert_store::CertResolver,
1014+
required_dns_records: Vec<String>,
9931015
) -> ZtDomainInfo {
9941016
// Get certificate data for status
9951017
let cert_data = kv_store.get_cert_data(&config.domain);
@@ -1003,15 +1025,22 @@ fn zt_domain_to_proto(
10031025
loaded_in_memory,
10041026
});
10051027

1028+
let challenge = match config.challenge {
1029+
ChallengeKind::Dns01 => "dns-01",
1030+
ChallengeKind::DnsPersist01 => "dns-persist-01",
1031+
};
1032+
10061033
ZtDomainInfo {
10071034
config: Some(ProtoZtDomainConfig {
10081035
domain: config.domain,
10091036
dns_cred_id: config.dns_cred_id,
10101037
port: config.port.into(),
10111038
node: config.node,
10121039
priority: config.priority,
1040+
challenge: challenge.to_string(),
10131041
}),
10141042
cert_status,
1043+
required_dns_records,
10151044
}
10161045
}
10171046

0 commit comments

Comments
 (0)