Skip to content

Commit 87573b1

Browse files
committed
gw: add admin API token authentication
The admin server has historically relied on network isolation alone: `construct()` does no caller checks, so anyone reachable on the admin port (default `0.0.0.0:8001` inside the deployment container) can call any admin RPC or load the dashboard. Combined with the dashboard's visibility into cluster state, this is an obvious hardening target. Add an opt-in shared-secret check, modelled on KMS `ensure_admin` but using the plain token in config (no hash) so the value can be injected via dstack encrypted env without round-tripping a SHA-256 step. - `AdminConfig.admin_token` (empty = no auth, with a startup WARN so existing deployments keep their current behaviour). - New `admin_auth::AdminAuthFairing` attached to the admin Rocket instance. Accepts the token via `X-Admin-Token`, `Authorization: Bearer <token>`, or `?token=...` (for the browser dashboard). Constant-time compare via `subtle::ConstantTimeEq`. Rejected requests are rewritten to a sentinel URI that returns HTTP 401, so all currently-mounted routes (prpc + dashboard) are covered without per-route guards. - Thread `ADMIN_TOKEN` through `dstack-app` entrypoint + compose so encrypted-env values land in `gateway.toml`. - Update `bootstrap-cluster.sh` to send `X-Admin-Token` when `ADMIN_TOKEN` is set.
1 parent 98bd47a commit 87573b1

10 files changed

Lines changed: 366 additions & 6 deletions

File tree

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/dstack-app/bootstrap-cluster.sh

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,25 @@
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+
# When admin auth is enabled, ADMIN_TOKEN must be set so curl can present
25+
# X-Admin-Token. Empty token = legacy unauthenticated mode.
26+
AUTH_HEADER=()
27+
if [ -n "$ADMIN_TOKEN" ]; then
28+
AUTH_HEADER=(-H "X-Admin-Token: $ADMIN_TOKEN")
29+
fi
30+
2331
echo "Waiting for gateway admin API at $ADMIN_ADDR..."
2432
max_retries=60
2533
retry=0
2634
while [ $retry -lt $max_retries ]; do
27-
if curl -sf "http://$ADMIN_ADDR/prpc/Status" >/dev/null 2>&1; then
35+
if curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Status" >/dev/null 2>&1; then
2836
break
2937
fi
3038
retry=$((retry + 1))
@@ -47,19 +55,19 @@ else
4755
fi
4856

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

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

6068
if [ "$cred_count" = "0" ]; then
6169
echo "Creating default DNS credential..."
62-
curl -sf -X POST "http://$ADMIN_ADDR/prpc/CreateDnsCredential" \
70+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/CreateDnsCredential" \
6371
-H "Content-Type: application/json" \
6472
-d '{"name":"cloudflare","provider_type":"cloudflare","cf_api_token":"'"$CF_API_TOKEN"'","set_as_default":true}' >/dev/null \
6573
&& echo " DNS credential created" || echo " WARN: failed to create DNS credential"
@@ -72,12 +80,12 @@ fi
7280

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

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

gateway/dstack-app/builder/entrypoint.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ 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_TOKEN"
3940

4041
# Validate $NODE_ID, must be a number
4142
if [[ ! "$NODE_ID" =~ ^[0-9]+$ ]]; then
@@ -89,6 +90,7 @@ sync_connections_interval = "${SYNC_CONNECTIONS_INTERVAL:-30s}"
8990
enabled = true
9091
address = "${ADMIN_LISTEN_ADDR:-0.0.0.0}"
9192
port = ${ADMIN_LISTEN_PORT:-8001}
93+
admin_token = "${ADMIN_TOKEN}"
9294
9395
[core.wg]
9496
public_key = "$PUBLIC_KEY"

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_TOKEN=${ADMIN_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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ 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) when
28+
# non-empty. Clients send it via the `X-Admin-Token` header (or `?token=...`
29+
# for the dashboard / browser links). Empty disables auth.
30+
admin_token = ""
2731

2832
[core.debug]
2933
insecure_enable_debug_rpc = false

0 commit comments

Comments
 (0)