Skip to content

Commit 8cfbecc

Browse files
committed
gw: admin RPC to override per-instance port policy
Operators can now override an instance's port policy through the Admin service, taking precedence over what the instance reports for itself. Three new methods on the Admin service: - SetInstancePortPolicy(instance_id, policy) Persists an admin override on the instance record. Errors with "instance not found" (404 semantics) if the instance is not registered — operators don't pre-create policy rows. - ClearInstancePortPolicy(instance_id) Removes the override. Effective policy reverts to whatever the instance reported (or none, fail-close again). - GetInstancePortPolicy(instance_id) -> { effective, source, instance_reported, admin_override } Returns both layers and the resolved effective policy. The `source` string ("admin" | "instance" | "none") makes "why was port X rejected" answerable in one call. Storage and resolution: - `InstanceData.admin_port_policy: Option<PortPolicy>` lives next to the existing `port_policy` field, persisted to WaveKV under the same `inst/{id}` record so overrides sync across nodes for free. - `instance_port_policy()` (used by both `is_port_allowed` and `should_send_pp`) now returns `admin_port_policy.or(port_policy)`. - App upgrade (compose_hash change) clears the instance-reported policy as before, but the admin override survives. Operator intent is stronger than app updates; they can re-evaluate explicitly. - Lazy fetch only ever writes `port_policy`, never touches the override. Tests: - 5 new unit tests: override beats instance, override can open what the instance restricts (and vice versa), clearing reverts, unknown instance errors, override survives compose_hash change.
1 parent 7f372f3 commit 8cfbecc

8 files changed

Lines changed: 362 additions & 19 deletions

File tree

gateway/rpc/proto/gateway_rpc.proto

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,18 @@ service Admin {
439439
rpc GetCertbotConfig(google.protobuf.Empty) returns (CertbotConfigResponse) {}
440440
// Set global certbot configuration (includes ACME URL)
441441
rpc SetCertbotConfig(SetCertbotConfigRequest) returns (google.protobuf.Empty) {}
442+
443+
// ==================== Per-Instance Port Policy Override ====================
444+
// Set an admin override for an instance's port policy. Takes precedence
445+
// over the policy reported by the instance itself, and survives app
446+
// upgrades. Errors if the instance is not registered.
447+
rpc SetInstancePortPolicy(SetInstancePortPolicyRequest) returns (google.protobuf.Empty) {}
448+
// Clear the admin override for an instance, reverting to the
449+
// instance-reported policy. Errors if the instance is not registered.
450+
rpc ClearInstancePortPolicy(ClearInstancePortPolicyRequest) returns (google.protobuf.Empty) {}
451+
// Inspect both the admin override and the instance-reported policy for an
452+
// instance, plus the effective policy the proxy will enforce.
453+
rpc GetInstancePortPolicy(GetInstancePortPolicyRequest) returns (GetInstancePortPolicyResponse) {}
442454
}
443455

444456
// ==================== DNS Credential Messages ====================
@@ -648,3 +660,36 @@ message SetCertbotConfigRequest {
648660
// ACME server URL (empty means use default Let's Encrypt production)
649661
optional string acme_url = 4;
650662
}
663+
664+
// ==================== Per-Instance Port Policy Override Messages ====================
665+
666+
// Set an admin override for an instance.
667+
message SetInstancePortPolicyRequest {
668+
// The instance to override.
669+
string instance_id = 1;
670+
// The policy to apply. An empty `ports` list with `restrict_mode = true`
671+
// is a valid "deny everything" lockdown.
672+
PortPolicy policy = 2;
673+
}
674+
675+
// Clear the admin override for an instance.
676+
message ClearInstancePortPolicyRequest {
677+
string instance_id = 1;
678+
}
679+
680+
// Inspect an instance's port-policy state.
681+
message GetInstancePortPolicyRequest {
682+
string instance_id = 1;
683+
}
684+
685+
message GetInstancePortPolicyResponse {
686+
// The policy the proxy will actually enforce. Absent when neither admin
687+
// nor instance has set anything (fail-close until populated).
688+
optional PortPolicy effective = 1;
689+
// Where `effective` came from: "admin", "instance", or "none".
690+
string source = 2;
691+
// The policy reported by the instance itself, if any.
692+
optional PortPolicy instance_reported = 3;
693+
// The admin override, if any.
694+
optional PortPolicy admin_override = 4;
695+
}

