Skip to content

Commit cbb1f2f

Browse files
committed
feat(kms): serve ClearImageCache on a dedicated authenticated admin listener
1 parent f0d0e06 commit cbb1f2f

8 files changed

Lines changed: 372 additions & 62 deletions

File tree

dstack/kms/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,41 @@ CVMs running in dstack support three boot modes:
3030
- `key-provider` in RTMR: `{"type": "kms", "id": "<kms-root-pubkey>"}`
3131
- `app-id` is equal to the address of the deployed App Smart Contract.
3232

33+
## Admin API authentication
34+
35+
The KMS public RPCs authenticate callers by RA-TLS attestation. Operator-facing
36+
admin RPCs (currently `ClearImageCache`) are instead served on a **separate admin
37+
listener** (`[core.admin]` in `kms.toml`) behind the same shared HTTP
38+
authenticator used by the VMM and gateway, configured identically to the gateway
39+
admin API, so the credential travels in the `Authorization: Bearer <token>` or
40+
`X-Admin-Token: <token>` header:
41+
42+
```toml
43+
[core.admin]
44+
enabled = true
45+
address = "127.0.0.1"
46+
port = 8001
47+
# generate with: openssl rand -hex 32
48+
auth_token = "<token>"
49+
# htpasswd_file = "/etc/kms/admin.htpasswd" # bcrypt (htpasswd -B), optional
50+
insecure_no_auth = false
51+
```
52+
53+
The token can also be supplied via the `DSTACK_KMS_ADMIN_TOKEN` or
54+
`ADMIN_API_TOKEN` environment variables instead of the config file. The admin
55+
listener fails closed: enabled with neither `auth_token` nor `htpasswd_file`
56+
(and `insecure_no_auth = false`) refuses to start. Call it with, for example:
57+
58+
```bash
59+
curl -X POST "http://127.0.0.1:8001/prpc/Admin.ClearImageCache?json" \
60+
-H "Authorization: Bearer $TOKEN" \
61+
-H "Content-Type: application/json" \
62+
-d '{"image_hash": "<hash>", "config_hash": "<hash>"}'
63+
```
64+
65+
This admin authentication is separate from the on-chain / webhook authorization
66+
below, which decides whether a CVM may boot and receive keys.
67+
3368
## KMS Implementation
3469

3570
### Components

dstack/kms/kms.toml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ mandatory = false
2525
[core]
2626
cert_dir = "/etc/kms/certs"
2727
subject_postfix = ".dstack"
28-
admin_token_hash = ""
2928
site_name = ""
3029
# Whether trusted RPCs require the KMS to first attest itself to its own
3130
# auth API. Defaults to true (strict). Set to false ONLY when running KMS
@@ -53,6 +52,25 @@ cache_dir = "/usr/share/dstack/images"
5352
download_url = "http://localhost:8000/{OS_IMAGE_HASH}.tar.gz"
5453
download_timeout = "2m"
5554

55+
# Admin API: serves operator RPCs (e.g. ClearImageCache) on a dedicated
56+
# listener behind the shared HTTP authenticator. Disabled by default; when
57+
# enabled it fails closed unless auth_token or htpasswd_file is set.
58+
[core.admin]
59+
enabled = false
60+
# Bind address/port for the admin listener (keep it off the public network).
61+
address = "127.0.0.1"
62+
port = 8001
63+
# Shared admin token required by every admin RPC. Can also be supplied via the
64+
# `DSTACK_KMS_ADMIN_TOKEN` or `ADMIN_API_TOKEN` env vars. Clients send it as
65+
# `Authorization: Bearer <token>` or the `X-Admin-Token: <token>` header.
66+
# Required unless insecure_no_auth = true. Generate with: openssl rand -hex 32
67+
auth_token = ""
68+
# Optional Apache bcrypt htpasswd file (create with `htpasswd -B -c file admin`).
69+
htpasswd_file = ""
70+
# Development/testing escape hatch only. Never enable this on an admin
71+
# interface that is reachable from the network.
72+
insecure_no_auth = false
73+
5674
[core.metrics]
5775
# Expose unauthenticated Prometheus metrics on /metrics.
5876
# Disable this if the KMS RPC listener is not protected by network policy

dstack/kms/rpc/proto/kms_rpc.proto

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,20 @@ service KMS {
105105
rpc GetTempCaCert(google.protobuf.Empty) returns (GetTempCaCertResponse);
106106
// Sign a certificate
107107
rpc SignCert(SignCertRequest) returns (SignCertResponse);
108+
}
109+
110+
// The KMS admin RPC service. Served on a separate admin listener
111+
// (`[core.admin]`) behind the shared HTTP authenticator, so the credential
112+
// travels in the `Authorization`/`X-Admin-Token` header rather than the
113+
// request body.
114+
service Admin {
108115
// Clear the image cache
109116
rpc ClearImageCache(ClearImageCacheRequest) returns (google.protobuf.Empty);
110117
}
111118

