Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- shared API authentication (`dstack-api-auth`) protecting the full VMM HTTP/pRPC/UI surface and unifying Gateway/KMS admin auth: bearer/`X-Admin-Token`/HTTP Basic/bcrypt htpasswd, constant-time verification (#796)

## [0.5.5] - 2025-10-20

### Added
Expand Down
13 changes: 13 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ reuse = true
image_path = "./images"
run_path = "./run/vm"

[auth]
enabled = true
# generate with: openssl rand -hex 32
tokens = ["<paste output of: openssl rand -hex 32>"]

[cvm]
kms_urls = []
gateway_urls = []
Expand All @@ -93,6 +98,10 @@ address = "vsock:2"
port = 10000
```

> **VMM API authentication (required for non-localhost binds).** Since [PR #796](https://github.com/Dstack-TEE/dstack/pull/796), the `[auth]` token guards the *entire* VMM surface — creating and stopping CVMs, the web UI, and all pRPC calls — not just `/logs`. Because the example above binds `tcp:0.0.0.0:9080` (reachable off the host), you MUST enable `[auth]`; otherwise the whole control API is exposed unauthenticated. Auth is fail-closed: with `enabled = true` and no usable credential, requests are rejected.
>
> Clients authenticate with `Authorization: Bearer <token>` (or the `X-Admin-Token: <token>` header). Instead of inline `tokens`, you can point to an Apache bcrypt htpasswd file with `htpasswd_file = "/etc/dstack/admin.htpasswd"` (create it with `htpasswd -B -c /etc/dstack/admin.htpasswd admin`). Only bcrypt (`-B`) entries are accepted.

Download guest images from [dstack guest-OS releases](https://github.com/Dstack-TEE/dstack/releases) and extract to `./images/`.

> For reproducible builds and verification, see the [Security Model](./security/security-model.md).
Expand All @@ -113,6 +122,8 @@ Production KMS requires:

#### Auth Server Options

> **Note:** the boot-authorization webhook below (auth-simple / auth-eth) is a *different* mechanism from the KMS admin-API authentication. The webhook allowlists which CVMs may boot and receive keys; the admin API (`[core.admin]` in `kms.toml`) guards operator RPCs. See [dstack-kms admin authentication](../dstack/kms/README.md#admin-api-authentication).

| Server | Use Case | Configuration |
|--------|----------|---------------|
| [auth-simple](../dstack/kms/auth-simple/) | Config-file-based whitelisting | JSON config file |
Expand Down Expand Up @@ -428,6 +439,8 @@ curl http://<new-kms>:9203/finish
>
> If you skip this, `Onboard.Onboard` or later trusted RPCs will fail with KMS authorization errors.

> **Admin authentication.** Onboarding itself is gated by attestation and the authorization backend above — not by a token. Separately, the KMS *admin* RPCs (for example `ClearImageCache`) are served on a dedicated `[core.admin]` listener behind the shared HTTP authenticator, just like the VMM and gateway: set `[core.admin] enabled = true` with an `auth_token` (or the `DSTACK_KMS_ADMIN_TOKEN` / `ADMIN_API_TOKEN` env vars), and clients send `Authorization: Bearer <token>` or `X-Admin-Token`. Enabled with neither `auth_token` nor `htpasswd_file` (and `insecure_no_auth = false`) fails closed — the KMS refuses to start. See [dstack-kms admin authentication](../dstack/kms/README.md#admin-api-authentication).

---

## Deploying Apps
Expand Down
25 changes: 25 additions & 0 deletions docs/dstack-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,28 @@ Open `vmm.toml` and adjust dstack-gateway configuration in the `gateway` section

- `base_domain`: Same as `base_domain` from `gateway.toml`'s `core.proxy` section
- `port`: Same as `listen_port` from `gateway.toml`'s `core.proxy` section

## Admin API authentication

The gateway exposes a separate admin API (used for sync, WireGuard peer management, and other operator RPCs). Configure it in the `core.admin` section of `gateway.toml`:

```toml
[core.admin]
enabled = true
address = "0.0.0.0:9016"
# generate with: openssl rand -hex 32
admin_token = "<paste output of: openssl rand -hex 32>"
# alternatively, an Apache bcrypt htpasswd file (htpasswd -B -c admin.htpasswd admin)
# htpasswd_file = "/etc/dstack/gateway-admin.htpasswd"
insecure_no_auth = false
```

- `enabled`: enable the admin API server.
- `address`: bind address/port for the admin API.
- `admin_token`: shared admin token. It can also be supplied via the environment variables `DSTACK_GATEWAY_ADMIN_TOKEN` or `ADMIN_API_TOKEN` instead of the config file.
- `htpasswd_file`: path to an Apache bcrypt htpasswd file (create with `htpasswd -B -c admin.htpasswd admin`); only bcrypt entries are accepted. Can be used instead of, or alongside, `admin_token`.
- `insecure_no_auth`: development-only escape hatch that disables admin authentication. Never enable it on a network-reachable admin interface.

The admin server is fail-closed: if it is enabled with no `admin_token` and no `htpasswd_file`, and `insecure_no_auth` is `false`, it refuses to start rather than exposing an unauthenticated admin API.

Clients authenticate by sending `Authorization: Bearer <token>` or the `X-Admin-Token: <token>` header.
10 changes: 10 additions & 0 deletions docs/security/security-best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ The KMS TLS listener may keep `rpc.tls.mutual.mandatory = false` because bootstr

App key release and KMS key handover still require verified caller attestation from the RA-TLS client certificate. Certificate signing verifies the CSR signature and embedded attestation before signing.

## Management/admin API authentication

The VMM, gateway, and KMS management surfaces must have authentication enabled in production:

- VMM: set `[auth] enabled = true` with `tokens` (or `htpasswd_file`) — this guards the entire VMM HTTP/pRPC/UI surface. Never bind to a non-localhost address without it. Clients send `Authorization: Bearer <token>` or `X-Admin-Token`.
- Gateway: set `[core.admin] admin_token` (or `htpasswd_file`) and keep `insecure_no_auth = false`. Clients send `Authorization: Bearer <token>` or `X-Admin-Token`.
- KMS: enable `[core.admin]` with an `auth_token` (or `htpasswd_file`); the admin RPCs are served on a dedicated listener and clients send `Authorization: Bearer <token>` or `X-Admin-Token`. Enabled with no credential denies all admin RPCs (fail-closed).

All three share the same HTTP authenticator: bcrypt-only htpasswd (via `htpasswd -B`), constant-time token comparison, and fail-closed behavior.

## Keep private material owner-only

Secret-bearing files should be owner-only (`0600`) wherever possible, including app keys, decrypted env files, KMS root keys, gateway WireGuard/TLS keys, and ACME credentials. Preserve restrictive permissions when copying volumes, backing up `/etc/kms/certs`, or moving gateway and certbot state between hosts. Public issue [#606](https://github.com/Dstack-TEE/dstack/issues/606) tracks the remaining low-cost hardening work in dstack-managed file writes.
Expand Down
4 changes: 4 additions & 0 deletions docs/tutorials/attestation-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Verify you have a running CVM:

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
./src/vmm-cli.py --url http://127.0.0.1:9080 lsvm
```
Expand All @@ -141,6 +142,7 @@ The VMM provides a `/guest/Info` endpoint that proxies into the CVM and retrieve

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

# Get the VM UUID for hello-world
Expand Down Expand Up @@ -384,6 +386,7 @@ Compare the CVM's actual measurements against your expected values:
# verify-measurements.sh

cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

# Get VM UUID
Expand Down Expand Up @@ -576,6 +579,7 @@ echo "Image: $IMAGE_VERSION"
echo ""

cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

# --- Step 1: Get VM UUID ---
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/gateway-build-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ Now generate the VMM deployment manifest:
```bash
cd ~/dstack/dstack/vmm

export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

./src/vmm-cli.py --url http://127.0.0.1:9080 compose \
Expand Down
2 changes: 2 additions & 0 deletions docs/tutorials/gateway-service-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ cd ~/gateway-deploy
set -a; source .env; set +a

cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

./src/vmm-cli.py --url http://127.0.0.1:9080 deploy \
Expand Down Expand Up @@ -408,6 +409,7 @@ Navigate to the VMM directory first:

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
```

Expand Down
3 changes: 3 additions & 0 deletions docs/tutorials/hello-world-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ Use `vmm-cli.py compose` to generate the encrypted deployment manifest. The `--g

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

./src/vmm-cli.py --url http://127.0.0.1:9080 compose \
Expand Down Expand Up @@ -265,6 +266,7 @@ ssh user@your-server

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

SRV_DOMAIN=$(grep ^SRV_DOMAIN ~/gateway-deploy/.env | cut -d= -f2)
Expand Down Expand Up @@ -373,6 +375,7 @@ Navigate to the VMM directory:

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
```

Expand Down
3 changes: 2 additions & 1 deletion docs/tutorials/kms-cvm-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Use the VMM CLI tool to deploy the CVM:
cd ~/dstack/dstack/vmm

# Set VMM auth from saved token
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

# Generate app-compose.json with local key provider enabled
Expand Down Expand Up @@ -306,7 +307,7 @@ curl -s -H "Authorization: Bearer $(cat ~/.dstack/secrets/vmm-auth-token)" \
"http://127.0.0.1:9080/logs?id=VM_ID&follow=true&ansi=false"
```

> **Note:** The VMM logs endpoint requires Bearer token authentication. The `vmm-cli.py logs` command may not work with token auth — use curl directly as shown above.
> **Note:** The VMM logs endpoint requires authentication. `vmm-cli.py logs` sends credentials automatically when `DSTACK_VMM_TOKEN` (or `--token`) is set, so it works against an auth-enabled VMM; the curl form above is an equivalent alternative.

Look for these log messages indicating KMS entered onboard mode:
```
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/troubleshooting-first-application.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ If `/guest/Info` returns empty or errors, check that the CVM is running:

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
./src/vmm-cli.py --url http://127.0.0.1:9080 lsvm
```
Expand Down
2 changes: 2 additions & 0 deletions docs/tutorials/troubleshooting-gateway-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ sudo systemctl restart dstack-vmm

**"Authentication required"** — Set the auth token:
```bash
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
```

Expand All @@ -72,6 +73,7 @@ export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

```bash
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)