gateway/src/admin_service.rs

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
88
use anyhow::{bail, Context, Result};
99
use dstack_gateway_rpc::{
1010
admin_server::{AdminRpc, AdminServer},
11-
CertAttestationInfo, CertbotConfigResponse, CreateDnsCredentialRequest,
12-
DeleteDnsCredentialRequest, DeleteZtDomainRequest, DnsCredentialInfo,
13-
ForceReleaseCertLockRequest, GetDefaultDnsCredentialResponse, GetDnsCredentialRequest,
14-
GetInfoRequest, GetInfoResponse, GetInstanceHandshakesRequest, GetInstanceHandshakesResponse,
11+
CertAttestationInfo, CertbotConfigResponse, ClearInstancePortPolicyRequest,
12+
CreateDnsCredentialRequest, DeleteDnsCredentialRequest, DeleteZtDomainRequest,
13+
DnsCredentialInfo, ForceReleaseCertLockRequest, GetDefaultDnsCredentialResponse,
14+
GetDnsCredentialRequest, GetInfoRequest, GetInfoResponse, GetInstanceHandshakesRequest,
15+
GetInstanceHandshakesResponse, GetInstancePortPolicyRequest, GetInstancePortPolicyResponse,
1516
GetMetaResponse, GetNodeStatusesResponse, GetZtDomainRequest, GlobalConnectionsStats,
1617
HandshakeEntry, HostInfo, LastSeenEntry, ListCertAttestationsRequest,
1718
ListCertAttestationsResponse, ListDnsCredentialsResponse, ListZtDomainsResponse,
18-
NodeStatusEntry, PeerSyncStatus as ProtoPeerSyncStatus, RenewCertResponse,
19-
RenewZtDomainCertRequest, RenewZtDomainCertResponse, SetCertbotConfigRequest,
20-
SetDefaultDnsCredentialRequest, SetNodeStatusRequest, SetNodeUrlRequest, StatusResponse,
19+
NodeStatusEntry, PeerSyncStatus as ProtoPeerSyncStatus, PortAttrs as RpcPortAttrs,
20+
PortPolicy as RpcPortPolicy, RenewCertResponse, RenewZtDomainCertRequest,
21+
RenewZtDomainCertResponse, SetCertbotConfigRequest, SetDefaultDnsCredentialRequest,
22+
SetInstancePortPolicyRequest, SetNodeStatusRequest, SetNodeUrlRequest, StatusResponse,
2123
StoreSyncStatus, UpdateDnsCredentialRequest, WaveKvStatusResponse, ZtDomainCertStatus,
2224
ZtDomainConfig as ProtoZtDomainConfig, ZtDomainInfo,
2325
};
@@ -26,8 +28,9 @@ use tracing::info;
2628
use wavekv::node::NodeStatus as WaveKvNodeStatus;
2729

2830
use crate::{
29-
kv::{DnsCredential, DnsProvider, NodeStatus, ZtDomainConfig},
31+
kv::{DnsCredential, DnsProvider, NodeStatus, PortFlags, PortPolicy, ZtDomainConfig},
3032
main_service::Proxy,
33+
models::PortPolicyView,
3134
proxy::NUM_CONNECTIONS,
3235
};
3336

