Skip to content

Commit 87ce31c

Browse files
committed
gw: per-instance PROXY protocol via app-compose port_attrs
Replace the global outbound_pp_enabled switch with a per-(instance, port) lookup so different ports of the same backend can have different PP behaviour. PP is declared by the app and reported to the gateway through authenticated channels — never by client SNI. Pipeline: 1. dstack-types::AppCompose grows a "ports" array. Each entry carries a port number and a "pp" flag. Because it's part of app-compose.json it is measured into compose_hash and attested. 2. RegisterCvmRequest grows an optional PortAttrsList. New CVMs include their port_attrs at WireGuard registration time. The optional wrapper lets the gateway distinguish "not reported" (legacy CVM) from "reported empty" (new CVM with no PP-enabled port). 3. The gateway stores port_attrs on InstanceInfo and persists/syncs it via WaveKV (InstanceData), keyed by instance_id (different instances of the same app may run different code). 4. AddressInfo now carries instance_id, and connect_multiple_hosts returns the winner's instance_id. The proxy looks up that instance's port_attrs to decide whether to send a PROXY header. 5. Backward compat: if an instance has no port_attrs (legacy CVM), the gateway lazily fetches them via the agent's Info() RPC, parses tcb_info.app_compose, and caches the result in WaveKV. PROXY protocol module is unchanged; only the *decision* of whether to send a header moves from a global config to a per-instance lookup.
1 parent d5a137c commit 87ce31c

16 files changed

Lines changed: 262 additions & 30 deletions

dstack-types/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ pub struct AppCompose {
4545
pub storage_fs: Option<String>,
4646
#[serde(default, with = "human_size")]
4747
pub swap_size: u64,
48+
/// Per-port attributes consumed by the gateway (e.g. PROXY protocol).
49+
#[serde(default)]
50+
pub ports: Vec<PortAttrs>,
51+
}
52+
53+
#[derive(Deserialize, Serialize, Debug, Clone)]
54+
pub struct PortAttrs {
55+
pub port: u16,
56+
/// Whether the gateway should send a PROXY protocol header on outbound
57+
/// connections to this port.
58+
#[serde(default)]
59+
pub pp: bool,
4860
}
4961

