From aab785afc1f52258821c001f1fa2f3488b3b73ac Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 16:46:26 +0200 Subject: [PATCH] Add agent onboarding tokens with Usage-page issuance UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-node onboarding tokens replace the placeholder in the agent connect command: raw cagt_ tokens are returned once at issuance, only sha256 hashes persist in agent_auth_tokens, and listing exposes metadata only. Public agent routes accept env-configured shared tokens or any active DB-issued token; successful validation stamps last_used_at and revoked tokens are rejected — verified end-to-end against a real stack (register, heartbeat, revoke → 401, bogus → 401). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP.md | 15 +- app/backend/internal/api/agents.go | 144 ++++++++++++++++-- .../database/sqlcdb/agent_tokens.sql.go | 122 +++++++++++++++ .../internal/database/sqlcdb/models.go | 9 ++ .../internal/database/sqlcdb/querier.go | 5 + .../20260918000000_agent_auth_tokens.sql | 20 +++ app/backend/sqlc/queries/agent_tokens.sql | 22 +++ app/backend/sqlc/schema.sql | 9 ++ app/frontend/src/features/secondary/pages.tsx | 94 +++++++++++- app/frontend/src/generated/api-types.ts | 141 +++++++++++++++++ app/frontend/src/lib/api-client.ts | 19 +++ docs/api/openapi.yaml | 119 +++++++++++++++ 12 files changed, 700 insertions(+), 19 deletions(-) create mode 100644 app/backend/internal/database/sqlcdb/agent_tokens.sql.go create mode 100644 app/backend/migrations_goose/20260918000000_agent_auth_tokens.sql create mode 100644 app/backend/sqlc/queries/agent_tokens.sql diff --git a/ROADMAP.md b/ROADMAP.md index 9a0b8c4..b8e83c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,10 +50,10 @@ The backend exposes **~90 route registrations**; the frontend consumes roughly | Managed databases (CRUD, actions, backup/restore) | done | **missing** | mentioned in README | | Preview environments (CRUD, promote, cleanup) | done | **missing** | not documented | | Security scans, vulnerabilities, compliance/GDPR reports | done | **missing** | README lists it | -| Audit logs | done | partial (list call exists; thin UI) | — | +| Audit logs | done | done — `/settings/audit-logs` filterable page | done | | Autoscaling policies + manual scale | done | **missing** | `docs/guides/AUTOSCALING.md` describes UI that does not exist | | HA / failover policies | done | **missing** | not documented | -| Node agents (register additional VPS/LXC/VM hosts, heartbeats) | done | partial — agents list only, no onboard flow | spec Task 7 partially done | +| Node agents (register additional VPS/LXC/VM hosts, heartbeats) | done | done — token issue/revoke + install command on Usage page | done | | API gateway (merged APwhy): upstream services, API keys, rate limits, ops/traffic analytics | management API done — native handlers under `/api/v1/gateway/*`, schema-corrected; **traffic proxy path still not mounted** (Phase 2) | **missing** | not documented | | Proxmox provider (cluster/nodes/VM/LXC CRUD) | written, **routes never registered** — unreachable | none | not documented | | Notifications (build/audit feed) | via existing APIs | done (shell bell menu) | — | @@ -274,9 +274,14 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo. and accepts `user_id`/`actor`/`since` filters. Widening is consistent with the platform's no-roles trust model (every authed user can already create users). -- [ ] **Agents/nodes**: onboarding flow — generate token, show install command - for `cmd/agent`, node cards with heartbeat status + telemetry on Usage - page. +- [x] **Agents/nodes**: onboarding flow on the Usage page — issue per-node + tokens (raw `cagt_…` shown once, only sha256 hash stored in + `agent_auth_tokens`), one-time copy + prefilled + `CONTAINR_API_URL`/`CONTAINR_AGENT_AUTH_TOKEN` install command, issued + tokens list with label/created/last-used and revoke (revoked tokens + rejected at register/heartbeat — verified e2e). Env-configured shared + tokens (`CONTAINR_AGENT_AUTH_TOKEN(S)`) still accepted. Node cards with + heartbeat status + telemetry were already on the Usage page. - [ ] **API Gateway (merged APwhy, full revival)** — new runtime work, not just UI. Target: every upstream service published through Containr is reachable, key-gated, rate-limited, and metered: diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index 0d8e901..ac82b79 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -2,9 +2,13 @@ package api import ( "context" + "crypto/rand" + "crypto/sha256" "crypto/subtle" "database/sql" + "encoding/hex" "encoding/json" + "errors" "fmt" "math" "net/http" @@ -331,7 +335,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { } authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c)) - if !isValidAgentAuthToken(authToken) { + if !h.isValidAgentAuthToken(c.Request.Context(), authToken) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) return } @@ -532,7 +536,7 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { return } authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c)) - if !isValidAgentAuthToken(authToken) { + if !h.isValidAgentAuthToken(c.Request.Context(), authToken) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) return } @@ -582,7 +586,7 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { // GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents. func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) { - if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) { + if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) return } @@ -603,7 +607,7 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) { // CompleteCommand lets a node agent report command completion or failure. func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) { - if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) { + if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) return } @@ -1013,22 +1017,47 @@ func buildMetricPoint(ts time.Time, resources NodeResources, load SystemLoad, co } func isValidAgentAuthToken(token string) bool { - candidates := configuredAgentAuthTokens() - if len(candidates) == 0 { + token = strings.TrimSpace(token) + if token == "" { return false } + for _, candidate := range configuredAgentAuthTokens() { + if subtle.ConstantTimeCompare([]byte(token), []byte(candidate)) == 1 { + return true + } + } + return false +} + +// isValidAgentAuthTokenDB accepts env-configured shared tokens or a +// DB-issued onboarding token (matched by sha256 hash, never stored raw). +func (h *NodeAgentHandler) isValidAgentAuthToken(ctx context.Context, token string) bool { + if isValidAgentAuthToken(token) { + return true + } + token = strings.TrimSpace(token) if token == "" { return false } - - for _, candidate := range candidates { - if subtle.ConstantTimeCompare([]byte(token), []byte(candidate)) == 1 { - return true - } + sum := sha256.Sum256([]byte(token)) + hash := hex.EncodeToString(sum[:]) + if _, err := h.q.GetActiveAgentAuthTokenByHash(ctx, hash); err != nil { + return false } - return false + _ = h.q.TouchAgentAuthToken(ctx, hash) + return true +} + +func generateAgentAuthToken() (token string, hash string, err error) { + buf := make([]byte, 24) + if _, err = rand.Read(buf); err != nil { + return "", "", err + } + token = "cagt_" + hex.EncodeToString(buf) + sum := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(sum[:]), nil } func agentAuthTokenFromRequest(c *gin.Context) string { @@ -1093,6 +1122,90 @@ func maxInt(a, b int) int { return b } +// --- onboarding token management --- + +func agentAuthTokenJSON(t sqlcdb.AgentAuthToken) gin.H { + row := gin.H{ + "id": t.ID, + "label": t.Label, + "revoked": t.RevokedAt.Valid, + } + if t.CreatedAt.Valid { + row["created_at"] = t.CreatedAt.Time + } + if t.LastUsedAt.Valid { + row["last_used_at"] = t.LastUsedAt.Time + } + if t.RevokedAt.Valid { + row["revoked_at"] = t.RevokedAt.Time + } + return row +} + +// CreateAgentToken issues a new onboarding token. The raw token is returned +// once and never stored — only its sha256 hash persists. +func (h *NodeAgentHandler) CreateAgentToken(c *gin.Context) { + var req struct { + Label string `json:"label"` + } + _ = c.ShouldBindJSON(&req) + + token, hash, err := generateAgentAuthToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + row, err := h.q.CreateAgentAuthToken(c.Request.Context(), sqlcdb.CreateAgentAuthTokenParams{ + TokenHash: hash, + Label: strings.TrimSpace(req.Label), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"}) + return + } + + payload := agentAuthTokenJSON(row) + payload["token"] = token + c.JSON(http.StatusCreated, payload) +} + +// ListAgentTokens returns issued onboarding tokens (metadata only, never hashes). +func (h *NodeAgentHandler) ListAgentTokens(c *gin.Context) { + rows, err := h.q.ListAgentAuthTokens(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"}) + return + } + + tokens := make([]gin.H, 0, len(rows)) + for _, row := range rows { + tokens = append(tokens, agentAuthTokenJSON(row)) + } + c.JSON(http.StatusOK, gin.H{"tokens": tokens}) +} + +// RevokeAgentToken marks an onboarding token as revoked; agents presenting it +// are rejected from then on. +func (h *NodeAgentHandler) RevokeAgentToken(c *gin.Context) { + id, err := uuid.Parse(strings.TrimSpace(c.Param("id"))) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token id"}) + return + } + + row, err := h.q.RevokeAgentAuthToken(c.Request.Context(), id) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, sql.ErrNoRows) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": "Token not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "revoked", "id": row.ID}) +} + // SetupPublicRoutes registers token-authenticated agent ingestion routes. func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) { agents := router.Group("/agents") @@ -1106,6 +1219,13 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) { // SetupRoutes registers authenticated dashboard/control agent routes. func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { + tokens := router.Group("/agent-tokens") + { + tokens.POST("", h.CreateAgentToken) + tokens.GET("", h.ListAgentTokens) + tokens.DELETE("/:id", h.RevokeAgentToken) + } + agents := router.Group("/agents") { agents.GET("", h.GetAgents) diff --git a/app/backend/internal/database/sqlcdb/agent_tokens.sql.go b/app/backend/internal/database/sqlcdb/agent_tokens.sql.go new file mode 100644 index 0000000..a2aeffc --- /dev/null +++ b/app/backend/internal/database/sqlcdb/agent_tokens.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: agent_tokens.sql + +package sqlcdb + +import ( + "context" + + "github.com/google/uuid" +) + +const createAgentAuthToken = `-- name: CreateAgentAuthToken :one +INSERT INTO agent_auth_tokens (token_hash, label) +VALUES ($1, $2) +RETURNING id, token_hash, label, created_at, last_used_at, revoked_at +` + +type CreateAgentAuthTokenParams struct { + TokenHash string `json:"token_hash"` + Label string `json:"label"` +} + +func (q *Queries) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) { + row := q.db.QueryRowContext(ctx, createAgentAuthToken, arg.TokenHash, arg.Label) + var i AgentAuthToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.Label, + &i.CreatedAt, + &i.LastUsedAt, + &i.RevokedAt, + ) + return i, err +} + +const getActiveAgentAuthTokenByHash = `-- name: GetActiveAgentAuthTokenByHash :one +SELECT id, token_hash, label, created_at, last_used_at, revoked_at FROM agent_auth_tokens +WHERE token_hash = $1 AND revoked_at IS NULL +LIMIT 1 +` + +func (q *Queries) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) { + row := q.db.QueryRowContext(ctx, getActiveAgentAuthTokenByHash, tokenHash) + var i AgentAuthToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.Label, + &i.CreatedAt, + &i.LastUsedAt, + &i.RevokedAt, + ) + return i, err +} + +const listAgentAuthTokens = `-- name: ListAgentAuthTokens :many +SELECT id, token_hash, label, created_at, last_used_at, revoked_at FROM agent_auth_tokens +ORDER BY created_at DESC +` + +func (q *Queries) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) { + rows, err := q.db.QueryContext(ctx, listAgentAuthTokens) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AgentAuthToken{} + for rows.Next() { + var i AgentAuthToken + if err := rows.Scan( + &i.ID, + &i.TokenHash, + &i.Label, + &i.CreatedAt, + &i.LastUsedAt, + &i.RevokedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const revokeAgentAuthToken = `-- name: RevokeAgentAuthToken :one +UPDATE agent_auth_tokens SET revoked_at = NOW() +WHERE id = $1 AND revoked_at IS NULL +RETURNING id, token_hash, label, created_at, last_used_at, revoked_at +` + +func (q *Queries) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) { + row := q.db.QueryRowContext(ctx, revokeAgentAuthToken, id) + var i AgentAuthToken + err := row.Scan( + &i.ID, + &i.TokenHash, + &i.Label, + &i.CreatedAt, + &i.LastUsedAt, + &i.RevokedAt, + ) + return i, err +} + +const touchAgentAuthToken = `-- name: TouchAgentAuthToken :exec +UPDATE agent_auth_tokens SET last_used_at = NOW() +WHERE token_hash = $1 AND revoked_at IS NULL +` + +func (q *Queries) TouchAgentAuthToken(ctx context.Context, tokenHash string) error { + _, err := q.db.ExecContext(ctx, touchAgentAuthToken, tokenHash) + return err +} diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index a39eaaa..b310d60 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -13,6 +13,15 @@ import ( "github.com/sqlc-dev/pqtype" ) +type AgentAuthToken struct { + ID uuid.UUID `json:"id"` + TokenHash string `json:"token_hash"` + Label string `json:"label"` + CreatedAt sql.NullTime `json:"created_at"` + LastUsedAt sql.NullTime `json:"last_used_at"` + RevokedAt sql.NullTime `json:"revoked_at"` +} + type AgentCommand struct { ID string `json:"id"` Type string `json:"type"` diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 704eb37..f33072e 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -16,6 +16,7 @@ type Querier interface { CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error) CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error) + CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error) CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error) CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error @@ -26,6 +27,7 @@ type Querier interface { DeleteAgent(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) + GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error) @@ -40,6 +42,7 @@ type Querier interface { GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error + ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error) ListAgents(ctx context.Context) ([]NodeAgent, error) ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) @@ -50,10 +53,12 @@ type Querier interface { ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) + RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error + TouchAgentAuthToken(ctx context.Context, tokenHash string) error UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error) UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error diff --git a/app/backend/migrations_goose/20260918000000_agent_auth_tokens.sql b/app/backend/migrations_goose/20260918000000_agent_auth_tokens.sql new file mode 100644 index 0000000..66256e8 --- /dev/null +++ b/app/backend/migrations_goose/20260918000000_agent_auth_tokens.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- Per-agent onboarding tokens. Hash (sha256 hex) is stored, never the raw token; +-- the raw value is shown once at creation time. +CREATE TABLE IF NOT EXISTS public.agent_auth_tokens ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + token_hash text NOT NULL, + label character varying(255) DEFAULT '' NOT NULL, + created_at timestamp with time zone DEFAULT now(), + last_used_at timestamp with time zone, + revoked_at timestamp with time zone, + CONSTRAINT agent_auth_tokens_pkey PRIMARY KEY (id), + CONSTRAINT agent_auth_tokens_token_hash_key UNIQUE (token_hash) +); + +CREATE INDEX IF NOT EXISTS idx_agent_auth_tokens_active + ON public.agent_auth_tokens (token_hash) + WHERE revoked_at IS NULL; + +-- +goose Down +DROP TABLE IF EXISTS public.agent_auth_tokens; diff --git a/app/backend/sqlc/queries/agent_tokens.sql b/app/backend/sqlc/queries/agent_tokens.sql new file mode 100644 index 0000000..8d69eae --- /dev/null +++ b/app/backend/sqlc/queries/agent_tokens.sql @@ -0,0 +1,22 @@ +-- name: CreateAgentAuthToken :one +INSERT INTO agent_auth_tokens (token_hash, label) +VALUES ($1, $2) +RETURNING *; + +-- name: ListAgentAuthTokens :many +SELECT * FROM agent_auth_tokens +ORDER BY created_at DESC; + +-- name: GetActiveAgentAuthTokenByHash :one +SELECT * FROM agent_auth_tokens +WHERE token_hash = $1 AND revoked_at IS NULL +LIMIT 1; + +-- name: TouchAgentAuthToken :exec +UPDATE agent_auth_tokens SET last_used_at = NOW() +WHERE token_hash = $1 AND revoked_at IS NULL; + +-- name: RevokeAgentAuthToken :one +UPDATE agent_auth_tokens SET revoked_at = NOW() +WHERE id = $1 AND revoked_at IS NULL +RETURNING *; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index a0a136d..a36d361 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -160,6 +160,15 @@ CREATE TABLE agent_commands ( completed_at TIMESTAMP WITH TIME ZONE ); +CREATE TABLE agent_auth_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT NOT NULL UNIQUE, + label VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_used_at TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE +); + CREATE TABLE agent_heartbeats ( id VARCHAR(255) PRIMARY KEY, node_agent_id VARCHAR(255) NOT NULL REFERENCES node_agents(id) ON DELETE CASCADE, diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 86c951c..bb76b02 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -15,8 +15,13 @@ import { listGitProviders, listProjects, listTemplates, + createAgentToken, + listAgentTokens, + revokeAgentToken, updateCurrentUserProfile, + type AgentAuthTokenCreated, } from '@/lib/api-client'; +import { formatRelative } from '@/lib/time'; import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client'; import { useBuildUpdates } from '@/lib/use-build-updates'; import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components'; @@ -52,6 +57,7 @@ import { MemoryStick, ScrollText, ChevronRight, + Copy, } from 'lucide-react'; function SecondaryPageHeader({ title, description }: { title: string; description: string }) { @@ -180,6 +186,24 @@ export function UsagePage() { queryFn: listAgents, refetchInterval: 15_000, }); + const agentTokensQuery = useQuery({ + queryKey: ['agent-tokens'], + queryFn: listAgentTokens, + }); + const [tokenLabel, setTokenLabel] = useState(''); + const [issuedToken, setIssuedToken] = useState(null); + const createTokenMutation = useMutation({ + mutationFn: () => createAgentToken(tokenLabel.trim()), + onSuccess: (data) => { + setIssuedToken(data); + setTokenLabel(''); + queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }); + }, + }); + const revokeTokenMutation = useMutation({ + mutationFn: (id: string) => revokeAgentToken(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }), + }); const builds = buildsQuery.data?.builds ?? []; const liveStatus = useBuildUpdates( @@ -263,7 +287,7 @@ export function UsagePage() { const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0); const agentEndpoint = getAgentPublicBaseUrl(); const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\ -CONTAINR_AGENT_AUTH_TOKEN= \\ +CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? ''} \\ containr-agent`; let buildActivityBody = 'Track deployment frequency and failed rollouts over time.'; @@ -506,12 +530,78 @@ containr-agent`; Connect Node + +
+ setTokenLabel(e.target.value)} + placeholder="Token label (e.g. node name)" + className="h-8 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all" + /> + +
+ + {issuedToken && ( +
+
+ {issuedToken.token} + +
+

Shown once — store it now. Only the hash is kept server-side.

+
+ )} + {createTokenMutation.isError && ( +

+ {createTokenMutation.error instanceof Error ? createTokenMutation.error.message : 'Failed to issue token'} +

+ )} +
{connectCommand}

