Skip to content

Commit 84babfa

Browse files
committed
gw: harden admin auth defaults and unify ADMIN_API_TOKEN naming
Address review-style improvements lifted from PR #675: - Fail-by-default. When `admin.enabled = true`, gateway now refuses to start unless either an `admin_token` is configured (via `core.admin.admin_token`, `DSTACK_GATEWAY_ADMIN_TOKEN`, or `ADMIN_API_TOKEN`) or `insecure_no_auth = true` is set explicitly. Replaces the previous "empty token = open access + WARN" policy. e2e configs opt into `insecure_no_auth = true`. - Env-var fallback in Rust. `AdminAuthFairing::from_config` resolves the token from config first, then `DSTACK_GATEWAY_ADMIN_TOKEN`, then `ADMIN_API_TOKEN`. Operators running the binary directly no longer have to template the TOML. - SHA-256 in-memory storage. The plaintext token is hashed at startup; only the 32-byte digest is retained. Verification SHA-256s the request token and constant-time compares the digests via `subtle::ConstantTimeEq`. - dstack-app deployment chain renamed `ADMIN_TOKEN` -> `ADMIN_API_TOKEN` end-to-end (deploy-to-vmm.sh auto-generates it into .env, docker-compose forwards it, entrypoint.sh injects it into `gateway.toml`, bootstrap-cluster.sh requires it and sends `Authorization: Bearer`). - Docs in `gateway/docs/cluster-deployment.md` now show the bearer-auth pattern in every admin-curl example. Tests: 4 new cases in `admin_auth::tests` cover the from_config policy (insecure flag, config path, env fallbacks, error message). All 13 `admin_auth` tests pass; clippy with `-D clippy::expect_used -D clippy::unwrap_used` is clean.
1 parent 87573b1 commit 84babfa

12 files changed

Lines changed: 199 additions & 35 deletions

File tree

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: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ fi
2121

2222
ADMIN_ADDR="${1:-${GATEWAY_ADMIN_RPC_ADDR:-127.0.0.1:9203}}"
2323

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")
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
2929
fi
30+
AUTH_HEADER=(-H "Authorization: Bearer $ADMIN_API_TOKEN")
3031

3132
echo "Waiting for gateway admin API at $ADMIN_ADDR..."
3233
max_retries=60

gateway/dstack-app/builder/entrypoint.sh

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

4146
# Validate $NODE_ID, must be a number
4247
if [[ ! "$NODE_ID" =~ ^[0-9]+$ ]]; then
@@ -90,7 +95,7 @@ sync_connections_interval = "${SYNC_CONNECTIONS_INTERVAL:-30s}"
9095
enabled = true
9196
address = "${ADMIN_LISTEN_ADDR:-0.0.0.0}"
9297
port = ${ADMIN_LISTEN_PORT:-8001}
93-
admin_token = "${ADMIN_TOKEN}"
98+
admin_token = "${ADMIN_API_TOKEN}"
9499
95100
[core.wg]
96101
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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +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:-}
44+
- ADMIN_API_TOKEN=${ADMIN_API_TOKEN:-}
4545
- INBOUND_PP_ENABLED=${INBOUND_PP_ENABLED:-false}
4646
- TIMEOUT_PP_HEADER=${TIMEOUT_PP_HEADER:-5s}
4747
- PORT_POLICY_FETCH_TIMEOUT=${PORT_POLICY_FETCH_TIMEOUT:-10s}

