Skip to content

Commit 099dd47

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 099dd47

8 files changed

Lines changed: 356 additions & 15 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: 70 additions & 7 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

@@ -491,6 +491,7 @@ fn build_state_from_kv_store(instances: BTreeMap<String, InstanceData>) -> Proxy
491491
.unwrap_or(UNIX_EPOCH),
492492
port_policy: data.port_policy,
493493
port_policy_hash: data.port_policy_hash,
494+
admin_port_policy: data.admin_port_policy,
494495
connections: Default::default(),
495496
};
496497
state.allocated_addresses.insert(data.ip);
@@ -786,6 +787,7 @@ fn reload_instances_from_kv_store(proxy: &Proxy, store: &KvStore) -> Result<()>
786787
.unwrap_or(UNIX_EPOCH),
787788
port_policy: data.port_policy.clone(),
788789
port_policy_hash: data.port_policy_hash.clone(),
790+
admin_port_policy: data.admin_port_policy.clone(),
789791
connections: Default::default(),
790792
};
791793

@@ -914,6 +916,7 @@ impl ProxyState {
914916
reg_time: encode_ts(existing.reg_time),
915917
port_policy: existing.port_policy.clone(),
916918
port_policy_hash: existing.port_policy_hash.clone(),
919+
admin_port_policy: existing.admin_port_policy.clone(),
917920
};
918921
if let Err(err) = self.kv_store.sync_instance(&existing.id, &data) {
919922
error!("failed to sync existing instance to KvStore: {err:?}");
@@ -934,6 +937,7 @@ impl ProxyState {
934937
reg_time: SystemTime::now(),
935938
port_policy,
936939
port_policy_hash: compose_hash.to_string(),
940+
admin_port_policy: None,
937941
connections: Default::default(),
938942
};
939943
self.add_instance(host_info.clone());
@@ -945,10 +949,14 @@ impl ProxyState {
945949
self.state.instances.get(instance_id).map(|i| i.ip)
946950
}
947951

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().
952+
/// Lookup the effective port_policy for an instance: admin override wins,
953+
/// otherwise fall back to the instance-reported policy. `None` means
954+
/// neither is set — caller fails closed and may schedule a lazy fetch.
950955
pub(crate) fn instance_port_policy(&self, instance_id: &str) -> Option<&PortPolicy> {
951-
self.state.instances.get(instance_id)?.port_policy.as_ref()
956+
let info = self.state.instances.get(instance_id)?;
957+
info.admin_port_policy
958+
.as_ref()
959+
.or(info.port_policy.as_ref())
952960
}
953961

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

@@ -980,6 +1042,7 @@ impl ProxyState {
9801042
reg_time: encode_ts(info.reg_time),
9811043
port_policy: info.port_policy.clone(),
9821044
port_policy_hash: info.port_policy_hash.clone(),
1045+
admin_port_policy: info.admin_port_policy.clone(),
9831046
};
9841047
if let Err(err) = self.kv_store.sync_instance(&info.id, &data) {
9851048
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)