112119
message ClearImageCacheRequest {
113-
string token = 1;
114-
string image_hash = 2;
115-
string config_hash = 3;
120+
string image_hash = 1;
121+
string config_hash = 2;
116122
}
117123

118124
message BootstrapRequest {

dstack/kms/src/admin_auth.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//! KMS adapter for the shared API authenticator, guarding the admin listener.
6+
//!
7+
//! Unlike the KMS public RPC surface (which authenticates callers by RA-TLS
8+
//! attestation), the admin API is reached by operators/tooling, so it uses the
9+
//! same shared-token/htpasswd HTTP mechanism as the VMM and gateway. The token
10+
//! is configured via `core.admin.auth_token`, or the `DSTACK_KMS_ADMIN_TOKEN` /
11+
//! `ADMIN_API_TOKEN` environment variables.
12+
13+
use anyhow::{bail, Result};
14+
use dstack_api_auth::{Authenticator, HttpAuthConfig, HttpAuthFairing};
15+
use rocket::Route;
16+
17+
use crate::config::AdminConfig;
18+
19+
const ENV_ADMIN_TOKEN: &str = "DSTACK_KMS_ADMIN_TOKEN";
20+
const ENV_ADMIN_TOKEN_COMPAT: &str = "ADMIN_API_TOKEN";
21+
22+
pub struct AdminAuthFairing(HttpAuthFairing);
23+
24+
impl AdminAuthFairing {
25+
pub fn from_config(config: &AdminConfig) -> Result<Self> {
26+
if config.insecure_no_auth {
27+
return Ok(Self(HttpAuthFairing::new(
28+
Authenticator::disabled(),
29+
http_config(),
30+
)));
31+
}
32+
let token = if !config.auth_token.is_empty() {
33+
config.auth_token.trim().to_owned()
34+
} else {
35+
std::env::var(ENV_ADMIN_TOKEN)
36+
.or_else(|_| std::env::var(ENV_ADMIN_TOKEN_COMPAT))
37+
.unwrap_or_default()
38+
.trim()
39+
.to_owned()
40+
};
41+
if token.is_empty() && config.htpasswd_file.as_os_str().is_empty() {
42+
bail!(
43+
"admin API is enabled but neither auth_token nor htpasswd_file is configured; \
44+
set core.admin.auth_token, {ENV_ADMIN_TOKEN}, {ENV_ADMIN_TOKEN_COMPAT}, \
45+
core.admin.htpasswd_file, or insecure_no_auth = true (testing only)"
46+
);
47+
}
48+
let mut auth = Authenticator::from_tokens([token]);
49+
if !config.htpasswd_file.as_os_str().is_empty() {
50+
auth = auth.with_htpasswd_file(&config.htpasswd_file)?;
51+
}
52+
Ok(Self(HttpAuthFairing::new(auth, http_config())))
53+
}
54+
}
55+
56+
fn http_config() -> HttpAuthConfig {
57+
HttpAuthConfig {
58+
realm: "dstack-kms admin".into(),
59+
token_header: Some("X-Admin-Token".into()),
60+
allow_get_query_token: false,
61+
}
62+
}
63+
64+
#[rocket::async_trait]
65+
impl rocket::fairing::Fairing for AdminAuthFairing {
66+
fn info(&self) -> rocket::fairing::Info {
67+
self.0.info()
68+
}
69+
async fn on_request(&self, req: &mut rocket::Request<'_>, data: &mut rocket::Data<'_>) {
70+
self.0.on_request(req, data).await
71+
}
72+
}
73+
74+
pub fn routes() -> Vec<Route> {
75+
dstack_api_auth::routes()
76+
}
77+
78+
#[cfg(test)]
79+
mod tests {
80+
use super::*;
81+
82+
#[test]
83+
fn enabled_without_credentials_fails_closed() {
84+
// the token can also come from the environment; make sure neither var is
85+
// set so this exercises the missing-credential path.
86+
std::env::remove_var(ENV_ADMIN_TOKEN);
87+
std::env::remove_var(ENV_ADMIN_TOKEN_COMPAT);
88+
let cfg = AdminConfig {
89+
enabled: true,
90+
..Default::default()
91+
};
92+
assert!(AdminAuthFairing::from_config(&cfg).is_err());
93+
}
94+
95+
#[test]
96+
fn insecure_no_auth_is_allowed() {
97+
let cfg = AdminConfig {
98+
enabled: true,
99+
insecure_no_auth: true,
100+
..Default::default()
101+
};
102+
assert!(AdminAuthFairing::from_config(&cfg).is_ok());
103+
}
104+
105+
#[test]
106+
fn configured_token_builds() {
107+
let cfg = AdminConfig {
108+
enabled: true,
109+
auth_token: "secret".into(),
110+
..Default::default()
111+
};
112+
assert!(AdminAuthFairing::from_config(&cfg).is_ok());
113+
}
114+
}

dstack/kms/src/admin_service.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//! KMS admin RPC service, served on the `[core.admin]` listener behind the
6+
//! shared HTTP authenticator. Callers are authenticated by the admin token /
7+
//! htpasswd via `Authorization`/`X-Admin-Token`, so the handlers do no token
8+
//! checks of their own.
9+
10+
use anyhow::Result;
11+
use dstack_kms_rpc::{
12+
admin_server::{AdminRpc, AdminServer},
13+
ClearImageCacheRequest,
14+
};
15+
use ra_rpc::{CallContext, RpcCall};
16+
17+
use crate::main_service::KmsState;
18+
19+
pub struct AdminRpcHandler {
20+
state: KmsState,
21+
}
22+
23+
impl AdminRpc for AdminRpcHandler {
24+
async fn clear_image_cache(self, request: ClearImageCacheRequest) -> Result<()> {
25+
self.state
26+
.clear_image_cache(&request.image_hash, &request.config_hash)
27+
}
28+
}
29+
30+
impl RpcCall<KmsState> for AdminRpcHandler {
31+
type PrpcService = AdminServer<Self>;
32+
33+
fn construct(context: CallContext<'_, KmsState>) -> Result<Self> {
34+
Ok(AdminRpcHandler {
35+
state: context.state.clone(),
36+
})
37+
}
38+
}

dstack/kms/src/config.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,6 @@ pub(crate) struct KmsConfig {
5959
/// mirroring `sev_snp_key_release`.
6060
#[serde(default)]
6161
pub aws_nitro_tpm_key_release: bool,
62-
#[serde(with = "serde_human_bytes")]
63-
pub admin_token_hash: Vec<u8>,
6462
#[serde(default)]
6563
pub site_name: String,
6664
/// Whether trusted RPCs require the KMS to first attest itself to its
@@ -70,6 +68,10 @@ pub(crate) struct KmsConfig {
7068
#[serde(default = "default_true")]
7169
pub enforce_self_authorization: bool,
7270
pub metrics: MetricsConfig,
71+
/// Admin API listener + authentication. The admin RPCs (e.g.
72+
/// `ClearImageCache`) are served here, behind the shared HTTP authenticator.
73+
#[serde(default)]
74+
pub admin: AdminConfig,
7375
}
7476

7577
#[derive(Debug, Clone, Deserialize)]
@@ -78,6 +80,29 @@ pub(crate) struct MetricsConfig {
7880
pub enabled: bool,
7981
}
8082

83+
/// Admin API listener + authentication, mirroring the gateway `[core.admin]`
84+
/// section. The listen `address`/`port` are read from the same `[core.admin]`
85+
/// section by Rocket. The token travels in the `Authorization`/`X-Admin-Token`
86+
/// header.
87+
#[derive(Debug, Clone, Default, Deserialize)]
88+
pub(crate) struct AdminConfig {
89+
/// Whether to serve the admin API at all.
90+
#[serde(default)]
91+
pub enabled: bool,
92+
/// Shared admin token required to call any admin RPC. Can also be supplied
93+
/// via `DSTACK_KMS_ADMIN_TOKEN` / `ADMIN_API_TOKEN`. Required unless
94+
/// `insecure_no_auth = true`.
95+
#[serde(default)]
96+
pub auth_token: String,
97+
/// Optional Apache bcrypt htpasswd file, accepted in addition to the token.
98+
#[serde(default)]
99+
pub htpasswd_file: PathBuf,
100+
/// Development-only escape hatch: serve the admin API with no auth. Never
101+
/// enable on a network-reachable listener.
102+
#[serde(default)]
103+
pub insecure_no_auth: bool,
104+
}
105+
81106
fn default_true() -> bool {
82107
true
83108
}
@@ -160,3 +185,23 @@ pub(crate) struct OnboardConfig {
160185
pub enabled: bool,
161186
pub auto_bootstrap_domain: String,
162187
}
188+
189+
#[cfg(test)]
190+
mod tests {
191+
use super::*;
192+
193+
#[test]
194+
fn default_config_parses_with_admin_disabled_and_no_hash() {
195+
let figment = load_config_figment(None);
196+
let config: KmsConfig = figment
197+
.focus("core")
198+
.extract()
199+
.expect("kms.toml must parse into KmsConfig");
200+
assert!(!config.admin.enabled, "admin must be off by default");
201+
assert!(
202+
config.admin.auth_token.is_empty(),
203+
"default admin token must be empty (fail-closed)"
204+
);
205+
assert!(!config.admin.insecure_no_auth);
206+
}
207+
}

0 commit comments

Comments
 (0)