55use std:: sync:: atomic:: Ordering ;
66use std:: time:: { Duration , SystemTime , UNIX_EPOCH } ;
77
8- use anyhow:: { bail, Context , Result } ;
8+ use anyhow:: { bail, ensure , Context , Result } ;
99use dstack_gateway_rpc:: {
1010 admin_server:: { AdminRpc , AdminServer } ,
1111 CertAttestationInfo , CertbotConfigResponse , ClearInstancePortPolicyRequest ,
@@ -24,14 +24,18 @@ use dstack_gateway_rpc::{
2424 WaveKvStatusResponse , ZtDomainCertStatus , ZtDomainConfig as ProtoZtDomainConfig , ZtDomainInfo ,
2525} ;
2626use ra_rpc:: { CallContext , RpcCall } ;
27- use tracing:: info;
27+ use tracing:: { info, warn } ;
2828use wavekv:: node:: NodeStatus as WaveKvNodeStatus ;
2929
3030use crate :: {
31- kv:: { DnsCredential , DnsProvider , NodeStatus , PortFlags , PortPolicy , ZtDomainConfig } ,
31+ kv:: {
32+ DnsCredential , DnsProvider , GlobalCertbotConfig , NodeStatus , PortFlags , PortPolicy ,
33+ ZtDomainConfig ,
34+ } ,
3235 main_service:: Proxy ,
3336 models:: PortPolicyView ,
3437 proxy:: { stats:: accel_status, NUM_CONNECTIONS } ,
38+ time:: now_secs,
3539} ;
3640
3741pub struct AdminRpcHandler {
@@ -310,7 +314,7 @@ impl AdminRpc for AdminRpcHandler {
310314 . into_iter ( )
311315 . map ( dns_cred_to_proto)
312316 . collect ( ) ;
313- let default_id = kv_store. get_default_dns_credential_id ( ) ;
317+ let default_id = kv_store. get_default_dns_credential_id ( ) ? ;
314318 Ok ( ListDnsCredentialsResponse {
315319 credentials,
316320 default_id,
@@ -323,7 +327,7 @@ impl AdminRpc for AdminRpcHandler {
323327 ) -> Result < DnsCredentialInfo > {
324328 let kv_store = self . state . kv_store ( ) ;
325329 let cred = kv_store
326- . get_dns_credential ( & request. id )
330+ . get_dns_credential ( & request. id ) ?
327331 . context ( "dns credential not found" ) ?;
328332 Ok ( dns_cred_to_proto ( cred) )
329333 }
@@ -383,7 +387,7 @@ impl AdminRpc for AdminRpcHandler {
383387 let kv_store = self . state . kv_store ( ) ;
384388
385389 let mut cred = kv_store
386- . get_dns_credential ( & request. id )
390+ . get_dns_credential ( & request. id ) ?
387391 . context ( "dns credential not found" ) ?;
388392
389393 // Update name if provided
@@ -414,7 +418,7 @@ impl AdminRpc for AdminRpcHandler {
414418 let kv_store = self . state . kv_store ( ) ;
415419
416420 // Check if this is the default credential
417- if let Some ( default_id) = kv_store. get_default_dns_credential_id ( ) {
421+ if let Some ( default_id) = kv_store. get_default_dns_credential_id ( ) ? {
418422 if default_id == request. id {
419423 bail ! ( "cannot delete the default DNS credential; set a different default first" ) ;
420424 }
@@ -438,8 +442,12 @@ impl AdminRpc for AdminRpcHandler {
438442
439443 async fn get_default_dns_credential ( self ) -> Result < GetDefaultDnsCredentialResponse > {
440444 let kv_store = self . state . kv_store ( ) ;
441- let default_id = kv_store. get_default_dns_credential_id ( ) . unwrap_or_default ( ) ;
442- let credential = kv_store. get_default_dns_credential ( ) . map ( dns_cred_to_proto) ;
445+ let default_id = kv_store
446+ . get_default_dns_credential_id ( ) ?
447+ . unwrap_or_default ( ) ;
448+ let credential = kv_store
449+ . get_default_dns_credential ( ) ?
450+ . map ( dns_cred_to_proto) ;
443451 Ok ( GetDefaultDnsCredentialResponse {
444452 default_id,
445453 credential,
@@ -454,7 +462,7 @@ impl AdminRpc for AdminRpcHandler {
454462
455463 // Verify the credential exists
456464 kv_store
457- . get_dns_credential ( & request. id )
465+ . get_dns_credential ( & request. id ) ?
458466 . context ( "dns credential not found" ) ?;
459467
460468 kv_store. set_default_dns_credential_id ( & request. id ) ?;
@@ -609,7 +617,7 @@ impl AdminRpc for AdminRpcHandler {
609617 // ==================== Global Certbot Configuration ====================
610618
611619 async fn get_certbot_config ( self ) -> Result < CertbotConfigResponse > {
612- let config = self . state . kv_store ( ) . get_certbot_config ( ) ;
620+ let config = self . state . kv_store ( ) . get_certbot_config ( ) ? ;
613621 Ok ( CertbotConfigResponse {
614622 renew_interval_secs : config. renew_interval . as_secs ( ) ,
615623 renew_before_expiration_secs : config. renew_before_expiration . as_secs ( ) ,
@@ -620,22 +628,7 @@ impl AdminRpc for AdminRpcHandler {
620628
621629 async fn set_certbot_config ( self , request : SetCertbotConfigRequest ) -> Result < ( ) > {
622630 let kv_store = self . state . kv_store ( ) ;
623- let mut config = kv_store. get_certbot_config ( ) ;
624-
625- // Update only the fields that are specified
626- if let Some ( secs) = request. renew_interval_secs {
627- config. renew_interval = Duration :: from_secs ( secs) ;
628- }
629- if let Some ( secs) = request. renew_before_expiration_secs {
630- config. renew_before_expiration = Duration :: from_secs ( secs) ;
631- }
632- if let Some ( secs) = request. renew_timeout_secs {
633- config. renew_timeout = Duration :: from_secs ( secs) ;
634- }
635- if let Some ( url) = request. acme_url {
636- config. acme_url = url;
637- }
638-
631+ let config = merge_certbot_config ( kv_store. get_certbot_config ( ) , request) ?;
639632 kv_store. set_certbot_config ( & config) ?;
640633 info ! (
641634 "Updated certbot config: renew_interval={:?}, renew_before_expiration={:?}, renew_timeout={:?}, acme_url={:?}" ,
@@ -759,13 +752,6 @@ impl RpcCall<Proxy> for AdminRpcHandler {
759752
760753// ==================== Helper Functions ====================
761754
762- fn now_secs ( ) -> u64 {
763- SystemTime :: now ( )
764- . duration_since ( UNIX_EPOCH )
765- . unwrap_or_default ( )
766- . as_secs ( )
767- }
768-
769755fn generate_cred_id ( ) -> String {
770756 use std:: time:: SystemTime ;
771757 let ts = SystemTime :: now ( )
@@ -851,7 +837,7 @@ fn proto_to_zt_domain_config(
851837 // Validate DNS credential if specified
852838 if let Some ( ref cred_id) = dns_cred_id {
853839 kv_store
854- . get_dns_credential ( cred_id)
840+ . get_dns_credential ( cred_id) ?
855841 . context ( "specified dns credential not found" ) ?;
856842 }
857843
@@ -899,6 +885,118 @@ fn zt_domain_to_proto(
899885 }
900886}
901887
888+ /// Apply a partial certbot-config update to the stored record.
889+ ///
890+ /// SetCertbotConfig is a merge: a field the operator leaves unset keeps its
891+ /// stored value. That needs a readable base, and `global/certbot_config` is a
892+ /// singleton with no delete RPC — so if an unreadable record simply failed the
893+ /// call, the corruption would be permanent, and since `do_rotate_acme_credentials`
894+ /// reads the same key it would keep RotateAcmeCredentials blocked along with it.
895+ ///
896+ /// Merging into the defaults instead is not the answer either: `acme_url`
897+ /// defaults to empty, which means Let's Encrypt production. An operator who hit
898+ /// a corrupt record and then tuned `renew_interval` would silently move issuance
899+ /// off their staging or private ACME server and start burning real rate limits —
900+ /// exactly the switch the fail-closed reader exists to prevent.
901+ ///
902+ /// So an unreadable record is repairable, but only by a request that states
903+ /// every field. Nothing is ever inherited from a record we cannot read.
904+ fn merge_certbot_config (
905+ stored : Result < GlobalCertbotConfig > ,
906+ request : SetCertbotConfigRequest ,
907+ ) -> Result < GlobalCertbotConfig > {
908+ let mut config = match stored {
909+ Ok ( config) => config,
910+ Err ( err) => {
911+ ensure ! (
912+ request. renew_interval_secs. is_some( )
913+ && request. renew_before_expiration_secs. is_some( )
914+ && request. renew_timeout_secs. is_some( )
915+ && request. acme_url. is_some( ) ,
916+ "the stored certbot config is unreadable ({err:#}), so it can only be \
917+ replaced as a whole: resend with renew_interval_secs, \
918+ renew_before_expiration_secs, renew_timeout_secs and acme_url all set"
919+ ) ;
920+ warn ! ( "certbot config is unreadable ({err:#}); replacing it wholesale" ) ;
921+ GlobalCertbotConfig :: default ( )
922+ }
923+ } ;
924+
925+ // Update only the fields that are specified
926+ if let Some ( secs) = request. renew_interval_secs {
927+ config. renew_interval = Duration :: from_secs ( secs) ;
928+ }
929+ if let Some ( secs) = request. renew_before_expiration_secs {
930+ config. renew_before_expiration = Duration :: from_secs ( secs) ;
931+ }
932+ if let Some ( secs) = request. renew_timeout_secs {
933+ config. renew_timeout = Duration :: from_secs ( secs) ;
934+ }
935+ if let Some ( url) = request. acme_url {
936+ config. acme_url = url;
937+ }
938+ Ok ( config)
939+ }
940+
941+ #[ cfg( test) ]
942+ mod certbot_config_tests {
943+ use super :: * ;
944+
945+ fn stored ( ) -> GlobalCertbotConfig {
946+ GlobalCertbotConfig {
947+ renew_interval : Duration :: from_secs ( 3600 ) ,
948+ acme_url : "https://acme-staging.example/directory" . to_string ( ) ,
949+ ..Default :: default ( )
950+ }
951+ }
952+
953+ #[ test]
954+ fn a_partial_update_keeps_the_fields_it_does_not_mention ( ) {
955+ let merged = merge_certbot_config (
956+ Ok ( stored ( ) ) ,
957+ SetCertbotConfigRequest {
958+ renew_timeout_secs : Some ( 60 ) ,
959+ ..Default :: default ( )
960+ } ,
961+ )
962+ . expect ( "a readable record merges" ) ;
963+ assert_eq ! ( merged. renew_timeout, Duration :: from_secs( 60 ) ) ;
964+ assert_eq ! ( merged. acme_url, stored( ) . acme_url) ;
965+ }
966+
967+ #[ test]
968+ fn a_partial_update_cannot_repair_an_unreadable_record ( ) {
969+ // Falling back to the defaults here would reset `acme_url` to empty,
970+ // silently moving issuance to Let's Encrypt production.
971+ let err = merge_certbot_config (
972+ Err ( anyhow:: anyhow!( "corrupt record" ) ) ,
973+ SetCertbotConfigRequest {
974+ renew_interval_secs : Some ( 60 ) ,
975+ ..Default :: default ( )
976+ } ,
977+ )
978+ . expect_err ( "a partial update must not inherit from an unreadable record" ) ;
979+ assert ! ( err. to_string( ) . contains( "acme_url" ) , "{err:#}" ) ;
980+ }
981+
982+ #[ test]
983+ fn a_complete_request_replaces_an_unreadable_record ( ) {
984+ // The only repair path: no field is inherited, so nothing is guessed.
985+ let merged = merge_certbot_config (
986+ Err ( anyhow:: anyhow!( "corrupt record" ) ) ,
987+ SetCertbotConfigRequest {
988+ renew_interval_secs : Some ( 60 ) ,
989+ renew_before_expiration_secs : Some ( 86400 ) ,
990+ renew_timeout_secs : Some ( 30 ) ,
991+ acme_url : Some ( "https://acme-staging.example/directory" . to_string ( ) ) ,
992+ } ,
993+ )
994+ . expect ( "a complete request replaces the record" ) ;
995+ assert_eq ! ( merged. renew_interval, Duration :: from_secs( 60 ) ) ;
996+ assert_eq ! ( merged. acme_url, "https://acme-staging.example/directory" ) ;
997+ }
998+ }
999+
9021000#[ cfg( test) ]
9031001mod zt_domain_tests {
9041002 use super :: validate_zt_domain;
0 commit comments