Skip to content

guest-agent: scope-gated external API - #1469

Merged
kvinwang merged 6 commits into
nextfrom
feat/guest-api-jwt-auth
Oct 28, 2026
Merged

kvinwang merged 6 commits into
nextfrom
feat/guest-api-jwt-auth

Conversation

@kvinwang

@kvinwang kvinwang commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What

The guest agent's external listener (port 8090) answered anyone who could route
to the CVM. It now requires a scope per route, granted by a signed Ed25519 JWT
or by the host's guest_agent_api sys-config.

Why

Two independent gates, and only one of them is a trust boundary:

Gate Source Measured Answers
public_logs / public_sysinfo / public_tcbinfo app-compose yes may this data leave the TEE?
scopes sys-config no may this caller ask?

They combine with &&. Scope configuration is deliberately unmeasured: it
decides internet exposure, which is the app owner's call, not what the TEE
vouches for. A token can only hide a block, never reveal one the app kept
private.

Nothing is anonymous by default. The sample vmm.toml fills in
[cvm.guest_agent_api] with what an unconfigured 8090 used to serve -- the
dashboard, its identity block, guest:version and guest:health -- while
containers, sysinfo, logs and app-key attestation need a token. The gateway
works either way because its token is minted inside the CVM from the app root
key (dstack:guest-api:issuer:v1) and carried on RegisterCvmRequest; it holds
guest:health and nothing else.

How

  • dstack-api-auth::jwt is a fixed-algorithm subset: the header is a constant,
    so none, HMAC, jku/x5u/jwk cannot be expressed. Tokens are bound to
    the app id, capped at an hour, and a rejected token never degrades to
    anonymous.
  • Routes are matched after percent-decoding and segment normalisation, because
    that is what the router matches on -- /%70rpc/Info and /prpc//Info are the
    same route as /prpc/Info. The check fails closed: a path it does not name
    gets 401. ra-rpc now strips the service prefix once (trim_start_matches
    turned /prpc/Worker.Worker.Info into Info).
  • The dashboard is filtered block by block: identity, sysinfo, containers, logs
    and TCB each need their own scope, and a hidden block is not computed.
  • The watchdog still probes the external listener and counts 401 as alive.
  • The fairing reads the token's shape before it looks up the app id, so a
    malformed header cannot drive a boot attestation; Info is only fetched for
    the dashboard when a block would render it.
  • External issuers (name = expected iss, list of hex Ed25519 keys for
    rotation) are configured in sys-config only.
  • The CVM is one trust domain: any process with dstack.sock can derive the
    issuer key via GetKey. Documented, not sandboxed.

Compatibility

An old gateway sees 401s from a new guest; PollResult::Unreachable after
failure_threshold polls drops the instance from rotation until the gateway is
upgraded. The reverse direction is quieter: a new guest registering with an
un-upgraded node loses its token when that record syncs, and the upgraded peers
then refuse the probe. Upgrade every gateway node before rolling guests.
Interim mitigation is documented in docs/guest-api-auth.md.

requirements. is untouched, so no compose hash or on-chain change.

kvinwang and others added 4 commits October 21, 2026 23:34
The external listener answered anyone who could route to the CVM. It now
requires a scope per route, granted by an Ed25519 JWT or by the host's
`guest_agent_api` sys-config. Nothing is anonymous by default.

This is an exposure control, not a confidentiality one: `public_logs`,
`public_sysinfo` and `public_tcbinfo` still decide what may leave the TEE and
are still measured. A scope can only narrow what they allow.

- `dstack-api-auth::jwt`: a fixed-algorithm subset of JWT. The header is a
  constant, so `none`, HMAC and key-carrying headers cannot be expressed.
  Tokens are bound to the app id and capped at an hour.
- The gateway's token is minted inside the CVM from the app root key and
  carried on `RegisterCvmRequest`, so registration needs no new distribution
  channel.
- Paths are checked after percent-decoding and segment normalisation, since
  the router matches on that rather than on the raw string.
- The dashboard is filtered block by block: identity, sysinfo, containers,
  logs and TCB each need their own scope.
- The systemd watchdog probes the internal socket, which cannot depend on
  anonymous access being configured.

Upgrade order matters: an old gateway sees 401s from a new guest and drops it
from rotation after `failure_threshold` polls. See docs/guest-api-auth.md.

Signed-off-by: Kevin Wang <wy721@qq.com>
`/prpc/Worker.Worker.Info` reached `Info` with no token: the scope check
stripped one `Worker.` prefix and saw an unknown method, which it let
through, while ra-rpc stripped every leading prefix with
`trim_start_matches` and dispatched it. ra-rpc now strips exactly one,
and a path the scope table does not name is refused instead of passed on.

Tokens were refused for the life of the process if the startup identity
decode failed: the app id was only bound when `identity()` succeeded, and
nothing on the token path retried it. The fairing now asks `identity()`
itself whenever a token is presented, which also drops the `OnceLock`.

Also:
- The gateway token carries only `guest:health`. A CVM that mints one
  always reports its port policy, so the lazy `Info` fetch never runs with
  it; the `port_policy.rs` plumbing is reverted.
- The systemd watchdog probes the external listener again and counts a 401
  as alive, instead of moving to the internal socket.
- The bearer scheme is matched case-insensitively.
- Docs: the CVM is one trust domain, so any process with `dstack.sock` can
  derive the issuer key through `GetKey`; fix who holds the app root key.

Signed-off-by: Kevin Wang <wy721@qq.com>
The scope fairing resolved the app id before looking at the token, so any
string in an Authorization header could drive a boot attestation and queue
behind attest_lock. Split `jwt::decode` out of `Verifier::verify` so the
fairing reads the header and claims first, and only then asks for the app id.

`verify_decoded` checks the signature before the claims: a claim error from an
unsigned token describes a guess at the caller's intent, not the token.

Also:

- the dashboard skips `Worker.Info` unless a block would render it, so an
  anonymous page view no longer costs an attestation;
- the watchdog builds one HTTP client instead of one per heartbeat;
- `EffectiveScopes` answers 401, not 403, when the fairing published nothing;
- the gateway drops an `agent_token` longer than 4 KiB with a warning: it is
  self-declared, persisted and synced, and must not be sized by the CVM.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Kevin Wang <wy721@qq.com>
There is no built-in anonymous default, so the sample configs are the only
place a host learns what to open. Fill in `[cvm.guest_agent_api]` in the
shipped `vmm.toml`, in the `dstackup install` renderer, and in the two
dev/test stacks with what an unconfigured 8090 used to serve: the dashboard
plus the identity, version and health it publishes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Kevin Wang <wy721@qq.com>
@kvinwang
kvinwang force-pushed the feat/guest-api-jwt-auth branch from f78d1fe to 2d75cd1 Compare October 23, 2026 02:04
@kvinwang
kvinwang requested a review from Leechael October 23, 2026 16:11
@kvinwang
kvinwang enabled auto-merge October 28, 2026 09:22
@kvinwang
kvinwang merged commit 6af58af into next Oct 28, 2026
14 of 15 checks passed
@kvinwang
kvinwang deleted the feat/guest-api-jwt-auth branch October 28, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant