diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml deleted file mode 100644 index a3b9ba8..0000000 --- a/.github/workflows/registry.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Registry - -on: - push: - branches: [main] - paths: [.graphite.yml] - -jobs: - sync: - name: Sync registry - uses: project-graphite/actions/.github/workflows/reusable-sync-registry.yml@main - secrets: inherit 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/backups.ts b/app/src/server/backups.ts new file mode 100644 index 0000000..a1f81b6 --- /dev/null +++ b/app/src/server/backups.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { appendFile, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { config } from './config.ts'; +import { Refusal } from './refusal.ts'; + +export interface BackupItem { + path: string; + kind: 'postgres' | 'volume'; + bytes: number; + service?: string; + volume?: string; + sqlite?: string[]; +} + +export interface BackupSummary { + project: string; + stamp: string; + started: string; + finished?: string; + archive?: string; + bytes?: number; + imported?: boolean; + ok: boolean; + errors: string[]; + items: BackupItem[]; +} + +export interface BackupRequest { + id: string; + action: 'run' | 'verify' | 'restore'; + project: string; + archive?: string; + by: string; + state: 'waiting' | 'running' | 'done' | 'failed'; + output?: string; + at?: string; +} + +export const projectPattern = /^[a-z0-9][a-z0-9_-]*$/; +export const stampPattern = /^\d{8}T\d{6}Z$/; +const hour = 3600_000; +const states: Record = { request: 'waiting', running: 'running', result: 'done' }; + +export function backupRoot(root = config.hostRoot) { + return path.join(root, 'var/lib/graphite/backups'); +} + +async function json(file: string) { + return JSON.parse(await readFile(file, 'utf8')) as T; +} + +function folders(root: string) { + return readdir(root, { withFileTypes: true }).then( + (entries) => entries.filter((entry) => entry.isDirectory() && projectPattern.test(entry.name)).map((entry) => entry.name), + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + return []; + } + throw error; + }, + ); +} + +export async function listBackups(root = backupRoot()) { + return Promise.all( + (await folders(root)).sort().map(async (project) => { + const files = (await readdir(path.join(root, project))).filter((name) => stampPattern.test(name.replace(/\.json$/, '')) && name.endsWith('.json')); + const archives = await Promise.all(files.map((name) => json(path.join(root, project, name)))); + return { project, archives: archives.sort((a, b) => b.stamp.localeCompare(a.stamp)) }; + }), + ); +} + +export function stampTime(stamp: string) { + return Date.parse(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T${stamp.slice(9, 11)}:${stamp.slice(11, 13)}:${stamp.slice(13, 15)}Z`); +} + +export async function backupConditions(now: number, root = backupRoot()) { + return (await listBackups(root)).flatMap(({ project, archives }) => { + const latest = archives.find((archive) => !archive.imported); + if (!latest) { + return []; + } + if (now - stampTime(latest.stamp) > 26 * hour) { + return [{ key: `backup:${project}`, title: `${project} has no recent backup`, detail: `The newest backup is ${Math.round((now - stampTime(latest.stamp)) / hour)} hours old.` }]; + } + if (!latest.ok) { + return [{ key: `backup:${project}`, title: `${project}'s last backup failed`, detail: latest.errors.join('; ') }]; + } + return []; + }); +} + +export async function requests(root = backupRoot()) { + const folder = path.join(root, '.requests'); + const names = await readdir(folder).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + return [] as string[]; + } + throw error; + }); + const found = await Promise.all( + names.map(async (name) => { + const [id, suffix] = name.split('.'); + const state = states[suffix]; + if (!state) { + return undefined; + } + const order = await json & { ok?: boolean }>(path.join(folder, name)); + return { ...order, id, state: state === 'done' && order.ok === false ? 'failed' : state } as BackupRequest; + }), + ); + return found.filter((entry) => entry !== undefined).sort((a, b) => (b.at ?? '￿').localeCompare(a.at ?? '￿')).slice(0, 20); +} + +export async function audit(login: string, action: string, project: string, archive?: string, root = backupRoot()) { + await mkdir(root, { recursive: true }); + await appendFile(path.join(root, 'audit.log'), `${JSON.stringify({ at: new Date().toISOString(), login, action, project, archive })}\n`); +} + +export async function order(action: BackupRequest['action'], project: string, by: string, archive?: string, root = backupRoot()) { + if (!projectPattern.test(project)) { + throw new Refusal(400, `${project} is not a project name.`); + } + if (archive !== undefined) { + await stat(path.join(root, project, archive)).catch(() => { + throw new Refusal(404, `There is no ${project}/${archive}.`); + }); + } + const id = randomUUID(); + const folder = path.join(root, '.requests'); + await mkdir(folder, { recursive: true }); + await writeFile(path.join(folder, `${id}.partial`), JSON.stringify({ action, project, archive, by })); + await rename(path.join(folder, `${id}.partial`), path.join(folder, `${id}.request`)); + await audit(by, action, project, archive, root); + return id; +} + +export async function download(response: ServerResponse, project: string, stamp: string, login: string, root = backupRoot()) { + const file = path.join(root, project, `${stamp}.tar`); + const size = ( + await stat(file).catch(() => { + throw new Refusal(404, `There is no ${project}/${stamp}.tar.`); + }) + ).size; + await audit(login, 'download', project, `${stamp}.tar`, root); + response.writeHead(200, { + 'Content-Type': 'application/x-tar', + 'Content-Length': size, + 'Content-Disposition': `attachment; filename="${project}-${stamp}.tar"`, + 'Cache-Control': 'no-store', + }); + await pipeline(createReadStream(file), response); +} + +export async function upload(request: IncomingMessage, project: string, login: string, now = new Date(), root = backupRoot()) { + if (!projectPattern.test(project)) { + throw new Refusal(400, `${project} is not a project name.`); + } + const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); + const folder = path.join(root, project); + await mkdir(folder, { recursive: true }); + const partial = path.join(folder, `${stamp}.tar.partial`); + try { + await pipeline(request, createWriteStream(partial)); + } catch (error) { + await rm(partial, { force: true }); + throw error; + } + await rename(partial, path.join(folder, `${stamp}.tar`)); + await audit(login, 'import', project, `${stamp}.tar`, root); + return { stamp, id: await order('verify', project, login, `${stamp}.tar`, root) }; +} 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..e3b2a50 100644 --- a/app/src/server/main.ts +++ b/app/src/server/main.ts @@ -1,30 +1,26 @@ 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'; import { authenticate, callback, githubToken, login, logout, organisationMembers, roleNow, sessionMs, type Role, type User } from './auth.ts'; +import { download, listBackups, order, requests, upload } from './backups.ts'; 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 +136,121 @@ 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/backups') { + if (!user.owner) { + throw new Refusal(403, 'Only organisation owners can do that.'); + } + return { projects: await listBackups(), requests: await requests() }; + } + const backup = /^POST \/api\/backups\/([a-z0-9][a-z0-9_-]*)\/(?:run|(\d{8}T\d{6}Z)\/restore)$/.exec(route); + if (backup) { + await requireOwnerNow(request); + return { id: backup[2] ? await order('restore', backup[1], user.login, `${backup[2]}.tar`) : await order('run', backup[1], user.login) }; + } if (route === 'GET /api/me') { access.seen(user.login); return { ...user, grants: user.owner ? ['*'] : access.scopes(user.login), ssh: user.owner ? config.sshTarget : null }; @@ -153,8 +262,10 @@ async function api(user: User, request: http.IncomingMessage, url: URL) { return docs(); } if (route === 'GET /api/overview') { - const [{ host }, usage, list] = await Promise.all([snapshot(), diskUsage(), allProjects()]); + const [{ host }, usage, list, backups] = await Promise.all([snapshot(), diskUsage(), allProjects(), listBackups()]); + const latest = backups.flatMap(({ archives }) => archives.filter((archive) => !archive.imported).slice(0, 1)); return { + backups: { projects: latest.length, verified: latest.filter((archive) => archive.ok).length, latest: latest.map((archive) => archive.stamp).sort().at(-1) ?? null }, host, docker: { images: usage.images, @@ -162,7 +273,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 +332,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]); @@ -301,14 +412,25 @@ async function handle(request: http.IncomingMessage, response: http.ServerRespon await requireNow(request, user, `project:${container.project}`); return open(request, response, target, url.search); } - const download = /^\/api\/containers\/([0-9a-f]{12,64})\/logs$/.exec(url.pathname); - if (download && request.method === 'GET') { - return downloadLogs(response, await findContainer(download[1])); + const logFile = /^\/api\/containers\/([0-9a-f]{12,64})\/logs$/.exec(url.pathname); + if (logFile && request.method === 'GET') { + return downloadLogs(response, await findContainer(logFile[1])); + } + const archive = /^\/api\/backups\/([a-z0-9][a-z0-9_-]*)\/(\d{8}T\d{6}Z)\.tar$/.exec(url.pathname); + if (archive && request.method === 'GET') { + await requireOwnerNow(request); + return download(response, archive[1], archive[2], user.login); } if (url.pathname.startsWith('/api/')) { if (request.method === 'POST' && request.headers.origin && request.headers.origin !== new URL(config.appUrl).origin) { throw new Refusal(403, 'Cross-origin requests are refused.'); } + const importing = /^\/api\/backups\/([a-z0-9][a-z0-9_-]*)\/import$/.exec(url.pathname); + if (importing && request.method === 'POST') { + await requireOwnerNow(request); + response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(await upload(request, importing[1], user.login))); + return; + } const result = await api(user, request, url); response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(result)); return; diff --git a/app/src/server/monitor.ts b/app/src/server/monitor.ts index 8ed6763..c1a469e 100644 --- a/app/src/server/monitor.ts +++ b/app/src/server/monitor.ts @@ -1,6 +1,5 @@ -import { readdir, stat } from 'node:fs/promises'; -import path from 'node:path'; import type { Alert, Alerts, Condition } from './alerts.ts'; +import { backupConditions } from './backups.ts'; import { config } from './config.ts'; import { docker } from './docker.ts'; import { snapshot, type ContainerView } from './inventory.ts'; @@ -12,7 +11,6 @@ interface Inspect { type Host = Awaited>['host']; -const hour = 3600_000; const restarts = new Map(); export function completed(container: ContainerView) { @@ -59,25 +57,6 @@ export function containerConditions(views: ContainerView[], inspects: Map { - const found: Condition[] = []; - for (const container of views.filter((candidate) => candidate.service === 'backup' && candidate.state === 'running')) { - const folder = path.join(root, 'var/lib/docker/volumes', `${container.project}_backups`, '_data'); - const names = await readdir(folder).catch(() => [] as string[]); - const dumps = await Promise.all(names.filter((name) => name.endsWith('.dump')).map(async (name) => (await stat(path.join(folder, name))).mtimeMs)); - const newest = Math.max(0, ...dumps); - const late = newest ? now - newest > 26 * hour : now - container.created > hour; - if (late) { - found.push({ - key: `backup:${container.project}`, - title: `${container.project} has no recent backup`, - detail: newest ? `The newest dump is ${Math.round((now - newest) / hour)} hours old.` : 'There is no dump in its backups volume.', - }); - } - } - return found; -} - async function conditions(now: number) { const { host, views } = await snapshot(); const inspects = new Map( @@ -87,7 +66,7 @@ async function conditions(now: number) { .map(async (container) => [container.id, await docker('GET', `/containers/${container.id}/json`)] as const), ), ); - return [...hostConditions(host), ...containerConditions(views, inspects, now), ...(await backupConditions(views, now))]; + return [...hostConditions(host), ...containerConditions(views, inspects, now), ...(await backupConditions(now))]; } async function github(method: string, route: string, body: unknown) { 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..dfb5bce 100644 --- a/app/src/web/App.tsx +++ b/app/src/web/App.tsx @@ -6,12 +6,14 @@ import { can, get, Unauthorized, type AlertsData, type User } from './api'; import { usePoll } from './usePoll'; import { Admin } from './pages/Admin'; import { Alerts } from './pages/Alerts'; +import { Backups } from './pages/Backups'; import { ContainerPage } from './pages/ContainerPage'; import { Docs } from './pages/Docs'; 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 +42,10 @@ 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 (user.owner && hash === '/backups') return ; if (hash === '/images') return ; if (hash === '/volumes') return ; if (hash === '/alerts') return ; @@ -63,12 +69,18 @@ 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|backups)/.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: '#/backups', label: 'Backups', icon: 'download', active: hash === '/backups' }, + { 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..0e894f6 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,63 @@ 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 })[]; + backups: { projects: number; verified: number; latest: string | null }; +} + +export interface BackupSummary { + project: string; + stamp: string; + bytes?: number; + imported?: boolean; + ok: boolean; + errors: string[]; + items: { path: string; kind: 'postgres' | 'volume'; bytes: number; sqlite?: string[] }[]; +} + +export interface BackupRequest { + id: string; + action: 'run' | 'verify' | 'restore'; + project: string; + archive?: string; + by: string; + state: 'waiting' | 'running' | 'done' | 'failed'; + output?: string; + at?: string; +} + +export interface BackupsData { + projects: { project: string; archives: BackupSummary[] }[]; + requests: BackupRequest[]; +} + +export function stampDate(stamp: string) { + return new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T${stamp.slice(9, 11)}:${stamp.slice(11, 13)}:${stamp.slice(13, 15)}Z`); +} + +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}`)} /> + ))} +
+ )} + + )} + + + ); +} + +function Import({ projects, onDone }: { projects: string[]; onDone: () => void }) { + const [project, setProject] = useState(''); + const [message, setMessage] = useState(); + const file = useRef(null); + const send = () => { + const chosen = file.current?.files?.[0]; + if (!project || !chosen) { + setMessage('Choose a project and an archive.'); + return; + } + setMessage('Uploading…'); + fetch(`/api/backups/${project}/import`, { method: 'POST', headers: { 'Content-Type': 'application/x-tar' }, body: chosen }) + .then(async (response) => { + if (!response.ok) { + throw new Error((await response.text()) || `The console answered ${response.status}.`); + } + setMessage('Uploaded; the backup service is verifying it. Restore it from the list once it shows verified.'); + onDone(); + }) + .catch((error: Error) => setMessage(error.message)); + }; + return ( +
+

Import an archive

+
+
+ + +
+ {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 6fdbc0f..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'; @@ -67,7 +67,7 @@ function status(project: Project) { return ; } -function ProjectList({ projects }: { projects: Project[] }) { +function ProjectList({ projects }: { projects: OverviewData['projects'] }) { const [filter, setFilter] = useState(''); const [pinned, setPinned] = useState(() => load('console.pinned', [])); const toggle = (slug: string) => { @@ -115,7 +115,12 @@ function ProjectList({ projects }: { projects: Project[] }) { {project.slug} - {status(project)} + + + {status(project)} + {project.needsRedeploy && } + + {percent(project.cpu)} {bytes(project.memory)} {project.storage ? bytes(project.storage.volumes + project.storage.images) : '—'} @@ -145,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/src/web/pages/ProjectPage.tsx b/app/src/web/pages/ProjectPage.tsx index c5f7212..a0ff15f 100644 --- a/app/src/web/pages/ProjectPage.tsx +++ b/app/src/web/pages/ProjectPage.tsx @@ -5,6 +5,7 @@ import { bytes, can, percent, post, shortImage, stateColor, stateOf, type Projec import { Logs } from '../components/Logs'; import { Metrics } from '../components/Metrics'; import { usePoll } from '../usePoll'; +import { SecretList } from './Secrets'; function Wake({ slug, onWoken }: { slug: string; onWoken: () => void }) { const [message, setMessage] = useState(); @@ -89,6 +90,13 @@ export function ProjectPage({ slug, user }: { slug: string; user: User }) {
+ {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} /> +