Skip to content

Commit 8eac462

Browse files
authored
Merge pull request #361 from Dstack-TEE/pp
gw: implement PROXY protocol
2 parents fe1f75a + 8cfbecc commit 8eac462

26 files changed

Lines changed: 1439 additions & 47 deletions

Cargo.lock

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ yaml-rust2 = "0.10.4"
247247
luks2 = "0.5.0"
248248
scopeguard = "1.2.0"
249249
tar = "0.4"
250+
proxy-protocol = "0.5.0"
250251

251252
[profile.release]
252253
panic = "abort"

dstack-types/src/lib.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,30 @@ pub struct AppCompose {
4545
pub storage_fs: Option<String>,
4646
#[serde(default, with = "human_size")]
4747
pub swap_size: u64,
48+
/// Per-port policy consumed by the gateway (PROXY protocol opt-in,
49+
/// optional port whitelist).
50+
#[serde(default)]
51+
pub port_policy: PortPolicy,
52+
}
53+
54+
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
55+
pub struct PortPolicy {
56+
/// Per-port attributes (PROXY protocol opt-in, etc.).
57+
#[serde(default)]
58+
pub ports: Vec<PortAttrs>,
59+
/// When true, the gateway only forwards traffic to ports listed in `ports`.
60+
/// All other ports are rejected at TCP-accept time.
61+
#[serde(default)]
62+
pub restrict_mode: bool,
63+
}
64+
65+
#[derive(Deserialize, Serialize, Debug, Clone)]
66+
pub struct PortAttrs {
67+
pub port: u16,
68+
/// Whether the gateway should send a PROXY protocol header on outbound
69+
/// connections to this port.
70+
#[serde(default)]
71+
pub pp: bool,
4872
}
4973