5062
fn default_true() -> bool {

dstack-util/src/system_setup.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ use crate::{
5353
use cert_client::CertRequestClient;
5454
use cmd_lib::run_fun as cmd;
5555
use dstack_gateway_rpc::{
56-
gateway_client::GatewayClient, RegisterCvmRequest, RegisterCvmResponse, WireGuardPeer,
56+
gateway_client::GatewayClient, PortAttrs as RpcPortAttrs, PortAttrsList, RegisterCvmRequest,
57+
RegisterCvmResponse, WireGuardPeer,
5758
};
5859
use ra_tls::rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256};
5960
use serde_human_bytes as hex_bytes;
@@ -446,11 +447,24 @@ impl<'a> GatewayContext<'a> {
446447
gateway_url: &str,
447448
key_store: &GatewayKeyStore,
448449
) -> Result<RegisterCvmResponse> {
450+
let port_attrs = PortAttrsList {
451+
attrs: self
452+
.shared
453+
.app_compose
454+
.ports
455+
.iter()
456+
.map(|p| RpcPortAttrs {
457+
port: p.port as u32,
458+
pp: p.pp,
459+
})
460+
.collect(),
461+
};
449462
let client =
450463
self.create_gateway_client(gateway_url, &key_store.client_key, &key_store.client_cert)?;
451464
let result = client
452465
.register_cvm(RegisterCvmRequest {
453466
client_public_key: key_store.wg_pk.clone(),
467+
port_attrs: Some(port_attrs.clone()),
454468
})
455469
.await
456470
.context("Failed to register CVM");
@@ -471,6 +485,7 @@ impl<'a> GatewayContext<'a> {
471485
client
472486
.register_cvm(RegisterCvmRequest {
473487
client_public_key: key_store.wg_pk.clone(),
488+
port_attrs: Some(port_attrs),
474489
})
475490
.await
476491
.context("Failed to register CVM")

gateway/gateway.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,6 @@ external_port = 443
6060
max_connections_per_app = 2000
6161
# Whether to read PROXY protocol from inbound connections (e.g. from Cloudflare).
6262
inbound_pp_enabled = false
63-
# Whether to send PROXY protocol headers to backend apps.
64-
outbound_pp_enabled = false
6563

6664
[core.proxy.timeouts]
6765
# Timeout for establishing a connection to the target app.

gateway/rpc/proto/gateway_rpc.proto

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ package gateway;
1212
message RegisterCvmRequest {
1313
// The public key of the WireGuard interface of the CVM.
1414
string client_public_key = 1;
15+
// Per-port attributes the gateway should apply when proxying to this CVM.
16+
// Wrapped in a message so we can distinguish "not reported" (old CVM →
17+
// gateway falls back to fetching app-compose via Info()) from "reported
18+
// empty" (new CVM with no special port behaviour).
19+
optional PortAttrsList port_attrs = 2;
20+
}
21+
22+
// PortAttrsList wraps a list of PortAttrs so it can be optional on the wire.
23+
message PortAttrsList {
24+
repeated PortAttrs attrs = 1;
25+
}
26+
27+
// PortAttrs declares per-port behaviour for the gateway.
28+
message PortAttrs {
29+
// The CVM port these attributes apply to.
30+
uint32 port = 1;
31+
// Whether the gateway should send a PROXY protocol header on outbound
32+
// connections to this port.
33+
bool pp = 2;
1534
}
1635

1736
// DebugRegisterCvmRequest is the request for DebugRegisterCvm (only works when debug_mode is enabled).

gateway/src/config.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,13 @@ pub struct ProxyConfig {
117117
pub app_address_ns_compat: bool,
118118
/// Maximum concurrent connections per app. 0 means unlimited.
119119
pub max_connections_per_app: u64,
120+
/// Port the dstack guest-agent listens on inside each CVM. Used by the
121+
/// gateway to fetch app metadata (e.g. port_attrs for legacy CVMs).
122+
pub agent_port: u16,
120123
/// Whether to read PROXY protocol headers from inbound connections
121124
/// (e.g. when behind a PP-aware load balancer like Cloudflare).
122125
#[serde(default)]
123126
pub inbound_pp_enabled: bool,
124-
/// Whether to send PROXY protocol headers on outbound connections to backend apps.
125-
/// This is a server-side setting; it must NOT be controlled by client input (e.g. SNI).
126-
#[serde(default)]
127-
pub outbound_pp_enabled: bool,
128127
}
129128

130129
#[derive(Debug, Clone, Deserialize)]

gateway/src/debug_service.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ impl DebugRpc for DebugRpcHandler {
3535
&request.app_id,
3636
&request.instance_id,
3737
&request.client_public_key,
38+
None,
3839
)
3940
}
4041

gateway/src/kv/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,26 @@ use serde::{Deserialize, Serialize};
4242
use tokio::sync::watch;
4343
use wavekv::{node::NodeState, types::NodeId, Node};
4444

45+
/// Per-port flags applied by the gateway when proxying to a CVM port.
46+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
47+
pub struct PortFlags {
48+
/// Send a PROXY protocol header on outbound connections to this port.
49+
#[serde(default)]
50+
pub pp: bool,
51+
}
52+
4553
/// Instance core data (persistent)
4654
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4755
pub struct InstanceData {
4856
pub app_id: String,
4957
pub ip: Ipv4Addr,
5058
pub public_key: String,
5159
pub reg_time: u64,
60+
/// Per-port flags reported at registration. `None` means "not reported"
61+
/// (legacy CVM); the gateway will fall back to fetching app-compose via
62+
/// Info() on first connection and populate this lazily.
63+
#[serde(default)]
64+
pub port_attrs: Option<BTreeMap<u16, PortFlags>>,
5265
}
5366

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

gateway/src/main_service.rs

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ use crate::{
3737
config::{Config, TlsConfig},
3838
kv::{
3939
fetch_peers_from_bootnode, AppIdValidator, HttpsClientConfig, InstanceData, KvStore,
40-
NodeData, NodeStatus, WaveKvSyncService,
40+
NodeData, NodeStatus, PortFlags, WaveKvSyncService,
4141
},
4242
models::{InstanceInfo, WgConf},
4343
proxy::{create_acceptor_with_cert_resolver, AddressGroup, AddressInfo},
@@ -378,12 +378,16 @@ impl Proxy {
378378
})
379379
}
380380

381-
/// Register a CVM with the given app_id, instance_id and client_public_key
381+
/// Register a CVM with the given app_id, instance_id and client_public_key.
382+
///
383+
/// `port_attrs = None` means the CVM didn't report port attributes (legacy
384+
/// CVM). The gateway will lazily fetch them via Info() on first connection.
382385
pub fn do_register_cvm(
383386
&self,
384387
app_id: &str,
385388
instance_id: &str,
386389
client_public_key: &str,
390+
port_attrs: Option<BTreeMap<u16, PortFlags>>,
387391
) -> Result<RegisterCvmResponse> {
388392
let mut state = self.lock();
389393

@@ -403,7 +407,7 @@ impl Proxy {
403407
bail!("[{instance_id}] client public key is empty");
404408
}
405409
let client_info = state
406-
.new_client_by_id(instance_id, app_id, client_public_key)
410+
.new_client_by_id(instance_id, app_id, client_public_key, port_attrs)
407411
.context("failed to allocate IP address for client")?;
408412
if let Err(err) = state.reconfigure() {
409413
error!("failed to reconfigure: {err:?}");
@@ -425,7 +429,7 @@ impl Proxy {
425429
}),
426430
agent: Some(GuestAgentConfig {
427431
external_port: port.into(),
428-
internal_port: 8090,
432+
internal_port: state.config.proxy.agent_port.into(),
429433
domain: base_domain,
430434
app_address_ns_prefix: state.config.proxy.app_address_ns_prefix.clone(),
431435
}),
@@ -449,6 +453,7 @@ fn build_state_from_kv_store(instances: BTreeMap<String, InstanceData>) -> Proxy
449453
reg_time: UNIX_EPOCH
450454
.checked_add(Duration::from_secs(data.reg_time))
451455
.unwrap_or(UNIX_EPOCH),
456+
port_attrs: data.port_attrs,
452457
connections: Default::default(),
453458
};
454459
state.allocated_addresses.insert(data.ip);
@@ -742,6 +747,7 @@ fn reload_instances_from_kv_store(proxy: &Proxy, store: &KvStore) -> Result<()>
742747
reg_time: UNIX_EPOCH
743748
.checked_add(Duration::from_secs(data.reg_time))
744749
.unwrap_or(UNIX_EPOCH),
750+
port_attrs: data.port_attrs.clone(),
745751
connections: Default::default(),
746752
};
747753

