Registering a runner asks when its token expires (30 days to 1 year, or never; 6 months by default). Expired runners can't claim jobs and are marked in the runner list. Existing runners never expire. The Docker image moves to Node 24, which the SSH server needs for the post-quantum key exchange. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 9 changed files with 81 additions and 27 deletions.
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # syntax=docker/dockerfile:1 |
| 2 | 2 | |
| 3 | 3 | # ---- build: install dependencies (which also downloads PocketBase into bin/) and build Next.js |
| 4 | - FROM node:22-bookworm-slim AS build | |
| 4 | + FROM node:24-bookworm-slim AS build | |
| 5 | 5 | WORKDIR /app |
| 6 | 6 | COPY --from=oven/bun:1.4.2 /usr/local/bin/bun /usr/local/bin/bun |
| 7 | 7 | RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates unzip && rm -rf /var/lib/apt/lists/* |
| @@ -15,7 +15,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone | ||
| 15 | 15 | RUN bun run build |
| 16 | 16 | |
| 17 | 17 | # ---- runtime: the standalone server, PocketBase, git |
| 18 | - FROM node:22-bookworm-slim | |
| 18 | + FROM node:24-bookworm-slim | |
| 19 | 19 | RUN apt-get update && apt-get install -y --no-install-recommends git tini ca-certificates && rm -rf /var/lib/apt/lists/* |
| 20 | 20 | WORKDIR /app |
| 21 | 21 | |
| @@ -21,7 +21,12 @@ async function runnerScope(repo: { owner: string; name: string } | null): Promis | ||
| 21 | 21 | export async function createRunnerAction(repo: { owner: string; name: string } | null, _prev: FormState, formData: FormData) { |
| 22 | 22 | const { scope, path } = await runnerScope(repo); |
| 23 | 23 | try { |
| 24 | - const token = await createRunner(scope, { name: String(formData.get("name") ?? ""), labels: String(formData.get("labels") ?? "") }); | |
| 24 | + const expiry = String(formData.get("expiry") ?? ""); | |
| 25 | + const token = await createRunner(scope, { | |
| 26 | + name: String(formData.get("name") ?? ""), | |
| 27 | + labels: String(formData.get("labels") ?? ""), | |
| 28 | + expiresInDays: expiry === "never" ? null : Number(expiry), | |
| 29 | + }); | |
| 25 | 30 | revalidatePath(path); |
| 26 | 31 | return { token }; |
| 27 | 32 | } catch (e) { |
| @@ -3,33 +3,19 @@ import { headers } from "next/headers"; | ||
| 3 | 3 | import { Label as MuLabel } from "@unlocalhosted/metalui"; |
| 4 | 4 | import { deleteTokenAction } from "@/app/actions/settings"; |
| 5 | 5 | import { ConfirmButton } from "@/components/confirm-button"; |
| 6 | + import { Expiry } from "@/components/expiry"; | |
| 6 | 7 | import { ShieldLockIcon } from "@/components/icons"; |
| 7 | 8 | import { SettingsSection } from "@/components/metal"; |
| 8 | 9 | import { ListRow, PlateSection, RowGlyph } from "@/components/settings-plate"; |
| 9 | 10 | import { Blankslate, Label, PageTitle, RefChip } from "@/components/ui"; |
| 10 | 11 | import { requireViewer } from "@/lib/auth"; |
| 11 | - import { formatDate, timeAgo } from "@/lib/format"; | |
| 12 | + import { timeAgo } from "@/lib/format"; | |
| 12 | 13 | import { canWrite, listReposForUser, repoAccess } from "@/lib/repos"; |
| 13 | 14 | import { listTokens, TOKEN_PREFIX } from "@/lib/tokens"; |
| 14 | 15 | import { NewTokenForm } from "./form"; |
| 15 | 16 | |
| 16 | 17 | export const metadata: Metadata = { title: "API tokens" }; |
| 17 | 18 | |
| 18 | - function Expiry({ expiresAt, expired }: { expiresAt: number | null; expired: boolean }) { | |
| 19 | - if (!expiresAt) return <b>Never expires</b>; | |
| 20 | - if (expired) | |
| 21 | - return ( | |
| 22 | - <> | |
| 23 | - Expired <b>{timeAgo(expiresAt)}</b> | |
| 24 | - </> | |
| 25 | - ); | |
| 26 | - return ( | |
| 27 | - <> | |
| 28 | - Expires <b>{formatDate(expiresAt)}</b> | |
| 29 | - </> | |
| 30 | - ); | |
| 31 | - } | |
| 32 | - | |
| 33 | 19 | export default async function TokensPage() { |
| 34 | 20 | const viewer = await requireViewer(); |
| 35 | 21 | const tokens = await listTokens(viewer.id); |
| @@ -1,12 +1,21 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useActionState } from "react"; |
| 4 | + import { FormField, Select } from "@unlocalhosted/metalui"; | |
| 4 | 5 | import type { FormState } from "@/app/actions/types"; |
| 5 | 6 | import { PlusIcon, ServerIcon, TagIcon } from "@/components/icons"; |
| 6 | 7 | import { CopyIconButton, TextField } from "@/components/settings-fields"; |
| 7 | 8 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 8 | 9 | import { Alert, Button } from "@/components/ui"; |
| 9 | 10 | |
| 11 | + const expiries = [ | |
| 12 | + { value: "30", label: "30 days" }, | |
| 13 | + { value: "90", label: "90 days" }, | |
| 14 | + { value: "180", label: "6 months" }, | |
| 15 | + { value: "365", label: "1 year" }, | |
| 16 | + { value: "never", label: "No expiration" }, | |
| 17 | + ]; | |
| 18 | + | |
| 10 | 19 | function Command({ children }: { children: string }) { |
| 11 | 20 | return ( |
| 12 | 21 | <div className="flex items-start gap-2 rounded-row well py-1.5 pr-1.5 pl-3.5"> |
| @@ -79,6 +88,11 @@ export function NewRunnerForm({ | ||
| 79 | 88 | </> |
| 80 | 89 | } |
| 81 | 90 | /> |
| 91 | + <FormField> | |
| 92 | + <FormField.Label>Token expiration</FormField.Label> | |
| 93 | + <Select name="expiry" defaultValue="180" options={expiries} aria-label="Token expiration" className="w-44" /> | |
| 94 | + <FormField.Description>Once it expires, the runner stops picking up jobs and has to be registered again.</FormField.Description> | |
| 95 | + </FormField> | |
| 82 | 96 | {state?.error && <Alert>{state.error}</Alert>} |
| 83 | 97 | </PlateBody> |
| 84 | 98 | <PlateFooter> |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import { Label as MuLabel, StatusBadge } from "@unlocalhosted/metalui"; |
| 2 | 2 | import { ConfirmButton } from "@/components/confirm-button"; |
| 3 | + import { Expiry } from "@/components/expiry"; | |
| 3 | 4 | import { ServerIcon } from "@/components/icons"; |
| 4 | 5 | import { SettingsSection } from "@/components/metal"; |
| 5 | 6 | import { ListRow, PlateSection, RowGlyph } from "@/components/settings-plate"; |
| 6 | - import { Blankslate, RefChip } from "@/components/ui"; | |
| 7 | + import { Blankslate, Label, RefChip } from "@/components/ui"; | |
| 7 | 8 | import { timeAgo } from "@/lib/format"; |
| 8 | 9 | import { RUNNER_TOKEN_PREFIX, type Runner } from "@/lib/runners"; |
| 9 | 10 | |
| @@ -31,7 +32,9 @@ export function RunnerList({ runners, deleteAction }: { runners: Runner[]; delet | ||
| 31 | 32 | title={ |
| 32 | 33 | <> |
| 33 | 34 | <span className="truncate">{r.name}</span> |
| 34 | - {r.busy ? ( | |
| 35 | + {r.expired ? ( | |
| 36 | + <Label tone="danger">Expired</Label> | |
| 37 | + ) : r.busy ? ( | |
| 35 | 38 | <StatusBadge led="waiting">Busy</StatusBadge> |
| 36 | 39 | ) : r.online ? ( |
| 37 | 40 | <StatusBadge led="live">Idle</StatusBadge> |
| @@ -69,7 +72,8 @@ export function RunnerList({ runners, deleteAction }: { runners: Runner[]; delet | ||
| 69 | 72 | </> |
| 70 | 73 | ) : ( |
| 71 | 74 | "Never connected" |
| 72 | - )} | |
| 75 | + )}{" "} | |
| 76 | + · <Expiry expiresAt={r.expiresAt} expired={r.expired} /> | |
| 73 | 77 | </MuLabel> |
| 74 | 78 | </> |
| 75 | 79 | } |
| @@ -0,0 +1,17 @@ | ||
| 1 | + import { formatDate, timeAgo } from "@/lib/format"; | |
| 2 | + | |
| 3 | + /** "Never expires", "Expires Oct 2, 2026" or "Expired 3 days ago", for tokens and runners. */ | |
| 4 | + export function Expiry({ expiresAt, expired }: { expiresAt: number | null; expired: boolean }) { | |
| 5 | + if (!expiresAt) return <b>Never expires</b>; | |
| 6 | + if (expired) | |
| 7 | + return ( | |
| 8 | + <> | |
| 9 | + Expired <b>{timeAgo(expiresAt)}</b> | |
| 10 | + </> | |
| 11 | + ); | |
| 12 | + return ( | |
| 13 | + <> | |
| 14 | + Expires <b>{formatDate(expiresAt)}</b> | |
| 15 | + </> | |
| 16 | + ); | |
| 17 | + } | |
| @@ -14,7 +14,7 @@ export const json = (body: unknown, status = 200) => Response.json(body, { statu | ||
| 14 | 14 | export async function runnerFromRequest(request: Request): Promise<RunnerIdentity | Response> { |
| 15 | 15 | const header = request.headers.get("authorization") ?? ""; |
| 16 | 16 | const runner = header.startsWith("Bearer ") ? await authenticateRunner(header.slice(7).trim()) : null; |
| 17 | - if (!runner) return json({ error: "Unknown runner token. Register the runner again in Settings → Runners." }, 401); | |
| 17 | + if (!runner) return json({ error: "Unknown or expired runner token. Register the runner again in Settings → Runners." }, 401); | |
| 18 | 18 | return runner; |
| 19 | 19 | } |
| 20 | 20 | |
| @@ -26,6 +26,8 @@ export type Runner = { | ||
| 26 | 26 | version: string | null; |
| 27 | 27 | createdAt: number; |
| 28 | 28 | lastSeenAt: number | null; |
| 29 | + expiresAt: number | null; | |
| 30 | + expired: boolean; | |
| 29 | 31 | online: boolean; |
| 30 | 32 | /** The job it is running, if any. */ |
| 31 | 33 | busy: { runNumber: number; jobName: string; repo: string } | null; |
| @@ -40,6 +42,7 @@ export function systemLabels(row: { os: string | null; arch: string | null }) { | ||
| 40 | 42 | } |
| 41 | 43 | |
| 42 | 44 | const isOnline = (lastSeenAt: number) => !!lastSeenAt && Date.now() - lastSeenAt < ONLINE_WINDOW_MS; |
| 45 | + const isExpired = (expiresAt: number) => !!expiresAt && expiresAt < Date.now(); | |
| 43 | 46 | |
| 44 | 47 | /** The job each of these runners is running, if any. */ |
| 45 | 48 | async function busyJobs(runnerIds: string[]) { |
| @@ -74,6 +77,8 @@ function toRunner(row: RecordModel, busy: Map<string, NonNullable<Runner["busy"] | ||
| 74 | 77 | version: orNull(row.version), |
| 75 | 78 | createdAt: row.created_at, |
| 76 | 79 | lastSeenAt: orNull(row.last_seen_at), |
| 80 | + expiresAt: orNull(row.expires_at), | |
| 81 | + expired: isExpired(row.expires_at), | |
| 77 | 82 | online: isOnline(row.last_seen_at), |
| 78 | 83 | busy: busy.get(row.id) ?? null, |
| 79 | 84 | }; |
| @@ -92,11 +97,19 @@ export function parseLabels(input: string) { | ||
| 92 | 97 | } |
| 93 | 98 | |
| 94 | 99 | /** Registers a runner and returns its secret token; only its hash is stored. */ |
| 95 | - export async function createRunner(scope: RunnerScope, input: { name: string; labels: string }) { | |
| 100 | + export async function createRunner( | |
| 101 | + scope: RunnerScope, | |
| 102 | + input: { name: string; labels: string; expiresInDays: number | null }, | |
| 103 | + ) { | |
| 96 | 104 | const name = input.name.trim().slice(0, 100); |
| 97 | 105 | if (!name) throw new RunnerError("Give the runner a name, e.g. the machine it runs on."); |
| 98 | 106 | const labels = parseLabels(input.labels); |
| 107 | + const days = input.expiresInDays; | |
| 108 | + if (days !== null && !(Number.isInteger(days) && days > 0 && days <= 3650)) { | |
| 109 | + throw new RunnerError("Choose when the runner’s token expires."); | |
| 110 | + } | |
| 99 | 111 | const secret = `${RUNNER_TOKEN_PREFIX}${randomBytes(30).toString("base64url")}`; |
| 112 | + const now = Date.now(); | |
| 100 | 113 | await (await db()).collection("runners").create({ |
| 101 | 114 | repo: "repoId" in scope ? scope.repoId : "", |
| 102 | 115 | user: "userId" in scope ? scope.userId : "", |
| @@ -104,7 +117,8 @@ export async function createRunner(scope: RunnerScope, input: { name: string; la | ||
| 104 | 117 | labels, |
| 105 | 118 | token_hash: sha256(secret), |
| 106 | 119 | hint: secret.slice(-4), |
| 107 | - created_at: Date.now(), | |
| 120 | + created_at: now, | |
| 121 | + expires_at: days ? now + days * 86400e3 : 0, | |
| 108 | 122 | }); |
| 109 | 123 | return secret; |
| 110 | 124 | } |
| @@ -122,11 +136,11 @@ export async function deleteRunner(scope: RunnerScope, runnerId: string) { | ||
| 122 | 136 | |
| 123 | 137 | export type RunnerIdentity = { id: string; name: string; repoId: string | null; userId: string | null; labels: string[] }; |
| 124 | 138 | |
| 125 | - /** Resolves a runner token from the runner API, or null. */ | |
| 139 | + /** Resolves a runner token from the runner API, or null if unknown or expired. */ | |
| 126 | 140 | export async function authenticateRunner(secret: string): Promise<RunnerIdentity | null> { |
| 127 | 141 | if (!secret.startsWith(RUNNER_TOKEN_PREFIX)) return null; |
| 128 | 142 | const row = await findFirst("runners", filter("token_hash = {:hash}", { hash: sha256(secret) })); |
| 129 | - if (!row) return null; | |
| 143 | + if (!row || isExpired(row.expires_at)) return null; | |
| 130 | 144 | return { |
| 131 | 145 | id: row.id, |
| 132 | 146 | name: row.name, |
| @@ -0,0 +1,14 @@ | ||
| 1 | + // Runner tokens can expire, like API tokens. 0 means never, so existing runners keep working. | |
| 2 | + | |
| 3 | + migrate( | |
| 4 | + (app) => { | |
| 5 | + const runners = app.findCollectionByNameOrId("runners"); | |
| 6 | + runners.fields.add(new NumberField({ name: "expires_at", onlyInt: true, min: 0 })); | |
| 7 | + app.save(runners); | |
| 8 | + }, | |
| 9 | + (app) => { | |
| 10 | + const runners = app.findCollectionByNameOrId("runners"); | |
| 11 | + runners.fields.removeByName("expires_at"); | |
| 12 | + app.save(runners); | |
| 13 | + }, | |
| 14 | + ); | |