5074
fn default_true() -> bool {

dstack-util/src/system_setup.rs

Lines changed: 18 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, PortPolicy as RpcPortPolicy,
57+
RegisterCvmRequest, RegisterCvmResponse, WireGuardPeer,
5758
};
5859
use ra_tls::rcgen::{KeyPair, PKCS_ECDSA_P256_SHA256};
5960
use serde_human_bytes as hex_bytes;
@@ -446,11 +447,26 @@ impl<'a> GatewayContext<'a> {
446447
gateway_url: &str,
447448
key_store: &GatewayKeyStore,
448449
) -> Result<RegisterCvmResponse> {
450+
let port_policy = RpcPortPolicy {
451+
ports: self
452+
.shared
453+
.app_compose
454+
.port_policy
455+
.ports
456+
.iter()
457+
.map(|p| RpcPortAttrs {
458+
port: p.port as u32,
459+
pp: p.pp,
460+
})
461+
.collect(),
462+
restrict_mode: self.shared.app_compose.port_policy.restrict_mode,
463+
};
449464
let client =
450465
self.create_gateway_client(gateway_url, &key_store.client_key, &key_store.client_cert)?;
451466
let result = client
452467
.register_cvm(RegisterCvmRequest {
453468
client_public_key: key_store.wg_pk.clone(),
469+
port_policy: Some(port_policy.clone()),
454470
})
455471
.await
456472
.context("Failed to register CVM");
@@ -471,6 +487,7 @@ impl<'a> GatewayContext<'a> {
471487
client
472488
.register_cvm(RegisterCvmRequest {
473489
client_public_key: key_store.wg_pk.clone(),
490+
port_policy: Some(port_policy),
474491
})
475492
.await
476493
.context("Failed to register CVM")

gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ hyper-rustls.workspace = true
5454
http-body-util.workspace = true
5555
x509-parser.workspace = true
5656
jemallocator.workspace = true
57+
proxy-protocol.workspace = true
5758
wavekv.workspace = true
5859
tdx-attest.workspace = true
5960
flate2.workspace = true

gateway/dstack-app/builder/entrypoint.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ localhost_enabled = false
111111
app_address_ns_compat = true
112112
workers = ${PROXY_WORKERS:-32}
113113
max_connections_per_app = ${MAX_CONNECTIONS_PER_APP:-0}
114+
inbound_pp_enabled = ${INBOUND_PP_ENABLED:-false}
114115
115116
[core.proxy.timeouts]
116117
connect = "${TIMEOUT_CONNECT:-5s}"
@@ -122,6 +123,13 @@ idle = "${TIMEOUT_IDLE:-10m}"
122123
write = "${TIMEOUT_WRITE:-5s}"
123124
shutdown = "${TIMEOUT_SHUTDOWN:-5s}"
124125
total = "${TIMEOUT_TOTAL:-5h}"
126+
pp_header = "${TIMEOUT_PP_HEADER:-5s}"
127+
128+
[core.proxy.port_policy_fetch]
129+
timeout = "${PORT_POLICY_FETCH_TIMEOUT:-10s}"
130+
max_retries = ${PORT_POLICY_FETCH_MAX_RETRIES:-5}
131+
backoff_initial = "${PORT_POLICY_FETCH_BACKOFF_INITIAL:-1s}"
132+
backoff_max = "${PORT_POLICY_FETCH_BACKOFF_MAX:-30s}"
125133
126134
[core.recycle]
127135
enabled = true

gateway/dstack-app/deploy-to-vmm.sh

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ if [ -f ".env" ]; then
3131
# Load variables from .env
3232
echo "Loading environment variables from .env file..."
3333
set -a
34+
# shellcheck disable=SC1091
3435
source .env
3536
set +a
3637
else
@@ -92,7 +93,14 @@ GUEST_AGENT_ADDR=127.0.0.1:9206
9293
WG_ADDR=0.0.0.0:9202
9394
9495
# The token used to launch the App
95-
APP_LAUNCH_TOKEN=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
96+
APP_LAUNCH_TOKEN=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 32 | head -n 1)
97+
98+
# PROXY protocol: read v1/v2 header from inbound connections (e.g. when this
99+
# gateway sits behind a PP-aware L4 LB such as Cloudflare Spectrum or haproxy
100+
# with send-proxy). Set to "true" only if the upstream LB is configured to
101+
# send PROXY headers; otherwise leave disabled or every connection will be
102+
# rejected.
103+
# INBOUND_PP_ENABLED=false
96104
97105
EOF
98106
echo "Please edit the .env file and set the required variables, then run this script again."
@@ -125,7 +133,7 @@ done
125133

126134
CLI="../../vmm/src/vmm-cli.py --url $VMM_RPC"
127135

128-
WG_PORT=$(echo $WG_ADDR | cut -d':' -f2)
136+
WG_PORT=$(echo "$WG_ADDR" | cut -d':' -f2)
129137
COMPOSE_TMP=$(mktemp)
130138

131139
cp docker-compose.yaml "$COMPOSE_TMP"
@@ -175,6 +183,7 @@ APP_LAUNCH_TOKEN=$APP_LAUNCH_TOKEN
175183
RPC_DOMAIN=$RPC_DOMAIN
176184
NODE_ID=$NODE_ID
177185
PROXY_LISTEN_PORT=$PROXY_LISTEN_PORT
186+
INBOUND_PP_ENABLED=${INBOUND_PP_ENABLED:-false}
178187
EOF
179188

180189
if [ -n "$APP_COMPOSE_FILE" ]; then

gateway/dstack-app/docker-compose.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ services:
4141
- TIMEOUT_TOTAL=${TIMEOUT_TOTAL:-5h}
4242
- ADMIN_LISTEN_ADDR=${ADMIN_LISTEN_ADDR:-0.0.0.0}
4343
- ADMIN_LISTEN_PORT=${ADMIN_LISTEN_PORT:-8001}
44+
- INBOUND_PP_ENABLED=${INBOUND_PP_ENABLED:-false}
45+
- TIMEOUT_PP_HEADER=${TIMEOUT_PP_HEADER:-5s}
46+
- PORT_POLICY_FETCH_TIMEOUT=${PORT_POLICY_FETCH_TIMEOUT:-10s}
47+
- PORT_POLICY_FETCH_MAX_RETRIES=${PORT_POLICY_FETCH_MAX_RETRIES:-5}
48+
- PORT_POLICY_FETCH_BACKOFF_INITIAL=${PORT_POLICY_FETCH_BACKOFF_INITIAL:-1s}
49+
- PORT_POLICY_FETCH_BACKOFF_MAX=${PORT_POLICY_FETCH_BACKOFF_MAX:-30s}
4450
restart: always
4551

4652
volumes:

gateway/gateway.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ workers = 32
5858
external_port = 443
5959
# Maximum concurrent connections per app. 0 means unlimited.
6060
max_connections_per_app = 2000
61+
# Whether to read PROXY protocol from inbound connections (e.g. from Cloudflare).
62+
inbound_pp_enabled = false
63+
64+
[core.proxy.port_policy_fetch]
65+
# Background lazy-fetch of port_policy from legacy CVM agents.
66+
# Single Info() RPC timeout.
67+
timeout = "10s"
68+
# Retries cover the WireGuard / agent warmup window after registration.
69+
max_retries = 5
70+
# Exponential backoff between retries; doubles each attempt up to backoff_max.
71+
backoff_initial = "1s"
72+
backoff_max = "30s"
6173

6274
[core.proxy.timeouts]
6375
# Timeout for establishing a connection to the target app.
@@ -81,6 +93,8 @@ write = "5s"
8193
shutdown = "5s"
8294
# Timeout for total connection duration.
8395
total = "5h"
96+
# Timeout for proxy protocol header.
97+
pp_header = "5s"
8498

8599
[core.recycle]
86100
enabled = true

gateway/rpc/proto/gateway_rpc.proto

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@ 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 policy 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 PortPolicy port_policy = 2;
20+
}
21+
22+
// PortPolicy carries the gateway-relevant per-port configuration declared by
23+
// the app in its compose file. Keeping `ports` and `restrict_mode` together
24+
// lets a single Option distinguish "not reported" from "reported".
25+
message PortPolicy {
26+
// Per-port attributes (PROXY protocol opt-in, etc.).
27+
repeated PortAttrs ports = 1;
28+
// When true, the gateway only forwards traffic to ports listed in `ports`
29+
// and rejects connections to any other port at TCP-accept time.
30+
bool restrict_mode = 2;
31+
}
32+
33+
// PortAttrs declares per-port behaviour for the gateway.
34+
message PortAttrs {
35+
// The CVM port these attributes apply to.
36+
uint32 port = 1;
37+
// Whether the gateway should send a PROXY protocol header on outbound
38+
// connections to this port.
39+
bool pp = 2;
1540
}
1641

1742
// DebugRegisterCvmRequest is the request for DebugRegisterCvm (only works when debug_mode is enabled).
@@ -414,6 +439,18 @@ service Admin {
414439
rpc GetCertbotConfig(google.protobuf.Empty) returns (CertbotConfigResponse) {}
415440
// Set global certbot configuration (includes ACME URL)
416441
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) {}
417454
}
418455

419456
// ==================== DNS Credential Messages ====================
@@ -623,3 +660,36 @@ message SetCertbotConfigRequest {
623660
// ACME server URL (empty means use default Let's Encrypt production)
624661
optional string acme_url = 4;
625662
}
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+
}

0 commit comments

Comments
 (0)