Skip to content

Commit 823e39a

Browse files
committed
feat(gateway): add safe ACME credential rotation
1 parent 5c6700b commit 823e39a

4 files changed

Lines changed: 130 additions & 25 deletions

File tree

dstack/gateway/rpc/proto/gateway_rpc.proto

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ message AcmeInfoResponse {
164164
string account_attestation = 5;
165165
}
166166

167+
// Result of replacing the shared ACME account credentials.
168+
message RotateAcmeCredentialsResponse {
169+
// URI of the newly-created ACME account. The private credentials are never returned.
170+
string account_uri = 1;
171+
// Number of ZT domains whose CAA records were updated for the new account.
172+
uint32 domains_updated = 2;
173+
}
174+
167175
// Get HostInfo for associated instance id.
168176
message GetInfoRequest {
169177
string id = 1;
@@ -466,6 +474,10 @@ service Admin {
466474
rpc GetCertbotConfig(google.protobuf.Empty) returns (CertbotConfigResponse) {}
467475
// Set global certbot configuration (includes ACME URL)
468476
rpc SetCertbotConfig(SetCertbotConfigRequest) returns (google.protobuf.Empty) {}
477+
// Create a new ACME account, update every ZT-domain CAA record, and then
478+
// replace the shared credentials. Call only one gateway at a time because
479+
// WaveKV does not provide compare-and-swap.
480+
rpc RotateAcmeCredentials(google.protobuf.Empty) returns (RotateAcmeCredentialsResponse) {}
469481

470482
// ==================== Per-Instance Port Policy Override ====================
471483
// Set an admin override for an instance's port policy. Takes precedence

dstack/gateway/src/admin_service.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ use dstack_gateway_rpc::{
1818
ListCertAttestationsResponse, ListDnsCredentialsResponse, ListZtDomainsResponse,
1919
NodeStatusEntry, PeerSyncStatus as ProtoPeerSyncStatus, PortAttrs as RpcPortAttrs,
2020
PortPolicy as RpcPortPolicy, RenewCertResponse, RenewZtDomainCertRequest,
21-
RenewZtDomainCertResponse, SetCertbotConfigRequest, SetDefaultDnsCredentialRequest,
22-
SetInstancePortPolicyRequest, SetNodeStatusRequest, SetNodeUrlRequest, StatusResponse,
23-
StoreSyncStatus, UpdateDnsCredentialRequest, WaveKvStatusResponse, ZtDomainCertStatus,
24-
ZtDomainConfig as ProtoZtDomainConfig, ZtDomainInfo,
21+
RenewZtDomainCertResponse, RotateAcmeCredentialsResponse, SetCertbotConfigRequest,
22+
SetDefaultDnsCredentialRequest, SetInstancePortPolicyRequest, SetNodeStatusRequest,
23+
SetNodeUrlRequest, StatusResponse, StoreSyncStatus, UpdateDnsCredentialRequest,
24+
WaveKvStatusResponse, ZtDomainCertStatus, ZtDomainConfig as ProtoZtDomainConfig, ZtDomainInfo,
2525
};
2626
use ra_rpc::{CallContext, RpcCall};
2727
use tracing::info;
@@ -100,6 +100,14 @@ impl AdminRpc for AdminRpcHandler {
100100
self.state.reload_all_certs_from_kvstore()
101101
}
102102

103+
async fn rotate_acme_credentials(self) -> Result<RotateAcmeCredentialsResponse> {
104+
let (account_uri, domains_updated) = self.state.rotate_acme_credentials().await?;
105+
Ok(RotateAcmeCredentialsResponse {
106+
account_uri,
107+
domains_updated: domains_updated.try_into().unwrap_or(u32::MAX),
108+
})
109+
}
110+
103111
async fn status(self) -> Result<StatusResponse> {
104112
self.status().await
105113
}

dstack/gateway/src/distributed_certbot.rs

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ use tracing::{error, info, warn};
2020

2121
use crate::cert_store::CertResolver;
2222
use crate::kv::{
23-
AcmeAttestation, CertAttestation, CertCredentials, CertData, DnsProvider, KvStore,
24-
ZtDomainConfig,
23+
AcmeAttestation, CertAttestation, CertCredentials, CertData, DnsCredential, DnsProvider,
24+
KvStore, ZtDomainConfig,
2525
};
2626

2727
/// Lock timeout for certificate renewal (10 minutes)
@@ -34,11 +34,10 @@ const DEFAULT_ACME_URL: &str = "https://acme-v02.api.letsencrypt.org/directory";
3434
pub struct DistributedCertBot {
3535
kv_store: Arc<KvStore>,
3636
cert_resolver: Arc<CertResolver>,
37-
/// Serializes CAA reconciliation within this process.
37+
/// Serializes CAA reconciliation and credential rotation within this process.
3838
///
39-
/// This is deliberately not a cluster-wide lock: CAA reconciliation is a rare
40-
/// manual operation, so a node-local guard against concurrent admin calls is
41-
/// enough and avoids a distributed lock that could be left behind on crash.
39+
/// This is deliberately not a cluster-wide lock because WaveKV does not
40+
/// provide compare-and-swap.
4241
caa_lock: Mutex<()>,
4342
}
4443

@@ -51,6 +50,89 @@ impl DistributedCertBot {
5150
}
5251
}
5352

53+
async fn dns_client(&self, domain: &str, config: &ZtDomainConfig) -> Result<Dns01Client> {
54+
let dns_cred = if let Some(ref cred_id) = config.dns_cred_id {
55+
self.kv_store
56+
.get_dns_credential(cred_id)
57+
.context("specified DNS credential not found")?
58+
} else {
59+
self.kv_store
60+
.get_default_dns_credential()
61+
.context("no default DNS credential configured")?
62+
};
63+
64+
match &dns_cred.provider {
65+
DnsProvider::Cloudflare { api_token, api_url } => {
66+
Dns01Client::new_cloudflare(domain.to_string(), api_token.clone(), api_url.clone())
67+
.await
68+
}
69+
}
70+
}
71+
72+
/// Rotate the shared ACME account without interrupting certificate serving.
73+
///
74+
/// CAA records are updated before the new credentials are published. WaveKV
75+
/// has no CAS operation, so the lock only serializes calls handled by this
76+
/// node; operators must not rotate through multiple nodes concurrently.
77+
pub async fn rotate_acme_credentials(&self) -> Result<(String, usize)> {
78+
let Ok(_guard) = self.caa_lock.try_lock() else {
79+
bail!("ACME credential rotation or CAA reconciliation is already in progress");
80+
};
81+
let configs = self.kv_store.list_zt_domain_configs();
82+
let first = configs
83+
.first()
84+
.context("no ZT-Domain configured for ACME credential rotation")?;
85+
let certbot_config = self.config();
86+
let acme_url = if certbot_config.acme_url.is_empty() {
87+
DEFAULT_ACME_URL
88+
} else {
89+
&certbot_config.acme_url
90+
};
91+
92+
let first_dns_cred = dns_credential_for(&self.kv_store, first)?;
93+
let dns_client = self.dns_client(&first.domain, first).await?;
94+
let client = AcmeClient::new_account(
95+
acme_url,
96+
dns_client,
97+
first_dns_cred.max_dns_wait,
98+
first_dns_cred.dns_txt_ttl,
99+
)
100+
.await
101+
.context("failed to create replacement ACME account")?;
102+
let credentials = client
103+
.dump_credentials()
104+
.context("failed to encode replacement ACME credentials")?;
105+
let account_uri = client.account_id().to_string();
106+
107+
for config in &configs {
108+
let dns_cred = dns_credential_for(&self.kv_store, config)?;
109+
let dns_client = self.dns_client(&config.domain, config).await?;
110+
let client = AcmeClient::load(
111+
dns_client,
112+
&credentials,
113+
dns_cred.max_dns_wait,
114+
dns_cred.dns_txt_ttl,
115+
)
116+
.await
117+
.with_context(|| format!("failed to prepare ACME client for {}", config.domain))?;
118+
client
119+
.set_caa_records(&[format!("*.{}", config.domain)])
120+
.await
121+
.with_context(|| format!("failed to update CAA for {}", config.domain))?;
122+
}
123+
124+
// Publish only after every CAA update succeeds. Readers create an ACME
125+
// client per operation, so all nodes recover on their next retry after
126+
// WaveKV propagates this value.
127+
self.kv_store.save_acme_credentials(&CertCredentials {
128+
acme_credentials: credentials,
129+
})?;
130+
if let Err(err) = self.generate_and_save_acme_attestation(&account_uri).await {
131+
warn!("failed to attest rotated ACME account: {err:?}");
132+
}
133+
Ok((account_uri, configs.len()))
134+
}
135+
54136
/// Get the current certbot configuration from KV store
55137
fn config(&self) -> crate::kv::GlobalCertbotConfig {
56138
self.kv_store.get_certbot_config()
@@ -363,23 +445,10 @@ impl DistributedCertBot {
363445
config: &ZtDomainConfig,
364446
) -> Result<AcmeClient> {
365447
// Get DNS credential (from config or default)
366-
let dns_cred = if let Some(ref cred_id) = config.dns_cred_id {
367-
self.kv_store
368-
.get_dns_credential(cred_id)
369-
.context("specified DNS credential not found")?
370-
} else {
371-
self.kv_store
372-
.get_default_dns_credential()
373-
.context("no default DNS credential configured")?
374-
};
448+
let dns_cred = dns_credential_for(&self.kv_store, config)?;
375449

376450
// Create DNS client based on provider
377-
let dns01_client = match &dns_cred.provider {
378-
DnsProvider::Cloudflare { api_token, api_url } => {
379-
Dns01Client::new_cloudflare(domain.to_string(), api_token.clone(), api_url.clone())
380-
.await?
381-
}
382-
};
451+
let dns01_client = self.dns_client(domain, config).await?;
383452

384453
// Use ACME URL from certbot config, fall back to default if not set
385454
let config = self.config();
@@ -556,6 +625,18 @@ impl DistributedCertBot {
556625
}
557626
}
558627

628+
fn dns_credential_for(kv_store: &KvStore, config: &ZtDomainConfig) -> Result<DnsCredential> {
629+
if let Some(ref cred_id) = config.dns_cred_id {
630+
kv_store
631+
.get_dns_credential(cred_id)
632+
.context("specified DNS credential not found")
633+
} else {
634+
kv_store
635+
.get_default_dns_credential()
636+
.context("no default DNS credential configured")
637+
}
638+
}
639+
559640
fn now_secs() -> u64 {
560641
SystemTime::now()
561642
.duration_since(UNIX_EPOCH)

dstack/gateway/src/main_service.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,10 @@ impl Proxy {
430430
}
431431
}
432432

433+
pub(crate) async fn rotate_acme_credentials(&self) -> Result<(String, usize)> {
434+
self.certbot.rotate_acme_credentials().await
435+
}
436+
433437
/// Get ACME info for all managed domains (or a specific domain)
434438
pub(crate) fn acme_info(&self, domain: Option<&str>) -> Result<AcmeInfoResponse> {
435439
let kv_store = self.kv_store.clone();

0 commit comments

Comments
 (0)