# Get KMS VM ID and remove it
Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/troubleshooting-kms-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ cat ~/kms-deployment/docker-compose.yml | grep ports -A2

# Check CVM status via vmm-cli.py
cd ~/dstack/dstack/vmm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat ~/.dstack/secrets/vmm-auth-token)
./src/vmm-cli.py --url http://127.0.0.1:9080 lsvm
```
Expand Down
43 changes: 31 additions & 12 deletions docs/vmm-cli-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,26 +70,46 @@ export DSTACK_VMM_URL=unix:/path/to/socket

### Authentication

If your dstack-vmm server requires authentication, you can provide credentials using:
When the dstack-vmm server has `[auth] enabled = true`, the token now guards the
*entire* management surface (listing/creating/stopping VMs, deploys, logs, and
the web UI) — not just the logs endpoint. Provide credentials in one of two
forms.

#### Environment Variables (Recommended)
#### Bearer token (Recommended)

```bash
# Set authentication credentials
export DSTACK_VMM_AUTH_USER=your-username
export DSTACK_VMM_AUTH_PASSWORD=your-password
Pass the VMM API token directly; it is sent as `Authorization: Bearer <token>`.

# Then use CLI normally
```bash
# your VMM API token. `dstackup install` writes it to
# <config-dir>/vmm-auth-token (default /etc/dstack/vmm-auth-token) — and the
# local `dstack` CLI reads it automatically; a manual setup (see the VMM
# configuration tutorial) stores it at ~/.dstack/secrets/vmm-auth-token.
export DSTACK_VMM_TOKEN=$(cat /etc/dstack/vmm-auth-token)
./vmm-cli.py lsvm

# or as a flag
./vmm-cli.py --token "$DSTACK_VMM_TOKEN" lsvm
```

