diff --git a/INSTALL.md b/INSTALL.md index f6dd52b..def5015 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -425,8 +425,13 @@ Subscribers get JSON on channels `pool:shares`, `pool:rejects`, The dashboard reads the same SQLite the C proxy writes. It's a single Node process serving both the public `/` view and the admin `/admin` -view; the admin routes are only enabled when both `ADMIN_USER` and -`ADMIN_PASSWORD` are set in the env. +view; the admin routes are only enabled when credentials are configured: +either both `ADMIN_USER` and `ADMIN_PASSWORD` in the env, or +`ADMIN_CREDENTIALS_FILE` pointing at a `user:password` file (handy for +docker secrets — same shape as the enforcer's RPC cookie). The file +takes precedence when both are set. A trailing newline in the file is +ignored; neither the user nor the password may contain `:` (it's the +HTTP Basic auth separator — the dashboard refuses to boot if one does). ### Systemd drop-in diff --git a/dashboard/server.js b/dashboard/server.js index 478a7be..8552ce2 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -8,6 +8,7 @@ */ import express from 'express'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { renderFile } from 'ejs'; @@ -55,8 +56,43 @@ const PUBLIC_STRATUM_URL = process.env.PUBLIC_STRATUM_URL || 'stratum+tcp:// file` and + * docker secrets end with one. Spaces stay significant. */ + raw = raw.replace(/[\r\n]+$/, ''); + const sep = raw.indexOf(':'); + if (sep < 1 || sep === raw.length - 1) { + throw new Error(`ADMIN_CREDENTIALS_FILE ${file}: expected "user:password"`); + } + user = raw.slice(0, sep); + pass = raw.slice(sep + 1); + } + /* HTTP Basic auth uses ':' as the user/password separator, so a colon + * in either would silently truncate at login time. Refuse at boot. */ + if (user.includes(':') || pass.includes(':')) { + throw new Error('admin credentials must not contain ":" ' + + '(HTTP Basic auth uses it as the separator)'); + } + return { user, pass }; +} +const { user: ADMIN_USER, pass: ADMIN_PASS } = loadAdminCredentials(); const RESERVE_ADDRESS = process.env.POOL_THUNDER_RESERVE_ADDRESS || '(unset)'; const THUNDER_RPC_URL = process.env.THUNDER_RPC_URL || 'http://127.0.0.1:6009'; const PAYOUT_ADMIN_URL = process.env.PAYOUT_ADMIN_URL || ''; @@ -66,7 +102,7 @@ const THUNDER_SIDECHAIN_ID = parseInt(process.env.THUNDER_SIDECHAIN_ID || '9', 1 function requireAdminAuth(req, res, next) { if (!ADMIN_USER || !ADMIN_PASS) { - return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD'); + return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD or ADMIN_CREDENTIALS_FILE'); } const h = req.headers.authorization || ''; if (h.startsWith('Basic ')) {