diff --git a/dashboard/lib/actions.js b/dashboard/lib/actions.js new file mode 100644 index 0000000..52cb1cd --- /dev/null +++ b/dashboard/lib/actions.js @@ -0,0 +1,212 @@ +/* Backend implementations for the admin dashboard's write actions. + * + * Every function returns { ok, msg, detail? } so the route handler can + * uniformly format flash messages regardless of which action ran. Never + * throws — errors are captured into { ok: false, msg }. + * + * External systems these talk to: + * - Thunder JSON-RPC — nudgeMine, removeFromMempool + * - payout worker HTTP — triggerPayout + * - bip300301_enforcer — createDeposit (via grpcurl on the host) + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +/* ---------- Thunder RPC helper ---------- */ + +async function thunderRpc(rpcUrl, method, params) { + const body = { jsonrpc: '2.0', id: 1, method, params: params ?? [] }; + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 30_000); + try { + const r = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: ctl.signal, + }); + const text = await r.text(); + let j; + try { j = JSON.parse(text); } + catch { throw new Error(`thunder rpc ${method}: non-json response: ${text.slice(0, 200)}`); } + if (j.error) { + const msg = j.error.message || JSON.stringify(j.error); + throw new Error(`thunder rpc ${method}: ${msg}`); + } + return j.result; + } finally { + clearTimeout(t); + } +} + +/* ---------- Action #4: nudge Thunder to mint a sidechain block ---------- */ + +export async function nudgeMine({ thunderRpcUrl }) { + try { + const result = await thunderRpc(thunderRpcUrl, 'mine', []); + return { + ok: true, + msg: 'Thunder mine nudged', + detail: result ? String(result).slice(0, 200) : 'ok', + }; + } catch (e) { + /* Thunder often returns "BMM request with same prev_bytes already + * exists" when it can't create a new BMM commit yet. That's not + * really a failure from the operator's POV — surface it plainly. */ + const msg = e.message || String(e); + return { ok: false, msg: 'nudge mine failed', detail: msg }; + } +} + +/* ---------- Action #3: remove a stuck tx from Thunder's mempool ---------- */ + +const TXID_RE = /^[0-9a-fA-F]{64}$/; + +export async function removeFromMempool({ thunderRpcUrl, txid }) { + if (!TXID_RE.test(txid || '')) { + return { ok: false, msg: 'invalid txid', detail: 'expected 64 hex chars' }; + } + try { + const result = await thunderRpc(thunderRpcUrl, 'remove_from_mempool', [txid]); + return { + ok: true, + msg: `removed ${txid.slice(0, 12)}… from Thunder mempool`, + detail: result != null ? String(result).slice(0, 200) : 'ok', + }; + } catch (e) { + return { ok: false, msg: 'remove_from_mempool failed', detail: e.message }; + } +} + +/* ---------- Action #2: kick the payout worker to run one cycle ---------- */ + +export async function triggerPayout({ payoutAdminUrl }) { + if (!payoutAdminUrl) { + return { ok: false, msg: 'PAYOUT_ADMIN_URL not configured' }; + } + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 60_000); + try { + const r = await fetch(new URL('/tick', payoutAdminUrl), { + method: 'POST', + signal: ctl.signal, + }); + const j = await r.json().catch(() => ({})); + if (!r.ok || j.ok === false) { + return { + ok: false, + msg: 'payout worker /tick failed', + detail: j.error || `http ${r.status}`, + }; + } + const res = j.result || {}; + return { + ok: true, + msg: `payout tick fired`, + detail: `attempted=${res.attempted ?? 0} paid=${res.paid ?? 0} ` + + `failed=${res.failed ?? 0}` + + (res.reserve_short ? ' (reserve short)' : ''), + }; + } catch (e) { + return { ok: false, msg: 'payout worker unreachable', detail: e.message }; + } finally { + clearTimeout(t); + } +} + +/* ---------- Action #1: BTC → Thunder deposit (via bip300301_enforcer) ---------- */ + +/* We shell out to `grpcurl` because the enforcer's gRPC on :50051 uses + * server reflection — no .proto files needed, one static Go binary handles + * it. The exact call matches ~/scripts/deposit_all_sidechains.sh on the + * pool host: + * + * grpcurl -d '{sidechain_id, address, value_sats, fee_sats}' -plaintext \ + * ENFORCER_ADDR cusf.mainchain.v1.WalletService/CreateDepositTransaction + * + * On success the enforcer returns a txid; we insert into the pool DB's + * `deposits` table so the admin ledger shows the operator action. + */ +export async function createDeposit({ + grpcurlBin, enforcerGrpcAddr, sidechainId, address, valueSats, feeSats, + db, /* writable handle */ +}) { + /* Validate inputs up front — grpcurl errors are less friendly. */ + const sid = Number(sidechainId); + if (!Number.isInteger(sid) || sid < 0 || sid > 255) { + return { ok: false, msg: 'invalid sidechain_id (expected 0..255)' }; + } + if (!address || typeof address !== 'string' || address.length < 8 || address.length > 128) { + return { ok: false, msg: 'invalid recipient address' }; + } + const val = BigInt(valueSats || 0); + if (val <= 0n) return { ok: false, msg: 'value_sats must be > 0' }; + const fee = BigInt(feeSats || 0); + if (fee < 0n) return { ok: false, msg: 'fee_sats must be >= 0' }; + + const payload = JSON.stringify({ + sidechain_id: sid, + address, + value_sats: Number(val), + fee_sats: Number(fee), + }); + let stdout; + try { + const res = await execFileAsync(grpcurlBin, [ + '-plaintext', + '-d', payload, + enforcerGrpcAddr, + 'cusf.mainchain.v1.WalletService/CreateDepositTransaction', + ], { timeout: 60_000, maxBuffer: 1024 * 1024 }); + stdout = res.stdout; + } catch (e) { + const stderr = (e.stderr || '').toString().slice(0, 500); + const stdouterr = (e.stdout || '').toString().slice(0, 300); + return { + ok: false, + msg: 'CreateDepositTransaction failed', + detail: stderr || stdouterr || e.message, + }; + } + + /* Enforcer returns JSON with a `txid` field (bytes as base64 or hex + * depending on version). Parse whatever it gave us; if we can't find + * a txid, still consider the call a success and echo the raw output + * so the operator can inspect. */ + let j = {}; + try { j = JSON.parse(stdout); } catch { /* ignore */ } + const txid = + j.txid?.hex || j.txid?.value?.hex || + (typeof j.txid === 'string' ? j.txid : null); + + /* Log to deposits so /admin history shows it. Best-effort — never fail + * the whole action just because the DB write hiccuped. `db` may be + * null if the writable handle couldn't open (e.g. file missing) — + * still consider the deposit successful. */ + if (db && typeof db.prepare === 'function') { + try { + const nowSec = Math.floor(Date.now() / 1000); + db.prepare(` + INSERT INTO deposits + (ts, btc_txid, sats_deposited, fee_sats, thunder_recipient, ctip_before, ctip_after, note) + VALUES (?, ?, ?, ?, ?, NULL, NULL, ?) + `).run(nowSec, txid || '(pending)', Number(val), Number(fee), + address, `via admin dashboard, sidechain_id=${sid}`); + } catch (e) { + return { + ok: true, + msg: `deposit tx created (DB log failed: ${e.message})`, + detail: txid ? `txid=${txid}` : stdout.slice(0, 300), + }; + } + } + return { + ok: true, + msg: `deposit tx submitted`, + detail: txid ? `txid=${txid}, ${val} sats to ${address}` : + `see raw enforcer response: ${stdout.slice(0, 200)}`, + }; +} diff --git a/dashboard/lib/admin-router.js b/dashboard/lib/admin-router.js new file mode 100644 index 0000000..2e2e334 --- /dev/null +++ b/dashboard/lib/admin-router.js @@ -0,0 +1,197 @@ +/* Admin router: the 5 sub-pages + the 4 write-action POSTs + the + * per-worker audit page + /admin/logout. + * + * Every route on this router is protected by requireAdminAuth (mounted + * in server.js). Writable DB access is confined to the deposit action. + * All page renders share a single adminSummary() probe to keep the + * chrome (balances, health chips) consistent across tabs. + */ + +import express from 'express'; +import * as admin from './admin.js'; +import * as actions from './actions.js'; +import { issueToken, consumeToken } from './csrf.js'; + +export function createAdminRouter({ + db, // read-only handle + dbRw, // writable handle (lazy wrapper) + THUNDER_RPC_URL, + PAYOUT_ADMIN_URL, + ENFORCER_GRPC_ADDR, + GRPCURL_BIN, + THUNDER_SIDECHAIN_ID, + RESERVE_ADDRESS, + PPS_SATS_PER_DIFF, +} = {}) { + const router = express.Router(); + const parseAdminForm = express.urlencoded({ extended: false, limit: '4kb' }); + + /* --- shared probe: called once per admin page render --- */ + async function adminSummary() { + const [reserve, enforcer, totals, workers, inFlight, payouts, deposits, blocks] = + await Promise.all([ + admin.thunderBalance(THUNDER_RPC_URL), + admin.enforcerBalance(GRPCURL_BIN, ENFORCER_GRPC_ADDR), + Promise.resolve(admin.poolTotals(db)), + Promise.resolve(admin.perWorkerBalances(db)), + Promise.resolve(admin.inFlight(db)), + Promise.resolve(admin.recentPayouts(db, 25)), + Promise.resolve(admin.recentDeposits(db, 25)), + Promise.resolve(admin.recentBlocksFound(db, 15)), + ]); + return { + reserve, enforcer, + reserveAddress: RESERVE_ADDRESS, + reserveSidechainId: THUNDER_SIDECHAIN_ID, + payoutAdminConfigured: !!PAYOUT_ADMIN_URL, + totals, workers, inFlight, payouts, deposits, blocks, + }; + } + + /* --- flash: read from query string on GET, write via redirect on POST --- */ + function readFlash(req) { + const msg = typeof req.query.flash === 'string' ? req.query.flash : ''; + if (!msg) return null; + return { + ok: req.query.flashOk === '1', + msg, + detail: typeof req.query.flashDetail === 'string' ? req.query.flashDetail : '', + }; + } + function flashRedirect(res, result, defaultTarget = '/admin') { + const q = new URLSearchParams(); + q.set('flash', result.msg || (result.ok ? 'ok' : 'failed')); + q.set('flashOk', result.ok ? '1' : '0'); + if (result.detail) q.set('flashDetail', result.detail); + res.redirect(302, defaultTarget + '?' + q.toString()); + } + /* Actions honour a hidden `return_to` form field so each POST goes + * back to the page it was fired from. Whitelist against a small set + * of admin routes so a bad actor can't redirect to elsewhere. */ + const RETURN_TO_ALLOW = new Set([ + '/admin', '/admin/overview', + '/admin/workers', '/admin/deposits', '/admin/payouts', '/admin/tools', + ]); + function returnTarget(req) { + const t = req.body?.return_to; + if (typeof t === 'string' && RETURN_TO_ALLOW.has(t)) return t; + return '/admin'; + } + + /* --- page helpers --- */ + async function renderAdminPage(view, req, res) { + const summary = await adminSummary(); + res.render(view, { + ...summary, + csrfToken: issueToken(), + flash: readFlash(req), + }); + } + + /* --- CSRF gate for POST actions --- */ + function requireCsrf(req, res, next) { + if (consumeToken(req.body?.csrf)) return next(); + flashRedirect(res, + { ok: false, msg: 'CSRF token missing or already used', + detail: 'refresh the admin page and retry' }, + returnTarget(req)); + } + + /* --- GET page routes --- */ + router.get('/', (req, res, next) => renderAdminPage('admin-overview', req, res).catch(next)); + router.get('/overview', (req, res) => res.redirect(301, '/admin')); + router.get('/workers', (req, res, next) => renderAdminPage('admin-workers', req, res).catch(next)); + router.get('/deposits', (req, res, next) => renderAdminPage('admin-deposits', req, res).catch(next)); + router.get('/payouts', (req, res, next) => renderAdminPage('admin-payouts', req, res).catch(next)); + router.get('/tools', (req, res, next) => renderAdminPage('admin-tools', req, res).catch(next)); + + /* --- Per-worker audit (unchanged surface, uses admin.workerAudit) --- */ + function workerAuditFor(workerId) { + const audit = admin.workerAudit(db, workerId, { rate: PPS_SATS_PER_DIFF }); + if (!audit) return null; + audit.payouts = admin.payoutsForWorker(db, workerId, 100); + return audit; + } + router.get('/worker/:id', (req, res) => { + try { + const audit = workerAuditFor(parseInt(req.params.id, 10)); + if (!audit) return res.status(404).render('404', { what: 'worker' }); + res.render('admin-worker', { audit }); + } catch (e) { + res.status(500).send('admin: ' + e.message); + } + }); + + /* --- JSON API kept for scripted consumers --- */ + router.get('/api/summary', async (req, res) => { + try { res.json(await adminSummary()); } + catch (e) { res.status(500).json({ error: e.message }); } + }); + router.get('/api/worker/:id', (req, res) => { + try { + const audit = workerAuditFor(parseInt(req.params.id, 10)); + if (!audit) return res.status(404).json({ error: 'unknown worker id' }); + res.json(audit); + } catch (e) { + res.status(500).json({ error: e.message }); + } + }); + + /* --- POST write actions --- */ + router.post('/action/nudge-mine', parseAdminForm, requireCsrf, async (req, res) => { + const r = await actions.nudgeMine({ thunderRpcUrl: THUNDER_RPC_URL }); + flashRedirect(res, r, returnTarget(req)); + }); + + router.post('/action/remove-from-mempool', parseAdminForm, requireCsrf, async (req, res) => { + const r = await actions.removeFromMempool({ + thunderRpcUrl: THUNDER_RPC_URL, + txid: (req.body?.txid || '').trim(), + }); + flashRedirect(res, r, returnTarget(req)); + }); + + router.post('/action/trigger-payout', parseAdminForm, requireCsrf, async (req, res) => { + const r = await actions.triggerPayout({ payoutAdminUrl: PAYOUT_ADMIN_URL }); + flashRedirect(res, r, returnTarget(req)); + }); + + router.post('/action/deposit', parseAdminForm, requireCsrf, async (req, res) => { + const r = await actions.createDeposit({ + grpcurlBin: GRPCURL_BIN, + enforcerGrpcAddr: ENFORCER_GRPC_ADDR, + sidechainId: req.body?.sidechain_id ?? THUNDER_SIDECHAIN_ID, + address: (req.body?.address || '').trim(), + valueSats: req.body?.value_sats, + feeSats: req.body?.fee_sats, + db: dbRw?.get() || null, + }); + flashRedirect(res, r, returnTarget(req)); + }); + + /* --- /admin/logout: 401 clears the browser's cached Basic creds --- + * Realm string must be ASCII (HTTP headers) — no em dashes here. */ + router.get('/logout', (_req, res) => { + res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin - signed out"'); + res.status(401).send( + '' + + 'signed out · simplepool' + + '' + + '
' + + '

Signed out

' + + '

Browser Basic-auth cache is cleared. ' + + 'Sign back in or ' + + 'head to the public dashboard.

' + + '
'); + }); + + /* --- central error handler for /admin/* — never leak stack traces --- */ + router.use((err, _req, res, _next) => { + console.error('[admin] route error:', err); + flashRedirect(res, + { ok: false, msg: 'admin route error', detail: err.message || String(err) }, + '/admin'); + }); + + return router; +} diff --git a/dashboard/lib/admin.js b/dashboard/lib/admin.js index 4c12051..5a323a9 100644 --- a/dashboard/lib/admin.js +++ b/dashboard/lib/admin.js @@ -254,6 +254,33 @@ export function recentBlocksFound(handle, limit = 15) { })); } +/* Probe the enforcer wallet's BTC balance via gRPC (WalletService.GetBalance). + * Shells out to grpcurl — same binary the deposit action uses. Short + * timeout so the admin page renders even when the enforcer is slow. + * Returns { ok, confirmed_sats, pending_sats, has_synced, error? }. */ +export async function enforcerBalance(grpcurlBin, enforcerGrpcAddr, timeoutMs = 3000) { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execFileAsync = promisify(execFile); + try { + const { stdout } = await execFileAsync(grpcurlBin, [ + '-plaintext', '-d', '{}', + enforcerGrpcAddr, + 'cusf.mainchain.v1.WalletService/GetBalance', + ], { timeout: timeoutMs, maxBuffer: 256 * 1024 }); + const j = JSON.parse(stdout); + return { + ok: true, + confirmed_sats: Number(j.confirmedSats ?? 0), + pending_sats: Number(j.pendingSats ?? 0), + has_synced: Boolean(j.hasSynced), + }; + } catch (e) { + const msg = (e.stderr || '').toString().slice(0, 200) || e.message; + return { ok: false, error: msg, confirmed_sats: 0, pending_sats: 0, has_synced: false }; + } +} + /* Minimal Thunder JSON-RPC client — we only need `balance()`. Short * timeout so the admin page renders promptly even when Thunder is * unreachable. */ diff --git a/dashboard/lib/csrf.js b/dashboard/lib/csrf.js new file mode 100644 index 0000000..b6fb5b6 --- /dev/null +++ b/dashboard/lib/csrf.js @@ -0,0 +1,40 @@ +/* Tiny in-memory CSRF token store. + * + * HTTP Basic auth alone doesn't defend against CSRF: a malicious page can + * make the browser POST cross-site with the cached basic-auth header. We + * mint a random token when the admin page is rendered, stash it, and + * require it back on every POST /admin/action/*. The attacker can't read + * the admin page (basic auth blocks the GET), so they can't obtain a + * valid token. + * + * Tokens are single-use and expire after CSRF_TTL_MS. Kept in memory — + * fine for a single dashboard process; a multi-worker deployment would + * need shared storage. */ + +import { randomBytes } from 'node:crypto'; + +const CSRF_TTL_MS = 60 * 60 * 1000; // 1 hour + +const tokens = new Map(); // token -> expiresAtMs + +function sweep() { + const now = Date.now(); + for (const [t, exp] of tokens) if (exp < now) tokens.delete(t); +} + +export function issueToken() { + sweep(); + const t = randomBytes(24).toString('base64url'); + tokens.set(t, Date.now() + CSRF_TTL_MS); + return t; +} + +/* Consumes the token — a second call with the same token returns false. */ +export function consumeToken(t) { + if (!t || typeof t !== 'string') return false; + sweep(); + const exp = tokens.get(t); + if (!exp) return false; + tokens.delete(t); + return exp >= Date.now(); +} diff --git a/dashboard/lib/db-admin.js b/dashboard/lib/db-admin.js new file mode 100644 index 0000000..60d0e24 --- /dev/null +++ b/dashboard/lib/db-admin.js @@ -0,0 +1,13 @@ +/* Writable SQLite handle used by admin write-actions (only the + * deposit action today). + * + * Kept in its own module so no read-side code (stats.js, admin.js) can + * accidentally reach for the writable handle. server.js imports both + * openDb (read-only) and openAdminDb (writable) explicitly and passes + * each to the routes that need it. */ + +import { openDb } from './db.js'; + +export function openAdminDb(dbPath) { + return openDb(dbPath, { readonly: false }); +} diff --git a/dashboard/lib/db.js b/dashboard/lib/db.js index 62cecc2..e334a0b 100644 --- a/dashboard/lib/db.js +++ b/dashboard/lib/db.js @@ -7,7 +7,7 @@ import Database from 'better-sqlite3'; import fs from 'node:fs'; -export function openDb(dbPath) { +export function openDb(dbPath, { readonly = true } = {}) { let db = null; let lastTryMs = 0; @@ -18,7 +18,7 @@ export function openDb(dbPath) { lastTryMs = now; if (!fs.existsSync(dbPath)) return null; try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); + db = new Database(dbPath, { readonly, fileMustExist: true }); // WAL is required for read-consistency while the C proxy writes. db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 2000'); diff --git a/dashboard/lib/fmt.js b/dashboard/lib/fmt.js new file mode 100644 index 0000000..2d54c0d --- /dev/null +++ b/dashboard/lib/fmt.js @@ -0,0 +1,43 @@ +/* Formatting helpers shared across views. server.js drops them onto + * res.locals so every EJS render sees them without a per-view + * <% const fmt... %> preamble. */ + +export const fmtN = (n) => + new Intl.NumberFormat('en-US').format(n || 0); + +export const fmtF = (n, d = 4) => + (typeof n === 'number' ? n.toFixed(d) : '—'); + +export const fmtTs = (t) => + t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—'; + +export const ago = (t) => { + if (!t) return '—'; + const s = Math.max(0, Math.floor(Date.now() / 1000) - t); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.floor(s / 60) + 'm ago'; + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; + return Math.floor(s / 86400) + 'd ago'; +}; + +export const fmtSats = (sats) => { + if (!sats) return '0 sats'; + const btc = sats / 1e8; + if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)'; + return fmtN(sats) + ' sats'; +}; + +/* Adaptive-precision percent — keeps small miners visible instead of + * rendering them as 0.00%. Used by the public overview. */ +export const fmtPct = (p) => { + if (p == null || !isFinite(p)) return '—'; + if (p === 0) return '0%'; + const abs = Math.abs(p); + if (abs >= 1) return p.toFixed(2) + '%'; + if (abs >= 0.01) return p.toFixed(3) + '%'; + if (abs >= 0.0001) return p.toFixed(4) + '%'; + return p.toPrecision(2) + '%'; +}; + +/* Convenience bundle for res.locals middleware. */ +export const all = { fmtN, fmtF, fmtTs, ago, fmtSats, fmtPct }; diff --git a/dashboard/public/style.css b/dashboard/public/style.css index 2986e3a..b636d1e 100644 --- a/dashboard/public/style.css +++ b/dashboard/public/style.css @@ -31,10 +31,46 @@ a:hover { text-decoration: underline; } display: flex; align-items: baseline; gap: 16px; + flex-wrap: wrap; } -.brand { font-weight: 700; font-size: 18px; color: var(--fg); } +.top-admin { border-bottom-color: #c66; background: #c6660d; color: #fff; } +.top-admin .brand { color: #fff; } +.top-admin a { color: #fff; } +.top-admin .tag-admin { color: #fff; background: #922; padding: 2px 8px; border-radius: 3px; } +.brand { font-weight: 700; font-size: 18px; color: var(--fg); text-decoration: none; } .tag { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; } +.topnav { + display: flex; + gap: 14px; + margin-left: 8px; + align-items: baseline; +} +.topnav a { + color: var(--muted); + text-decoration: none; + font-size: 14px; + padding: 2px 0; + border-bottom: 2px solid transparent; +} +.topnav a:hover { color: var(--fg); } +.topnav a.active { color: var(--fg); border-bottom-color: currentColor; font-weight: 600; } +.top-admin .topnav a { color: #eee; } +.top-admin .topnav a.active { color: #fff; border-bottom-color: #fff; } + +.topsearch { margin-left: auto; display: flex; gap: 4px; } +.topsearch input { padding: 4px 8px; font-size: 13px; border: 1px solid var(--border); border-radius: 3px; min-width: 240px; } +.topsearch button { padding: 4px 10px; font-size: 13px; } + +.logout { margin-left: auto; font-size: 13px; opacity: 0.85; } +.logout:hover { opacity: 1; } + +.flash { border-left: 4px solid; margin-top: 12px; } +.flash-ok { border-left-color: #5b8; } +.flash-ok h2 { color: #5b8; } +.flash-err { border-left-color: #c66; } +.flash-err h2 { color: #c66; } + .wrap { max-width: 1100px; margin: 0 auto; diff --git a/dashboard/server.js b/dashboard/server.js index 91b9c46..478a7be 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -1,48 +1,95 @@ +/* simplepool dashboard — two surfaces, one process: + * + * - Public read-only: /, /worker/:name, /blocks, /api/* + * - Admin: /admin/* (Basic auth on every route) + * + * Admin routes live in lib/admin-router.js; server.js is the assembly: + * open DB handles, mount routes, wire middleware. + */ + import express from 'express'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { renderFile } from 'ejs'; import { openDb } from './lib/db.js'; +import { openAdminDb } from './lib/db-admin.js'; import * as stats from './lib/stats.js'; -import * as admin from './lib/admin.js'; /* PPS ADMIN PATCH */ +import * as fmt from './lib/fmt.js'; +import { createAdminRouter } from './lib/admin-router.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const PORT = parseInt(process.env.PORT || '8081', 10); -// Default to the live SQLite database. WAL mode + readonly handle is safe -// to point at the file the proxy is actively writing to. The snapshot path -// (data/shares.snapshot.db) is still supported for users who run the cron -// `.backup` job documented in the README — set PROXY_DB_PATH to override. -const DB_PATH = process.env.PROXY_DB_PATH || '../data/shares.db'; +const PORT = parseInt(process.env.PORT || '8081', 10); +const DB_PATH = process.env.PROXY_DB_PATH || '../data/shares.db'; const app = express(); app.engine('ejs', renderFile); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.disable('x-powered-by'); + +/* --- security headers on every response --------------------------------- */ +app.use((_req, res, next) => { + /* No cookies here yet, but if any land later they must be same-origin. */ + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'same-origin'); + res.setHeader('Permissions-Policy', 'interest-cohort=()'); + next(); +}); + +/* --- fmt helpers on every render ---------------------------------------- */ +app.use((_req, res, next) => { + Object.assign(res.locals, fmt.all); + next(); +}); + app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '1h' })); -const db = openDb(path.resolve(__dirname, DB_PATH)); +/* Two handles: read-only for public + admin read routes, writable one + * confined to admin write actions (lives inside admin router). */ +const db = openDb(path.resolve(__dirname, DB_PATH)); +const dbRw = openAdminDb(path.resolve(__dirname, DB_PATH)); -/* Shown on the public "Connect a miner" card. Env-driven so the shipped - * template doesn't hardcode any particular deployment's host. */ +/* --- public-side config ------------------------------------------------- */ const PUBLIC_STRATUM_URL = process.env.PUBLIC_STRATUM_URL || 'stratum+tcp://:3334'; -/* Read once here so the public per-worker page can render the audit - * cross-check for the miner without needing admin auth. The admin - * patch block below also reads this — the constant is shared. */ -const PPS_SATS_PER_DIFF = parseFloat(process.env.POOL_PPS_SATS_PER_DIFF || '1000'); - -app.get('/', (req, res) => { - const ov = stats.overview(db); - const lb = stats.leaderboard(db); +const PPS_SATS_PER_DIFF = parseFloat(process.env.POOL_PPS_SATS_PER_DIFF || '1000'); + +/* --- admin-side config -------------------------------------------------- */ +const ADMIN_USER = process.env.ADMIN_USER || ''; +const ADMIN_PASS = process.env.ADMIN_PASSWORD || ''; +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 || ''; +const ENFORCER_GRPC_ADDR = process.env.ENFORCER_GRPC_ADDR || '127.0.0.1:50051'; +const GRPCURL_BIN = process.env.GRPCURL_BIN || 'grpcurl'; +const THUNDER_SIDECHAIN_ID = parseInt(process.env.THUNDER_SIDECHAIN_ID || '9', 10); + +function requireAdminAuth(req, res, next) { + if (!ADMIN_USER || !ADMIN_PASS) { + return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD'); + } + const h = req.headers.authorization || ''; + if (h.startsWith('Basic ')) { + const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':'); + if (u === ADMIN_USER && p === ADMIN_PASS) return next(); + } + res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin"'); + return res.status(401).send('unauthorised'); +} + +/* ================================ PUBLIC ================================ */ + +app.get('/', (_req, res) => { + const ov = stats.overview(db); + const lb = stats.leaderboard(db); const lbAddr = stats.leaderboardByAddress(db); const blocks = stats.recentBlocks(db, 5); - const node = stats.nodeStatus(db); + const node = stats.nodeStatus(db); res.render('index', { ov, lb, lbAddr, blocks, node, - stratumUrl: PUBLIC_STRATUM_URL, + stratumUrl: PUBLIC_STRATUM_URL, fmtHashrate: stats.fmtHashrate, - fmtBtc: stats.fmtBtc, - fmtPct: stats.fmtPct, + fmtBtc: stats.fmtBtc, }); }); @@ -52,10 +99,19 @@ app.get('/worker/:name', (req, res) => { res.render('worker', { ...w, name: req.params.name, fmtHashrate: stats.fmtHashrate, - fmtPct: stats.fmtPct, }); }); +/* Search box on the public nav points here (GET /worker-lookup?name=…). + * Strip whitespace, cap length, redirect to the canonical URL — or 404 + * quietly if empty. */ +app.get('/worker-lookup', (req, res) => { + const raw = typeof req.query.name === 'string' ? req.query.name.trim() : ''; + if (!raw) return res.redirect(302, '/'); + const name = raw.slice(0, 200); + return res.redirect(302, '/worker/' + encodeURIComponent(name)); +}); + app.get('/blocks', (req, res) => { const beforeTs = req.query.before ? Number(req.query.before) : null; const page = stats.allBlocks(db, { limit: 50, beforeTs }); @@ -66,10 +122,11 @@ app.get('/blocks', (req, res) => { }); }); -app.get('/api/overview', (req, res) => res.json(stats.overview(db))); -app.get('/api/node', (req, res) => res.json(stats.nodeStatus(db) || {})); -app.get('/api/leaderboard', (req, res) => res.json(stats.leaderboard(db))); -app.get('/api/leaderboard/by-address', (req, res) => res.json(stats.leaderboardByAddress(db))); +/* --- JSON API (unchanged) ---------------------------------------------- */ +app.get('/api/overview', (_req, res) => res.json(stats.overview(db))); +app.get('/api/node', (_req, res) => res.json(stats.nodeStatus(db) || {})); +app.get('/api/leaderboard', (_req, res) => res.json(stats.leaderboard(db))); +app.get('/api/leaderboard/by-address',(_req, res) => res.json(stats.leaderboardByAddress(db))); app.get('/api/worker/:name', (req, res) => { const w = stats.worker(db, req.params.name); if (!w.worker) return res.status(404).json({ error: 'unknown worker' }); @@ -80,96 +137,27 @@ app.get('/api/blocks', (req, res) => { const limit = req.query.limit ? Number(req.query.limit) : 50; res.json(stats.allBlocks(db, { limit, beforeTs })); }); -app.get('/healthz', (req, res) => res.json({ ok: true, db_ready: db.ready() })); - - -/* PPS ADMIN PATCH — pool operator view. Basic auth via ADMIN_USER / - * ADMIN_PASSWORD env vars; both required or the routes 503 (so we - * fail closed if someone forgets to set them). */ -const ADMIN_USER = process.env.ADMIN_USER || ''; -const ADMIN_PASS = process.env.ADMIN_PASSWORD || ''; -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'; -/* PPS_SATS_PER_DIFF is declared at the top of the file so both the - * public /worker/:name view and the admin routes can share it. */ - -function requireAdminAuth(req, res, next) { - if (!ADMIN_USER || !ADMIN_PASS) { - return res.status(503).send('admin disabled — set ADMIN_USER + ADMIN_PASSWORD'); - } - const h = req.headers.authorization || ''; - if (h.startsWith('Basic ')) { - const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':'); - if (u === ADMIN_USER && p === ADMIN_PASS) return next(); - } - res.setHeader('WWW-Authenticate', 'Basic realm="simplepool admin"'); - return res.status(401).send('unauthorised'); -} - -async function adminSummary() { - const [reserve, totals, workers, inFlight, payouts, deposits, blocks] = await Promise.all([ - admin.thunderBalance(THUNDER_RPC_URL), - Promise.resolve(admin.poolTotals(db)), - Promise.resolve(admin.perWorkerBalances(db)), - Promise.resolve(admin.inFlight(db)), - Promise.resolve(admin.recentPayouts(db, 25)), - Promise.resolve(admin.recentDeposits(db, 25)), - Promise.resolve(admin.recentBlocksFound(db, 15)), - ]); - return { - reserve, reserveAddress: RESERVE_ADDRESS, - totals, workers, inFlight, - payouts, deposits, blocks, - }; -} - -app.get('/admin', requireAdminAuth, async (req, res) => { - try { - const data = await adminSummary(); - res.render('admin', data); - } catch (e) { - res.status(500).send('admin: ' + e.message); - } -}); - -app.get('/api/admin/summary', requireAdminAuth, async (req, res) => { - try { - res.json(await adminSummary()); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); - -/* Per-worker audit: "why is my balance N sats?" answered end-to-end. */ -function workerAuditFor(workerId) { - const audit = admin.workerAudit(db, workerId, { rate: PPS_SATS_PER_DIFF }); - if (!audit) return null; - audit.payouts = admin.payoutsForWorker(db, workerId, 100); - return audit; -} - -app.get('/admin/worker/:id', requireAdminAuth, (req, res) => { - try { - const audit = workerAuditFor(parseInt(req.params.id, 10)); - if (!audit) return res.status(404).render('404', { what: 'worker' }); - res.render('admin-worker', { audit }); - } catch (e) { - res.status(500).send('admin: ' + e.message); - } -}); - -app.get('/api/admin/worker/:id', requireAdminAuth, (req, res) => { - try { - const audit = workerAuditFor(parseInt(req.params.id, 10)); - if (!audit) return res.status(404).json({ error: 'unknown worker id' }); - res.json(audit); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); -/* END PPS ADMIN PATCH */ - -app.use((req, res) => res.status(404).render('404', { what: 'page' })); +app.get('/healthz', (_req, res) => res.json({ ok: true, db_ready: db.ready() })); + +/* ================================ ADMIN ================================ */ + +app.use('/admin', + requireAdminAuth, + createAdminRouter({ + db, + dbRw, + THUNDER_RPC_URL, + PAYOUT_ADMIN_URL, + ENFORCER_GRPC_ADDR, + GRPCURL_BIN, + THUNDER_SIDECHAIN_ID, + RESERVE_ADDRESS, + PPS_SATS_PER_DIFF, + })); + +/* ================================ 404 =================================== */ + +app.use((_req, res) => res.status(404).render('404', { what: 'page' })); app.listen(PORT, () => { console.log(`simplepool dashboard on :${PORT} (db: ${db.path})`); diff --git a/dashboard/views/admin-deposits.ejs b/dashboard/views/admin-deposits.ejs new file mode 100644 index 0000000..9eacf52 --- /dev/null +++ b/dashboard/views/admin-deposits.ejs @@ -0,0 +1,95 @@ + + + +<%- include('partial/head', { title: 'admin · deposits · simplepool', refresh: 30 }) %> + + +<%- include('partial/nav-admin', { active: 'deposits' }) %> +
+ <%- include('partial/flash', { flash }) %> + +
+

Deposits (mainchain → Thunder)

+

+ The pool operator batches accumulated BTC into Thunder here. + Each deposit spends enforcer-wallet UTXOs into a BIP300 + sidechain-#<%= reserveSidechainId %> tx; the enforcer signs and + broadcasts, the mainchain confirms, and Thunder eventually + processes the deposit into a spendable UTXO the payout worker + can draw from. +

+
+ +
+

Fund the reserve

+ <% if (enforcer.ok) { %> +

+ Enforcer wallet spendable: + <%= fmtSats(enforcer.confirmed_sats) %> + <% if (enforcer.pending_sats > 0) { %> +  (+<%= fmtSats(enforcer.pending_sats) %> pending) + <% } %> +

+ <% } else { %> +

+ Enforcer gRPC unreachable: <%= enforcer.error %> +

+ <% } %> + +
+ + + + + + + + + + + + + + +
+
+ +
+

History

+ <% if (deposits.length === 0) { %> +

No deposits recorded.

+ <% } else { %> + + + + + + + <% deposits.forEach(d => { %> + + + + + + + + + <% }) %> + +
WhenAmountFeeThunder recipientMainchain txidCtip seq
<%= ago(d.ts) %><%= fmtSats(d.sats_deposited) %><%= fmtSats(d.fee_sats) %><%= d.thunder_recipient %><%= d.btc_txid.slice(0,16) %>…<%= d.btc_txid.slice(-6) %><%= d.ctip_seq_before %> → <%= d.ctip_seq_after %>
+ <% } %> +
+
+ + + diff --git a/dashboard/views/admin-overview.ejs b/dashboard/views/admin-overview.ejs new file mode 100644 index 0000000..9c33f91 --- /dev/null +++ b/dashboard/views/admin-overview.ejs @@ -0,0 +1,119 @@ + + + +<%- include('partial/head', { title: 'admin · overview · simplepool', refresh: 15 }) %> + + +<%- include('partial/nav-admin', { active: 'overview' }) %> +
+ <%- include('partial/flash', { flash }) %> + +
+

Overview

+

+ Live balances and pool-wide totals. Use the nav above for + per-page detail (workers, deposits, payouts) and the tools tab + for one-off ops like nudging Thunder or clearing stuck txs. +

+
+ +
+

Thunder reserve

+ <% if (reserve.ok) { %> +
+
<%= fmtSats(reserve.available_sats) %>
+
<%= fmtSats(reserve.total_sats) %>
+
+

<%= reserveAddress %>

+ <% } else { %> +

Thunder RPC unreachable: <%= reserve.error %>

+

<%= reserveAddress %>

+ <% } %> +
+ +
+

Pool BTC (enforcer wallet)

+

+ Spendable balance the Deposit BTC → Thunder + action can draw from. Fund it by sending BTC to a fresh + enforcer address from your operator wallet. +

+ <% if (enforcer.ok) { %> +
+
<%= fmtSats(enforcer.confirmed_sats) %>
+
<%= fmtSats(enforcer.pending_sats) %>
+
<%= enforcer.has_synced ? '✓' : '✗' %>
+
+ <% } else { %> +

Enforcer gRPC unreachable: <%= enforcer.error %>

+ <% } %> +

+ Ready to deposit? Go to Deposits → +

+
+ +
+

PPS ledger totals

+
+
<%= fmtSats(totals.owed) %>
+
<%= fmtSats(totals.accrued) %>
+
<%= fmtSats(totals.paid) %>
+
<%= fmtN(totals.workers) %>
+
+ <% if (reserve.ok && totals.owed > reserve.available_sats) { %> +

+ ⚠ Reserve is short by + <%= fmtSats(totals.owed - reserve.available_sats) %>. + The payout worker will keep skipping ticks until this is closed. + Head to Deposits to top up the reserve. +

+ <% } %> +

+ Per-worker breakdown → +

+
+ +
+

Recent blocks

+ <% if (blocks.length === 0) { %> +

No blocks recorded yet.

+ <% } else { %> +

+ Every block's coinbase pays the pool's pool_btc_address + (pps-classic). Operator batches accumulated BTC into Thunder via the + deposits page. +

+ + + + + + + <% blocks.forEach(b => { %> + + + + + + + + + <% }) %> + +
WhenHeightBlock hashFinderRewardFee
<%= ago(b.ts) %><%= fmtN(b.height) %><%= b.hash.slice(0,16) %>…<%= b.hash.slice(-6) %> + <% if (b.finder_id) { %> + <%= b.finder_name || b.finder_address || 'unknown' %> + <% } else { %> + (unattributed) + <% } %> + <%= fmtSats(b.reward_sats) %><%= fmtSats(b.fee_sats) %>
+ <% } %> +
+
+ + + diff --git a/dashboard/views/admin-payouts.ejs b/dashboard/views/admin-payouts.ejs new file mode 100644 index 0000000..f0fa663 --- /dev/null +++ b/dashboard/views/admin-payouts.ejs @@ -0,0 +1,103 @@ + + + +<%- include('partial/head', { title: 'admin · payouts · simplepool', refresh: 15 }) %> + + +<%- include('partial/nav-admin', { active: 'payouts' }) %> +
+ <%- include('partial/flash', { flash }) %> + +
+

Payouts

+

+ The Thunder payout worker ticks every ~30s and drains any + worker whose owed balance ≥ min-payout. Trigger a cycle + manually below if you don't want to wait. +

+
+ +
+

Trigger cycle now

+
+ + +

+ Ask the payout worker to run one cycle immediately. + Safe to fire multiple times — the worker holds an + in-flight row for anything it touched. + <% if (!payoutAdminConfigured) { %> +
+ Not configured — set PAYOUT_ADMIN_URL on the dashboard service. + + <% } %> +

+ +
+
+ +
+

In-flight

+ <% if (inFlight.length === 0) { %> +

Nothing in flight — clean.

+ <% } else { %> +

+ Rows here need operator attention. Empty txid + means the Thunder broadcast may not have gone out; a set + txid means the broadcast succeeded but the DB + finalize crashed. Never auto-resolved. +

+ + + + + + + <% inFlight.forEach(r => { %> + + + + + + + + + <% }) %> + +
#WorkerAmountAgetxidState
<%= r.id %><%= r.worker_name %><%= fmtSats(r.sats) %><%= ago(r.started_at) %><%= r.txid || '—' %><%= r.txid ? 'broadcast, finalize pending' : 'unbroadcast' %>
+ <% } %> +
+ +
+

Recent payouts (Thunder)

+ <% if (payouts.length === 0) { %> +

No successful payouts yet.

+ <% } else { %> + + + + + + + <% payouts.forEach(p => { %> + + + + + + + + + <% }) %> + +
WhenWorkerAmountFeeThunder txidNote
<%= ago(p.paid_at) %><%= p.worker_name %><%= fmtSats(p.sats) %><%= fmtSats(p.fee_sats) %><%= p.txid.slice(0,16) %>…<%= p.txid.slice(-6) %><%= p.note || '' %>
+ <% } %> +
+
+ + + diff --git a/dashboard/views/admin-tools.ejs b/dashboard/views/admin-tools.ejs new file mode 100644 index 0000000..322cd6d --- /dev/null +++ b/dashboard/views/admin-tools.ejs @@ -0,0 +1,60 @@ + + + +<%- include('partial/head', { title: 'admin · tools · simplepool' }) %> + + +<%- include('partial/nav-admin', { active: 'tools' }) %> +
+ <%- include('partial/flash', { flash }) %> + +
+

Tools

+

+ One-off ops on Thunder. All CSRF-gated; each redirects back + here with a green/red banner. None of these move money — + payouts and deposits have their own pages. +

+
+ +
+

Nudge Thunder mine

+
+ + +

+ Call mine on the Thunder RPC once. Thunder + builds a fresh BMM commit if it can; harmless if it + can't (the "already exists" error is fine). Complements + the systemd thunder-mine.timer. +

+ +
+
+ +
+

Remove stale Thunder tx

+
+ + +

+ Drop a stuck tx from Thunder's mempool. Use when a payout + keeps failing with utxo double spent because + a phantom tx is holding the reserve UTXO. Thunder's + mempool is persisted, so a daemon restart doesn't clear + these — this endpoint does. +

+ + + +
+
+
+ + + diff --git a/dashboard/views/admin-worker.ejs b/dashboard/views/admin-worker.ejs index 91509bf..bf57f7c 100644 --- a/dashboard/views/admin-worker.ejs +++ b/dashboard/views/admin-worker.ejs @@ -1,21 +1,4 @@ <% -const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0); -const fmtF = (n, d = 4) => (typeof n === 'number' ? n.toFixed(d) : '—'); -const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—'; -const ago = (t) => { - if (!t) return '—'; - const s = Math.max(0, Math.floor(Date.now() / 1000) - t); - if (s < 60) return s + 's ago'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; -}; -const fmtSats = (sats) => { - if (!sats) return '0 sats'; - const btc = sats / 1e8; - if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)'; - return fmtN(sats) + ' sats'; -}; /* Precompute a running_accrued column for the recent-shares table. * The rows come newest-first, so we walk in reverse to accumulate. */ const recentAsc = [...audit.recent].reverse(); @@ -30,19 +13,11 @@ const drift = audit.totals.accrued_computed - naiveAccrued; - - - - audit · <%= audit.worker.name %> · simplepool - +<%- include('partial/head', { title: `audit · ${audit.worker.name} · simplepool`, refresh: 30 }) %> -
- simplepool - worker audit - ← back to admin -
-
+<%- include('partial/nav-admin', { active: 'worker' }) %> +