gateway/gateway.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +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) when
28-
# non-empty. Clients send it via the `X-Admin-Token` header (or `?token=...`
29-
# for the dashboard / browser links). Empty disables auth.
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`.
3032
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
3136

3237
[core.debug]
3338
insecure_enable_debug_rpc = false

gateway/src/admin_auth.rs

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,30 +17,66 @@
1717
//! Rejected requests are forwarded to a sentinel route that returns HTTP 401,
1818
//! so all admin routes (prpc-generated and dashboard) are protected by a single
1919
//! attachment without modifying the route declarations.
20+
//!
21+
//! The token is only ever held in memory as its SHA-256 hash; the configured
22+
//! plaintext is dropped right after the fairing is constructed.
2023
24+
use anyhow::{bail, Result};
2125
use rocket::{
2226
fairing::{Fairing, Info, Kind},
2327
http::{uri::Origin, Method, Status},
2428
Data, Request, Route,
2529
};
30+
use sha2::{Digest, Sha256};
2631
use subtle::ConstantTimeEq;
2732

33+
use crate::config::AdminConfig;
34+
2835
const UNAUTH_URI: &str = "/__admin_unauthorized";
2936
const HEADER_NAME: &str = "X-Admin-Token";
3037
const QUERY_PARAM: &str = "token";
38+
const ENV_ADMIN_TOKEN: &str = "DSTACK_GATEWAY_ADMIN_TOKEN";
39+
const ENV_ADMIN_TOKEN_COMPAT: &str = "ADMIN_API_TOKEN";
3140

3241
pub struct AdminAuthFairing {
33-
/// `None` means auth is disabled (empty config); any request is allowed.
34-
token: Option<String>,
42+
/// SHA-256 of the configured token. `None` = auth disabled (insecure mode).
43+
token_hash: Option<[u8; 32]>,
3544
}
3645

3746
impl AdminAuthFairing {
38-
pub fn new(token: String) -> Self {
47+
/// Build a fairing from a resolved plaintext token. Empty disables auth.
48+
pub fn new(token: &str) -> Self {
3949
Self {
40-
token: (!token.is_empty()).then_some(token),
50+
token_hash: (!token.is_empty()).then(|| sha256(token.as_bytes())),
4151
}
4252
}
4353

54+
/// Resolve a token from config + env, applying the auth policy:
55+
/// - `insecure_no_auth = true` → disabled (caller is expected to warn)
56+
/// - else require a non-empty token from `admin_token`,
57+
/// `DSTACK_GATEWAY_ADMIN_TOKEN`, or `ADMIN_API_TOKEN`.
58+
pub fn from_config(config: &AdminConfig) -> Result<Self> {
59+
if config.insecure_no_auth {
60+
return Ok(Self { token_hash: None });
61+
}
62+
let token = if !config.admin_token.is_empty() {
63+
config.admin_token.clone()
64+
} else {
65+
std::env::var(ENV_ADMIN_TOKEN)
66+
.or_else(|_| std::env::var(ENV_ADMIN_TOKEN_COMPAT))
67+
.unwrap_or_default()
68+
};
69+
let token = token.trim();
70+
if token.is_empty() {
71+
bail!(
72+
"admin API is enabled but no admin_token is configured; \
73+
set core.admin.admin_token, {ENV_ADMIN_TOKEN}, or {ENV_ADMIN_TOKEN_COMPAT}, \
74+
or set core.admin.insecure_no_auth = true (testing only)"
75+
);
76+
}
77+
Ok(Self::new(token))
78+
}
79+
4480
fn extract_token(req: &Request<'_>) -> Option<String> {
4581
if let Some(t) = req.headers().get_one(HEADER_NAME) {
4682
return Some(t.to_string());
@@ -63,6 +99,10 @@ impl AdminAuthFairing {
6399
}
64100
}
65101

102+
fn sha256(bytes: &[u8]) -> [u8; 32] {
103+
Sha256::digest(bytes).into()
104+
}
105+
66106
/// Rebuild the request URI without the `token` query parameter, if present.
67107
/// Returns `None` when there is nothing to strip.
68108
fn strip_token_query(uri: &Origin<'_>) -> Option<Origin<'static>> {
@@ -99,15 +139,16 @@ impl Fairing for AdminAuthFairing {
99139
}
100140

101141
async fn on_request(&self, req: &mut Request<'_>, _: &mut Data<'_>) {
102-
let Some(expected) = self.token.as_deref() else {
142+
let Some(expected_hash) = self.token_hash.as_ref() else {
103143
return;
104144
};
105145
// Avoid infinite re-routing if the fairing fires on the sentinel itself.
106146
if req.uri().path() == UNAUTH_URI {
107147
return;
108148
}
109149
let provided = Self::extract_token(req).unwrap_or_default();
110-
let matches: bool = provided.as_bytes().ct_eq(expected.as_bytes()).into();
150+
let provided_hash = sha256(provided.as_bytes());
151+
let matches: bool = provided_hash.ct_eq(expected_hash).into();
111152
if !matches {
112153
if let Ok(origin) = Origin::parse_owned(UNAUTH_URI.to_string()) {
113154
req.set_uri(origin);
@@ -198,7 +239,7 @@ mod tests {
198239

199240
async fn make_client(token: &str) -> Client {
200241
let r = rocket::build()
201-
.attach(AdminAuthFairing::new(token.to_string()))
242+
.attach(AdminAuthFairing::new(token))
202243
.mount("/", routes())
203244
.mount("/", rocket::routes![protected_get, protected_post, echo]);
204245
Client::tracked(r).await.unwrap()
@@ -308,6 +349,86 @@ mod tests {
308349
assert_eq!(body, "token=<absent> other=keep");
309350
}
310351

352+
fn hash_of(fairing: &AdminAuthFairing) -> Option<[u8; 32]> {
353+
fairing.token_hash
354+
}
355+
356+
#[test]
357+
fn from_config_disabled_when_insecure_flag_set() {
358+
let cfg = AdminConfig {
359+
enabled: true,
360+
admin_token: String::new(),
361+
insecure_no_auth: true,
362+
};
363+
let fairing = match AdminAuthFairing::from_config(&cfg) {
364+
Ok(f) => f,
365+
Err(e) => panic!("expected Ok, got err: {e}"),
366+
};
367+
assert!(hash_of(&fairing).is_none());
368+
}
369+
370+
#[test]
371+
fn from_config_uses_config_token() {
372+
let cfg = AdminConfig {
373+
enabled: true,
374+
admin_token: "from-config".into(),
375+
insecure_no_auth: false,
376+
};
377+
let fairing = match AdminAuthFairing::from_config(&cfg) {
378+
Ok(f) => f,
379+
Err(e) => panic!("expected Ok, got err: {e}"),
380+
};
381+
assert_eq!(hash_of(&fairing), Some(sha256(b"from-config")));
382+
}
383+
384+
// Env-touching cases are combined into a single test so cargo's parallel
385+
// runner doesn't race on `DSTACK_GATEWAY_ADMIN_TOKEN` / `ADMIN_API_TOKEN`.
386+
#[test]
387+
fn from_config_env_paths() {
388+
let empty_cfg = AdminConfig {
389+
enabled: true,
390+
admin_token: String::new(),
391+
insecure_no_auth: false,
392+
};
393+
394+
// Baseline: no env, no config token → error.
395+
unsafe {
396+
std::env::remove_var(ENV_ADMIN_TOKEN);
397+
std::env::remove_var(ENV_ADMIN_TOKEN_COMPAT);
398+
}
399+
let err = match AdminAuthFairing::from_config(&empty_cfg) {
400+
Err(e) => e,
401+
Ok(_) => panic!("expected error, got Ok"),
402+
};
403+
assert!(err.to_string().contains("no admin_token is configured"));
404+
405+
// Primary env var picked up.
406+
unsafe {
407+
std::env::set_var(ENV_ADMIN_TOKEN, "from-env");
408+
}
409+
let fairing = match AdminAuthFairing::from_config(&empty_cfg) {
410+
Ok(f) => f,
411+
Err(e) => panic!("expected Ok, got err: {e}"),
412+
};
413+
assert_eq!(hash_of(&fairing), Some(sha256(b"from-env")));
414+
unsafe {
415+
std::env::remove_var(ENV_ADMIN_TOKEN);
416+
}
417+
418+
// Compat env var picked up when primary is absent.
419+
unsafe {
420+
std::env::set_var(ENV_ADMIN_TOKEN_COMPAT, "from-compat");
421+
}
422+
let fairing = match AdminAuthFairing::from_config(&empty_cfg) {
423+
Ok(f) => f,
424+
Err(e) => panic!("expected Ok, got err: {e}"),
425+
};
426+
assert_eq!(hash_of(&fairing), Some(sha256(b"from-compat")));
427+
unsafe {
428+
std::env::remove_var(ENV_ADMIN_TOKEN_COMPAT);
429+
}
430+
}
431+
311432
#[rocket::async_test]
312433
async fn unauth_returns_401_on_all_methods() {
313434
let client = make_client("s3cret").await;

0 commit comments

Comments
 (0)