@@ -20,8 +20,8 @@ use tracing::{error, info, warn};
2020
2121use crate :: cert_store:: CertResolver ;
2222use 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";
3434pub 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+
559640fn now_secs ( ) -> u64 {
560641 SystemTime :: now ( )
561642 . duration_since ( UNIX_EPOCH )
0 commit comments