Why is this balance what it is?

diff --git a/dashboard/views/admin-workers.ejs b/dashboard/views/admin-workers.ejs new file mode 100644 index 0000000..fc8f6bb --- /dev/null +++ b/dashboard/views/admin-workers.ejs @@ -0,0 +1,59 @@ + + + +<%- include('partial/head', { title: 'admin · workers · simplepool', refresh: 30 }) %> + + +<%- include('partial/nav-admin', { active: 'workers' }) %> +

+ <%- include('partial/flash', { flash }) %> + +
+

Per-worker balances

+

+ Every worker with a non-zero PPS ledger, sorted by + outstanding first. Click audit → for the byte-level + share breakdown the miner sees on their public page too. +

+
+ +
+ <% if (workers.length === 0) { %> +

No workers have a PPS balance yet.

+ <% } else { %> + + + + + + + + + + + + + + <% workers.forEach(w => { %> + + + + + + + + + + <% }) %> + +
WorkerThunder addressOwedAccruedPaidUpdated
<%= w.worker_name %><%= w.thunder_address %><%= fmtSats(w.owed) %><%= fmtSats(w.accrued) %><%= fmtSats(w.paid) %><%= ago(w.last_updated) %>audit →
+ <% } %> +
+
+
+ auto-refresh 30s + · + admin · workers +
+ + diff --git a/dashboard/views/admin.ejs b/dashboard/views/admin.ejs deleted file mode 100644 index 2cd0976..0000000 --- a/dashboard/views/admin.ejs +++ /dev/null @@ -1,262 +0,0 @@ -<% -const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0); -const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—'; -const ago = (t) => { - if (!t) return '—'; - const s = Math.max(0, Math.floor(Date.now() / 1000) - t); - if (s < 60) return s + 's ago'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; -}; -const fmtSats = (sats) => { - if (!sats) return '0 sats'; - const btc = sats / 1e8; - if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)'; - return fmtN(sats) + ' sats'; -}; -%> - - - - - - - admin · simplepool - - - -
- simplepool - pool operator -
-
-
-