@@ -625,6 +628,73 @@ impl AdminRpc for AdminRpcHandler {
625628
);
626629
Ok(())
627630
}
631+
632+
async fn set_instance_port_policy(self, request: SetInstancePortPolicyRequest) -> Result<()> {
633+
let proto = request.policy.context("port policy is required")?;
634+
let policy = port_policy_from_proto(proto)?;
635+
self.state
636+
.lock()
637+
.set_admin_port_policy(&request.instance_id, policy)
638+
}
639+
640+
async fn clear_instance_port_policy(
641+
self,
642+
request: ClearInstancePortPolicyRequest,
643+
) -> Result<()> {
644+
self.state
645+
.lock()
646+
.clear_admin_port_policy(&request.instance_id)
647+
}
648+
649+
async fn get_instance_port_policy(
650+
self,
651+
request: GetInstancePortPolicyRequest,
652+
) -> Result<GetInstancePortPolicyResponse> {
653+
let view = self
654+
.state
655+
.lock()
656+
.instance_port_policy_view(&request.instance_id)
657+
.with_context(|| format!("instance {} not found", request.instance_id))?;
658+
Ok(port_policy_view_to_proto(view))
659+
}
660+
}
661+
662+
fn port_policy_from_proto(proto: RpcPortPolicy) -> Result<PortPolicy> {
663+
let mut ports = std::collections::BTreeMap::new();
664+
for attr in proto.ports {
665+
let port = u16::try_from(attr.port)
666+
.with_context(|| format!("port {} out of u16 range", attr.port))?;
667+
ports.insert(port, PortFlags { pp: attr.pp });
668+
}
669+
Ok(PortPolicy {
670+
ports,
671+
restrict_mode: proto.restrict_mode,
672+
})
673+
}
674+
675+
fn port_policy_to_proto(policy: &PortPolicy) -> RpcPortPolicy {
676+
RpcPortPolicy {
677+
ports: policy
678+
.ports
679+
.iter()
680+
.map(|(port, flags)| RpcPortAttrs {
681+
port: u32::from(*port),
682+
pp: flags.pp,
683+
})
684+
.collect(),
685+
restrict_mode: policy.restrict_mode,
686+
}
687+
}
688+
689+
fn port_policy_view_to_proto(view: PortPolicyView) -> GetInstancePortPolicyResponse {
690+
let source = view.source().to_string();
691+
let effective = view.effective().map(port_policy_to_proto);
692+
GetInstancePortPolicyResponse {
693+
effective,
694+
source,
695+
instance_reported: view.instance_reported.as_ref().map(port_policy_to_proto),
696+
admin_override: view.admin_override.as_ref().map(port_policy_to_proto),
697+
}
628698
}
629699

630700
fn build_store_status(

gateway/src/kv/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ pub struct InstanceData {
8181
/// the cache is invalidated and re-fetched lazily.
8282
#[serde(default)]
8383
pub port_policy_hash: String,
84+
/// Operator-set override applied via the Admin RPC. Takes precedence over
85+
/// the instance-reported `port_policy` when set, and survives app upgrades
86+
/// (compose_hash changes do not clear it). Cleared explicitly via
87+
/// ClearInstancePortPolicy.
88+
#[serde(default)]
89+
pub admin_port_policy: Option<PortPolicy>,
8490
}
8591

8692
/// Gateway node status (stored separately for independent updates)

gateway/src/main_service.rs

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use crate::{
4242
fetch_peers_from_bootnode, AppIdValidator, HttpsClientConfig, InstanceData, KvStore,
4343
NodeData, NodeStatus, PortPolicy, WaveKvSyncService,
4444
},
45-
models::{InstanceInfo, WgConf},
45+
models::{InstanceInfo, PortPolicyView, WgConf},
4646
proxy::{create_acceptor_with_cert_resolver, AddressGroup, AddressInfo},
4747
};
4848