Agent endpoint: {agentEndpoint}

-

Set CONTAINR_AGENT_AUTH_TOKEN to a value accepted by the backend.

+

Use an issued token above, or set CONTAINR_AGENT_AUTH_TOKENS on the backend.

Agent sends host resources, polls pending Docker commands, and reports command results.

+ + {(agentTokensQuery.data ?? []).length > 0 && ( +
+

Issued tokens

+
+ {(agentTokensQuery.data ?? []).map((t) => ( +
+
+ {t.label || t.id?.slice(0, 8)} + + {t.revoked ? 'revoked' : t.last_used_at ? `used ${formatRelative(t.last_used_at)}` : 'unused'} + +
+ {!t.revoked && ( + + )} +
+ ))} +
+
+ )} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index df79a48..e276e2e 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -1193,6 +1193,127 @@ export interface paths { patch?: never; trace?: never; }; + "/agent-tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List agent onboarding tokens + * @description Returns issued onboarding token metadata (never token values or hashes). + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + tokens?: components["schemas"]["AgentAuthToken"][]; + }; + }; + }; + }; + }; + put?: never; + /** + * Issue agent onboarding token + * @description Generates a new agent onboarding token. The raw token is returned once and never stored — only its SHA-256 hash persists. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CreateAgentTokenRequest"]; + }; + }; + responses: { + /** @description Token issued; raw value returned once */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AgentAuthTokenCreated"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent-tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke agent onboarding token + * @description Marks a token as revoked. Agents presenting it are rejected from then on. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status?: string; + id?: string; + }; + }; + }; + /** @description Token not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; @@ -7334,6 +7455,26 @@ export interface components { /** Format: date-time */ updated_at?: string; }; + AgentAuthToken: { + /** Format: uuid */ + id: string; + label: string; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + last_used_at?: string; + revoked: boolean; + /** Format: date-time */ + revoked_at?: string; + }; + AgentAuthTokenCreated: components["schemas"]["AgentAuthToken"] & { + /** @description Raw token value, returned exactly once at creation */ + token: string; + }; + CreateAgentTokenRequest: { + /** @description Human-readable label for the token (e.g. node name) */ + label?: string; + }; AgentCapabilities: { container_runtimes?: string[]; supported_architectures?: string[]; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 4663ef7..d63b209 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -883,6 +883,25 @@ export async function listAgents(): Promise { return normalizeAgentArray(payload.agents); } +export type AgentAuthToken = components['schemas']['AgentAuthToken']; +export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated']; + +export async function createAgentToken(label?: string): Promise { + return requestJson(`/agent-tokens`, { + method: 'POST', + body: JSON.stringify({ label: label ?? '' }), + }); +} + +export async function listAgentTokens(): Promise { + const payload = await requestJson<{ tokens?: AgentAuthToken[] }>(`/agent-tokens`); + return payload.tokens ?? []; +} + +export async function revokeAgentToken(id: string): Promise { + await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' }); +} + export async function createProject(input: CreateProjectInput): Promise { const payload = await requestJson(`/projects`, { method: 'POST', diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b7bc29e..1c7696c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -821,6 +821,82 @@ paths: schema: $ref: '#/components/schemas/DeployTemplateResponse' + # Agent Onboarding Tokens + /agent-tokens: + post: + tags: + - Agents + summary: Issue agent onboarding token + description: Generates a new agent onboarding token. The raw token is returned once and never stored — only its SHA-256 hash persists. + security: + - bearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAgentTokenRequest' + responses: + '201': + description: Token issued; raw value returned once + content: + application/json: + schema: + $ref: '#/components/schemas/AgentAuthTokenCreated' + get: + tags: + - Agents + summary: List agent onboarding tokens + description: Returns issued onboarding token metadata (never token values or hashes). + security: + - bearerAuth: [] + responses: + '200': + description: Token list + content: + application/json: + schema: + type: object + properties: + tokens: + type: array + items: + $ref: '#/components/schemas/AgentAuthToken' + + /agent-tokens/{id}: + delete: + tags: + - Agents + summary: Revoke agent onboarding token + description: Marks a token as revoked. Agents presenting it are rejected from then on. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Token revoked + content: + application/json: + schema: + type: object + properties: + status: + type: string + id: + type: string + '404': + description: Token not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # Audit Logs /audit-logs: get: @@ -5507,6 +5583,49 @@ components: type: string format: date-time + AgentAuthToken: + type: object + properties: + id: + type: string + format: uuid + label: + type: string + created_at: + type: string + format: date-time + last_used_at: + type: string + format: date-time + revoked: + type: boolean + revoked_at: + type: string + format: date-time + required: + - id + - label + - created_at + - revoked + + AgentAuthTokenCreated: + allOf: + - $ref: '#/components/schemas/AgentAuthToken' + - type: object + properties: + token: + type: string + description: Raw token value, returned exactly once at creation + required: + - token + + CreateAgentTokenRequest: + type: object + properties: + label: + type: string + description: Human-readable label for the token (e.g. node name) + AgentCapabilities: type: object properties: