diff --git a/README.md b/README.md index 8a61437..56bbf60 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,19 @@ with this machine's shell settings — the same keys as the checked-in | Key | Meaning | | --- | --- | | `port` | Port ProdDash serves on (default `24500`). The macOS launcher writes this one, so the port it is set to holds however ProdDash is started. | -| `adminPasscode` | If set, the admin page asks for this shared passcode. Leave `""` for none — it's a plain-HTTP LAN tool. | +| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. | +| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. | | `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. | | `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). | +**Locked out?** Delete `adminPin` from the data directory's `proddash.json` +and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode +overrides the PIN and always gets you in. + +Only `/admin` and the admin API are behind the PIN. Dashboards stay open, so a +wall display needs no PIN, and it is unrelated to a module's own PIN (Slack's +4–8 digit send PIN, say), which guards that module's actions only. + **Updating from your own git server.** ProdDash fetches one `tar.gz` of the branch — nothing else — so any Gitea or Forgejo works. Point `forge` at its base URL and `repo` at the owner/repo there: diff --git a/public/admin/admin.css b/public/admin/admin.css index 0ce9b0b..7eec5df 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -512,3 +512,49 @@ a.btn { text-decoration: none; } color: var(--accent); } #theme-status { margin-top: 4px; } + +/* ── the admin PIN ── */ + +/* Digits, evenly spaced, so six of them read as a PIN rather than a word. */ +.pin-input { + font-variant-numeric: tabular-nums; + letter-spacing: 0.35em; + text-align: center; + max-width: 9em; +} +.pin-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; +} +.pin-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} +.pin-field[hidden] { display: none; } +.pin-field input { + padding: 8px 10px; + font-size: 14px; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 7px; +} +.pin-field input:focus { outline: none; border-color: var(--accent); } +.pin-actions { + display: flex; + gap: 8px; + margin-left: auto; +} +.pin-note { margin-top: 12px; } +.pin-note code { + font-size: 0.92em; + padding: 1px 5px; + border-radius: 4px; + background: var(--bg); + border: 1px solid var(--border); +} diff --git a/public/admin/admin.js b/public/admin/admin.js index dfc1d02..a76cbb1 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -2,8 +2,10 @@ Everything here is server-wide: module enable/disable and admin config (written to config/modules.json, module re-inited on save), plus named - layout management. Guarded by the shared passcode when one is set. */ + layout management. Behind the admin PIN when one is set — a shell setting of + its own, nothing to do with a module's PIN (Slack's send PIN, say). */ +const securitySection = document.getElementById('security-section'); const loginCard = document.getElementById('login-card'); const loginStatus = document.getElementById('login-status'); const passcodeInput = document.getElementById('passcode'); @@ -62,20 +64,30 @@ function showLogin() { layoutsSection.hidden = true; updateCard.hidden = true; themeSection.hidden = true; + securitySection.hidden = true; + passcodeInput.value = ''; passcodeInput.focus(); } +let loggingIn = false; + async function login() { + if (loggingIn) return; + loggingIn = true; loginStatus.textContent = ''; loginStatus.className = 'admin-status'; try { - await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) }); + await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ pin: passcodeInput.value }) }); passcodeInput.value = ''; loginCard.hidden = true; await loadState(); } catch (e) { + passcodeInput.value = ''; loginStatus.textContent = e.message; loginStatus.className = 'admin-status error'; + passcodeInput.focus(); + } finally { + loggingIn = false; } } @@ -83,6 +95,83 @@ document.getElementById('login-btn').addEventListener('click', login); passcodeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); }); +// Six digits is the whole PIN, so there is nothing left to press. +passcodeInput.addEventListener('input', () => { + passcodeInput.value = passcodeInput.value.replace(/\D/g, '').slice(0, 6); + if (passcodeInput.value.length === 6) login(); +}); + +/* ── the admin PIN ───────────────────────────────────────────────────── */ + +const pinCurrentField = document.getElementById('pin-current-field'); +const pinCurrent = document.getElementById('pin-current'); +const pinNew = document.getElementById('pin-new'); +const pinRepeat = document.getElementById('pin-repeat'); +const pinSaveBtn = document.getElementById('pin-save'); +const pinClearBtn = document.getElementById('pin-clear'); +const pinStatus = document.getElementById('pin-status'); +const pinStateEl = document.getElementById('pin-state'); + +for (const input of [pinCurrent, pinNew, pinRepeat]) { + input.addEventListener('input', () => { + input.value = input.value.replace(/\D/g, '').slice(0, 6); + }); +} + +/** Paint the section for "no PIN yet" or "one is set". */ +function renderPin(state) { + const set = Boolean(state.pinSet); + const fromEnv = Boolean(state.pinFromEnv); + securitySection.hidden = false; + pinCurrentField.hidden = !set; + pinClearBtn.hidden = !set; + document.getElementById('pin-new-label').textContent = set ? 'New PIN' : 'PIN'; + pinSaveBtn.textContent = set ? 'Change PIN' : 'Set PIN'; + pinStateEl.textContent = fromEnv + ? 'A passcode is set in this server’s environment (PRODDASH_PASSCODE) and takes precedence. Anything set here applies once that is removed.' + : set + ? 'Admin is locked. This page asks for the PIN, and so does every admin request.' + : 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.'; + if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json'; +} + +function pinMessage(text, bad = false) { + pinStatus.textContent = text; + pinStatus.className = 'admin-status' + (bad ? ' error' : ' ok'); +} + +async function savePin(clearing = false) { + pinStatus.textContent = ''; + pinStatus.className = 'admin-status'; + const current = pinCurrent.value; + const next = clearing ? '' : pinNew.value; + if (!clearing) { + if (!/^\d{6}$/.test(next)) return pinMessage('The PIN must be exactly 6 digits.', true); + if (next !== pinRepeat.value) return pinMessage('The two PINs are not the same.', true); + } + if (!pinCurrentField.hidden && !/^\d{6}$/.test(current)) { + return pinMessage('Enter the current PIN to change it.', true); + } + pinSaveBtn.disabled = true; + pinClearBtn.disabled = true; + try { + const body = await api('/api/admin/pin', { method: 'POST', body: JSON.stringify({ pin: next, current }) }); + for (const i of [pinCurrent, pinNew, pinRepeat]) i.value = ''; + pinMessage(body.pinSet ? 'PIN saved. It is asked for from the next visit.' : 'PIN removed — admin is open again.'); + await loadState(); + } catch (e) { + pinMessage(e.message, true); + } finally { + pinSaveBtn.disabled = false; + pinClearBtn.disabled = false; + } +} + +pinSaveBtn.addEventListener('click', () => savePin(false)); +pinClearBtn.addEventListener('click', () => { + if (confirm('Turn the admin PIN off? Anyone on the network could then change this server’s settings.')) savePin(true); +}); +pinRepeat.addEventListener('keydown', (e) => { if (e.key === 'Enter') savePin(false); }); /* ── module cards ───────────────────────────────────────────────────── */ @@ -856,6 +945,7 @@ async function loadState() { for (const mod of state.modules || []) { modulesList.appendChild(buildModuleCard(mod)); } + renderPin(state); loadTheme(); loadLayouts(); loadCatalog(false); diff --git a/public/admin/index.html b/public/admin/index.html index ecccb6c..a327283 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -18,10 +18,11 @@
+ +
diff --git a/server.js b/server.js index dd91463..aeb13c1 100644 --- a/server.js +++ b/server.js @@ -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$$"; '' 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 {