diff --git a/api/servers.ts b/api/servers.ts index 17f4208..ee3120a 100644 --- a/api/servers.ts +++ b/api/servers.ts @@ -2,6 +2,8 @@ import { join } from 'node:path'; import { db } from 'officerdb/db'; import { eq } from 'drizzle-orm'; import { fofosAdminSettings } from '../db/schema'; +import { randomBytes } from 'node:crypto'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { readDatabases } from './databases'; /** @@ -47,10 +49,56 @@ export type ServerStatus = { restarts: number | null; /** How the site would be reached, once it is up. */ url: string | null; + /** + * Does the port actually answer? + * + * Asked separately from pm2's status because the two disagree, and the disagreement is the whole + * point. `bun --hot` survives a crash at startup: the process stays alive, pm2 reports `online`, and + * nothing is listening. That is exactly what development did — reported up, served nothing, and the + * panel repeated it confidently. + * + * Null when there is no port to try. + */ + reachable: boolean | null; }; const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; +const PURPOSE = 'fofos-admin'; + +/** + * The site's boot secrets, made once and kept. + * + * The site refuses to serve a request without them, and neither is a decision worth putting in front of + * anybody: they are long random strings, and asking a person to invent one is asking for a worse one. + * Regenerating on every start would be worse still — it would invalidate every session and every token + * anybody had written down. + */ +async function siteSecrets(): Promise<{ apiToken: string; sessionSecret: string }> { + const row = await readRow(); + const existing = { + apiToken: row?.siteApiToken ? decryptSecret(PURPOSE, row.siteApiToken) : null, + sessionSecret: row?.siteSessionSecret ? decryptSecret(PURPOSE, row.siteSessionSecret) : null, + }; + if (existing.apiToken && existing.sessionSecret) { + return { apiToken: existing.apiToken, sessionSecret: existing.sessionSecret }; + } + + // 64 hex characters. The site requires at least 32 and there is no reason to be near the limit. + const apiToken = existing.apiToken ?? randomBytes(32).toString('hex'); + const sessionSecret = existing.sessionSecret ?? randomBytes(32).toString('hex'); + + const values = { + siteApiToken: encryptSecret(PURPOSE, apiToken), + siteSessionSecret: encryptSecret(PURPOSE, sessionSecret), + updatedAt: new Date(), + }; + if (row) await db.update(fofosAdminSettings).set(values).where(eq(fofosAdminSettings.id, row.id)); + else await db.insert(fofosAdminSettings).values(values); + + return { apiToken, sessionSecret }; +} + const run = async ( argv: string[], timeoutMs = 60_000, @@ -108,19 +156,23 @@ export async function readServers(): Promise { } } - return (['development', 'production'] as const).map((name) => { - const found = processes.find((entry) => entry.name === PROCESS[name]); - const port = ports[name]; - return { - name, - process: PROCESS[name], - port, - status: found?.pm2_env?.status ?? null, - uptimeMs: found?.pm2_env?.pm_uptime && found.pm2_env.status === 'online' ? Date.now() - found.pm2_env.pm_uptime : null, - restarts: found?.pm2_env?.restart_time ?? null, - url: port ? `http://127.0.0.1:${port}` : null, - }; - }); + return Promise.all( + (['development', 'production'] as const).map(async (name) => { + const found = processes.find((entry) => entry.name === PROCESS[name]); + const port = ports[name]; + return { + name, + process: PROCESS[name], + port, + status: found?.pm2_env?.status ?? null, + uptimeMs: + found?.pm2_env?.pm_uptime && found.pm2_env.status === 'online' ? Date.now() - found.pm2_env.pm_uptime : null, + restarts: found?.pm2_env?.restart_time ?? null, + url: port ? `http://127.0.0.1:${port}` : null, + reachable: port === null ? null : await answers(port), + }; + }), + ); } export async function savePorts(dev: unknown, prod: unknown): Promise { @@ -169,8 +221,25 @@ export async function startServer(which: ServerName): Promise<{ ok: boolean; out const url = new URL(raw); url.pathname = `/${target.name}`; + /* + Everything the site refuses to boot without. + + It has no `.env` in the checkout, and it should not need one: every value here is either known to + this plugin already (the port, the media directory, the database) or a random secret nobody should + be asked to invent. `src/env-file.ts` reads files first and the environment last, so a checkout that + DOES write one still wins — this fills absence, it does not override a choice. + */ + const secrets = await siteSecrets(); + const env: Record = { BUN_PORT: String(port), + FOFOS_API_TOKEN: secrets.apiToken, + ADMIN_SESSION_SECRET: secrets.sessionSecret, + // Loopback. Production warns about it and serves anyway; a real domain belongs in the checkout's + // own env file, where it is a deployment fact rather than something this panel invented. + PUBLIC_URL: `http://127.0.0.1:${port}`, + API_URL: `http://127.0.0.1:${port}`, + FOFOS_MEDIA_DIR: join(row.rootPath, 'media'), // Both names, because the site's guard compares them and one alone leaves the other falling back // to whatever the repository shipped with. Same reasoning as the schema push. ...(databases.production.name ? { FOFOS_DB_PRODUCTION: databases.production.name } : {}), @@ -210,3 +279,19 @@ export async function stopServer(which: ServerName): Promise<{ ok: boolean; outp const result = await run([PM2, 'delete', PROCESS[which]], 30_000); return { ok: result.ok, output: `$ pm2 delete ${PROCESS[which]}\n${result.output}` }; } + +/** + * Does anything answer on this port? + * + * ANY response counts, including a 404 or a 500: the question is whether something is listening, not + * whether it is happy. A short timeout, because a port nobody holds refuses immediately and one that is + * firewalled would otherwise hang this request. + */ +async function answers(port: number): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1500), redirect: 'manual' }); + return true; + } catch { + return false; + } +} diff --git a/db/schema.ts b/db/schema.ts index f6286d5..c31c30f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -155,6 +155,21 @@ export const fofosAdminSettings = pgTable('fofos_admin_settings', { devPort: integer('dev_port'), prodPort: integer('prod_port'), + /** + * Secrets the SITE needs at boot, generated here on first start and kept. + * + * `FOFOS_API_TOKEN` guards the site's own admin API — the one this plugin will call — and + * `ADMIN_SESSION_SECRET` signs its sessions. Both are required before the site will serve a request, + * and neither is a decision anybody wants to make: they are long random strings, and asking somebody + * to invent one is asking them to invent a worse one. + * + * Encrypted with `encryptSecret('fofos-admin', …)` — its own purpose key, so a leak of that one opens + * these and nothing else. Generated once and reused, because regenerating on every start would + * invalidate every session and every token anybody had noted down. + */ + siteApiToken: text('site_api_token'), + siteSessionSecret: text('site_session_secret'), + /** * When the last one finished, which is what makes the schedule survive a restart. * diff --git a/web/ServersPanel.tsx b/web/ServersPanel.tsx index 312ee5e..cb6fff2 100644 --- a/web/ServersPanel.tsx +++ b/web/ServersPanel.tsx @@ -37,6 +37,7 @@ type Server = { uptimeMs: number | null; restarts: number | null; url: string | null; + reachable: boolean | null; }; const uptime = (ms: number) => { @@ -120,7 +121,10 @@ export const ServersPanel = () => { prod !== String(servers?.find((s) => s.name === 'production')?.port ?? ''); const row = (server: Server, value: string, onChange: (next: string) => void) => { + // pm2 saying `online` is not the same as the site answering — `bun --hot` stays alive after a + // startup crash and serves nothing. The dot follows what ANSWERS; the words report both. const online = server.status === 'online'; + const serving = server.reachable === true; const label = server.name === 'development' ? 'Development' : 'Production'; return ( @@ -129,17 +133,22 @@ export const ServersPanel = () => {

{/* pm2's own word, not an interpretation of it. `errored` and `stopped` mean different things to whoever has to fix it. */} - + {label} {server.status ?? 'not started'} + {/* Said out loud, because it is the case that misleads: the process is up and the site is + not there. */} + {online && !serving ? ' · not answering' : ''} {online && server.uptimeMs !== null ? ` · up ${uptime(server.uptimeMs)}` : ''} {server.restarts ? ` · ${server.restarts} restarts` : ''}

{server.process} - {online && server.url ? ` · ${server.url}` : ''} + {serving && server.url ? ` · ${server.url}` : ''}

@@ -152,10 +161,13 @@ export const ServersPanel = () => { autoComplete="off" /> + {/* Fixed widths, because "Start" and "Restart" are different lengths and a row that sizes to its + own label leaves the two rows' inputs and buttons on different vertical lines. */}