Pool operator view

-

- Read-only. Reserve balance is a live probe of Thunder's - JSON-RPC; everything else is the same SQLite the pool - writes. Reconciliation is manual — see - payout/README.md for the runbook. -

-
- -
-

Thunder reserve

- <% if (reserve.ok) { %> -
-
<%= fmtSats(reserve.available_sats) %>
-
<%= fmtSats(reserve.total_sats) %>
-
-

<%= reserveAddress %>

- <% } else { %> -

Thunder RPC unreachable: <%= reserve.error %>

-

<%= reserveAddress %>

- <% } %> -
- -
-

PPS ledger totals

-
-
<%= fmtSats(totals.owed) %>
-
<%= fmtSats(totals.accrued) %>
-
<%= fmtSats(totals.paid) %>
-
<%= fmtN(totals.workers) %>
-
- <% if (reserve.ok && totals.owed > reserve.available_sats) { %> -

- ⚠ Reserve is short by - <%= fmtSats(totals.owed - reserve.available_sats) %>. - The payout worker will keep skipping ticks until this is closed - (deposit BTC into the reserve via the two-step deposit service). -

- <% } %> -
- -
-

Per-worker balances

- <% if (workers.length === 0) { %> -

No workers have a PPS balance yet.

- <% } else { %> - - - - - - - - - - - - - - <% workers.forEach(w => { %> - - - - - - - - - - <% }) %> - -
WorkerThunder addressOwedAccruedPaidUpdated
<%= w.worker_name %><%= w.thunder_address %><%= fmtSats(w.owed) %><%= fmtSats(w.accrued) %><%= fmtSats(w.paid) %><%= ago(w.last_updated) %>audit →
- <% } %> -
- -
-