#### Command Line Arguments
#### HTTP Basic

The server also accepts HTTP Basic, where the password may be the shared token
(any username, e.g. `admin`) or an entry in the server's `htpasswd_file`.

```bash
./vmm-cli.py --auth-user your-username --auth-password your-password lsvm
export DSTACK_VMM_AUTH_USER=admin
export DSTACK_VMM_AUTH_PASSWORD=$(cat /etc/dstack/vmm-auth-token)
./vmm-cli.py lsvm

# or as flags
./vmm-cli.py --auth-user admin --auth-password "$DSTACK_VMM_AUTH_PASSWORD" lsvm
```

**Note:** Environment variables take precedence over command line arguments for authentication.
**Note:** A bearer token takes precedence over Basic when both are set. For each
setting, command-line flags take precedence over environment variables, which
take precedence over the config file. Setting only one half of a Basic
credential (e.g. `DSTACK_VMM_AUTH_PASSWORD` without `DSTACK_VMM_AUTH_USER`) is
rejected with an error rather than silently sending an unauthenticated request.


## Basic Commands
Expand Down Expand Up @@ -325,8 +345,7 @@ After successful deployment, verify your VM is running correctly:
export DSTACK_VMM_URL=http://127.0.0.1:12000

# If authentication is required
export DSTACK_VMM_AUTH_USER=your-username
export DSTACK_VMM_AUTH_PASSWORD=your-password
export DSTACK_VMM_TOKEN=$(cat ~/.dstack/secrets/vmm-auth-token)

