Skip to content

Commit df0b302

Browse files
authored
Merge pull request #674 from Dstack-TEE/feat/gateway-admin-token
gw: add admin API token authentication
2 parents 07d2cf6 + 6f37929 commit df0b302

16 files changed

Lines changed: 719 additions & 62 deletions

Cargo.lock

Lines changed: 1 addition & 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
@@ -192,6 +192,7 @@ rustls-webpki = "0.103.10"
192192
schnorrkel = "0.11.4"
193193
sha2 = { version = "0.10.8", default-features = false }
194194
sha3 = "0.10.8"
195+
subtle = "2"
195196
blake2 = "0.10.6"
196197
tokio-rustls = { version = "0.26.2", features = ["ring"] }
197198
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }

gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ uuid = { workspace = true, features = ["v4"] }
6262
rmp-serde.workspace = true
6363
or-panic.workspace = true
6464
base64.workspace = true
65+
subtle.workspace = true
6566

6667
[target.'cfg(unix)'.dependencies]
6768
nix = { workspace = true, features = ["resource"] }

gateway/docs/cluster-deployment.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,18 @@ Important:
289289

290290
### 2.7 Verify Cluster Sync
291291

292+
The admin API requires a bearer token (see `core.admin.admin_token` in `gateway.toml`,
293+
or the `ADMIN_API_TOKEN` env injected by `deploy-to-vmm.sh`). Export it once:
294+
292295
```bash
296+
export ADMIN_API_TOKEN=... # value from .env or gateway.toml
297+
ADMIN_AUTH=(-H "Authorization: Bearer $ADMIN_API_TOKEN")
298+
293299
# Check sync status on any node (replace port with your admin port)
294-
curl -s http://localhost:9016/prpc/WaveKvStatus | jq .
300+
curl -s "${ADMIN_AUTH[@]}" http://localhost:9016/prpc/WaveKvStatus | jq .
295301

296302
# List known cluster nodes
297-
curl -s http://localhost:9016/prpc/Status | jq '.nodes'
303+
curl -s "${ADMIN_AUTH[@]}" http://localhost:9016/prpc/Status | jq '.nodes'
298304
```
299305

300306
A healthy cluster sync shows:
@@ -567,7 +573,8 @@ $CLI info <vm-id>
567573
Check that the gateway sees the new app:
568574

569575
```bash
570-
curl -s http://localhost:<admin-port>/prpc/Status | jq '.hosts'
576+
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
577+
http://localhost:<admin-port>/prpc/Status | jq '.hosts'
571578
```
572579

573580
Expected output should include an entry with the app's `instance_id` and an assigned WireGuard IP:
@@ -619,8 +626,11 @@ Gateway supports automatic TLS certificate management via the ACME protocol. Con
619626
### 6.1 Configure ACME Service
620627

621628
```bash
629+
ADMIN_AUTH=(-H "Authorization: Bearer $ADMIN_API_TOKEN")
630+
622631
# Set ACME URL (Let's Encrypt production)
623-
curl -X POST "http://localhost:9016/prpc/SetCertbotConfig" \
632+
curl -X POST "${ADMIN_AUTH[@]}" \
633+
"http://localhost:9016/prpc/SetCertbotConfig" \
624634
-H "Content-Type: application/json" \
625635
-d '{"acme_url": "https://acme-v02.api.letsencrypt.org/directory"}'
626636

@@ -637,7 +647,8 @@ The Cloudflare API token needs the **DNS:Edit** permission on the target zone. C
637647
Cloudflare example:
638648

639649
```bash
640-
curl -X POST "http://localhost:9016/prpc/CreateDnsCredential" \
650+
curl -X POST "${ADMIN_AUTH[@]}" \
651+
"http://localhost:9016/prpc/CreateDnsCredential" \
641652
-H "Content-Type: application/json" \
642653
-d '{
643654
"name": "cloudflare-prod",
@@ -669,15 +680,17 @@ Parameter description:
669680
Basic usage (using default DNS credential):
670681

671682
```bash
672-
curl -X POST "http://localhost:9016/prpc/AddZtDomain" \
683+
curl -X POST "${ADMIN_AUTH[@]}" \
684+
"http://localhost:9016/prpc/AddZtDomain" \
673685
-H "Content-Type: application/json" \
674686
-d '{"domain": "example.com", "port": 443}'
675687
```
676688

677689
Specifying DNS credential and node binding:
678690