In-flight payouts

- <% if (inFlight.length === 0) { %> -

Nothing in flight — clean.

- <% } else { %> -

- Rows here need operator attention. Empty txid - means the Thunder broadcast may not have gone out; a - set txid means the broadcast succeeded but the - DB finalize crashed. Never auto-resolved. -

- - - - - - - - - - - - - <% inFlight.forEach(r => { %> - - - - - - - - - <% }) %> - -
#WorkerAmountAgetxidState
<%= r.id %><%= r.worker_name %><%= fmtSats(r.sats) %><%= ago(r.started_at) %><%= r.txid || '—' %><%= r.txid ? 'broadcast, finalize pending' : 'unbroadcast' %>
- <% } %> -
- -
-

Recent payouts (Thunder)

- <% if (payouts.length === 0) { %> -

No successful payouts yet. Populates once the payout worker sends a Thunder tx (or after a manual reconcile).

- <% } else { %> - - - - - - - - - - - - - <% payouts.forEach(p => { %> - - - - - - - - - <% }) %> - -
WhenWorkerAmountFeeThunder txidNote
<%= ago(p.paid_at) %><%= p.worker_name %><%= fmtSats(p.sats) %><%= fmtSats(p.fee_sats) %><%= p.txid.slice(0,16) %>…<%= p.txid.slice(-6) %><%= p.note || '' %>
- <% } %> -
- -
-

Deposits (mainchain → Thunder)

- <% if (deposits.length === 0) { %> -

No deposits recorded. See OPERATOR_GUIDE.md or use scripts/log-deposit.sh after issuing a CreateDepositTransaction.

- <% } else { %> - - - - - - - - - - - - - <% deposits.forEach(d => { %> - - - - - - - - - <% }) %> - -
WhenAmountFeeThunder recipientMainchain txidCtip seq
<%= ago(d.ts) %><%= fmtSats(d.sats_deposited) %><%= fmtSats(d.fee_sats) %><%= d.thunder_recipient %><%= d.btc_txid.slice(0,16) %>…<%= d.btc_txid.slice(-6) %><%= d.ctip_seq_before %> → <%= d.ctip_seq_after %>
- <% } %> -
- -
-

Recent blocks (coinbase → pool BTC wallet)

- <% if (blocks.length === 0) { %> -

No blocks recorded yet.

- <% } else { %> -

- Every block's coinbase pays the pool's pool_btc_address (in pps-classic - mode) for reward. Operator batches accumulated BTC into Thunder via the - deposits above. To see the coinbase txid on-chain: - bitcoin-cli getblock <hash> 2. -

- - - - - - - - - - - - - <% blocks.forEach(b => { %> - - - - - - - - - <% }) %> - -
WhenHeightBlock hashFinderRewardFee
<%= ago(b.ts) %><%= fmtN(b.height) %><%= b.hash.slice(0,16) %>…<%= b.hash.slice(-6) %> - <% if (b.finder_id) { %> - <%= b.finder_name || b.finder_address || 'unknown' %> - <% } else { %> - (unattributed) - <% } %> - <%= fmtSats(b.reward_sats) %><%= fmtSats(b.fee_sats) %>
- <% } %> -
-
-
- read-only · auto-refresh 15s - · - pool operator view -
- - diff --git a/dashboard/views/blocks.ejs b/dashboard/views/blocks.ejs index ee669bf..ecea175 100644 --- a/dashboard/views/blocks.ejs +++ b/dashboard/views/blocks.ejs @@ -1,32 +1,15 @@ <% -const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0); -const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—'; -const ago = (t) => { - if (!t) return '—'; - const s = Math.max(0, Math.floor(Date.now() / 1000) - t); - if (s < 60) return s + 's ago'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; -}; const shortHash = (h) => h ? (h.slice(0, 10) + '…' + h.slice(-8)) : '—'; const shortAddr = (a) => a ? (a.length > 18 ? a.slice(0, 10) + '…' + a.slice(-6) : a) : '—'; %> - - - - blocks · simplepool - +<%- include('partial/head', { title: 'blocks · simplepool', refresh: 60 }) %> -
- simplepool - blocks found -
-
+<%- include('partial/nav-public', { active: 'blocks' }) %> +

