the site gets what it needs to boot, and the status stops lying
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 <noreply@anthropic.com>
This commit is contained in:
+98
-13
@@ -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<ServerStatus[]> {
|
||||
}
|
||||
}
|
||||
|
||||
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<ServerStatus[]> {
|
||||
@@ -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<string, string> = {
|
||||
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<boolean> {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1500), redirect: 'manual' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user