# Create a basic docker-compose.yml
cat > docker-compose.yml << 'EOF'
Expand Down
43 changes: 37 additions & 6 deletions dstack/crates/dstack-cli-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
//! false` + a set `auto_bootstrap_domain`, the combination validated to make
//! bootstrap hands-off).
//! * `auth-allowlist.json` — read by the host-side Rust auth webhook.
//! * `vmm.toml` — the host VMM config (gateway + auth-token gating off).
//! * `vmm.toml` — the host VMM config (gateway off; management-API auth token
//! set by `dstackup install`).

use crate::host::Platform;
use anyhow::{Context, Result};
Expand Down Expand Up @@ -277,6 +278,11 @@ pub struct VmmRender {
pub kms_urls: Vec<String>,
/// confidential-computing platform (selects qemu/share-mode for the CVMs).
pub platform: Platform,
/// gate the management API behind a bearer/Basic token (`[auth] enabled`).
pub auth_enabled: bool,
/// the token accepted by the management API when `auth_enabled` is set.
/// Empty renders `tokens = []`.
pub auth_token: String,
}

impl Default for VmmRender {
Expand All @@ -295,13 +301,16 @@ impl Default for VmmRender {
key_provider_port: 3443,
kms_urls: Vec::new(),
platform: Platform::Tdx,
auth_enabled: false,
auth_token: String::new(),
}
}
}

/// render the host `vmm.toml`. Gateway and auth-token gating are off
/// (single-node direct-port access); CVMs use user-mode networking with host
/// port mapping.
/// render the host `vmm.toml`. Gateway is off (single-node direct-port
/// access); management-API auth is gated by `r.auth_enabled`/`r.auth_token`
/// (`dstackup install` generates a token and enables it). CVMs use user-mode
/// networking with host port mapping.
pub fn vmm_toml(r: &VmmRender) -> String {
format!(
r#"# generated by `dstackup install`
Expand Down Expand Up @@ -375,9 +384,12 @@ base_domain = "localhost"
port = 8082
agent_port = 8090

# management API auth. `dstackup install` generates a token and enables this
# so the VMM control surface (create/stop VM, UI, pRPC) is not exposed
# unauthenticated. Clients send `Authorization: Bearer <token>`.
[auth]
enabled = false
tokens = []
enabled = {auth_enabled}
tokens = [{auth_tokens}]

[supervisor]
exe = "{supervisor_exe}"
Expand Down Expand Up @@ -422,6 +434,12 @@ port = {kp_port}
host_api_port = r.host_api_port,
kp_addr = r.key_provider_addr,
kp_port = r.key_provider_port,
auth_enabled = r.auth_enabled,
auth_tokens = if r.auth_token.is_empty() {
String::new()
} else {
format!("\"{}\"", r.auth_token)
},
)
}

Expand All @@ -435,15 +453,28 @@ mod tests {
dashboard_addr: "tcp:127.0.0.1:19080".into(),
cid_start: 2000,
host_api_port: 10001,
auth_enabled: true,
auth_token: "deadbeef".into(),
..Default::default()
};
let rendered = vmm_toml(&r);
assert!(rendered.contains(r#"address = "tcp:127.0.0.1:19080""#));
assert!(rendered.contains("cid_start = 2000"));
assert!(rendered.contains("port = 10001"));
assert!(rendered.contains("enabled = true"));
assert!(rendered.contains(r#"tokens = ["deadbeef"]"#));
toml::from_str::<toml::Value>(&rendered).expect("vmm.toml must be valid TOML");
}

#[test]
fn vmm_toml_auth_disabled_renders_empty_tokens() {
let rendered = vmm_toml(&VmmRender::default());
// default (no token) must still be valid TOML with an empty token list.
assert!(rendered.contains("tokens = []"));
let v: toml::Value = toml::from_str(&rendered).expect("vmm.toml must be valid TOML");
assert_eq!(v["auth"]["enabled"].as_bool(), Some(false));
}

#[test]
fn kms_toml_has_single_node_invariants() {
let cfg = HostConfig {
Expand Down
Loading
Loading