From 2787e662711f0937e9b4ca7e9b9305effe838943 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Thu, 22 Oct 2026 19:32:39 -0700 Subject: [PATCH] dstack-util: keep the gateway token stable for half an hour Every registration minted a new token, so the instance record changed every three minutes even when nothing else did. Round iat down to the half hour and expire an hour later: Ed25519 signatures are deterministic, so registrations within one period send the same token, and it always has at least 30 minutes left. This does not yet reduce gateway writes, which happen on every registration regardless; it makes the record stable so a later skip-if-unchanged in the gateway can. Signed-off-by: Kevin Wang --- docs/guest-api-auth.md | 4 +++- dstack/crates/api-auth/src/jwt.rs | 6 +++--- dstack/dstack-util/src/system_setup.rs | 15 +++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/guest-api-auth.md b/docs/guest-api-auth.md index 3a665f5b4..f3b4cabc9 100644 --- a/docs/guest-api-auth.md +++ b/docs/guest-api-auth.md @@ -116,7 +116,9 @@ it over on registration, bound to the app id and short-lived: the agent, which are upgraded together but may briefly disagree, so it is never versioned again. 2. `dstack-util` signs a token with `iss = "dstack:guest-api:"`, - `aud = `, a 10-minute lifetime, and only the `guest:health` scope. + `aud = `, only the `guest:health` scope, `iat` rounded down to the + half hour and `exp` one hour later. Every registration within a half hour + carries the same token, and it never has less than 30 minutes left. 3. Registration carries it to the gateway, which presents it on the v1 `Health` poll. diff --git a/dstack/crates/api-auth/src/jwt.rs b/dstack/crates/api-auth/src/jwt.rs index 855af3150..33eb66747 100644 --- a/dstack/crates/api-auth/src/jwt.rs +++ b/dstack/crates/api-auth/src/jwt.rs @@ -21,9 +21,9 @@ use serde::{Deserialize, Serialize}; /// Longest lifetime a token may claim between `iat` and `exp`. /// /// There is no revocation list: a token is trusted until it expires, so the -/// lifetime is the whole of the exposure control. The gateway re-registers -/// every three minutes and mints a fresh token each time, so this costs it -/// nothing. +/// lifetime is the whole of the exposure control. The CVM re-registers with +/// the gateway every three minutes and mints a fresh token every half hour, so +/// this costs it nothing. pub const MAX_TOKEN_LIFETIME_SECS: u64 = 3600; /// Tolerance for clock skew in either direction. diff --git a/dstack/dstack-util/src/system_setup.rs b/dstack/dstack-util/src/system_setup.rs index 3e46ae25c..80326ee93 100644 --- a/dstack/dstack-util/src/system_setup.rs +++ b/dstack/dstack-util/src/system_setup.rs @@ -537,10 +537,12 @@ struct GatewayContext<'a> { /// it never needs `Info` from a CVM that can mint this token. const GATEWAY_AGENT_SCOPES: &[&str] = &[jwt::scope::HEALTH]; -/// Lifetime of the gateway's token. Registration refreshes every three -/// minutes; this survives a few failed refreshes so a gateway outage does not -/// also lock the gateway out of the agent. -const GATEWAY_AGENT_TOKEN_LIFETIME_SECS: u64 = 600; +/// How often the gateway's token changes. `iat` is rounded down to this, and +/// Ed25519 signatures are deterministic, so every registration within one +/// period carries the same token: the stored record stays unchanged instead of +/// changing every three minutes. With `exp` an hour after `iat`, a token always +/// has at least half an hour left, which outlasts many failed refreshes. +const GATEWAY_AGENT_TOKEN_PERIOD_SECS: u64 = 1800; /// Mint the token the gateway presents to this CVM's guest agent, signed with /// the key the agent derives the same way from the app root key. @@ -554,11 +556,12 @@ fn gateway_agent_token(app_id: &[u8], app_root_key: &[u8]) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_secs(); + let iat = now - now % GATEWAY_AGENT_TOKEN_PERIOD_SECS; let claims = jwt::Claims { iss: jwt::internal_issuer(&app_id), aud: app_id, - iat: now, - exp: now + GATEWAY_AGENT_TOKEN_LIFETIME_SECS, + iat, + exp: iat + jwt::MAX_TOKEN_LIFETIME_SECS, scope: GATEWAY_AGENT_SCOPES.join(" "), }; jwt::sign(&secret, &claims)