@@ -823,6 +829,7 @@ impl ProxyState {
823829
id: &str,
824830
app_id: &str,
825831
public_key: &str,
832+
port_attrs: Option<BTreeMap<u16, PortFlags>>,
826833
) -> Result<InstanceInfo> {
827834
if id.is_empty() {
828835
bail!("instance_id is empty (no_instance_id is set?)");
@@ -841,6 +848,10 @@ impl ProxyState {
841848
// Update reg_time so other nodes will pick up the change
842849
existing.reg_time = SystemTime::now();
843850
}
851+
// Always update port_attrs from the latest registration so changes
852+
// take effect across re-registrations. A `None` here (legacy CVM)
853+
// wipes any previously-cached attrs so the lazy fetch path runs again.
854+
existing.port_attrs = port_attrs.clone();
844855
let existing = existing.clone();
845856
if self.valid_ip(existing.ip) {
846857
// Sync existing instance to KvStore (might be from legacy state)
@@ -849,6 +860,7 @@ impl ProxyState {
849860
ip: existing.ip,
850861
public_key: existing.public_key.clone(),
851862
reg_time: encode_ts(existing.reg_time),
863+
port_attrs: existing.port_attrs.clone(),
852864
};
853865
if let Err(err) = self.kv_store.sync_instance(&existing.id, &data) {
854866
error!("failed to sync existing instance to KvStore: {err:?}");
@@ -867,19 +879,58 @@ impl ProxyState {
867879
ip,
868880
public_key: public_key.to_string(),
869881
reg_time: SystemTime::now(),
882+
port_attrs,
870883
connections: Default::default(),
871884
};
872885
self.add_instance(host_info.clone());
873886
Ok(host_info)
874887
}
875888

889+
/// Lookup an instance's IP. Returns `None` if the instance is unknown.
890+
pub(crate) fn instance_ip(&self, instance_id: &str) -> Option<Ipv4Addr> {
891+
self.state.instances.get(instance_id).map(|i| i.ip)
892+
}
893+
894+
/// Lookup an instance's port_attrs. `None` means the CVM never reported
895+
/// them (legacy CVM), so the caller should fall back to fetching via Info().
896+
pub(crate) fn instance_port_attrs(
897+
&self,
898+
instance_id: &str,
899+
) -> Option<BTreeMap<u16, PortFlags>> {
900+
self.state.instances.get(instance_id)?.port_attrs.clone()
901+
}
902+
903+
/// Update an instance's port_attrs (used after a lazy fetch via Info()).
904+
/// Persists to the WaveKV store so other gateway nodes pick it up.
905+
pub(crate) fn update_instance_port_attrs(
906+
&mut self,
907+
instance_id: &str,
908+
attrs: BTreeMap<u16, PortFlags>,
909+
) {
910+
let Some(info) = self.state.instances.get_mut(instance_id) else {
911+
return;
912+
};
913+
info.port_attrs = Some(attrs.clone());
914+
let data = InstanceData {
915+
app_id: info.app_id.clone(),
916+
ip: info.ip,
917+
public_key: info.public_key.clone(),
918+
reg_time: encode_ts(info.reg_time),
919+
port_attrs: Some(attrs),
920+
};
921+
if let Err(err) = self.kv_store.sync_instance(instance_id, &data) {
922+
error!("failed to sync updated port_attrs to KvStore: {err:?}");
923+
}
924+
}
925+
876926
fn add_instance(&mut self, info: InstanceInfo) {
877927
// Sync to KvStore
878928
let data = InstanceData {
879929
app_id: info.app_id.clone(),
880930
ip: info.ip,
881931
public_key: info.public_key.clone(),
882932
reg_time: encode_ts(info.reg_time),
933+
port_attrs: info.port_attrs.clone(),
883934
};
884935
if let Err(err) = self.kv_store.sync_instance(&info.id, &data) {
885936
error!("failed to sync instance to KvStore: {err:?}");
@@ -921,13 +972,15 @@ impl ProxyState {
921972
return Ok(smallvec![AddressInfo {
922973
ip: Ipv4Addr::new(127, 0, 0, 1),
923974
counter: Default::default(),
975+
instance_id: "localhost".to_string(),
924976
}]);
925977
}
926978
let n = self.config.proxy.connect_top_n;
927979
if let Some(instance) = self.state.instances.get(id) {
928980
return Ok(smallvec![AddressInfo {
929981
ip: instance.ip,
930982
counter: instance.connections.clone(),
983+
instance_id: instance.id.clone(),
931984
}]);
932985
};
933986
let app_instances = self.state.apps.get(id).context("app not found")?;
@@ -955,15 +1008,24 @@ impl ProxyState {
9551008
.filter_map(|instance_id| {
9561009
let instance = self.state.instances.get(instance_id)?;
9571010
let (_, elapsed) = handshakes.get(&instance.public_key)?;
958-
Some((instance.ip, *elapsed, instance.connections.clone()))
1011+
Some((
1012+
instance.ip,
1013+
*elapsed,
1014+
instance.connections.clone(),
1015+
instance.id.clone(),
1016+
))
9591017
})
9601018
.collect::<SmallVec<[_; 4]>>(),
9611019
};
9621020
instances.sort_by(|a, b| a.1.cmp(&b.1));
9631021
instances.truncate(n);
9641022
Ok(instances
9651023
.into_iter()
966-
.map(|(ip, _, counter)| AddressInfo { ip, counter })
1024+
.map(|(ip, _, counter, instance_id)| AddressInfo {
1025+
ip,
1026+
counter,
1027+
instance_id,
1028+
})
9671029
.collect())
9681030
}
9691031

@@ -973,6 +1035,7 @@ impl ProxyState {
9731035
return Some(smallvec![AddressInfo {
9741036
ip: info.ip,
9751037
counter: info.connections.clone(),
1038+
instance_id: info.id.clone(),
9761039
}]);
9771040
}
9781041

@@ -999,6 +1062,7 @@ impl ProxyState {
9991062
smallvec![AddressInfo {
10001063
ip: info.ip,
10011064
counter: info.connections.clone(),
1065+
instance_id: info.id.clone(),
10021066
}]
10031067
})
10041068
}
@@ -1271,8 +1335,14 @@ impl GatewayRpc for RpcHandler {
12711335
.context("App authorization failed")?;
12721336
let app_id = hex::encode(&app_info.app_id);
12731337
let instance_id = hex::encode(&app_info.instance_id);
1338+
let port_attrs = request.port_attrs.map(|list| {
1339+
list.attrs
1340+
.into_iter()
1341+
.map(|p| (p.port as u16, PortFlags { pp: p.pp }))
1342+
.collect::<BTreeMap<u16, PortFlags>>()
1343+
});
12741344
self.state
1275-
.do_register_cvm(&app_id, &instance_id, &request.client_public_key)
1345+
.do_register_cvm(&app_id, &instance_id, &request.client_public_key, port_attrs)
12761346
}
12771347

12781348
async fn acme_info(self) -> Result<AcmeInfoResponse> {

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
@@ -12,5 +12,6 @@ InstanceInfo {
1212
tv_sec: 0,
1313
tv_nsec: 0,
1414
},
15+
port_attrs: None,
1516
connections: 0,
1617
}

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
@@ -12,5 +12,6 @@ InstanceInfo {
1212
tv_sec: 0,
1313
tv_nsec: 0,
1414
},
15+
port_attrs: None,
1516
connections: 0,
1617
}

0 commit comments

Comments
 (0)