From 0260f0a6e30916bfa8ac6121701a0585272b6334 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:46:20 +0200 Subject: [PATCH 1/2] feat: a secret store with a Secrets page, per-project env files on the host and a Redeploy hint Values are encrypted with AES-256-GCM in /data/secrets.db, bound to their project and name, versioned, restorable and audited. Every change writes /etc/graphite/.env on the host atomically through HOST_ROOT, and a hand-edited file is reconciled into the store on start. SECRETS_KEY may hold new,old to rotate. Secrets are granted per project with secrets:; * does not include them. --- app/src/server/access.ts | 4 +- app/src/server/config.ts | 3 + app/src/server/main.ts | 121 +++++++- app/src/server/refusal.ts | 8 + app/src/server/secrets.ts | 446 +++++++++++++++++++++++++++++ app/src/web/App.tsx | 13 +- app/src/web/api.ts | 29 +- app/src/web/pages/Admin.tsx | 6 + app/src/web/pages/Overview.tsx | 9 +- app/src/web/pages/ProjectPage.tsx | 8 + app/src/web/pages/Secrets.tsx | 449 ++++++++++++++++++++++++++++++ app/test/access.spec.ts | 11 +- app/test/secrets.spec.ts | 247 ++++++++++++++++ compose.yaml | 1 + 14 files changed, 1333 insertions(+), 22 deletions(-) create mode 100644 app/src/server/refusal.ts create mode 100644 app/src/server/secrets.ts create mode 100644 app/src/web/pages/Secrets.tsx create mode 100644 app/test/secrets.spec.ts diff --git a/app/src/server/access.ts b/app/src/server/access.ts index 5c6e760..24920a8 100644 --- a/app/src/server/access.ts +++ b/app/src/server/access.ts @@ -2,10 +2,10 @@ import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; -export const scopePattern = /^(\*|area:(images|shell)|project:[a-z0-9-]+)$/; +export const scopePattern = /^(\*|area:(images|shell)|(project|secrets):[a-z0-9-]+)$/; export function permits(scopes: string[], scope: string) { - return scopes.includes('*') || scopes.includes(scope); + return scopes.includes(scope) || (scopes.includes('*') && !scope.startsWith('secrets:')); } export class Access { diff --git a/app/src/server/config.ts b/app/src/server/config.ts index 6d78349..df29d3d 100644 --- a/app/src/server/config.ts +++ b/app/src/server/config.ts @@ -27,5 +27,8 @@ export const config = { sablierUrl: process.env.SABLIER_URL ?? 'http://sablier:10000', self: process.env.HOSTNAME ?? '', sshTarget: process.env.SSH_TARGET ?? 'ubuntu@localhost', + secretsKey: process.env.SECRETS_KEY || undefined, + graphiteHome: process.env.GRAPHITE_HOME ?? '/srv/graphite', + agentUrl: process.env.AGENT_URL ?? 'http://172.17.0.1:9000', webRoot: new URL('../web/', import.meta.url), }; diff --git a/app/src/server/main.ts b/app/src/server/main.ts index 7bf39ac..884c074 100644 --- a/app/src/server/main.ts +++ b/app/src/server/main.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; import http from 'node:http'; +import path from 'node:path'; import { WebSocketServer } from 'ws'; import { Access, permits, scopePattern } from './access.ts'; import { Alerts } from './alerts.ts'; @@ -8,23 +9,17 @@ import { config } from './config.ts'; import { docker } from './docker.ts'; import { docs } from './docs.ts'; import { History, projectTotals, ranges, type Range } from './history.ts'; -import { collect, containers, diskUsage, projects, refresh, snapshot } from './inventory.ts'; +import { collect, containers, diskUsage, projects, refresh, snapshot, type ContainerView } from './inventory.ts'; import { monitor } from './monitor.ts'; import { open, openSocket, parseOpen } from './proxy.ts'; +import { Refusal } from './refusal.ts'; +import { appliedCompose, composeReferences, parseKeys, projectPattern, SecretStore } from './secrets.ts'; import { downloadLogs, logs, terminal } from './terminal.ts'; -class Refusal extends Error { - constructor( - readonly status: number, - message: string, - ) { - super(message); - } -} - const history = new History(config.dataDir); const access = new Access(config.dataDir); const alerts = new Alerts(config.dataDir); +const store = config.secretsKey ? new SecretStore(config.dataDir, parseKeys(config.secretsKey), path.join(config.hostRoot, 'etc/graphite')) : undefined; collect(history); monitor(alerts); @@ -140,8 +135,110 @@ async function members(request: http.IncomingMessage, user: User) { .sort((a, b) => Number(b.owner) - Number(a.owner) || a.login.localeCompare(b.login)); } +function secretStore() { + if (!store) { + throw new Refusal(503, "The secret store needs SECRETS_KEY in the console's env file."); + } + return store; +} + +function needsRedeploy(project: string, views: ContainerView[]) { + const changed = store?.changedAt(project) ?? null; + const created = views.filter((view) => view.project === project).map((view) => view.created); + return changed !== null && created.length > 0 && changed > Math.max(...created); +} + +async function secretProject(project: string, views: ContainerView[]) { + const stored = new Set(secretStore().list(project).map((secret) => secret.name)); + return { + project, + needsRedeploy: needsRedeploy(project, views), + missing: [...composeReferences(await appliedCompose(project))].filter(([name, reference]) => reference.required && !stored.has(name)).map(([name]) => name), + }; +} + +async function secretsApi(user: User, request: http.IncomingMessage, route: string) { + const secrets = secretStore(); + if (route === 'GET /api/secrets') { + await requireOwnerNow(request); + const views = await containers(); + const slugs = new Set([...secrets.projects(), ...views.map((view) => view.project)].filter((slug) => projectPattern.test(slug) && slug !== 'console' && slug !== 'other')); + return { projects: await Promise.all([...slugs].sort().map((slug) => secretProject(slug, views))), secrets: secrets.list() }; + } + const match = /^(GET|POST) \/api\/secrets\/([a-z0-9-]+)(?:\/([A-Z_][A-Z0-9_]*|import|delete|redeploy))?(?:\/(reveal|restore))?$/.exec(route); + if (!match) { + throw new Refusal(404, 'Not found.'); + } + const [, method, project, target, action] = match; + await requireNow(request, user, `secrets:${project}`); + if (method === 'GET' && !target) { + return { project: await secretProject(project, await containers()), secrets: secrets.list(project) }; + } + if (method === 'GET' && !action && /^[A-Z_]/.test(target)) { + return { ...secrets.detail(project, target), file: `${project}/compose.yaml`, usage: composeReferences(await appliedCompose(project)).get(target)?.lines ?? [] }; + } + if (method !== 'POST') { + throw new Refusal(404, 'Not found.'); + } + if (!target) { + secrets.create(project, await body(request), user.login); + return { ok: true }; + } + if (target === 'import' && !action) { + const { text, preview } = (await body(request)) as { text?: unknown; preview?: unknown }; + if (typeof text !== 'string') { + throw new Refusal(400, 'Paste the NAME=value lines to import.'); + } + return { names: secrets.importText(project, text, preview === true, user.login) }; + } + if (target === 'delete' && !action) { + const { names } = (await body(request)) as { names?: unknown }; + if (!Array.isArray(names) || !names.every((name) => typeof name === 'string')) { + throw new Refusal(400, 'Name the secrets to delete.'); + } + const references = composeReferences(await appliedCompose(project)); + const required = names.filter((name: string) => references.get(name)?.required); + if (required.length) { + throw new Refusal(409, `${project}/compose.yaml still requires ${required.join(', ')}.`); + } + secrets.remove(project, names, user.login); + return { ok: true }; + } + if (target === 'redeploy' && !action) { + await requireNow(request, user, `project:${project}`); + const response = await fetch(`${config.agentUrl}/reapply`, { + method: 'POST', + headers: { Authorization: `Bearer ${secrets.value('agent', 'CONSOLE_TOKEN')}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: project, by: user.login }), + signal: AbortSignal.timeout(660_000), + }); + if (!response.ok) { + throw new Refusal(502, `The agent answered ${response.status}: ${await response.text()}`); + } + secrets.audit(user.login, 'redeploy', project, null); + await refresh(); + return { ok: true }; + } + if (!action) { + secrets.update(project, target, await body(request), user.login); + return { ok: true }; + } + if (action === 'reveal') { + return { value: secrets.reveal(project, target, user.login) }; + } + const { version } = (await body(request)) as { version?: unknown }; + if (!Number.isInteger(version)) { + throw new Refusal(400, 'Name the version to restore.'); + } + secrets.restore(project, target, version as number, user.login); + return { ok: true }; +} + async function api(user: User, request: http.IncomingMessage, url: URL) { const route = `${request.method} ${url.pathname}`; + if (url.pathname.startsWith('/api/secrets')) { + return secretsApi(user, request, route); + } if (route === 'GET /api/me') { access.seen(user.login); return { ...user, grants: user.owner ? ['*'] : access.scopes(user.login), ssh: user.owner ? config.sshTarget : null }; @@ -162,7 +259,7 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { buildCache: usage.buildCache, volumes: usage.volumes.reduce((sum, volume) => sum + Math.max(0, volume.size), 0), }, - projects: list, + projects: list.map((project) => ({ ...project, needsRedeploy: needsRedeploy(project.slug, project.containers) })), }; } const project = /^GET \/api\/projects\/([a-z0-9-]+)$/.exec(route); @@ -221,7 +318,7 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { await requireOwnerNow(request); const { scopes } = (await body(request)) as { scopes?: unknown }; if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === 'string' && scopePattern.test(scope))) { - throw new Refusal(400, 'Access is a list of *, area:images, area:shell or project:.'); + throw new Refusal(400, 'Access is a list of *, area:images, area:shell, project: or secrets:.'); } access.grant(grant[1], scopes); closeRevoked(grant[1]); diff --git a/app/src/server/refusal.ts b/app/src/server/refusal.ts new file mode 100644 index 0000000..1bde189 --- /dev/null +++ b/app/src/server/refusal.ts @@ -0,0 +1,8 @@ +export class Refusal extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} diff --git a/app/src/server/secrets.ts b/app/src/server/secrets.ts new file mode 100644 index 0000000..e9f1e51 --- /dev/null +++ b/app/src/server/secrets.ts @@ -0,0 +1,446 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { config } from './config.ts'; +import { Refusal } from './refusal.ts'; + +export const projectPattern = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; +export const namePattern = /^[A-Z_][A-Z0-9_]*$/; +const types = ['secure', 'plain'] as const; +export type SecretType = (typeof types)[number]; + +export interface Key { + id: string; + key: Buffer; +} + +interface Row { + project: string; + name: string; + description: string; + type: SecretType; + version: number; + updated_by: string; + updated_at: number; +} + +interface Version { + project: string; + name: string; + version: number; + ciphertext: Uint8Array; + nonce: Uint8Array; + key_id: string; + updated_by: string; + updated_at: number; +} + +export function parseKeys(value: string): Key[] { + return value.split(',').map((entry) => { + const key = Buffer.from(entry.trim(), 'base64'); + if (key.length !== 32) { + throw new Error('SECRETS_KEY holds one or two base64 keys of 32 bytes, the new one first.'); + } + return { id: createHash('sha256').update(key).digest('hex').slice(0, 8), key }; + }); +} + +function seal(key: Key, project: string, name: string, value: string) { + const nonce = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key.key, nonce).setAAD(Buffer.from(`${project}/${name}`)); + return { ciphertext: Buffer.concat([cipher.update(value, 'utf8'), cipher.final(), cipher.getAuthTag()]), nonce, key_id: key.id }; +} + +function quote(value: string) { + if (/^[\w.:/+=@,%~-]*$/.test(value)) { + return value; + } + if (!/['\r\n]/.test(value)) { + return `'${value}'`; + } + return `"${value.replace(/[\\"$]/g, '\\$&').replaceAll('\n', '\\n').replaceAll('\r', '\\r')}"`; +} + +export function formatEnv(entries: Iterable<[string, string]>) { + return [...entries].map(([name, value]) => `${name}=${quote(value)}\n`).join(''); +} + +const escapes: Record = { n: '\n', r: '\r', t: '\t' }; + +export function parseEnv(text: string) { + const blank = /[ \t]*(?:#[^\n]*)?(?:\r?\n|$)/y; + const entry = + /[ \t]*(?:export[ \t]+)?([A-Z_][A-Z0-9_]*)[ \t]*=[ \t]*(?:'([^']*)'[ \t]*(?:#[^\n]*)?|"((?:[^"\\]|\\[\s\S])*)"[ \t]*(?:#[^\n]*)?|([^\r\n]*))(?:\r?\n|$)/y; + const entries = new Map(); + let at = 0; + while (at < text.length) { + blank.lastIndex = at; + entry.lastIndex = at; + if (blank.test(text)) { + at = blank.lastIndex; + continue; + } + const match = entry.exec(text); + if (!match) { + throw new Refusal(400, `Line ${text.slice(0, at).split('\n').length} is not NAME=value with a capitalised name.`); + } + entries.set( + match[1], + match[2] ?? (match[3] === undefined ? match[4].trimEnd() : match[3].replace(/\\([\s\S])/g, (_, character: string) => escapes[character] ?? character)), + ); + at = entry.lastIndex; + } + return entries; +} + +function same(a: Map, b: Map) { + return a.size === b.size && [...a].every(([name, value]) => b.get(name) === value); +} + +function secretFields(input: unknown) { + const { name, description = '', type = 'secure', value } = (input ?? {}) as Record; + if (typeof description !== 'string' || description.length > 500) { + throw new Refusal(400, 'The description is text of at most 500 characters.'); + } + if (!types.includes(type as SecretType)) { + throw new Refusal(400, 'The type is secure or plain.'); + } + return { name, description, type: type as SecretType, value }; +} + +function secretValue(value: unknown) { + if (typeof value !== 'string' || !value.trim()) { + throw new Refusal(400, 'The value cannot be empty.'); + } + return value.trim(); +} + +export function composeReferences(text: string) { + const references = new Map(); + text.split('\n').forEach((line, index) => { + for (const [, name, required] of line.matchAll(/\$\{([A-Za-z_]\w*)(:?\?)?/g)) { + const reference = references.get(name) ?? { required: false, lines: [] }; + reference.required ||= required !== undefined; + reference.lines.push(index + 1); + references.set(name, reference); + } + }); + return references; +} + +export async function appliedCompose(project: string) { + return readFile(path.join(config.hostRoot, config.graphiteHome, project, 'compose.yaml'), 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + return ''; + } + throw error; + }); +} + +export class SecretStore { + private readonly db: DatabaseSync; + + constructor( + directory: string, + private readonly keys: Key[], + private readonly hostDirectory: string, + ) { + mkdirSync(directory, { recursive: true }); + this.db = new DatabaseSync(path.join(directory, 'secrets.db')); + this.db.exec(` + CREATE TABLE IF NOT EXISTS secrets (project TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, type TEXT NOT NULL, version INTEGER NOT NULL, + updated_by TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (project, name)); + CREATE TABLE IF NOT EXISTS secret_versions (project TEXT NOT NULL, name TEXT NOT NULL, version INTEGER NOT NULL, ciphertext BLOB NOT NULL, nonce BLOB NOT NULL, + key_id TEXT NOT NULL, updated_by TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (project, name, version)); + CREATE TABLE IF NOT EXISTS secret_audit (id INTEGER PRIMARY KEY, login TEXT NOT NULL, action TEXT NOT NULL, project TEXT NOT NULL, name TEXT, at INTEGER NOT NULL); + `); + try { + this.rotate(); + this.reconcile(); + } catch (error) { + this.db.close(); + throw error; + } + } + + private transaction(work: () => void) { + this.db.exec('BEGIN IMMEDIATE'); + try { + work(); + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + private change(project: string, work: () => void) { + this.transaction(() => { + work(); + this.writeHost(project); + }); + } + + private open(row: Version) { + const key = this.keys.find((candidate) => candidate.id === row.key_id); + if (!key) { + throw new Error(`No key in SECRETS_KEY has id ${row.key_id}, which encrypted /${row.project}/${row.name}.`); + } + const data = Buffer.from(row.ciphertext); + const decipher = createDecipheriv('aes-256-gcm', key.key, row.nonce).setAAD(Buffer.from(`${row.project}/${row.name}`)); + decipher.setAuthTag(data.subarray(-16)); + return Buffer.concat([decipher.update(data.subarray(0, -16)), decipher.final()]).toString('utf8'); + } + + private rotate() { + const [current] = this.keys; + const stale = this.db.prepare('SELECT * FROM secret_versions WHERE key_id <> ?').all(current.id) as unknown as Version[]; + this.transaction(() => { + for (const row of stale) { + const sealed = seal(current, row.project, row.name, this.open(row)); + this.db + .prepare('UPDATE secret_versions SET ciphertext = ?, nonce = ?, key_id = ? WHERE project = ? AND name = ? AND version = ?') + .run(sealed.ciphertext, sealed.nonce, sealed.key_id, row.project, row.name, row.version); + } + }); + if (stale.length) { + console.log(`Re-encrypted ${stale.length} secret versions with key ${current.id}.`); + } + } + + private reconcile(at = Date.now()) { + mkdirSync(this.hostDirectory, { recursive: true, mode: 0o700 }); + const files = new Set( + readdirSync(this.hostDirectory).flatMap((file) => { + const project = file.endsWith('.env') ? file.slice(0, -4) : ''; + return projectPattern.test(project) && project !== 'console' ? [project] : []; + }), + ); + for (const project of new Set([...files, ...this.projects()])) { + this.change(project, () => { + if (!files.has(project)) { + return; + } + const onHost = parseEnv(readFileSync(path.join(this.hostDirectory, `${project}.env`), 'utf8')); + const stored = this.entries(project); + for (const [name, value] of onHost) { + if (stored.get(name) === value) { + continue; + } + if (stored.has(name)) { + this.addVersion(project, name, value, 'host', at); + } else { + this.insert(project, name, '', 'secure', value, 'host', at); + } + this.audit('host', 'reconcile', project, name, at); + } + for (const name of stored.keys()) { + if (!onHost.has(name)) { + this.delete(project, name); + this.audit('host', 'reconcile', project, name, at); + } + } + }); + } + } + + private writeHost(project: string) { + const file = path.join(this.hostDirectory, `${project}.env`); + const entries = this.entries(project); + if (existsSync(file) && same(parseEnv(readFileSync(file, 'utf8')), entries)) { + return; + } + mkdirSync(this.hostDirectory, { recursive: true, mode: 0o700 }); + writeFileSync(`${file}.new`, formatEnv(entries), { mode: 0o600 }); + renameSync(`${file}.new`, file); + } + + private checkProject(project: string) { + if (project === 'console') { + throw new Refusal(400, "The console's own secrets live in /etc/graphite/console.env, placed by hand."); + } + if (!projectPattern.test(project)) { + throw new Refusal(400, `${project} is not a project name.`); + } + } + + private exists(project: string, name: string) { + return this.db.prepare('SELECT 1 FROM secrets WHERE project = ? AND name = ?').get(project, name) !== undefined; + } + + private current(project: string, name: string) { + const row = this.db.prepare('SELECT * FROM secrets WHERE project = ? AND name = ?').get(project, name) as Row | undefined; + if (!row) { + throw new Refusal(404, `There is no /${project}/${name}.`); + } + return row; + } + + private versionOf(project: string, name: string, version: number) { + const row = this.db.prepare('SELECT * FROM secret_versions WHERE project = ? AND name = ? AND version = ?').get(project, name, version) as Version | undefined; + if (!row) { + throw new Refusal(404, `/${project}/${name} has no version ${version}.`); + } + return this.open(row); + } + + private entries(project: string) { + const rows = this.db + .prepare('SELECT v.* FROM secrets s JOIN secret_versions v ON v.project = s.project AND v.name = s.name AND v.version = s.version WHERE s.project = ? ORDER BY s.name') + .all(project) as unknown as Version[]; + return new Map(rows.map((row) => [row.name, this.open(row)])); + } + + private insert(project: string, name: string, description: string, type: SecretType, value: string, login: string, at: number) { + this.db + .prepare('INSERT INTO secrets (project, name, description, type, version, updated_by, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)') + .run(project, name, description, type, login, at); + this.addVersion(project, name, value, login, at); + } + + private addVersion(project: string, name: string, value: string, login: string, at: number) { + const { version } = this.db + .prepare('UPDATE secrets SET version = version + 1, updated_by = ?, updated_at = ? WHERE project = ? AND name = ? RETURNING version') + .get(login, at, project, name) as { version: number }; + const sealed = seal(this.keys[0], project, name, value); + this.db + .prepare('INSERT INTO secret_versions (project, name, version, ciphertext, nonce, key_id, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(project, name, version, sealed.ciphertext, sealed.nonce, sealed.key_id, login, at); + } + + private delete(project: string, name: string) { + this.db.prepare('DELETE FROM secrets WHERE project = ? AND name = ?').run(project, name); + this.db.prepare('DELETE FROM secret_versions WHERE project = ? AND name = ?').run(project, name); + } + + private describe(row: Row) { + return { + project: row.project, + name: row.name, + description: row.description, + type: row.type, + version: row.version, + updatedBy: row.updated_by, + updatedAt: row.updated_at, + value: row.type === 'plain' ? this.value(row.project, row.name) : null, + }; + } + + audit(login: string, action: string, project: string, name: string | null, at = Date.now()) { + this.db.prepare('INSERT INTO secret_audit (login, action, project, name, at) VALUES (?, ?, ?, ?, ?)').run(login, action, project, name, at); + } + + projects() { + return (this.db.prepare('SELECT DISTINCT project FROM secrets ORDER BY project').all() as { project: string }[]).map((row) => row.project); + } + + list(project?: string) { + return (this.db.prepare('SELECT * FROM secrets WHERE $project IS NULL OR project = $project ORDER BY project, name').all({ project: project ?? null }) as unknown as Row[]).map( + (row) => this.describe(row), + ); + } + + value(project: string, name: string) { + return this.versionOf(project, name, this.current(project, name).version); + } + + detail(project: string, name: string) { + const versions = this.db.prepare('SELECT * FROM secret_versions WHERE project = ? AND name = ? ORDER BY version DESC').all(project, name) as unknown as Version[]; + return { + ...this.describe(this.current(project, name)), + history: versions.map((row) => ({ version: row.version, length: this.open(row).length, updatedBy: row.updated_by, updatedAt: row.updated_at })), + }; + } + + changedAt(project: string) { + return statSync(path.join(this.hostDirectory, `${project}.env`), { throwIfNoEntry: false })?.mtimeMs ?? null; + } + + reveal(project: string, name: string, login: string, at = Date.now()) { + const value = this.value(project, name); + this.audit(login, 'reveal', project, name, at); + return value; + } + + create(project: string, input: unknown, login: string, at = Date.now()) { + this.checkProject(project); + const { name, description, type, value } = secretFields(input); + if (typeof name !== 'string' || !namePattern.test(name)) { + throw new Refusal(400, 'A name is capital letters, digits and underscores, and does not start with a digit.'); + } + if (this.exists(project, name)) { + throw new Refusal(409, `/${project}/${name} already exists.`); + } + const text = secretValue(value); + this.change(project, () => { + this.insert(project, name, description, type, text, login, at); + this.audit(login, 'create', project, name, at); + }); + } + + importText(project: string, text: string, preview: boolean, login: string, at = Date.now()) { + this.checkProject(project); + const entries = parseEnv(text); + if (!entries.size) { + throw new Refusal(400, 'There is no NAME=value line to import.'); + } + const taken = [...entries.keys()].filter((name) => this.exists(project, name)); + if (taken.length) { + throw new Refusal(409, `Already in /${project}/: ${taken.join(', ')}.`); + } + const empty = [...entries].filter(([, value]) => !value.trim()).map(([name]) => name); + if (empty.length) { + throw new Refusal(400, `Empty values for ${empty.join(', ')}.`); + } + if (!preview) { + this.change(project, () => { + for (const [name, value] of entries) { + this.insert(project, name, '', 'secure', value, login, at); + this.audit(login, 'create', project, name, at); + } + }); + } + return [...entries.keys()]; + } + + update(project: string, name: string, input: unknown, login: string, at = Date.now()) { + this.current(project, name); + const { description, type, value } = secretFields(input); + const text = value === undefined ? undefined : secretValue(value); + this.change(project, () => { + this.db + .prepare('UPDATE secrets SET description = ?, type = ?, updated_by = ?, updated_at = ? WHERE project = ? AND name = ?') + .run(description, type, login, at, project, name); + if (text !== undefined && text !== this.value(project, name)) { + this.addVersion(project, name, text, login, at); + } + this.audit(login, 'update', project, name, at); + }); + } + + restore(project: string, name: string, version: number, login: string, at = Date.now()) { + const value = this.versionOf(project, name, version); + this.change(project, () => { + this.addVersion(project, name, value, login, at); + this.audit(login, 'restore', project, name, at); + }); + } + + remove(project: string, names: string[], login: string, at = Date.now()) { + for (const name of names) { + this.current(project, name); + } + this.change(project, () => { + for (const name of names) { + this.delete(project, name); + this.audit(login, 'delete', project, name, at); + } + }); + } + + close() { + this.db.close(); + } +} diff --git a/app/src/web/App.tsx b/app/src/web/App.tsx index 56bc491..6d92f7d 100644 --- a/app/src/web/App.tsx +++ b/app/src/web/App.tsx @@ -12,6 +12,7 @@ import { HostShell } from './pages/HostShell'; import { Images } from './pages/Images'; import { Overview } from './pages/Overview'; import { ProjectPage } from './pages/ProjectPage'; +import { SecretPage, Secrets } from './pages/Secrets'; import { Volumes } from './pages/Volumes'; function useHash() { @@ -40,6 +41,9 @@ function Page({ hash, user }: { hash: string; user: User }) { if (project) return ; const docs = /^\/docs(?:\/(.+))?$/.exec(hash); if (docs) return ; + const secret = /^\/secrets\/([a-z0-9-]+)\/([A-Z_][A-Z0-9_]*)(?:\/(history|usage))?$/.exec(hash); + if (secret && can(user, `secrets:${secret[1]}`)) return ; + if (user.owner && hash === '/secrets') return ; if (hash === '/images') return ; if (hash === '/volumes') return ; if (hash === '/alerts') return ; @@ -63,12 +67,17 @@ export function App() { }, []); const pages: ShellTab[] = [ - { href: '#/', label: 'Overview', icon: 'home', active: !/^\/(images|volumes|alerts|shell|admin|docs)/.test(hash) }, + { href: '#/', label: 'Overview', icon: 'home', active: !/^\/(images|volumes|alerts|shell|admin|docs|secrets)/.test(hash) }, { href: '#/alerts', label: alerts ? `Alerts · ${alerts}` : 'Alerts', icon: 'bell', active: hash === '/alerts' }, { href: '#/images', label: 'Images', icon: 'library', active: hash === '/images' }, { href: '#/volumes', label: 'Volumes', icon: 'folder', active: hash === '/volumes' }, ...(user && can(user, 'area:shell') ? ([{ href: '#/shell', label: 'Shell', icon: 'monitor', active: hash === '/shell' }] satisfies ShellTab[]) : []), - ...(user?.owner ? ([{ href: '#/admin', label: 'Admin', icon: 'key', active: hash === '/admin' }] satisfies ShellTab[]) : []), + ...(user?.owner + ? ([ + { href: '#/secrets', label: 'Secrets', icon: 'lock', active: hash.startsWith('/secrets') }, + { href: '#/admin', label: 'Admin', icon: 'key', active: hash === '/admin' }, + ] satisfies ShellTab[]) + : []), { href: '#/docs', label: 'Docs', icon: 'file', active: hash.startsWith('/docs') }, ]; const nav: ShellNavItem[] = pages.map(({ href, label, active }) => ({ href, label, active })); diff --git a/app/src/web/api.ts b/app/src/web/api.ts index 684df96..1eea372 100644 --- a/app/src/web/api.ts +++ b/app/src/web/api.ts @@ -6,7 +6,7 @@ export interface User { } export function can(user: User, scope: string) { - return user.grants.includes('*') || user.grants.includes(scope); + return user.owner || user.grants.includes(scope) || (user.grants.includes('*') && !scope.startsWith('secrets:')); } export interface Member { @@ -75,7 +75,32 @@ export interface Host { export interface Overview { host: Host | null; docker: { images: number; containers: number; buildCache: number; volumes: number } | null; - projects: Project[]; + projects: (Project & { needsRedeploy: boolean })[]; +} + +export type SecretType = 'secure' | 'plain'; + +export interface Secret { + project: string; + name: string; + description: string; + type: SecretType; + version: number; + updatedBy: string; + updatedAt: number; + value: string | null; +} + +export interface SecretProject { + project: string; + needsRedeploy: boolean; + missing: string[]; +} + +export interface SecretDetail extends Secret { + history: { version: number; length: number; updatedBy: string; updatedAt: number }[]; + file: string; + usage: number[]; } export interface Point { diff --git a/app/src/web/pages/Admin.tsx b/app/src/web/pages/Admin.tsx index e375ed0..f350b28 100644 --- a/app/src/web/pages/Admin.tsx +++ b/app/src/web/pages/Admin.tsx @@ -32,6 +32,12 @@ function Access({ member, projects, onSaved }: { member: Member; projects: strin
{areas.map((area) => option(area.scope, area.label, area.scope !== '*' && everything))}
{projects.map((slug) => option(`project:${slug}`, slug, everything))}
+
+ secrets + {projects.map((slug) => ( + toggle(`secrets:${slug}`)} /> + ))} +
+ {can(user, `secrets:${slug}`) && ( +
+

Secrets

+ +
+ )} +

Logs

{project.containers.map((container) => ( diff --git a/app/src/web/pages/Secrets.tsx b/app/src/web/pages/Secrets.tsx new file mode 100644 index 0000000..2f47ee0 --- /dev/null +++ b/app/src/web/pages/Secrets.tsx @@ -0,0 +1,449 @@ +import Alert from '@mui/material/Alert'; +import MuiLink from '@mui/material/Link'; +import MuiTextField from '@mui/material/TextField'; +import { + Button, + Checkbox, + ConfirmDialog, + Dialog, + ErrorState, + PageHeader, + Select, + TagChip, + Tabs, + TextAreaField, + TextField, + timeAgo, + type SelectOption, +} from '@project-graphite/ui'; +import { useEffect, useState } from 'react'; +import { can, post, type Secret, type SecretDetail, type SecretProject, type SecretType, type User } from '../api'; +import { Segmented } from '../components/Segmented'; +import { usePoll } from '../usePoll'; + +const typeOptions: SelectOption[] = [ + { value: 'secure', label: 'Secure', hint: 'masked in the list' }, + { value: 'plain', label: 'Plain', hint: 'shown in the list, for values like a public key' }, +]; + +const when = (at: number) => timeAgo(new Date(at).toISOString()); + +function Redeploy({ project, onDone }: { project: string; onDone: () => void }) { + const [message, setMessage] = useState(); + const redeploy = () => { + setMessage('Redeploying…'); + post(`/api/secrets/${project}/redeploy`).then( + () => { + setMessage('Redeployed.'); + onDone(); + }, + (error: Error) => setMessage(error.message), + ); + }; + return ( + + + {message && {message}} + + ); +} + +function SecretSheet({ prefix, secret, onClose, onSaved }: { prefix: string; secret?: Secret; onClose: () => void; onSaved: () => void }) { + const [mode, setMode] = useState<'Create' | 'Import'>('Create'); + const [path, setPath] = useState(secret ? `/${secret.project}/${secret.name}` : prefix); + const [description, setDescription] = useState(secret?.description ?? ''); + const [type, setType] = useState(secret?.type ?? 'secure'); + const [value, setValue] = useState(''); + const [lines, setLines] = useState(''); + const [names, setNames] = useState(); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + const target = /^\/([a-z0-9-]+)\/([A-Z_][A-Z0-9_]*)?$/.exec(path); + + const send = (url: string, body: unknown, after: (result: T) => void) => { + setBusy(true); + setError(undefined); + post(url, body) + .then(after, (reason: Error) => setError(reason.message)) + .finally(() => setBusy(false)); + }; + const save = () => { + if (secret) { + send(`/api/secrets/${secret.project}/${secret.name}`, { description, type, value: value || undefined }, onSaved); + } else if (target?.[2]) { + send(`/api/secrets/${target[1]}`, { name: target[2], description, type, value }, onSaved); + } else { + setError('A name is //, the last segment in capitals, digits and underscores.'); + } + }; + const importLines = (preview: boolean) => { + if (!target) { + setError('The path is //.'); + return; + } + send<{ names: string[] }>(`/api/secrets/${target[1]}/import`, { text: lines, preview }, (result) => (preview ? setNames(result.names) : onSaved())); + }; + + return ( + +
+ {!secret && } + /' : '//'} + label={mode === 'Create' ? 'Name' : 'Path'} + onChange={(event) => { + setPath(event.target.value); + setNames(undefined); + }} + value={path} + /> + {mode === 'Create' ? ( + <> + setDescription(event.target.value)} value={description} /> + ({ value: slug, label: slug }))} placeholder="Project" value={project} /> +
+ + +
+ {message &&

{message}

} + + ); +} + +export function Backups() { + const { data, error, reload } = usePoll('/api/backups', 10_000); + const overview = usePoll('/api/overview', 60_000); + const [restoring, setRestoring] = useState(); + const [message, setMessage] = useState(); + if (error && !data) { + return void reload()} title="The console could not list the backups">{error.message}; + } + if (!data) { + return null; + } + const run = (project: string) => + post(`/api/backups/${project}/run`).then( + () => { + setMessage(`Asked the backup service to back up ${project}.`); + void reload(); + }, + (reason: Error) => setMessage(reason.message), + ); + const projects = [...new Set([...data.projects.map((entry) => entry.project), ...(overview.data?.projects ?? []).map((project) => project.slug)])].sort(); + return ( +
+ + + Every night at 03:00 UTC the backup service dumps each database labelled graphite.backup.postgres and archives each volume labelled graphite.backup, + with consistent copies of the SQLite databases inside, then reopens and verifies every archive. Archives are kept for 14 days. + + + {message &&

{message}

} + {data.requests.length > 0 && ( +
+

Recent requests

+ + + {data.requests.map((entry) => ( + + + + + + + + ))} + +
+ + + {entry.action} {entry.project} + {entry.archive && {entry.archive}} + {entry.by}{entry.output}{entry.at && timeAgo(entry.at)}
+
+ )} + {data.projects.map(({ project, archives }) => ( +
+
+

{project}

+ +
+
+ + + + + + + + + + + {archives.map((archive) => ( + setRestoring(archive)} /> + ))} + +
takencheckcontentssize +
+
+
+ ))} + {data.projects.length === 0 &&

No backup has run yet.

} + void reload()} projects={projects} /> + {restoring && ( + setRestoring(undefined)} + onConfirm={() => + post(`/api/backups/${restoring.project}/${restoring.stamp}/restore`).then(() => { + setMessage(`Asked the backup service to restore ${restoring.project}.`); + void reload(); + }) + } + title={`Restore ${restoring.project} from ${stampDate(restoring.stamp).toISOString().slice(0, 16).replace('T', ' ')}?`} + tone="danger" + > + The project stops, its databases and volumes are replaced by this archive, and it starts again. Everything written since the archive was taken is lost. + + )} +
+ ); +} diff --git a/app/src/web/pages/Overview.tsx b/app/src/web/pages/Overview.tsx index 1923964..fda7cc8 100644 --- a/app/src/web/pages/Overview.tsx +++ b/app/src/web/pages/Overview.tsx @@ -1,9 +1,9 @@ import IconButton from '@mui/material/IconButton'; import MuiLink from '@mui/material/Link'; import MuiTextField from '@mui/material/TextField'; -import { EmptyState, ErrorState, Meter, PageHeader, TagChip } from '@project-graphite/ui'; +import { EmptyState, ErrorState, Meter, PageHeader, TagChip, timeAgo } from '@project-graphite/ui'; import { useState } from 'react'; -import { bytes, completed, percent, type AlertsData, type Host, type Overview as OverviewData, type Project } from '../api'; +import { bytes, completed, percent, stampDate, type AlertsData, type Host, type Overview as OverviewData, type Project } from '../api'; import { Metrics } from '../components/Metrics'; import { load, save } from '../stored'; import { usePoll } from '../usePoll'; @@ -150,6 +150,14 @@ export function Overview() { )} {data?.host && } + {data && ( +

+ Backups:{' '} + {data.backups.latest + ? `last run ${timeAgo(stampDate(data.backups.latest).toISOString())}, ${data.backups.verified} of ${data.backups.projects} projects verified` + : 'none has run yet'} +

+ )} {data?.host && } {data?.host && data.docker && } {data && data.projects.length === 0 && Nothing runs on the VPS yet.} diff --git a/app/test/alerts.spec.ts b/app/test/alerts.spec.ts index 8320aab..054dc80 100644 --- a/app/test/alerts.spec.ts +++ b/app/test/alerts.spec.ts @@ -1,10 +1,11 @@ -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { Alerts } from '../src/server/alerts.ts'; +import { backupConditions } from '../src/server/backups.ts'; import type { ContainerView } from '../src/server/inventory.ts'; -import { backupConditions, containerConditions, hostConditions } from '../src/server/monitor.ts'; +import { containerConditions, hostConditions } from '../src/server/monitor.ts'; const directories: string[] = []; @@ -97,18 +98,19 @@ describe('rules', () => { expect(found.map((entry) => entry.key)).toEqual(['restarts:strata-redis-1']); }); - it('flags a backup older than 26 hours, or none after the first hour', async () => { + it('flags a project whose newest backup is older than 26 hours or failed verification', async () => { const root = temporary(); - const folder = path.join(root, 'var/lib/docker/volumes/strata_backups/_data'); + const folder = path.join(root, 'strata'); mkdirSync(folder, { recursive: true }); - const now = Date.now(); - const backup = [container('backup', { created: now - 2 * 3600_000 })]; - expect((await backupConditions(backup, now, root)).map((entry) => entry.detail)).toEqual(['There is no dump in its backups volume.']); - const dump = path.join(folder, 'strata-1.dump'); - writeFileSync(dump, ''); - utimesSync(dump, new Date(now - 3600_000), new Date(now - 3600_000)); - expect(await backupConditions(backup, now, root)).toEqual([]); - utimesSync(dump, new Date(now - 30 * 3600_000), new Date(now - 30 * 3600_000)); - expect((await backupConditions(backup, now, root)).map((entry) => entry.key)).toEqual(['backup:strata']); + const now = Date.parse('2026-11-05T10:00:00Z'); + const summary = (stamp: string, fields: object = {}) => + writeFileSync(path.join(folder, `${stamp}.json`), JSON.stringify({ project: 'strata', stamp, started: '', ok: true, errors: [], items: [], ...fields })); + summary('20261105T030000Z'); + expect(await backupConditions(now, root)).toEqual([]); + summary('20261105T040000Z', { ok: false, errors: ['postgres postgres: its container is not running'] }); + expect((await backupConditions(now, root)).map((entry) => entry.detail)).toEqual(['postgres postgres: its container is not running']); + expect((await backupConditions(now + 30 * 3600_000, root)).map((entry) => entry.key)).toEqual(['backup:strata']); + summary('20261105T090000Z', { imported: true }); + expect((await backupConditions(now, root)).map((entry) => entry.title)).toEqual(["strata's last backup failed"]); }); }); diff --git a/app/test/backups.spec.ts b/app/test/backups.spec.ts new file mode 100644 index 0000000..048bb4d --- /dev/null +++ b/app/test/backups.spec.ts @@ -0,0 +1,66 @@ +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import type { IncomingMessage } from 'node:http'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { afterEach, describe, expect, it } from 'vitest'; +import { listBackups, order, requests, upload } from '../src/server/backups.ts'; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function root() { + const directory = mkdtempSync(path.join(tmpdir(), 'console-backups-')); + directories.push(directory); + return directory; +} + +const summary = (project: string, stamp: string) => JSON.stringify({ project, stamp, started: '', ok: true, errors: [], items: [] }); + +describe('backups', () => { + it('lists every project and its runs, newest first, ignoring stray files', async () => { + const base = root(); + mkdirSync(path.join(base, 'strata')); + mkdirSync(path.join(base, '.requests')); + writeFileSync(path.join(base, 'strata', '20261104T030000Z.json'), summary('strata', '20261104T030000Z')); + writeFileSync(path.join(base, 'strata', '20261105T030000Z.json'), summary('strata', '20261105T030000Z')); + writeFileSync(path.join(base, 'strata', '20261105T030000Z.tar'), ''); + writeFileSync(path.join(base, 'strata', '20261106T030000Z.json.partial'), ''); + expect((await listBackups(base)).map(({ project, archives }) => [project, archives.map((archive) => archive.stamp)])).toEqual([ + ['strata', ['20261105T030000Z', '20261104T030000Z']], + ]); + expect(await listBackups(path.join(base, 'missing'))).toEqual([]); + }); + + it('orders a run for the backup service and reports its state', async () => { + const base = root(); + const id = await order('run', 'strata', 'amr', undefined, base); + expect(JSON.parse(readFileSync(path.join(base, '.requests', `${id}.request`), 'utf8'))).toEqual({ action: 'run', project: 'strata', by: 'amr' }); + expect((await requests(base)).map((entry) => [entry.id, entry.state])).toEqual([[id, 'waiting']]); + writeFileSync(path.join(base, '.requests', `${id}.result`), JSON.stringify({ action: 'run', project: 'strata', by: 'amr', ok: false, output: 'boom', at: '2026-11-05T03:00:00Z' })); + rmSync(path.join(base, '.requests', `${id}.request`)); + expect((await requests(base)).map((entry) => [entry.state, entry.output])).toEqual([['failed', 'boom']]); + await expect(order('restore', 'strata', 'amr', 'missing.tar', base)).rejects.toThrow('There is no strata/missing.tar.'); + await expect(order('run', '../etc', 'amr', undefined, base)).rejects.toThrow('is not a project name'); + }); + + it('stores an uploaded archive, asks for its verification and audits both', async () => { + const base = root(); + const result = await upload(Readable.from([Buffer.from('tar bytes')]) as IncomingMessage, 'console', 'amr', new Date('2026-11-05T12:34:56.789Z'), base); + expect(result.stamp).toBe('20261105T123456Z'); + expect(readFileSync(path.join(base, 'console', '20261105T123456Z.tar'), 'utf8')).toBe('tar bytes'); + expect(readdirSync(path.join(base, 'console'))).toEqual(['20261105T123456Z.tar']); + expect(JSON.parse(readFileSync(path.join(base, '.requests', `${result.id}.request`), 'utf8'))).toMatchObject({ action: 'verify', archive: '20261105T123456Z.tar' }); + expect( + readFileSync(path.join(base, 'audit.log'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line).action), + ).toEqual(['import', 'verify']); + }); +});