Skip to content

Commit abcc653

Browse files
committed
feat(dstackup): generate and enable a VMM API token on install
1 parent 4e24b58 commit abcc653

4 files changed

Lines changed: 120 additions & 24 deletions

File tree

dstack/crates/dstack-cli-core/src/config.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
//! false` + a set `auto_bootstrap_domain`, the combination validated to make
1010
//! bootstrap hands-off).
1111
//! * `auth-allowlist.json` — read by the host-side Rust auth webhook.
12-
//! * `vmm.toml` — the host VMM config (gateway + auth-token gating off).
12+
//! * `vmm.toml` — the host VMM config (gateway off; management-API auth token
13+
//! set by `dstackup install`).
1314
1415
use crate::host::Platform;
1516
use anyhow::{Context, Result};
@@ -278,6 +279,11 @@ pub struct VmmRender {
278279
pub kms_urls: Vec<String>,
279280
/// confidential-computing platform (selects qemu/share-mode for the CVMs).
280281
pub platform: Platform,
282+
/// gate the management API behind a bearer/Basic token (`[auth] enabled`).
283+
pub auth_enabled: bool,
284+
/// the token accepted by the management API when `auth_enabled` is set.
285+
/// Empty renders `tokens = []`.
286+
pub auth_token: String,
281287
}
282288

283289
impl Default for VmmRender {
@@ -296,13 +302,16 @@ impl Default for VmmRender {
296302
key_provider_port: 3443,
297303
kms_urls: Vec::new(),
298304
platform: Platform::Tdx,
305+
auth_enabled: false,
306+
auth_token: String::new(),
299307
}
300308
}
301309
}
302310

303-
/// render the host `vmm.toml`. Gateway and auth-token gating are off
304-
/// (single-node direct-port access); CVMs use user-mode networking with host
305-
/// port mapping.
311+
/// render the host `vmm.toml`. Gateway is off (single-node direct-port
312+
/// access); management-API auth is gated by `r.auth_enabled`/`r.auth_token`
313+
/// (`dstackup install` generates a token and enables it). CVMs use user-mode
314+
/// networking with host port mapping.
306315
pub fn vmm_toml(r: &VmmRender) -> String {
307316
format!(
308317
r#"# generated by `dstackup install`
@@ -376,9 +385,12 @@ base_domain = "localhost"
376385
port = 8082
377386
agent_port = 8090
378387
388+
# management API auth. `dstackup install` generates a token and enables this
389+
# so the VMM control surface (create/stop VM, UI, pRPC) is not exposed
390+
# unauthenticated. Clients send `Authorization: Bearer <token>`.
379391
[auth]
380-
enabled = false
381-
tokens = []
392+
enabled = {auth_enabled}
393+
tokens = [{auth_tokens}]
382394
383395
[supervisor]
384396
exe = "{supervisor_exe}"
@@ -423,6 +435,12 @@ port = {kp_port}
423435
host_api_port = r.host_api_port,
424436
kp_addr = r.key_provider_addr,
425437
kp_port = r.key_provider_port,
438+
auth_enabled = r.auth_enabled,
439+
auth_tokens = if r.auth_token.is_empty() {
440+
String::new()
441+
} else {
442+
format!("\"{}\"", r.auth_token)
443+
},
426444
)
427445
}
428446