Blocks found by the pool

<% if (rows.length === 0) { %> diff --git a/dashboard/views/index.ejs b/dashboard/views/index.ejs index 9090444..905ec15 100644 --- a/dashboard/views/index.ejs +++ b/dashboard/views/index.ejs @@ -1,30 +1,11 @@ -<% -const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0); -const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—'; -const ago = (t) => { - if (!t) return '—'; - const s = Math.max(0, Math.floor(Date.now() / 1000) - t); - if (s < 60) return s + 's ago'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; -}; -%> - - - - overview · simplepool - +<%- include('partial/head', { title: 'overview · simplepool', refresh: 15 }) %> -
- simplepool - stratum stats -
-
+<%- include('partial/nav-public', { active: 'overview' }) %> +
<% if (!ov.db_ready) { %>

no data yet

diff --git a/dashboard/views/partial/flash.ejs b/dashboard/views/partial/flash.ejs new file mode 100644 index 0000000..8ed7985 --- /dev/null +++ b/dashboard/views/partial/flash.ejs @@ -0,0 +1,9 @@ +<%# Green/red flash banner. Locals: flash ({ok, msg, detail?}) or null. %> +<% if (typeof flash !== 'undefined' && flash) { %> +
+

<%= flash.ok ? '✓' : '✗' %> <%= flash.msg %>

+ <% if (flash.detail) { %> +

<%= flash.detail %>

+ <% } %> +
+<% } %> diff --git a/dashboard/views/partial/head.ejs b/dashboard/views/partial/head.ejs new file mode 100644 index 0000000..ec95724 --- /dev/null +++ b/dashboard/views/partial/head.ejs @@ -0,0 +1,9 @@ +<%# Shared . Locals: title (required), refresh (optional, seconds). %> + + + +<% if (typeof refresh !== 'undefined' && refresh) { %> + +<% } %> +<%= (typeof title !== 'undefined' && title) ? title : 'simplepool' %> + diff --git a/dashboard/views/partial/nav-admin.ejs b/dashboard/views/partial/nav-admin.ejs new file mode 100644 index 0000000..f5c658c --- /dev/null +++ b/dashboard/views/partial/nav-admin.ejs @@ -0,0 +1,15 @@ +<%# Top nav for /admin/*. Locals: active ('overview'|'workers'|'deposits'|'payouts'|'tools'|'worker'). %> +<% const _aactive = (typeof active === 'string') ? active : ''; %> +<% function _acls(name) { return _aactive === name ? 'active' : ''; } %> +
+ simplepool + admin + + Log out +
diff --git a/dashboard/views/partial/nav-public.ejs b/dashboard/views/partial/nav-public.ejs new file mode 100644 index 0000000..f7ff91d --- /dev/null +++ b/dashboard/views/partial/nav-public.ejs @@ -0,0 +1,19 @@ +<%# Top nav for the public (read-only) dashboard. Locals: active ('overview'|'blocks'|'worker'). %> +<% const _pactive = (typeof active === 'string') ? active : ''; %> +
+ simplepool + stratum stats + + +
diff --git a/dashboard/views/worker.ejs b/dashboard/views/worker.ejs index ec72d1a..4cb6680 100644 --- a/dashboard/views/worker.ejs +++ b/dashboard/views/worker.ejs @@ -1,15 +1,4 @@ <% -const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0); -const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '—'; -const ago = (t) => { - if (!t) return '—'; - const s = Math.max(0, Math.floor(Date.now() / 1000) - t); - if (s < 60) return s + 's ago'; - if (s < 3600) return Math.floor(s / 60) + 'm ago'; - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; - return Math.floor(s / 86400) + 'd ago'; -}; - // SVG sparkline for the hashrate buckets. const W = 800, H = 120, PAD = 4; const vals = buckets.map(b => b.hashrate); @@ -27,26 +16,11 @@ if (n > 1) { - - - - <%= name %> · simplepool - +<%- include('partial/head', { title: `${name} · simplepool`, refresh: 15 }) %> -
- simplepool - worker -
-
- <% - const fmtSats = (sats) => { - if (!sats) return '0 sats'; - const btc = sats / 1e8; - if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)'; - return fmtN(sats) + ' sats'; - }; - %> +<%- include('partial/nav-public', { active: 'worker' }) %> +