@@ -78,10 +78,12 @@ pub struct ProxyInner {
7878
pub(crate) wavekv_sync: Option<Arc<WaveKvSyncService>>,
7979
/// HTTPS client config for mTLS (used for bootnode peer discovery)
8080
https_config: Option<HttpsClientConfig>,
81-
/// Sender for the background port_policy lazy-fetch worker. The proxy
82-
/// data path enqueues unknown instance_ids and waits for the cache to
83-
/// populate (fail-close): without a known policy, restrict_mode is
84-
/// indeterminate and we cannot safely allow traffic.
81+
/// Sender for the background port_policy lazy-fetch worker. On a cache
82+
/// miss the proxy data path enqueues the instance_id and immediately
83+
/// rejects the connection (fail-close); the fetch populates the cache
84+
/// asynchronously so subsequent connections can proceed. Without a
85+
/// known policy, `restrict_mode` is indeterminate and we cannot safely
86+
/// allow traffic.
8587
pub(crate) port_policy_tx: UnboundedSender<String>,
8688
}
8789

@@ -491,6 +493,7 @@ fn build_state_from_kv_store(instances: BTreeMap<String, InstanceData>) -> Proxy
491493
.unwrap_or(UNIX_EPOCH),
492494
port_policy: data.port_policy,
493495
port_policy_hash: data.port_policy_hash,
496+
admin_port_policy: data.admin_port_policy,
494497
connections: Default::default(),
495498
};
496499
state.allocated_addresses.insert(data.ip);
@@ -786,6 +789,7 @@ fn reload_instances_from_kv_store(proxy: &Proxy, store: &KvStore) -> Result<()>
786789
.unwrap_or(UNIX_EPOCH),
787790
port_policy: data.port_policy.clone(),
788791
port_policy_hash: data.port_policy_hash.clone(),
792+
admin_port_policy: data.admin_port_policy.clone(),
789793
connections: Default::default(),
790794
};
791795