@@ -436,15 +454,28 @@ mod tests {
436454
dashboard_addr: "tcp:127.0.0.1:19080".into(),
437455
cid_start: 2000,
438456
host_api_port: 10001,
457+
auth_enabled: true,
458+
auth_token: "deadbeef".into(),
439459
..Default::default()
440460
};
441461
let rendered = vmm_toml(&r);
442462
assert!(rendered.contains(r#"address = "tcp:127.0.0.1:19080""#));
443463
assert!(rendered.contains("cid_start = 2000"));
444464
assert!(rendered.contains("port = 10001"));
465+
assert!(rendered.contains("enabled = true"));
466+
assert!(rendered.contains(r#"tokens = ["deadbeef"]"#));
445467
toml::from_str::<toml::Value>(&rendered).expect("vmm.toml must be valid TOML");
446468
}
447469

470+
#[test]
471+
fn vmm_toml_auth_disabled_renders_empty_tokens() {
472+
let rendered = vmm_toml(&VmmRender::default());
473+
// default (no token) must still be valid TOML with an empty token list.
474+
assert!(rendered.contains("tokens = []"));
475+
let v: toml::Value = toml::from_str(&rendered).expect("vmm.toml must be valid TOML");
476+
assert_eq!(v["auth"]["enabled"].as_bool(), Some(false));
477+
}
478+
448479
#[test]
449480
fn kms_toml_has_single_node_invariants() {
450481
let cfg = HostConfig {

dstack/crates/dstackup/src/destroy.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
//! `dstackup destroy` — tear down what `install` started.
66
7+
use crate::install::read_token_file;
78
use crate::state::{read_state, state_path};
89
use crate::systemd::{remove_unit, systemctl, tool};
910
use anyhow::{Context, Result};
@@ -26,7 +27,8 @@ pub(crate) async fn cmd_destroy(prefix: Option<&str>, purge: bool) -> Result<()>
2627
// and the CVM qemu via the unit's cgroup. Look it up by recorded id
2728
// AND by name, so an install that died before persisting kms_vm_id
2829
// (or a torn state file) doesn't leave the CVM orphaned.
29-
if let Ok(vmm) = Vmm::connect(&st.client_url) {
30+
let token = read_token_file(Path::new(&st.client_token_path));
31+
if let Ok(vmm) = Vmm::connect_with_token(&st.client_url, token.as_deref()) {
3032
let mut target = st.kms_vm_id.clone();
3133
if target.is_none() {
3234
if let Ok(s) = vmm.status().await {

dstack/crates/dstackup/src/install.rs

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,16 @@ const DAEMON_BINARIES: &[(&str, &str)] = &[
2929
];
3030

3131
pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
32-
// --expose is not safe yet: the rendered vmm.toml binds the VM-control
33-
// plane with neither TLS nor an auth token (the management RPCs are not
34-
// behind an auth guard), so exposing it would hand deploy/destroy to anyone
35-
// who can reach the IP. Refuse until the TLS+token transport lands; the
36-
// supported path is localhost + an SSH tunnel.
32+
// --expose is not safe yet: the rendered vmm.toml now gates the management
33+
// API behind a generated token, but the transport is still plain HTTP, so
34+
// exposing it would send that bearer token in cleartext to anyone on-path.
35+
// Refuse until the TLS transport lands; the supported path is localhost + an
36+
// SSH tunnel.
3737
if let Some(ip) = &o.expose {
3838
bail!(
3939
"--expose {ip} is not yet safe: it would bind the VM-control plane on \
40-
{ip}:{port} with no TLS and no auth. reach the dashboard over an SSH \
41-
tunnel instead: ssh -L {port}:127.0.0.1:{port} <host>",
40+
{ip}:{port} over plain HTTP, leaking the API token in cleartext. reach \
41+
the dashboard over an SSH tunnel instead: ssh -L {port}:127.0.0.1:{port} <host>",
4242
port = o.dashboard_port
4343
);
4444
}
@@ -81,10 +81,24 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
8181
let client_url = format!("http://{bind}:{}", o.dashboard_port);
8282
let kms_port = resolve_kms_port(&o, &st)?;
8383

84+
// management-API token: reuse the one a prior install wrote (so re-runs and
85+
// an already-running VMM keep matching credentials), else mint a fresh one
86+
// below once the config dir exists. The existing token authenticates the
87+
// preflight probe against an already-running, auth-enabled VMM.
88+
let token_path = layout.config_dir.join("vmm-auth-token");
89+
let existing_token = read_token_file(&token_path);
90+
8491
// 4. preflight - fail BEFORE any side effect (image download, key provider,
8592
// dirs, units), so a CID/port clash can't half-install the host.
8693
let cid_start = pick_cid_start(o.cid_start, &host::occupied_cid_ranges())?;
87-
let kms_owned = kms_port_owned(&st, &client_url, kms_port, o.no_kms).await;
94+
let kms_owned = kms_port_owned(
95+
&st,
96+
&client_url,
97+
existing_token.as_deref(),
98+
kms_port,
99+
o.no_kms,
100+
)
101+
.await;
88102
let port_plan = tcp_port_plan(&o, &st, platform, &bind, &client_url, kms_port, kms_owned);
89103
preflight_ports(&port_plan)?;
90104

@@ -117,6 +131,15 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
117131
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
118132
}
119133

134+
// ensure a management-API token exists on disk (0600) before rendering the
135+
// config that references it. The local `dstack` CLI reads this path from the
136+
// install state, so it authenticates automatically.
137+
let vmm_token = match existing_token {
138+
Some(t) => t,
139+
None => generate_vmm_token()?,
140+
};
141+
write_token_file(&token_path, &vmm_token)?;
142+
120143
// 8. resolve the key provider - run our own unless told to use an existing
121144
// one (TDX only; SNP has no SGX local provider).
122145
let (kp_addr, kp_port, kp_own_project) =
@@ -144,6 +167,8 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
144167
key_provider_port: kp_port as u32,
145168
kms_urls: kms_urls.clone(),
146169
platform,
170+
auth_enabled: true,
171+
auth_token: vmm_token.clone(),
147172
..Default::default()
148173
});
149174
// the KMS-in-CVM reaches the host auth webhook at 10.0.2.2:<auth_port>.
@@ -191,6 +216,7 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
191216
st.run_dir = layout.run_dir.display().to_string();
192217
st.allowlist_path = allow_path.display().to_string();
193218
st.client_url = client_url.clone();
219+
st.client_token_path = token_path.display().to_string();
194220
st.auth_port = o.auth_port;
195221
st.platform = platform.vmm_str().to_string();
196222
st.image = o.image.clone();
@@ -215,7 +241,7 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
215241
st.auth_unit = auth_unit.clone();
216242

217243
// 11. VMM systemd unit (idempotent).
218-
if vmm_reachable(&client_url).await {
244+
if vmm_reachable(&client_url, Some(&vmm_token)).await {
219245
println!(" [ok] VMM already serving at {client_url}");
220246
} else {
221247
install_unit(
@@ -225,7 +251,7 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
225251
.context("installing the VMM unit")?;
226252
println!(" [ok] started {vmm_unit}.service");
227253
print!(" [..] waiting for VMM at {client_url} ");
228-
if wait_ready(&client_url, Duration::from_secs(25)).await {
254+
if wait_ready(&client_url, Some(&vmm_token), Duration::from_secs(25)).await {
229255
println!("=> ready");
230256
} else {
231257
println!("=> not ready within timeout (journalctl -u {vmm_unit})");
@@ -241,7 +267,7 @@ pub(crate) async fn cmd_install(mut o: InstallOpts) -> Result<()> {
241267
if o.no_kms {
242268
println!(" (--no-kms: skipping KMS deploy)");
243269
} else {
244-
let vmm = Vmm::connect(&client_url)?;
270+
let vmm = Vmm::connect_with_token(&client_url, Some(&vmm_token))?;
245271
let existing = match &st.kms_vm_id {
246272
Some(id) if vmm.has_vm(id).await => Some(id.clone()),
247273
_ => None,
@@ -1007,7 +1033,13 @@ fn tcp_port_plan(
10071033
ports
10081034
}
10091035

1010-
async fn kms_port_owned(st: &State, client_url: &str, kms_port: u16, no_kms: bool) -> bool {
1036+
async fn kms_port_owned(
1037+
st: &State,
1038+
client_url: &str,
1039+
token: Option<&str>,
1040+
kms_port: u16,
1041+
no_kms: bool,
1042+
) -> bool {
10111043
if no_kms
10121044
|| st.client_url != client_url
10131045
|| state_kms_port(st) != Some(kms_port)
@@ -1019,7 +1051,7 @@ async fn kms_port_owned(st: &State, client_url: &str, kms_port: u16, no_kms: boo
10191051
let Some(kms_vm_id) = &st.kms_vm_id else {
10201052
return false;
10211053
};
1022-
match Vmm::connect(client_url) {
1054+
match Vmm::connect_with_token(client_url, token) {
10231055
Ok(vmm) => vmm.has_vm(kms_vm_id).await,
10241056
Err(_) => false,
10251057
}
@@ -1158,18 +1190,18 @@ fn kms_vm_boot_state(
11581190
}
11591191

11601192
/// one-shot liveness probe of the VMM.
1161-
async fn vmm_reachable(client_url: &str) -> bool {
1162-
match Vmm::connect(client_url) {
1193+
async fn vmm_reachable(client_url: &str, token: Option<&str>) -> bool {
1194+
match Vmm::connect_with_token(client_url, token) {
11631195
Ok(vmm) => vmm.status().await.is_ok(),
11641196
Err(_) => false,
11651197
}
11661198
}
11671199

11681200
/// poll the VMM `Status` RPC until it succeeds or the deadline passes.
1169-
async fn wait_ready(client_url: &str, timeout: Duration) -> bool {
1201+
async fn wait_ready(client_url: &str, token: Option<&str>, timeout: Duration) -> bool {
11701202
let deadline = tokio::time::Instant::now() + timeout;
11711203
loop {
1172-
if let Ok(vmm) = Vmm::connect(client_url) {
1204+
if let Ok(vmm) = Vmm::connect_with_token(client_url, token) {
11731205
if vmm.status().await.is_ok() {
11741206
return true;
11751207
}
@@ -1181,6 +1213,33 @@ async fn wait_ready(client_url: &str, timeout: Duration) -> bool {
11811213
}
11821214
}
11831215

1216+
/// mint a 256-bit management-API token, hex-encoded, from the OS RNG.
1217+
fn generate_vmm_token() -> Result<String> {
1218+
let mut buf = [0u8; 32];
1219+
let mut f = fs::File::open("/dev/urandom").context("opening /dev/urandom")?;
1220+
std::io::Read::read_exact(&mut f, &mut buf).context("reading /dev/urandom")?;
1221+
Ok(hex::encode(buf))
1222+
}
1223+
1224+
/// read a previously written management-API token, if the file exists and is
1225+
/// non-empty.
1226+
pub(crate) fn read_token_file(path: &Path) -> Option<String> {
1227+
let token = fs::read_to_string(path).ok()?;
1228+
let token = token.trim();
1229+
(!token.is_empty()).then(|| token.to_string())
1230+
}
1231+
1232+
/// write the management-API token atomically with owner-only (0600)
1233+
/// permissions — it is a bearer credential.
1234+
fn write_token_file(path: &Path, token: &str) -> Result<()> {
1235+
use std::os::unix::fs::PermissionsExt;
1236+
dstack_cli_core::fsutil::write_atomic(path, token)
1237+
.with_context(|| format!("writing {}", path.display()))?;
1238+
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1239+
.with_context(|| format!("setting 0600 on {}", path.display()))?;
1240+
Ok(())
1241+
}
1242+
11841243
#[cfg(test)]
11851244
mod tests {
11861245
use super::*;

dstack/crates/dstackup/src/state.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ pub(crate) struct State {
3030
#[serde(default)]
3131
pub(crate) platform: String,
3232
pub(crate) client_url: String,
33+
/// path to the management-API bearer token file the local `dstack` CLI
34+
/// reads to authenticate against the VMM.
35+
#[serde(default)]
36+
pub(crate) client_token_path: String,
3337
pub(crate) auth_port: u16,
3438
/// systemd unit names (without the `.service` suffix).
3539
#[serde(default)]

0 commit comments

Comments
 (0)