<%= worker.name %>

<% if (worker.payout_address) { %> diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 32733b8..d523920 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -36,3 +36,21 @@ PAYOUT_MIN_SATS=10000 PAYOUT_INTERVAL_MS=30000 # Set to 1 to compute and log payouts without broadcasting them (safe first run). PAYOUT_DRY_RUN=0 + +# --- Admin write actions (POST buttons on /admin) --- +# Payout worker's admin HTTP endpoint. In the compose stack this is +# published on the host at 127.0.0.1:9080 and reached by the dashboard +# container via host.docker.internal:9080. +PAYOUT_ADMIN_URL=http://host.docker.internal:9080 +# Where the payout container binds its admin listener. 0.0.0.0 is safe +# because the host publish rule (in docker-compose.yml) already restricts +# the host-facing side to 127.0.0.1. +PAYOUT_ADMIN_BIND=0.0.0.0 +PAYOUT_ADMIN_PORT=9080 + +# bip300301_enforcer gRPC address the deposit action targets. Uses +# grpcurl (baked into the dashboard image) with reflection, so no .proto +# files needed. +ENFORCER_GRPC_ADDR=host.docker.internal:50051 +GRPCURL_BIN=/usr/local/bin/grpcurl +THUNDER_SIDECHAIN_ID=9 diff --git a/deploy/docker/Dockerfile.dashboard b/deploy/docker/Dockerfile.dashboard index d0bfed7..2b8aa43 100644 --- a/deploy/docker/Dockerfile.dashboard +++ b/deploy/docker/Dockerfile.dashboard @@ -16,8 +16,24 @@ RUN npm ci --omit=dev # ----------------------------------------------------------------------------- FROM node:20-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends tini \ - && rm -rf /var/lib/apt/lists/* +# grpcurl is used by the "Deposit BTC → Thunder" admin action — one +# static Go binary, uses gRPC reflection so no .proto files needed. +# Fetched from GitHub releases (Debian's apt version lags). +ARG TARGETARCH +ARG GRPCURL_VERSION=1.9.1 +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && case "$TARGETARCH" in \ + amd64) GRPCURL_ARCH=x86_64 ;; \ + arm64) GRPCURL_ARCH=arm64 ;; \ + *) echo "unsupported arch: $TARGETARCH"; exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/grpcurl.tgz \ + "https://github.com/fullstorydev/grpcurl/releases/download/v${GRPCURL_VERSION}/grpcurl_${GRPCURL_VERSION}_linux_${GRPCURL_ARCH}.tar.gz" \ + && tar -xzf /tmp/grpcurl.tgz -C /usr/local/bin grpcurl \ + && chmod +x /usr/local/bin/grpcurl \ + && rm /tmp/grpcurl.tgz WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY dashboard/ ./ diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index e04438d..e78e22e 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -57,8 +57,15 @@ services: POOL_PPS_SATS_PER_DIFF: ${POOL_PPS_SATS_PER_DIFF:-1000} ADMIN_USER: ${ADMIN_USER:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + # --- Write-action wiring --- + PAYOUT_ADMIN_URL: ${PAYOUT_ADMIN_URL:-http://host.docker.internal:9080} + ENFORCER_GRPC_ADDR: ${ENFORCER_GRPC_ADDR:-host.docker.internal:50051} + GRPCURL_BIN: ${GRPCURL_BIN:-/usr/local/bin/grpcurl} + THUNDER_SIDECHAIN_ID: ${THUNDER_SIDECHAIN_ID:-9} volumes: - - ../../data:/data:ro + # Dashboard now writes to the deposits table when the operator + # submits a deposit via /admin. Mount RW. + - ../../data:/data extra_hosts: # Linux: makes host.docker.internal resolve to the host gateway. # No-op on macOS/Windows where docker adds it natively. @@ -79,6 +86,15 @@ services: PAYOUT_MIN_SATS: ${PAYOUT_MIN_SATS:-10000} PAYOUT_INTERVAL_MS: ${PAYOUT_INTERVAL_MS:-30000} PAYOUT_DRY_RUN: ${PAYOUT_DRY_RUN:-0} + # Admin HTTP surface — dashboard reaches it at PAYOUT_ADMIN_URL. + # Bind to 0.0.0.0 (default) so the dashboard container can reach + # it via host.docker.internal:PAYOUT_ADMIN_PORT. + PAYOUT_ADMIN_BIND: ${PAYOUT_ADMIN_BIND:-0.0.0.0} + PAYOUT_ADMIN_PORT: ${PAYOUT_ADMIN_PORT:-9080} + ports: + # Expose the admin HTTP for the dashboard to hit. Loopback-only + # on the host is the sane default. + - "127.0.0.1:${PAYOUT_ADMIN_PORT:-9080}:9080" volumes: - ../../data:/data extra_hosts: diff --git a/deploy/systemd/simplepool-dashboard.service b/deploy/systemd/simplepool-dashboard.service index 36879db..beb2fdb 100644 --- a/deploy/systemd/simplepool-dashboard.service +++ b/deploy/systemd/simplepool-dashboard.service @@ -1,5 +1,5 @@ [Unit] -Description=simplepool dashboard (read-only web UI) +Description=simplepool dashboard (operator web UI) Documentation=https://github.com/rsantacroce/simplepool After=network-online.target Wants=network-online.target @@ -20,6 +20,14 @@ Environment=PROXY_DB_PATH=@ROOT@/data/shares.db # operator view (reserve balance, ledger totals, per-worker owed, # in-flight payouts). Without both, /admin returns 503 (fail-closed). Environment=THUNDER_RPC_URL=http://127.0.0.1:6009 +# --- Write-action wiring (POST buttons on /admin) --- +# Payout worker admin HTTP endpoint (see simplepool-payout.service). +Environment=PAYOUT_ADMIN_URL=http://127.0.0.1:9080 +# bip300301_enforcer gRPC address — the deposit action shells out to +# grpcurl (must be on PATH) to hit CreateDepositTransaction. +Environment=ENFORCER_GRPC_ADDR=127.0.0.1:50051 +Environment=GRPCURL_BIN=/usr/local/bin/grpcurl +Environment=THUNDER_SIDECHAIN_ID=9 # Environment=POOL_THUNDER_RESERVE_ADDRESS= # Environment=ADMIN_USER=admin # Environment=ADMIN_PASSWORD= @@ -36,7 +44,9 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=full ProtectHome=read-only -ReadOnlyPaths=@ROOT@ +# Dashboard writes to the deposits table when the operator submits a +# deposit via /admin/action/deposit — the DB dir must be writable. +ReadWritePaths=@ROOT@/data [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/simplepool-payout.service b/deploy/systemd/simplepool-payout.service index e56d21b..6b09894 100644 --- a/deploy/systemd/simplepool-payout.service +++ b/deploy/systemd/simplepool-payout.service @@ -18,6 +18,10 @@ Environment=THUNDER_RPC_URL=http://127.0.0.1:6009 # Environment=THUNDER_FROM_ADDRESS= Environment=PAYOUT_MIN_SATS=10000 Environment=PAYOUT_INTERVAL_MS=30000 +# Admin HTTP surface — used by the dashboard's "Trigger payout now" +# button. Loopback-only. Set port=0 to disable. +Environment=PAYOUT_ADMIN_BIND=127.0.0.1 +Environment=PAYOUT_ADMIN_PORT=9080 ExecStart=/usr/bin/node @ROOT@/payout/index.js Restart=on-failure RestartSec=5 diff --git a/payout/index.js b/payout/index.js index 90c5d5e..aac4c70 100644 --- a/payout/index.js +++ b/payout/index.js @@ -20,6 +20,7 @@ import { loadConfig } from './lib/config.js'; import { openDb } from './lib/db.js'; import { ThunderClient } from './lib/thunder.js'; import { startLoop, reportStuck } from './lib/payout.js'; +import { startAdminHttp } from './lib/admin-http.js'; const cfg = loadConfig(); @@ -50,10 +51,23 @@ reportStuck({ db }, log); const loop = startLoop({ db, thunder, cfg }, log); +/* Small HTTP admin surface for the dashboard's "Trigger payout now" + * button. port=0 disables. Loopback-only unless overridden. */ +let adminHttp = null; +if (cfg.adminHttpPort > 0) { + adminHttp = startAdminHttp({ + port: cfg.adminHttpPort, + bind: cfg.adminHttpBind, + ctx: { db, thunder, cfg }, + log, + }); +} + for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => { log.info(`got ${sig}, stopping`); loop.stop(); + adminHttp?.close(); db.close(); process.exit(0); }); diff --git a/payout/lib/admin-http.js b/payout/lib/admin-http.js new file mode 100644 index 0000000..ada471c --- /dev/null +++ b/payout/lib/admin-http.js @@ -0,0 +1,41 @@ +/* Small HTTP admin surface for the payout worker. + * + * Loopback-only by default (PAYOUT_ADMIN_BIND=127.0.0.1). One endpoint: + * + * POST /tick — run one payout cycle synchronously, return its result + * as JSON. The dashboard uses this to fire the + * "Trigger payout now" button without waiting for the + * next interval. + * + * Deliberately minimal — no auth (loopback bind is the boundary), no + * express dependency, no framework. */ + +import { createServer } from 'node:http'; +import { runOnce } from './payout.js'; + +export function startAdminHttp({ port, bind, ctx, log }) { + const server = createServer(async (req, res) => { + const send = (status, body) => { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); + }; + if (req.method === 'POST' && req.url === '/tick') { + try { + const result = await runOnce(ctx, log); + return send(200, { ok: true, result }); + } catch (e) { + log.error(`admin: /tick threw: ${e.message}`); + return send(500, { ok: false, error: e.message }); + } + } + if (req.method === 'GET' && req.url === '/healthz') { + return send(200, { ok: true }); + } + return send(404, { ok: false, error: 'not found' }); + }); + server.listen(port, bind, () => { + log.info(`payout admin http listening on ${bind}:${port}`); + }); + return server; +} diff --git a/payout/lib/config.js b/payout/lib/config.js index a905e3b..df4432d 100644 --- a/payout/lib/config.js +++ b/payout/lib/config.js @@ -40,5 +40,9 @@ export function loadConfig() { minSats: BigInt(process.env.PAYOUT_MIN_SATS || '10000'), maxPerTick: parseInt(process.env.PAYOUT_MAX_PER_TICK || '50', 10), dryRun: process.env.PAYOUT_DRY_RUN === '1', + /* Admin HTTP surface — used by the dashboard's "Trigger payout now" + * button. Loopback-bound by default; set port=0 to disable. */ + adminHttpBind: process.env.PAYOUT_ADMIN_BIND || '127.0.0.1', + adminHttpPort: parseInt(process.env.PAYOUT_ADMIN_PORT || '9080', 10), }; }