Skip to content

Commit cf0f3d0

Browse files
committed
refactor(gateway)!: replace the file-configured proxy certificate with Admin.ImportCert
`[core.proxy] cert_chain`/`cert_key` installed a certificate that lived in no KV key, so any cert store reload built from KV dropped it, taking `<id>.<base_domain>` routing down with it. Production deployments do not set it: certificates are issued by the distributed certbot and kept in KV. Remove it, and `base_domain`, which only named the domain for it. A certificate issued elsewhere is now installed with `Admin.ImportCert`, which stores it in KV like an issued one, so it replicates and survives reloads. The gateway does not renew it. The proxy data-path suite imports its test certificate through the RPC; the cluster suite and dev-stack.sh no longer configure one.
1 parent a3378e3 commit cf0f3d0

9 files changed

Lines changed: 61 additions & 73 deletions

File tree

‎docs/dstack-gateway.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,11 @@ records that pin issuance to its own ACME account.
2323

2424
Focus on these fields in the `core.proxy` section:
2525

26-
- `base_domain`: the wildcard domain for the proxy
2726
- `listen_addr` & `listen_port`: listen on `0.0.0.0` and preferably `443` in production. If using another port, specify it in the URL (see [URL Format](#url-format))
2827

2928
For example, if your base domain is `gateway.example.com`, app ID is `<app_id>`, listening on `80`, and dstack-gateway is on port 7777, the URL would be `https://<app_id>-80.gateway.example.com:7777`
3029

31-
Leave `cert_chain` and `cert_key` unset. They load a certificate you already
32-
have from disk at startup, for the case where something else issues it; the
33-
gateway's own issuance does not use them and does not write them.
30+
The base domain is not set here: it is the ZT domain you add in Step 3.
3431

3532
Two more sections matter for certificates:
3633

@@ -103,6 +100,16 @@ curl -sf "${AUTH[@]}" "http://$ADMIN_ADDR/prpc/ListZtDomains" \
103100
served. `POST /prpc/RenewCert` forces a round immediately instead of waiting for
104101
`renew_interval_secs`.
105102

103+
If something else issues the certificate, import it instead of adding a ZT
104+
domain. It is replicated like an issued one, but the gateway never renews it:
105+
106+
```bash
107+
jq -n --arg cert "$(cat fullchain.pem)" --arg key "$(cat privkey.pem)" \
108+
'{domain: "gateway.example.com", cert_pem: $cert, key_pem: $key}' \
109+
| curl -sf -X POST "${AUTH[@]}" "http://$ADMIN_ADDR/prpc/ImportCert" \
110+
-H "Content-Type: application/json" -d @-
111+
```
112+
106113
Pin issuance with `POST /prpc/SetCaa`, which writes CAA records naming Let's
107114
Encrypt and the gateway's ACME account URI for every configured domain, so no
108115
other account can have a certificate issued for them.
@@ -117,7 +124,7 @@ ACME URL disagree, so do not skip the rotation.
117124

118125
Open `vmm.toml` and adjust dstack-gateway configuration in the `gateway` section:
119126

120-
- `base_domain`: Same as `base_domain` from `gateway.toml`'s `core.proxy` section
127+
- `base_domain`: The ZT domain added in Step 3
121128
- `port`: Same as `listen_port` from `gateway.toml`'s `core.proxy` section
122129

123130
## URL Format

‎dstack/gateway/rpc/proto/gateway_rpc.proto‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,9 @@ service Admin {
552552
rpc ForceReleaseCertLock(ForceReleaseCertLockRequest) returns (google.protobuf.Empty) {}
553553
// List certificate attestations for a domain
554554
rpc ListCertAttestations(ListCertAttestationsRequest) returns (ListCertAttestationsResponse) {}
555+
// Install a certificate issued outside the gateway, replicated to every node.
556+
// The gateway serves it as-is and does not renew it.
557+
rpc ImportCert(ImportCertRequest) returns (google.protobuf.Empty) {}
555558

556559
// ==================== Global Certbot Configuration ====================
557560
// Get global certbot configuration (includes ACME URL)
@@ -824,6 +827,15 @@ message RenewZtDomainCertResponse {
824827
uint64 not_after = 2;
825828
}
826829

830+
// Import certificate request
831+
message ImportCertRequest {
832+
// Served for `*.<domain>`
833+
string domain = 1;
834+
// PEM chain, leaf first
835+
string cert_pem = 2;
836+
string key_pem = 3;
837+
}
838+
827839
// Force release certificate lock request
828840
message ForceReleaseCertLockRequest {
829841
string domain = 1;

‎dstack/gateway/src/admin_service.rs‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use dstack_gateway_rpc::{
1515
GetDnsCredentialRequest, GetInfoRequest, GetInfoResponse, GetInstanceHandshakesRequest,
1616
GetInstanceHandshakesResponse, GetInstancePortPolicyRequest, GetInstancePortPolicyResponse,
1717
GetMetaResponse, GetNodeStatusesResponse, GetZtDomainRequest, GlobalConnectionsStats,
18-
HandshakeEntry, HostInfo, LastSeenEntry, ListCertAttestationsRequest,
18+
HandshakeEntry, HostInfo, ImportCertRequest, LastSeenEntry, ListCertAttestationsRequest,
1919
ListCertAttestationsResponse, ListDnsCredentialsResponse, ListRejectedInstancesResponse,
2020
ListZtDomainsResponse, NodeStatusEntry, PeerSyncStatus as ProtoPeerSyncStatus,
2121
PortAttrs as RpcPortAttrs, PortPolicy as RpcPortPolicy, RejectedInstanceInfo, RemoveCvmRequest,
@@ -31,8 +31,9 @@ use tracing::{info, warn};
3131
use wavekv::node::NodeStatus as WaveKvNodeStatus;
3232

3333
use crate::{
34+
cert_store::get_cert_expiry,
3435
kv::{
35-
import::Rejection, DnsCredential, DnsProvider, GlobalCertbotConfig,
36+
import::Rejection, CertData, DnsCredential, DnsProvider, GlobalCertbotConfig,
3637
GlobalTombstoneGcConfig, NodeStatus, PortFlags, PortPolicy, ZtDomainConfig,
3738
},
3839
main_service::Proxy,
@@ -711,6 +712,23 @@ impl AdminRpc for AdminRpcHandler {
711712
Ok(ListCertAttestationsResponse { latest, history })
712713
}
713714

715+
async fn import_cert(self, request: ImportCertRequest) -> Result<()> {
716+
let domain = normalize_zt_domain(&request.domain)?;
717+
let not_after =
718+
get_cert_expiry(&request.cert_pem).context("failed to read the certificate expiry")?;
719+
let data = CertData {
720+
cert_pem: request.cert_pem,
721+
key_pem: request.key_pem,
722+
not_after,
723+
issued_by: self.state.kv_store().my_node_id(),
724+
issued_at: now_secs(),
725+
};
726+
self.state.cert_resolver.update_cert(&domain, &data)?;
727+
self.state.kv_store().save_cert_data(&domain, &data)?;
728+
info!("imported certificate for *.{domain}");
729+
Ok(())
730+
}
731+
714732
// ==================== Global Certbot Configuration ====================
715733

716734
async fn get_certbot_config(self) -> Result<CertbotConfigResponse> {

‎dstack/gateway/src/config.rs‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,6 @@ pub struct ProxyConfig {
225225
/// quietest core goes from 63% to 97% busy.
226226
#[serde(default = "default_true")]
227227
pub connection_rebalance: bool,
228-
#[serde(default)]
229-
pub base_domain: Option<String>,
230-
#[serde(default)]
231-
pub cert_chain: Option<PathBuf>,
232-
#[serde(default)]
233-
pub cert_key: Option<PathBuf>,
234228
pub app_address_ns_prefix: String,
235229
pub app_address_ns_compat: bool,
236230
/// Dedicated DNS servers for app-address TXT lookups.

‎dstack/gateway/src/main_service.rs‎

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ use crate::{
3939
cert_store::{CertResolver, CertStoreBuilder},
4040
config::{Config, TlsConfig},
4141
kv::{
42-
fetch_peers_from_bootnode, import, AppIdValidator, CertData, HttpsClientConfig,
43-
InstanceRecord, KvStore, LegacyOverrides, LoadedInstances, NodeData, NodeStatus,
44-
PortPolicy, PortPolicyOverride, ReplicatedWrites, WaveKvSyncService,
42+
fetch_peers_from_bootnode, import, AppIdValidator, HttpsClientConfig, InstanceRecord,
43+
KvStore, LegacyOverrides, LoadedInstances, NodeData, NodeStatus, PortPolicy,
44+
PortPolicyOverride, ReplicatedWrites, WaveKvSyncService,
4545
},
4646
models::{InstanceInfo, PortPolicyView, ReportedCapabilities, WgConf, WgPeer},
4747
proxy::{create_acceptor_with_cert_resolver, AddressGroup, AddressInfo, AppAddressResolver},
@@ -474,29 +474,6 @@ impl ProxyInner {
474474
all_cert_data.len()
475475
);
476476
}
477-
if let (Some(base_domain), Some(cert_chain), Some(cert_key)) = (
478-
&config.proxy.base_domain,
479-
&config.proxy.cert_chain,
480-
&config.proxy.cert_key,
481-
) {
482-
let cert_pem = std::fs::read_to_string(cert_chain).with_context(|| {
483-
format!("failed to read proxy cert_chain {}", cert_chain.display())
484-
})?;
485-
let key_pem = std::fs::read_to_string(cert_key)
486-
.with_context(|| format!("failed to read proxy cert_key {}", cert_key.display()))?;
487-
let now = now_secs();
488-
let cert_data = CertData {
489-
cert_pem,
490-
key_pem,
491-
not_after: now + 14 * 24 * 60 * 60,
492-
issued_by: config.sync.node_id,
493-
issued_at: now,
494-
};
495-
cert_resolver
496-
.update_cert(base_domain, &cert_data)
497-
.with_context(|| format!("failed to load static proxy cert for {base_domain}"))?;
498-
info!("CertStore: loaded static proxy certificate for *.{base_domain}");
499-
}
500477

501478
// Create multi-domain certbot (uses KvStore configs for DNS credentials and domains)
502479
let certbot = Arc::new(DistributedCertBot::new(

‎dstack/gateway/test-run/cluster/lib.sh‎

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -135,35 +135,14 @@ interface = "wg-cluster${node_id}"
135135
endpoint = "gateway-${node_id}:9013"
136136
137137
[core.proxy]
138-
cert_chain = "/etc/gateway/proxy.cert"
139-
cert_key = "/etc/gateway/proxy.key"
140-
base_domain = "cluster.test"
141138
listen_addr = "0.0.0.0"
142139
listen_port = 9014
143140
tappd_port = 8090
144141
external_port = 9014
145142
EOF
146-
# The proxy listener wants its certificate beside the config, and each test
147-
# has a config directory of its own now -- so generating it separately, once,
148-
# left every test after the first with a gateway that exits at startup with
149-
# "failed to read proxy cert_chain".
150-
generate_proxy_cert "$node_id"
151143
log_info "wrote node${node_id} config (bootnode=${bootnode_url:-none})"
152144
}
153145

154-
# The proxy listener wants a certificate even though this suite never drives the
155-
# data path. Self-signed is enough; the RPC certificates that actually gate
156-
# cluster membership come from the guest agent, not from here.
157-
generate_proxy_cert() {
158-
local node_id=$1
159-
local dir="$CONFIG_DIR/${CURRENT_TEST:-suite}/node${node_id}"
160-
[ -f "$dir/proxy.cert" ] && return 0
161-
openssl req -x509 -newkey rsa:2048 -nodes -days 2 \
162-
-keyout "$dir/proxy.key" -out "$dir/proxy.cert" \
163-
-subj "/CN=cluster.test" \
164-
-addext "subjectAltName=DNS:cluster.test,DNS:*.cluster.test" 2>/dev/null
165-
}
166-
167146
# Each test runs in a compose project of its own, so its containers, its logs
168147
# and its data start empty because they are new -- not because something cleaned
169148
# them. CURRENT_TEST is set by the runner before each test.

‎dstack/gateway/test-run/proxy/gwconfig.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,6 @@ def main():
8080
[core.proxy]
8181
listen_addr = "127.0.0.1"
8282
listen_port = {o["proxy_port"]}
83-
base_domain = "{o["base_domain"]}"
84-
cert_chain = "{cert}"
85-
cert_key = "{key}"
8683
workers = {o.get("workers", "2")}
8784
max_connections_per_app = 0
8885
buffer_size = 65536

‎dstack/gateway/test-run/test_proxy.sh‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,16 @@ stop_gateway() {
182182
done
183183
}
184184

185+
# The data dir is wiped per arm, so the wildcard certificate goes back in each time.
186+
import_cert() {
187+
python3 -c 'import json, sys; print(json.dumps({"domain": sys.argv[1],
188+
"cert_pem": open(sys.argv[2]).read(), "key_pem": open(sys.argv[3]).read()}))' \
189+
"$BASE_DOMAIN" "$WORK/certs/cert.pem" "$WORK/certs/key.pem" \
190+
| curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
191+
-H "Content-Type: application/json" -d @- \
192+
"http://127.0.0.1:$ADMIN_PORT/prpc/Admin.ImportCert" >/dev/null
193+
}
194+
185195
# start_gateway <label> [gwconfig key=value ...]
186196
start_gateway() {
187197
local label="$1"; shift
@@ -191,12 +201,14 @@ start_gateway() {
191201
python3 "$PROXY_DIR/gwconfig.py" "$WORK" \
192202
proxy_port="$PROXY_PORT" rpc_port="$RPC_PORT" admin_port="$ADMIN_PORT" \
193203
wg_port="$WG_PORT" wg_iface="$WG_IFACE" admin_token="$ADMIN_TOKEN" \
194-
base_domain="$BASE_DOMAIN" "$@" >"$WORK/gw.toml" || return 1
204+
"$@" >"$WORK/gw.toml" || return 1
195205
RUST_LOG="${RUST_LOG:-warn}" "$GATEWAY_BIN" -c "$WORK/gw.toml" \
196206
>"$GW_LOG" 2>&1 &
197207
GW_PID=$!
208+
local imported=""
198209
for _ in $(seq 100); do
199-
if probe fetch --port "$PROXY_PORT" --sni "$SNI_TERMINATE" --size 64 \
210+
[ -n "$imported" ] || { import_cert && imported=1; }
211+
if [ -n "$imported" ] && probe fetch --port "$PROXY_PORT" --sni "$SNI_TERMINATE" --size 64 \
200212
>/dev/null 2>&1; then
201213
return 0
202214
fi

‎tools/dev-stack.sh‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ CERBOT_WORKDIR=$RUN_DIR/certbot
2020
KMS_UPGRADE_REGISTRY_DIR=$RUN_DIR/kms/upgrade_registry
2121
KMS_CERT_LOG_DIR=$RUN_DIR/kms/cert_log/
2222

23-
GATEWAY_CERT=${GATEWAY_CERT:-$CERTS_DIR/live/cert.pem}
24-
GATEWAY_KEY=${GATEWAY_KEY:-$CERTS_DIR/live/key.pem}
25-
2623
CONFIG_FILE=./build-config.sh
2724

2825
check_config() {
@@ -80,8 +77,6 @@ GATEWAY_WG_INTERFACE=dgw-$USER
8077
GATEWAY_WG_LISTEN_PORT=$(($BASE_PORT + 3))
8178
GATEWAY_WG_IP=10.$SUBNET_INDEX.3.1
8279
GATEWAY_SERVE_PORT=$(($BASE_PORT + 4))
83-
GATEWAY_CERT=$CERBOT_WORKDIR/live/cert.pem
84-
GATEWAY_KEY=$CERBOT_WORKDIR/live/key.pem
8580
8681
BIND_PUBLIC_IP=0.0.0.0
8782
@@ -246,9 +241,6 @@ interface = "$GATEWAY_WG_INTERFACE"
246241
endpoint = "10.0.2.2:$GATEWAY_WG_LISTEN_PORT"
247242
248243
[core.proxy]
249-
cert_chain = "$GATEWAY_CERT"
250-
cert_key = "$GATEWAY_KEY"
251-
base_domain = "$GATEWAY_PUBLIC_DOMAIN"
252244
listen_addr = "$BIND_PUBLIC_IP"
253245
listen_port = $GATEWAY_SERVE_PORT
254246
agent_port = $AGENT_PORT

0 commit comments

Comments
 (0)