Repository navigation
guest-agent: scope-gated external API - #1469
Merged
Merged
Conversation
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
force-pushed
the
feat/guest-api-jwt-auth
branch
from
October 23, 2026 02:04
f78d1fe to
2d75cd1
Compare
kvinwang
enabled auto-merge
October 28, 2026 09:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_apisys-config.Why
Two independent gates, and only one of them is a trust boundary:
public_logs/public_sysinfo/public_tcbinfoThey combine with
&&. Scope configuration is deliberately unmeasured: itdecides 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.tomlfills in[cvm.guest_agent_api]with what an unconfigured 8090 used to serve -- thedashboard, its identity block,
guest:versionandguest:health-- whilecontainers, 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 onRegisterCvmRequest; it holdsguest:healthand nothing else.How
dstack-api-auth::jwtis a fixed-algorithm subset: the header is a constant,so
none, HMAC,jku/x5u/jwkcannot be expressed. Tokens are bound tothe app id, capped at an hour, and a rejected token never degrades to
anonymous.
that is what the router matches on --
/%70rpc/Infoand/prpc//Infoare thesame route as
/prpc/Info. The check fails closed: a path it does not namegets 401. ra-rpc now strips the service prefix once (
trim_start_matchesturned
/prpc/Worker.Worker.InfointoInfo).and TCB each need their own scope, and a hidden block is not computed.
malformed header cannot drive a boot attestation;
Infois only fetched forthe dashboard when a block would render it.
iss, list of hex Ed25519 keys forrotation) are configured in sys-config only.
dstack.sockcan derive theissuer key via
GetKey. Documented, not sandboxed.Compatibility
An old gateway sees 401s from a new guest;
PollResult::Unreachableafterfailure_thresholdpolls drops the instance from rotation until the gateway isupgraded. 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.