Add agent onboarding tokens with Usage-page issuance UI

Per-node onboarding tokens replace the placeholder <token> 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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 16:46:26 +02:00
1 parent 2bfa683084
commit aab785afc1
12 files changed
+700 -19

No files matched your search

+10 -5
View File
@@ -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:
+132 -12
View File
@@ -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)
@@ -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
}
@@ -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"`
@@ -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
@@ -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;
+22
View File
@@ -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 *;
+9
View File
@@ -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,
+92 -2
View File
@@ -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<AgentAuthTokenCreated | null>(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=<token> \\
CONTAINR_AGENT_AUTH_TOKEN=${issuedToken?.token ?? '<token>'} \\
containr-agent`;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
@@ -506,12 +530,78 @@ containr-agent`;
<Terminal size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
</div>
<div className="mb-3 flex gap-2">
<input
value={tokenLabel}
onChange={(e) => 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"
/>
<button
onClick={() => createTokenMutation.mutate()}
disabled={createTokenMutation.isPending}
className="h-8 px-3 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{createTokenMutation.isPending ? 'Issuing…' : 'Issue token'}
</button>
</div>
{issuedToken && (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
<div className="flex items-center justify-between gap-2">
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
<button
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
>
<Copy size={11} />
Copy
</button>
</div>
<p className="mt-1 text-xs text-[var(--text-tertiary)]">Shown once — store it now. Only the hash is kept server-side.</p>
</div>
)}
{createTokenMutation.isError && (
<p className="mb-3 text-xs text-[var(--error)]">
{createTokenMutation.error instanceof Error ? createTokenMutation.error.message : 'Failed to issue token'}
</p>
)}
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
<p>Set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKEN</span> to a value accepted by the backend.</p>
<p>Use an issued token above, or set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKENS</span> on the backend.</p>
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
</div>
{(agentTokensQuery.data ?? []).length > 0 && (
<div className="mt-4 border-t border-[var(--border-subtle)] pt-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Issued tokens</p>
<div className="space-y-1.5">
{(agentTokensQuery.data ?? []).map((t) => (
<div key={t.id} className="flex items-center justify-between gap-2 text-xs">
<div className="min-w-0">
<span className="text-[var(--text-primary)]">{t.label || t.id?.slice(0, 8)}</span>
<span className="ml-2 text-[var(--text-muted)]">
{t.revoked ? 'revoked' : t.last_used_at ? `used ${formatRelative(t.last_used_at)}` : 'unused'}
</span>
</div>
{!t.revoked && (
<button
onClick={() => revokeTokenMutation.mutate(t.id ?? '')}
disabled={revokeTokenMutation.isPending}
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
>
Revoke
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</section>
+141
View File
@@ -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[];
+19
View File
@@ -883,6 +883,25 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents);
}
export type AgentAuthToken = components['schemas']['AgentAuthToken'];
export type AgentAuthTokenCreated = components['schemas']['AgentAuthTokenCreated'];
export async function createAgentToken(label?: string): Promise<AgentAuthTokenCreated> {
return requestJson<AgentAuthTokenCreated>(`/agent-tokens`, {
method: 'POST',
body: JSON.stringify({ label: label ?? '' }),
});
}
export async function listAgentTokens(): Promise<AgentAuthToken[]> {
const payload = await requestJson<{ tokens?: AgentAuthToken[] }>(`/agent-tokens`);
return payload.tokens ?? [];
}
export async function revokeAgentToken(id: string): Promise<void> {
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST',
+119
View File
@@ -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: