Skip to content

Commit 1fd781e

Browse files
authored
Merge pull request #1035 from Dstack-TEE/fix/gateway-kv-robustness
fix(gateway): harden the KV→data-plane boundary against bad replicated state
2 parents 724293b + 76fa871 commit 1fd781e

14 files changed

Lines changed: 1741 additions & 265 deletions

dstack/gateway/src/admin_service.rs

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

8-
use anyhow::{bail, Context, Result};
8+
use anyhow::{bail, ensure, Context, Result};
99
use 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
};
2626
use ra_rpc::{CallContext, RpcCall};
27-
use tracing::info;
27+
use tracing::{info, warn};
2828
use wavekv::node::NodeStatus as WaveKvNodeStatus;
2929

3030
use 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

3741
pub 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-
769755
fn 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)]
9031001
mod zt_domain_tests {
9041002
use super::validate_zt_domain;

dstack/gateway/src/config.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,48 @@ impl WgConfig {
3131
fn validate(&self) -> Result<()> {
3232
validate(self.ip, &self.reserved_net, self.client_ip_range)
3333
}
34+
35+
/// Whether this gateway may allocate `ip` to a CVM registering with it.
36+
///
37+
/// Narrower than [`Self::is_routable_client_ip`]: `client_ip_range` is this
38+
/// node's *share* of the cluster's address space, and handing out an address
39+
/// from outside it would collide with whichever node owns that share.
40+
pub fn is_valid_client_ip(&self, ip: Ipv4Addr) -> bool {
41+
self.client_ip_range.contains(&ip) && self.is_routable_client_ip(ip)
42+
}
43+
44+
/// Whether `ip` may appear as a WireGuard peer address on this gateway.
45+
///
46+
/// Deliberately says nothing about *which pool* the address came from. A
47+
/// CVM registers with one gateway but is handed every gateway as a
48+
/// WireGuard server, so each node carries peers for the CVMs registered on
49+
/// the other nodes — and each node allocates from its own
50+
/// `client_ip_range`. Nothing in this node's config describes the other
51+
/// nodes' pools, and the deployments do not even agree on a shape that
52+
/// could be inferred: `dstack-app/deploy-to-vmm.sh` puts every pool inside
53+
/// one /16 that each interface covers, while `test-run/cluster.sh` and the
54+
/// e2e configs give each node a /24 that no other node's interface covers.
55+
/// Judging a replicated address by local topology refuses legitimate peers
56+
/// under the second shape, so this is limited to what a node can assert on
57+
/// its own: an ordinary unicast address that is not one of *this* gateway's.
58+
///
59+
/// What keeps the peer list coherent is not this check but the uniqueness
60+
/// pass in `kv::import` — no two instances may claim the same address —
61+
/// which holds cluster-wide because it runs over the whole KV contents.
62+
pub fn is_routable_client_ip(&self, ip: Ipv4Addr) -> bool {
63+
if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() || ip.is_broadcast() {
64+
return false;
65+
}
66+
// This gateway's own addresses: handing them to a peer would point the
67+
// interface's traffic into a tunnel.
68+
if self.ip.addr() == ip || self.ip.broadcast() == ip {
69+
return false;
70+
}
71+
if self.reserved_net.iter().any(|net| net.contains(&ip)) {
72+
return false;
73+
}
74+
true
75+
}
3476
}
3577

3678
fn validate(ip: Ipv4Net, reserved_net: &[Ipv4Net], client_ip_range: Ipv4Net) -> Result<()> {

dstack/gateway/src/debug_service.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ impl DebugRpc for DebugRpcHandler {
8585
// Get all instances
8686
let instances: Vec<InstanceEntry> = kv_store
8787
.load_all_instances()
88+
.decoded
8889
.into_iter()
8990
.map(|(instance_id, data)| InstanceEntry {
9091
instance_id,
@@ -117,11 +118,7 @@ impl DebugRpc for DebugRpcHandler {
117118
.instances
118119
.values()
119120
.map(|inst| {
120-
let reg_time = inst
121-
.reg_time
122-
.duration_since(std::time::UNIX_EPOCH)
123-
.map(|d| d.as_secs())
124-
.unwrap_or(0);
121+
let reg_time = crate::time::encode_ts(inst.reg_time);
125122
ProxyStateInstance {
126123
instance_id: inst.id.clone(),
127124
app_id: inst.app_id.clone(),

0 commit comments

Comments
 (0)