Merge pull request 'Admin sits behind a 6-digit PIN' (#1) from claude/admin-pin into main
This commit was merged in pull request #1.
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
* Configure via config/proddash.json (defaults, in the repo), an optional
|
||||
* proddash.json in the data directory (this machine's overrides), or env vars:
|
||||
* PORT (default: value in proddash.json, else 24500)
|
||||
* PRODDASH_PASSCODE (overrides adminPasscode in proddash.json)
|
||||
* PRODDASH_PASSCODE (a passcode that overrides the stored admin PIN —
|
||||
* also the way back in if the PIN is ever forgotten)
|
||||
* PRODDASH_FORGE (overrides `forge`: "github", or a Gitea/Forgejo base
|
||||
* URL such as http://10.3.8.90:3000)
|
||||
* PRODDASH_DATA_DIR (where module config + layouts are kept; see below)
|
||||
@@ -1170,12 +1171,109 @@ function restartServer(reason) {
|
||||
|
||||
/* ── admin API ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* Admin is behind a 6-digit PIN, set in the admin page and kept in this
|
||||
machine's proddash.json as a scrypt hash — never in the clear, because that
|
||||
file gets copied between machines. A dashboard needs no PIN; only /admin
|
||||
and the admin API do. Two ways back in if it is ever forgotten: set
|
||||
PRODDASH_PASSCODE in the environment, or delete "adminPin" from
|
||||
proddash.json in the data directory and restart. `adminPasscode` (the older
|
||||
free-text setting) still works so an existing install keeps running. */
|
||||
const PIN_RE = /^\d{6}$/;
|
||||
const PIN_MAX_WRONG = 5;
|
||||
const PIN_LOCKOUT_MS = 30000;
|
||||
const ADMIN_PASSCODE = String(process.env.PRODDASH_PASSCODE || shellConfig.adminPasscode || '');
|
||||
/** Session token: regenerating it every boot is fine — admins just re-enter the passcode. */
|
||||
/** Session token: regenerating it every boot is fine — admins just re-enter the PIN. */
|
||||
const adminToken = crypto.randomBytes(24).toString('hex');
|
||||
/** The stored PIN, as "scrypt$<salt>$<hash>"; '' when admin is open. */
|
||||
let adminPinHash = String(shellConfig.adminPin || '');
|
||||
const pinAttempts = new Map(); // ip → { count, lockedUntil }
|
||||
|
||||
function hashPin(pin, salt = crypto.randomBytes(16).toString('hex')) {
|
||||
return `scrypt$${salt}$${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** Constant-time check of a PIN against the stored hash. */
|
||||
function pinMatches(given, stored) {
|
||||
const parts = String(stored || '').split('$');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||
const expected = Buffer.from(parts[2], 'hex');
|
||||
const actual = crypto.scryptSync(String(given), parts[1], expected.length);
|
||||
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
/** Is a credential required at all? */
|
||||
function adminLocked() {
|
||||
return Boolean(ADMIN_PASSCODE || adminPinHash);
|
||||
}
|
||||
|
||||
/** True when `given` is the credential in force (the env passcode, or the PIN). */
|
||||
function credentialOk(given) {
|
||||
const s = String(given ?? '');
|
||||
if (!s) return false;
|
||||
if (ADMIN_PASSCODE) {
|
||||
const a = Buffer.from(s);
|
||||
const b = Buffer.from(ADMIN_PASSCODE);
|
||||
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
|
||||
}
|
||||
return adminPinHash ? pinMatches(s, adminPinHash) : false;
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return String(req.socket?.remoteAddress || 'unknown');
|
||||
}
|
||||
|
||||
/** Seconds of lockout left for this address, or 0. */
|
||||
function lockoutLeft(ip) {
|
||||
const a = pinAttempts.get(ip);
|
||||
if (!a || !a.lockedUntil) return 0;
|
||||
const left = a.lockedUntil - Date.now();
|
||||
if (left <= 0) {
|
||||
pinAttempts.delete(ip);
|
||||
return 0;
|
||||
}
|
||||
return Math.ceil(left / 1000);
|
||||
}
|
||||
|
||||
function noteWrongPin(ip) {
|
||||
const a = pinAttempts.get(ip) || { count: 0, lockedUntil: 0 };
|
||||
a.count += 1;
|
||||
if (a.count >= PIN_MAX_WRONG) {
|
||||
a.count = 0;
|
||||
a.lockedUntil = Date.now() + PIN_LOCKOUT_MS;
|
||||
console.warn(`[proddash] ${PIN_MAX_WRONG} wrong admin PINs from ${ip} — locked out for ${PIN_LOCKOUT_MS / 1000} s`);
|
||||
}
|
||||
pinAttempts.set(ip, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new PIN (6 digits), or '' to open admin up again. Keeps every other
|
||||
* key in this machine's proddash.json. Throws an httpError the route reports.
|
||||
*/
|
||||
function setAdminPin(pin) {
|
||||
const next = String(pin ?? '');
|
||||
if (next && !PIN_RE.test(next)) throw httpError(400, 'The PIN must be exactly 6 digits.');
|
||||
const file = path.join(DATA_DIR, 'proddash.json');
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) {
|
||||
existing = readJson(file, null);
|
||||
if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
|
||||
throw httpError(500, `${file} is not a JSON object — fix it by hand before changing the PIN.`);
|
||||
}
|
||||
}
|
||||
const hash = next ? hashPin(next) : '';
|
||||
const out = { ...existing };
|
||||
if (hash) out.adminPin = hash;
|
||||
else delete out.adminPin;
|
||||
writeJson(file, out);
|
||||
adminPinHash = hash;
|
||||
shellConfig.adminPin = hash;
|
||||
pinAttempts.clear();
|
||||
console.log(`[proddash] admin PIN ${hash ? 'set' : 'removed — admin is open again'}`);
|
||||
return { pinSet: Boolean(hash) };
|
||||
}
|
||||
|
||||
function isAuthed(req) {
|
||||
if (!ADMIN_PASSCODE) return true;
|
||||
if (!adminLocked()) return true;
|
||||
const cookies = String(req.headers.cookie || '');
|
||||
return cookies.split(';').some((c) => c.trim() === 'proddash_admin=' + adminToken);
|
||||
}
|
||||
@@ -1183,7 +1281,7 @@ function isAuthed(req) {
|
||||
/** Guard a state-changing admin route. Returns true when the request was refused. */
|
||||
function refuseAdminWrite(req, res) {
|
||||
if (!isAuthed(req)) {
|
||||
sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||
return true;
|
||||
}
|
||||
// Requiring application/json forces a CORS preflight that a hostile page
|
||||
@@ -1268,10 +1366,19 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
if (!ADMIN_PASSCODE) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||
if (String(body.passcode || '') !== ADMIN_PASSCODE) {
|
||||
return sendJson(res, 403, { error: 'Wrong passcode.' });
|
||||
if (!adminLocked()) return sendJson(res, 200, { ok: true, authRequired: false });
|
||||
const ip = clientIp(req);
|
||||
const left = lockoutLeft(ip);
|
||||
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||
// `passcode` is what the older admin page sent; `pin` is what this one does.
|
||||
if (!credentialOk(body.pin ?? body.passcode)) {
|
||||
noteWrongPin(ip);
|
||||
const now = lockoutLeft(ip);
|
||||
return sendJson(res, 403, now
|
||||
? { error: `Too many wrong PINs — try again in ${now} s.`, retryAfter: now }
|
||||
: { error: 'Wrong PIN.' });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
@@ -1280,12 +1387,50 @@ async function handleAdminApi(req, res, urlPath) {
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
if (urlPath === '/api/admin/pin' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
body = JSON.parse((await readBody(req)) || '{}');
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: 'Malformed request.' });
|
||||
}
|
||||
// Changing a credential re-asserts it, so a browser left open on the
|
||||
// admin page can't be used to take the machine over.
|
||||
if (adminPinHash) {
|
||||
const ip = clientIp(req);
|
||||
const left = lockoutLeft(ip);
|
||||
if (left) return sendJson(res, 429, { error: `Too many wrong PINs — try again in ${left} s.`, retryAfter: left });
|
||||
if (!pinMatches(String(body.current ?? ''), adminPinHash)) {
|
||||
noteWrongPin(ip);
|
||||
return sendJson(res, 403, { error: 'That is not the current PIN.' });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
}
|
||||
try {
|
||||
const result = setAdminPin(body.pin);
|
||||
// Whoever just set it has proved they are allowed, so hand them the
|
||||
// session rather than bouncing them to the unlock card they just built.
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Set-Cookie': `proddash_admin=${adminToken}; Path=/; SameSite=Lax; HttpOnly`,
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
return res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
return sendJson(res, err.status || 500, { error: err.message || 'Could not save the PIN.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (urlPath === '/api/admin/state' && req.method === 'GET') {
|
||||
if (!isAuthed(req)) {
|
||||
return sendJson(res, 401, { authRequired: true, authed: false });
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
authRequired: Boolean(ADMIN_PASSCODE),
|
||||
authRequired: adminLocked(),
|
||||
// The env passcode can't be changed from here; the page says so.
|
||||
pinSet: Boolean(adminPinHash),
|
||||
pinFromEnv: Boolean(ADMIN_PASSCODE),
|
||||
authed: true,
|
||||
version: SHELL_VERSION,
|
||||
dataDir: DATA_DIR,
|
||||
@@ -1466,7 +1611,7 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
}
|
||||
|
||||
if (!m[2] && req.method === 'DELETE') {
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin passcode first.', authRequired: true });
|
||||
if (!isAuthed(req)) return sendJson(res, 401, { error: 'Enter the admin PIN first.', authRequired: true });
|
||||
try {
|
||||
fs.unlinkSync(layoutPath(name));
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user