From ca43f7ef863b4a32727331f788f8bd3628be9a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 13:50:16 +0100 Subject: [PATCH] the site gets what it needs to boot, and the status stops lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, and the second is why the panel was confidently wrong. The site refuses to start without FOFOS_API_TOKEN, ADMIN_SESSION_SECRET, PUBLIC_URL, API_URL and FOFOS_MEDIA_DIR, and the checkout has no .env at all. Every one of those is either already known here — the port, the media directory — or a random secret nobody should be asked to invent. So they are generated once, kept encrypted under this plugin's own purpose key, and supplied. Once, not per start: regenerating would invalidate every session and token anybody had noted down. pm2 saying `online` is NOT the site being up. `bun --hot` survives a crash at startup — the process stays alive, pm2 reports online, and nothing is listening. That is precisely what development did: reported up, served nothing, and this panel repeated it. The status now also asks whether the port answers, any response counting, and says "not answering" out loud when the two disagree. And the buttons are a fixed width, because Start and Restart are different lengths and rows that size to their own label leave the inputs on different vertical lines. Co-Authored-By: Claude Opus 5 --- api/servers.ts | 111 ++++++++++++++++++++++++++++++++++++++----- db/schema.ts | 15 ++++++ web/ServersPanel.tsx | 17 ++++++- 3 files changed, 128 insertions(+), 15 deletions(-) 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. */}