679691
```bash
680-
curl -X POST "http://localhost:9016/prpc/AddZtDomain" \
692+
curl -X POST "${ADMIN_AUTH[@]}" \
693+
"http://localhost:9016/prpc/AddZtDomain" \
681694
-H "Content-Type: application/json" \
682695
-d '{
683696
"domain": "internal.example.com",
@@ -711,15 +724,16 @@ Note: After adding a domain, the certificate is not issued immediately. Gateway
711724
### 6.4 Manually Trigger Certificate Renewal
712725

713726
```bash
714-
curl -X POST "http://localhost:9016/prpc/RenewZtDomainCert" \
727+
curl -X POST "${ADMIN_AUTH[@]}" \
728+
"http://localhost:9016/prpc/RenewZtDomainCert" \
715729
-H "Content-Type: application/json" \
716730
-d '{"domain": "example.com", "force": true}'
717731
```
718732

719733
### 6.5 Check Certificate Status
720734

721735
```bash
722-
curl -s http://localhost:9016/prpc/ListZtDomains | jq .
736+
curl -s "${ADMIN_AUTH[@]}" http://localhost:9016/prpc/ListZtDomains | jq .
723737
```
724738

725739
A healthy certificate shows `has_cert: true` and `loaded_in_memory: true`:

gateway/dstack-app/bootstrap-cluster.sh

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,26 @@
1414
# Load .env if present
1515
if [ -f ".env" ]; then
1616
set -a
17+
# shellcheck source=/dev/null
1718
source .env
1819
set +a
1920
fi
2021

2122
ADMIN_ADDR="${1:-${GATEWAY_ADMIN_RPC_ADDR:-127.0.0.1:9203}}"
2223

24+
# bootstrap-cluster.sh authenticates to the admin API as an operator. The token
25+
# is generated by deploy-to-vmm.sh and persisted in .env.
26+
if [ -z "${ADMIN_API_TOKEN:-}" ]; then
27+
echo "ERROR: ADMIN_API_TOKEN must be set (check .env)" >&2
28+
exit 1
29+
fi
30+
AUTH_HEADER=(-H "Authorization: Bearer $ADMIN_API_TOKEN")
31+
2332
echo "Waiting for gateway admin API at $ADMIN_ADDR..."
2433
max_retries=60
2534
retry=0
2635
while [ $retry -lt $max_retries ]; do
27-
if curl -sf "http://$ADMIN_ADDR/prpc/Status" >/dev/null 2>&1; then
36+
if curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Status" >/dev/null 2>&1; then
2837
break
2938
fi
3039
retry=$((retry + 1))
@@ -47,19 +56,19 @@ else
4756
fi
4857

4958
echo "Setting certbot config (ACME URL: $ACME_URL)..."
50-
curl -sf -X POST "http://$ADMIN_ADDR/prpc/SetCertbotConfig" \
59+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/SetCertbotConfig" \
5160
-H "Content-Type: application/json" \
5261
-d '{"acme_url":"'"$ACME_URL"'","renew_interval_secs":3600,"renew_before_expiration_secs":864000,"renew_timeout_secs":300}' >/dev/null \
5362
&& echo " Certbot config set" || echo " WARN: failed to set certbot config"
5463

5564
# Create DNS credential if CF_API_TOKEN is provided and no credentials exist yet
5665
if [ -n "$CF_API_TOKEN" ]; then
57-
existing=$(curl -sf "http://$ADMIN_ADDR/prpc/ListDnsCredentials" 2>/dev/null)
66+
existing=$(curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/ListDnsCredentials" 2>/dev/null)
5867
cred_count=$(echo "$existing" | jq -r '.credentials | length' 2>/dev/null || echo "0")
5968

6069
if [ "$cred_count" = "0" ]; then
6170
echo "Creating default DNS credential..."
62-
curl -sf -X POST "http://$ADMIN_ADDR/prpc/CreateDnsCredential" \
71+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/CreateDnsCredential" \
6372
-H "Content-Type: application/json" \
6473
-d '{"name":"cloudflare","provider_type":"cloudflare","cf_api_token":"'"$CF_API_TOKEN"'","set_as_default":true}' >/dev/null \
6574
&& echo " DNS credential created" || echo " WARN: failed to create DNS credential"
@@ -72,12 +81,12 @@ fi
7281

7382
# Add ZT-Domain if SRV_DOMAIN is provided and domain doesn't exist yet
7483
if [ -n "$SRV_DOMAIN" ]; then
75-
existing=$(curl -sf "http://$ADMIN_ADDR/prpc/ListZtDomains" 2>/dev/null)
84+
existing=$(curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/ListZtDomains" 2>/dev/null)
7685
has_domain=$(echo "$existing" | jq -r '.domains[]? | select(.domain=="'"$SRV_DOMAIN"'") | .domain' 2>/dev/null)
7786

7887
if [ -z "$has_domain" ]; then
7988
echo "Adding ZT-Domain: $SRV_DOMAIN..."
80-
curl -sf -X POST "http://$ADMIN_ADDR/prpc/AddZtDomain" \
89+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/AddZtDomain" \
8190
-H "Content-Type: application/json" \
8291
-d '{"domain":"'"$SRV_DOMAIN"'","port":443,"priority":100}' >/dev/null \
8392
&& echo " ZT-Domain added" || echo " WARN: failed to add ZT-Domain"

gateway/dstack-app/builder/entrypoint.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ validate_env "$NODE_ID"
3636
validate_env "$WG_IP"
3737
validate_env "$WG_RESERVED_NET"
3838
validate_env "$WG_CLIENT_RANGE"
39+
validate_env "$ADMIN_API_TOKEN"
40+
41+
if [ -z "$ADMIN_API_TOKEN" ]; then
42+
echo "ADMIN_API_TOKEN must be set when admin API is enabled"
43+
exit 1
44+
fi
3945

4046
# Validate $NODE_ID, must be a number
4147
if [[ ! "$NODE_ID" =~ ^[0-9]+$ ]]; then
@@ -89,6 +95,7 @@ sync_connections_interval = "${SYNC_CONNECTIONS_INTERVAL:-30s}"
8995
enabled = true
9096
address = "${ADMIN_LISTEN_ADDR:-0.0.0.0}"
9197
port = ${ADMIN_LISTEN_PORT:-8001}
98+
admin_token = "${ADMIN_API_TOKEN}"
9299
93100
[core.wg]
94101
public_key = "$PUBLIC_KEY"

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ WG_ADDR=0.0.0.0:9202
9595
# The token used to launch the App
9696
APP_LAUNCH_TOKEN=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 32 | head -n 1)
9797
98+
# Bearer token required by the gateway admin API. Used by bootstrap-cluster.sh
99+
# and any operator who calls the admin API. Persisted into .env so cluster
100+
# bootstrap can reach the API after deploy.
101+
ADMIN_API_TOKEN=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | fold -w 48 | head -n 1)
102+
98103
# PROXY protocol: read v1/v2 header from inbound connections (e.g. when this
99104
# gateway sits behind a PP-aware L4 LB such as Cloudflare Spectrum or haproxy
100105
# with send-proxy). Set to "true" only if the upstream LB is configured to
@@ -117,6 +122,7 @@ required_env_vars=(
117122
"GATEWAY_APP_ID"
118123
"MY_URL"
119124
"APP_LAUNCH_TOKEN"
125+
"ADMIN_API_TOKEN"
120126
"NODE_ID"
121127
"KMS_URL"
122128
# "BOOTNODE_URL"
@@ -180,6 +186,7 @@ WG_IP=$WG_IP
180186
WG_RESERVED_NET=$WG_RESERVED_NET
181187
WG_CLIENT_RANGE=$WG_CLIENT_RANGE
182188
APP_LAUNCH_TOKEN=$APP_LAUNCH_TOKEN
189+
ADMIN_API_TOKEN=$ADMIN_API_TOKEN
183190
RPC_DOMAIN=$RPC_DOMAIN
184191
NODE_ID=$NODE_ID
185192
PROXY_LISTEN_PORT=$PROXY_LISTEN_PORT

gateway/dstack-app/docker-compose.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ 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+
- ADMIN_API_TOKEN=${ADMIN_API_TOKEN:-}
4445
- INBOUND_PP_ENABLED=${INBOUND_PP_ENABLED:-false}
4546
- TIMEOUT_PP_HEADER=${TIMEOUT_PP_HEADER:-5s}
4647
- PORT_POLICY_FETCH_TIMEOUT=${PORT_POLICY_FETCH_TIMEOUT:-10s}

gateway/gateway.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ timeout = "5s"
2424
[core.admin]
2525
enabled = false
2626
address = "127.0.0.1:8011"
27+
# Shared secret required by every admin endpoint (RPC + dashboard). Can also
28+
# be supplied via the `DSTACK_GATEWAY_ADMIN_TOKEN` or `ADMIN_API_TOKEN` env
29+
# vars. Clients send it as `Authorization: Bearer <token>`, `X-Admin-Token`,
30+
# or (GET only, for dashboard links) `?token=...`. Required unless
31+
# `insecure_no_auth = true`.
32+
admin_token = ""
33+
# Development/testing escape hatch only. Never enable this on an admin
34+
# interface that is reachable from the network.
35+
insecure_no_auth = false
2736

2837
[core.debug]
2938
insecure_enable_debug_rpc = false

0 commit comments

Comments
 (0)