@@ -914,6 +918,7 @@ impl ProxyState {
914918
reg_time: encode_ts(existing.reg_time),
915919
port_policy: existing.port_policy.clone(),
916920
port_policy_hash: existing.port_policy_hash.clone(),
921+
admin_port_policy: existing.admin_port_policy.clone(),
917922
};
918923
if let Err(err) = self.kv_store.sync_instance(&existing.id, &data) {
919924
error!("failed to sync existing instance to KvStore: {err:?}");
@@ -934,6 +939,7 @@ impl ProxyState {
934939
reg_time: SystemTime::now(),
935940
port_policy,
936941
port_policy_hash: compose_hash.to_string(),
942+
admin_port_policy: None,
937943
connections: Default::default(),
938944
};
939945
self.add_instance(host_info.clone());
@@ -945,10 +951,14 @@ impl ProxyState {
945951
self.state.instances.get(instance_id).map(|i| i.ip)
946952
}
947953

948-
/// Lookup an instance's port_policy. `None` means the CVM never reported
949-
/// it (legacy CVM), so the caller should fall back to fetching via Info().
954+
/// Lookup the effective port_policy for an instance: admin override wins,
955+
/// otherwise fall back to the instance-reported policy. `None` means
956+
/// neither is set — caller fails closed and may schedule a lazy fetch.
950957
pub(crate) fn instance_port_policy(&self, instance_id: &str) -> Option<&PortPolicy> {
951-
self.state.instances.get(instance_id)?.port_policy.as_ref()
958+
let info = self.state.instances.get(instance_id)?;
959+
info.admin_port_policy
960+
.as_ref()
961+
.or(info.port_policy.as_ref())
952962
}
953963

954964
/// Update an instance's port_policy (used after a lazy fetch via Info()).
@@ -957,17 +967,71 @@ impl ProxyState {
957967
let Some(info) = self.state.instances.get_mut(instance_id) else {
958968
return;
959969
};
960-
info.port_policy = Some(policy.clone());
970+
info.port_policy = Some(policy);
971+
self.persist_instance_record(instance_id);
972+
}
973+
974+
/// Snapshot view of an instance's port-policy state for inspection.
975+
pub(crate) fn instance_port_policy_view(&self, instance_id: &str) -> Option<PortPolicyView> {
976+
let info = self.state.instances.get(instance_id)?;
977+
Some(PortPolicyView {
978+
instance_reported: info.port_policy.clone(),
979+
admin_override: info.admin_port_policy.clone(),
980+
})
981+
}
982+
983+
/// Set an admin override. Errors if the instance is not registered.
984+
pub(crate) fn set_admin_port_policy(
985+
&mut self,
986+
instance_id: &str,
987+
policy: PortPolicy,
988+
) -> Result<()> {
989+
let Some(info) = self.state.instances.get_mut(instance_id) else {
990+
bail!("instance {instance_id} not found");
991+
};
992+
let prev = info.admin_port_policy.take();
993+
info.admin_port_policy = Some(policy.clone());
994+
info!(
995+
"admin set port_policy for instance {instance_id}: \
996+
restrict_mode={}, ports={} (prev: {})",
997+
policy.restrict_mode,
998+
policy.ports.len(),
999+
prev.is_some(),
1000+
);
1001+
self.persist_instance_record(instance_id);
1002+
Ok(())
1003+
}
1004+
1005+
/// Clear any admin override. Errors if the instance is not registered.
1006+
/// No-op (still Ok) if no override was set.
1007+
pub(crate) fn clear_admin_port_policy(&mut self, instance_id: &str) -> Result<()> {
1008+
let Some(info) = self.state.instances.get_mut(instance_id) else {
1009+
bail!("instance {instance_id} not found");
1010+
};
1011+
let had_override = info.admin_port_policy.take().is_some();
1012+
info!("admin cleared port_policy for instance {instance_id} (was set: {had_override})");
1013+
if had_override {
1014+
self.persist_instance_record(instance_id);
1015+
}
1016+
Ok(())
1017+
}
1018+
1019+
/// Persist the current in-memory `InstanceInfo` snapshot to WaveKV.
1020+
fn persist_instance_record(&self, instance_id: &str) {
1021+
let Some(info) = self.state.instances.get(instance_id) else {
1022+
return;
1023+
};
9611024
let data = InstanceData {
9621025
app_id: info.app_id.clone(),
9631026
ip: info.ip,
9641027
public_key: info.public_key.clone(),
9651028
reg_time: encode_ts(info.reg_time),
966-
port_policy: Some(policy),
1029+
port_policy: info.port_policy.clone(),
9671030
port_policy_hash: info.port_policy_hash.clone(),
1031+
admin_port_policy: info.admin_port_policy.clone(),
9681032
};
9691033
if let Err(err) = self.kv_store.sync_instance(instance_id, &data) {
970-
error!("failed to sync updated port_policy to KvStore: {err:?}");
1034+
error!("failed to sync instance {instance_id} to KvStore: {err:?}");
9711035
}
9721036
}
9731037

@@ -980,6 +1044,7 @@ impl ProxyState {
9801044
reg_time: encode_ts(info.reg_time),
9811045
port_policy: info.port_policy.clone(),
9821046
port_policy_hash: info.port_policy_hash.clone(),
1047+
admin_port_policy: info.admin_port_policy.clone(),
9831048
};
9841049
if let Err(err) = self.kv_store.sync_instance(&info.id, &data) {
9851050
error!("failed to sync instance to KvStore: {err:?}");

gateway/src/main_service/snapshots/dstack_gateway__main_service__tests__config-2.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ InstanceInfo {
1414
},
1515
port_policy: None,
1616
port_policy_hash: "",
17+
admin_port_policy: None,
1718
connections: 0,
1819
}

gateway/src/main_service/snapshots/dstack_gateway__main_service__tests__config.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ InstanceInfo {
1414
},
1515
port_policy: None,
1616
port_policy_hash: "",
17+
admin_port_policy: None,
1718
connections: 0,
1819
}

0 commit comments

Comments
 (0)