From 284fbbc3d34877146e760ce6828daffa6a5a344d Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 22 Jul 2026 01:10:33 +0200 Subject: [PATCH 1/3] admin: operator write actions on /admin (deposit, payout, mine, mempool) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the read-only admin dashboard into an actual operator control surface. Four buttons on /admin, each POSTing to a CSRF-gated route that redirects back with a flash message: 1. Deposit BTC → Thunder — shells out to grpcurl for the enforcer's cusf.mainchain.v1.WalletService/CreateDepositTransaction, then writes a row into the pool DB's deposits table. Assumes the enforcer wallet already holds spendable BTC UTXOs (operator pre-funds manually with bitcoin-cli sendtoaddress). 2. Trigger payout cycle now — POSTs to a new admin HTTP surface added to the payout worker; runs runOnce() synchronously and returns the tick result. 3. Remove stale Thunder tx — form with txid input, calls Thunder's remove_from_mempool RPC. Fixes the class of stuck states where a phantom tx pins the reserve UTXO and every subsequent transfer hits "utxo double spent". 4. Nudge Thunder mine — POSTs Thunder's mine RPC. Same idempotent nudge the systemd timer runs, exposed for manual use. Security: - CSRF: per-render random token, single-use, 1h TTL, in-memory store. HTTP Basic auth alone doesn't defend against a malicious cross-site POST that piggybacks the cached auth header — the CSRF token does. - Payout worker admin HTTP is loopback-bound by default and unauth'd; the loopback bind is the boundary. - Dashboard now writes to the deposits table, so the systemd unit loses ReadOnlyPaths=@ROOT@ in favour of ReadWritePaths=@ROOT@/data. Deploy: - New env: PAYOUT_ADMIN_URL, ENFORCER_GRPC_ADDR, GRPCURL_BIN, THUNDER_SIDECHAIN_ID on the dashboard; PAYOUT_ADMIN_BIND, PAYOUT_ADMIN_PORT on the payout worker. - Docker dashboard image now bakes grpcurl (v1.9.1, multi-arch via TARGETARCH). Compose publishes payout admin port on 127.0.0.1 so the dashboard container can reach it via host.docker.internal. - Systemd units + docker-compose.yml + .env.example all updated. Smoke-tested locally: payout admin HTTP returns clean JSON on /healthz and /tick; dashboard renders 4 forms with fresh CSRF tokens; POST without token / with a fake token gets rejected with a flash; POST with a valid token executes the action (nudge-mine correctly reports "fetch failed" when Thunder RPC is unreachable). --- dashboard/lib/actions.js | 208 ++++++++++++++++++++ dashboard/lib/csrf.js | 40 ++++ dashboard/server.js | 88 ++++++++- dashboard/views/admin.ejs | 105 +++++++++- deploy/docker/.env.example | 18 ++ deploy/docker/Dockerfile.dashboard | 20 +- deploy/docker/docker-compose.yml | 18 +- deploy/systemd/simplepool-dashboard.service | 14 +- deploy/systemd/simplepool-payout.service | 4 + payout/index.js | 14 ++ payout/lib/admin-http.js | 41 ++++ payout/lib/config.js | 4 + 12 files changed, 564 insertions(+), 10 deletions(-) create mode 100644 dashboard/lib/actions.js create mode 100644 dashboard/lib/csrf.js create mode 100644 payout/lib/admin-http.js diff --git a/dashboard/lib/actions.js b/dashboard/lib/actions.js new file mode 100644 index 0000000..b7abccd --- /dev/null +++ b/dashboard/lib/actions.js @@ -0,0 +1,208 @@ +/* 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. */ + 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/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/server.js b/dashboard/server.js index 91b9c46..bea864d 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -5,6 +5,8 @@ import { renderFile } from 'ejs'; import { openDb } from './lib/db.js'; import * as stats from './lib/stats.js'; import * as admin from './lib/admin.js'; /* PPS ADMIN PATCH */ +import * as actions from './lib/actions.js'; /* admin write-actions */ +import { issueToken, consumeToken } from './lib/csrf.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || '8081', 10); @@ -90,6 +92,12 @@ 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'; +/* Write-action config. All optional — routes surface a clear error if + * the relevant one is unset. */ +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); /* 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. */ @@ -123,15 +131,93 @@ async function adminSummary() { }; } +/* Parse the write-action forms. Confined to POSTs so it never touches + * the read-only routes. */ +const parseAdminForm = express.urlencoded({ extended: false, limit: '4kb' }); + +/* Flash message from a redirect. Query-string based (?flash=…&flashOk=1) + * so no cookies / no session store; EJS auto-escapes when rendering. */ +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) { + 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, '/admin?' + q.toString()); +} + app.get('/admin', requireAdminAuth, async (req, res) => { try { const data = await adminSummary(); - res.render('admin', data); + res.render('admin', { + ...data, + csrfToken: issueToken(), + flash: readFlash(req), + reserveSidechainId: THUNDER_SIDECHAIN_ID, + payoutAdminConfigured: !!PAYOUT_ADMIN_URL, + }); } catch (e) { res.status(500).send('admin: ' + e.message); } }); +/* Gate for write actions: valid Basic auth + valid CSRF token. */ +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', + }); +} + +app.post('/admin/action/nudge-mine', + requireAdminAuth, parseAdminForm, requireCsrf, + async (_req, res) => { + const r = await actions.nudgeMine({ thunderRpcUrl: THUNDER_RPC_URL }); + flashRedirect(res, r); + }); + +app.post('/admin/action/remove-from-mempool', + requireAdminAuth, parseAdminForm, requireCsrf, + async (req, res) => { + const r = await actions.removeFromMempool({ + thunderRpcUrl: THUNDER_RPC_URL, + txid: (req.body?.txid || '').trim(), + }); + flashRedirect(res, r); + }); + +app.post('/admin/action/trigger-payout', + requireAdminAuth, parseAdminForm, requireCsrf, + async (_req, res) => { + const r = await actions.triggerPayout({ payoutAdminUrl: PAYOUT_ADMIN_URL }); + flashRedirect(res, r); + }); + +app.post('/admin/action/deposit', + requireAdminAuth, 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, + }); + flashRedirect(res, r); + }); + app.get('/api/admin/summary', requireAdminAuth, async (req, res) => { try { res.json(await adminSummary()); diff --git a/dashboard/views/admin.ejs b/dashboard/views/admin.ejs index 2cd0976..cfe74c9 100644 --- a/dashboard/views/admin.ejs +++ b/dashboard/views/admin.ejs @@ -34,13 +34,110 @@ const fmtSats = (sats) => {

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. + The read half of this page is a live probe of Thunder + plus the pool's SQLite ledger. The Operator actions + card below is the write half — every button POSTs to a + CSRF-gated route and redirects here with a flash message. + See payout/README.md for the runbook when things + get weird.

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

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

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

<%= flash.detail %>

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

Operator actions

+

+ CSRF-protected POST forms. Each action returns you here with + a green ✓ or red ✗ flash. All four are safe to fire multiple + times — Thunder / the payout worker will report the truth if + the state hasn't changed. +

+ +
+ + +
+ + +

+ Call mine once. Thunder builds a fresh + BMM commit if it can; harmless if it can't. +

+ +
+ + +
+ + +

+ Ask the payout worker to run one cycle now instead + of waiting for its next tick. + <% if (!payoutAdminConfigured) { %> +
Not configured — set PAYOUT_ADMIN_URL. + <% } %> +

+ +
+ + +
+ + +

+ 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. +

+ + +
+ + +
+ + + +

+ Spend enforcer-wallet BTC into a Thunder deposit + (sidechain #<%= reserveSidechainId %>). Enforcer wallet + must already hold spendable UTXOs — fund it first + with a plain bitcoin-cli sendtoaddress + to an enforcer address. +

+ + + + + + + +
+ +
+
+

Thunder reserve

<% if (reserve.ok) { %> 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), }; } From e983bb9cba7d749ac705d551a99fd83fd8746e4e Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 22 Jul 2026 12:03:44 +0200 Subject: [PATCH 2/3] admin: fix deposit DB log + show enforcer BTC balance on /admin Two follow-ups after the first deploy of the write actions: 1. Deposit action's DB log crashed with "db.prepare is not a function". Root causes: - openDb() returns a lazy WRAPPER, not a raw better-sqlite3 handle; we need to call .get() first to unwrap. - The dashboard's existing handle is opened readonly:true, so even after unwrap the INSERT into `deposits` would fail. Fix: openDb now accepts { readonly } (default true, unchanged for the existing read path); server.js opens a second writable handle just for admin write actions and hands the unwrapped result to createDeposit. Also hardens createDeposit to skip the DB log gracefully if the handle is missing rather than throwing. 2. Operator asked for visibility into how much BTC is available to deposit. New enforcerBalance() probe shells out to grpcurl for cusf.mainchain.v1.WalletService/GetBalance (returns confirmed + pending sats + hasSynced). Rendered as a new "Pool BTC (enforcer wallet)" card next to the Thunder reserve card, and echoed inline inside the deposit form ("Spendable now: N sats") so the operator knows what they can spend without leaving the page. Verified locally: syntax clean, gRPC probe against the live enforcer returns confirmed=200977300 pending=19998800 hasSynced=true. --- dashboard/lib/actions.js | 34 +++++++++++++++++++--------------- dashboard/lib/admin.js | 27 +++++++++++++++++++++++++++ dashboard/lib/db.js | 4 ++-- dashboard/server.js | 16 ++++++++++++++-- dashboard/views/admin.ejs | 30 ++++++++++++++++++++++++++---- 5 files changed, 88 insertions(+), 23 deletions(-) diff --git a/dashboard/lib/actions.js b/dashboard/lib/actions.js index b7abccd..52cb1cd 100644 --- a/dashboard/lib/actions.js +++ b/dashboard/lib/actions.js @@ -183,21 +183,25 @@ export async function createDeposit({ (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. */ - 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), - }; + * 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, 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/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/server.js b/dashboard/server.js index bea864d..f78545b 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -24,6 +24,11 @@ app.disable('x-powered-by'); app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '1h' })); const db = openDb(path.resolve(__dirname, DB_PATH)); +/* Separate writable handle for admin write actions (only the deposit + * action needs it today — inserts into `deposits`). Kept distinct from + * the read-only `db` so accidental writes from stats.js / admin.js + * still hit the read-only handle and fail fast. */ +const dbRw = openDb(path.resolve(__dirname, DB_PATH), { readonly: false }); /* Shown on the public "Connect a miner" card. Env-driven so the shipped * template doesn't hardcode any particular deployment's host. */ @@ -115,8 +120,9 @@ function requireAdminAuth(req, res, next) { } async function adminSummary() { - const [reserve, totals, workers, inFlight, payouts, deposits, blocks] = await Promise.all([ + 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)), @@ -126,6 +132,7 @@ async function adminSummary() { ]); return { reserve, reserveAddress: RESERVE_ADDRESS, + enforcer, totals, workers, inFlight, payouts, deposits, blocks, }; @@ -206,6 +213,11 @@ app.post('/admin/action/trigger-payout', app.post('/admin/action/deposit', requireAdminAuth, parseAdminForm, requireCsrf, async (req, res) => { + /* Unwrap the lazy dbRw handle — createDeposit expects a raw + * better-sqlite3 Database with .prepare(). If the file isn't + * openable, dbGet returns null and createDeposit surfaces the + * gRPC result without the DB row. */ + const dbGet = dbRw.get(); const r = await actions.createDeposit({ grpcurlBin: GRPCURL_BIN, enforcerGrpcAddr: ENFORCER_GRPC_ADDR, @@ -213,7 +225,7 @@ app.post('/admin/action/deposit', address: (req.body?.address || '').trim(), valueSats: req.body?.value_sats, feeSats: req.body?.fee_sats, - db, + db: dbGet, }); flashRedirect(res, r); }); diff --git a/dashboard/views/admin.ejs b/dashboard/views/admin.ejs index cfe74c9..de641af 100644 --- a/dashboard/views/admin.ejs +++ b/dashboard/views/admin.ejs @@ -116,10 +116,13 @@ const fmtSats = (sats) => {

Spend enforcer-wallet BTC into a Thunder deposit - (sidechain #<%= reserveSidechainId %>). Enforcer wallet - must already hold spendable UTXOs — fund it first - with a plain bitcoin-cli sendtoaddress - to an enforcer address. + (sidechain #<%= reserveSidechainId %>). + <% if (enforcer.ok) { %> +
Spendable now: <%= fmtSats(enforcer.confirmed_sats) %> + <% if (enforcer.pending_sats > 0) { %> + (+<%= fmtSats(enforcer.pending_sats) %> pending) + <% } %> + <% } %>

{ <% } %>
+
+

Pool BTC (enforcer wallet)

+

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

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

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

+ <% } %> +
+

PPS ledger totals

From 5831c181cdc8a4dc9422687f5f3f6ad74e22530d Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 22 Jul 2026 12:24:10 +0200 Subject: [PATCH 3/3] dashboard: split /admin into 5 sub-pages + top-nav + shared partials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the read-only monolithic /admin page into a real operator console. Same functionality, but broken into focused tabs with clear navigation between them, and a matching public-side nav bar. Structure --------- Public (unchanged auth: none): GET / Overview GET /blocks Block history GET /worker/:name Per-worker GET /worker-lookup?name=X Redirects to /worker/X (nav search box) Admin (Basic auth on every route; router lives in lib/admin-router.js): GET /admin Overview (balances + health) GET /admin/workers Per-worker balance table GET /admin/deposits Deposits history + New Deposit form GET /admin/payouts Recent + in-flight + Trigger button GET /admin/tools Nudge mine + Remove-from-mempool GET /admin/worker/:id Per-worker audit (existed, now shares nav) GET /admin/logout 401 clears browser Basic-auth cache GET /admin/api/summary JSON (was /api/admin/summary) GET /admin/api/worker/:id JSON (was /api/admin/worker/:id) POST /admin/action/{nudge-mine,remove-from-mempool,trigger-payout,deposit} Robustness ---------- - Central error middleware inside the admin router — any thrown error becomes a red flash-redirect, never a leaked stack trace. - Action forms carry a hidden `return_to` so each POST redirects back to the page it was fired from; whitelist against a fixed set of admin paths prevents open-redirect abuse. - Writable SQLite handle moved to lib/db-admin.js; server.js opens two distinct handles and only the admin router receives the writable one, so no read path can accidentally trigger a write. - Security headers on every response: X-Content-Type-Options nosniff, X-Frame-Options DENY, Referrer-Policy same-origin, Permissions-Policy interest-cohort=(). - Fmt helpers (fmtN, fmtTs, ago, fmtSats, fmtPct) centralised in lib/fmt.js and dropped onto res.locals via middleware — views no longer duplicate the same 15-line preamble. Shared partials (views/partial/): head.ejs — + optional refresh + title nav-public.ejs — public top nav + worker-name search box nav-admin.ejs — admin top nav (5 tabs + Log out), orange bar flash.ejs — green/red banner after any action redirect CSS additions (public/style.css): .top-admin (distinct color for admin surface) .topnav / .topsearch / .logout / .flash-{ok,err} Old dashboard/views/admin.ejs (381 lines) removed — its sections are distributed across the five new admin-*.ejs views. Not changed ----------- - Auth scheme stays HTTP Basic (per operator ask). /admin/logout is the only new user-visible endpoint that touches auth state. - No JSON API refactor / SPA — every page is still server-rendered EJS. - Public routes and their JSON APIs (/api/*) unchanged. Smoke tested locally: all 7 admin routes render, security headers present, POST actions carry the return_to through the whitelist, /admin/logout returns 401 with a valid ASCII WWW-Authenticate header. --- dashboard/lib/admin-router.js | 197 +++++++++++++ dashboard/lib/db-admin.js | 13 + dashboard/lib/fmt.js | 43 +++ dashboard/public/style.css | 38 ++- dashboard/server.js | 318 +++++++-------------- dashboard/views/admin-deposits.ejs | 95 ++++++ dashboard/views/admin-overview.ejs | 119 ++++++++ dashboard/views/admin-payouts.ejs | 103 +++++++ dashboard/views/admin-tools.ejs | 60 ++++ dashboard/views/admin-worker.ejs | 31 +- dashboard/views/admin-workers.ejs | 59 ++++ dashboard/views/admin.ejs | 381 ------------------------- dashboard/views/blocks.ejs | 23 +- dashboard/views/index.ejs | 25 +- dashboard/views/partial/flash.ejs | 9 + dashboard/views/partial/head.ejs | 9 + dashboard/views/partial/nav-admin.ejs | 15 + dashboard/views/partial/nav-public.ejs | 19 ++ dashboard/views/worker.ejs | 32 +-- 19 files changed, 894 insertions(+), 695 deletions(-) create mode 100644 dashboard/lib/admin-router.js create mode 100644 dashboard/lib/db-admin.js create mode 100644 dashboard/lib/fmt.js create mode 100644 dashboard/views/admin-deposits.ejs create mode 100644 dashboard/views/admin-overview.ejs create mode 100644 dashboard/views/admin-payouts.ejs create mode 100644 dashboard/views/admin-tools.ejs create mode 100644 dashboard/views/admin-workers.ejs delete mode 100644 dashboard/views/admin.ejs create mode 100644 dashboard/views/partial/flash.ejs create mode 100644 dashboard/views/partial/head.ejs create mode 100644 dashboard/views/partial/nav-admin.ejs create mode 100644 dashboard/views/partial/nav-public.ejs 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/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/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 f78545b..478a7be 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -1,55 +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 actions from './lib/actions.js'; /* admin write-actions */ -import { issueToken, consumeToken } from './lib/csrf.js'; +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)); -/* Separate writable handle for admin write actions (only the deposit - * action needs it today — inserts into `deposits`). Kept distinct from - * the read-only `db` so accidental writes from stats.js / admin.js - * still hit the read-only handle and fail fast. */ -const dbRw = openDb(path.resolve(__dirname, DB_PATH), { readonly: false }); +/* 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'); +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); +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, }); }); @@ -59,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 }); @@ -73,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' }); @@ -87,187 +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'; -/* Write-action config. All optional — routes surface a clear error if - * the relevant one is unset. */ -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); -/* 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, 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, reserveAddress: RESERVE_ADDRESS, - enforcer, - totals, workers, inFlight, - payouts, deposits, blocks, - }; -} - -/* Parse the write-action forms. Confined to POSTs so it never touches - * the read-only routes. */ -const parseAdminForm = express.urlencoded({ extended: false, limit: '4kb' }); - -/* Flash message from a redirect. Query-string based (?flash=…&flashOk=1) - * so no cookies / no session store; EJS auto-escapes when rendering. */ -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) { - 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, '/admin?' + q.toString()); -} - -app.get('/admin', requireAdminAuth, async (req, res) => { - try { - const data = await adminSummary(); - res.render('admin', { - ...data, - csrfToken: issueToken(), - flash: readFlash(req), - reserveSidechainId: THUNDER_SIDECHAIN_ID, - payoutAdminConfigured: !!PAYOUT_ADMIN_URL, - }); - } catch (e) { - res.status(500).send('admin: ' + e.message); - } -}); - -/* Gate for write actions: valid Basic auth + valid CSRF token. */ -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', - }); -} - -app.post('/admin/action/nudge-mine', - requireAdminAuth, parseAdminForm, requireCsrf, - async (_req, res) => { - const r = await actions.nudgeMine({ thunderRpcUrl: THUNDER_RPC_URL }); - flashRedirect(res, r); - }); - -app.post('/admin/action/remove-from-mempool', - requireAdminAuth, parseAdminForm, requireCsrf, - async (req, res) => { - const r = await actions.removeFromMempool({ - thunderRpcUrl: THUNDER_RPC_URL, - txid: (req.body?.txid || '').trim(), - }); - flashRedirect(res, r); - }); - -app.post('/admin/action/trigger-payout', - requireAdminAuth, parseAdminForm, requireCsrf, - async (_req, res) => { - const r = await actions.triggerPayout({ payoutAdminUrl: PAYOUT_ADMIN_URL }); - flashRedirect(res, r); - }); - -app.post('/admin/action/deposit', - requireAdminAuth, parseAdminForm, requireCsrf, - async (req, res) => { - /* Unwrap the lazy dbRw handle — createDeposit expects a raw - * better-sqlite3 Database with .prepare(). If the file isn't - * openable, dbGet returns null and createDeposit surfaces the - * gRPC result without the DB row. */ - const dbGet = dbRw.get(); - 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: dbGet, - }); - flashRedirect(res, r); - }); - -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 %>
+ <% } %> +
+
+
+ auto-refresh 30s + · + admin · deposits +
+ + 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) %>
+ <% } %> +
+
+
+ auto-refresh 15s + · + admin · overview +
+ + 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 || '' %>
+ <% } %> +
+
+
+ auto-refresh 15s + · + admin · payouts +
+ + 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. +

+ + + +
+
+
+
+ admin · tools +
+ + 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 de641af..0000000 --- a/dashboard/views/admin.ejs +++ /dev/null @@ -1,381 +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

-

- The read half of this page is a live probe of Thunder - plus the pool's SQLite ledger. The Operator actions - card below is the write half — every button POSTs to a - CSRF-gated route and redirects here with a flash message. - See payout/README.md for the runbook when things - get weird. -

-
- - <% if (flash) { %> -
-

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

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

<%= flash.detail %>

- <% } %> -
- <% } %> - -
-

Operator actions

-

- CSRF-protected POST forms. Each action returns you here with - a green ✓ or red ✗ flash. All four are safe to fire multiple - times — Thunder / the payout worker will report the truth if - the state hasn't changed. -

- -
- - -
- - -

- Call mine once. Thunder builds a fresh - BMM commit if it can; harmless if it can't. -

- -
- - -
- - -

- Ask the payout worker to run one cycle now instead - of waiting for its next tick. - <% if (!payoutAdminConfigured) { %> -
Not configured — set PAYOUT_ADMIN_URL. - <% } %> -

- -
- - -
- - -

- 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. -

- - -
- - -
- - - -

- Spend enforcer-wallet BTC into a Thunder deposit - (sidechain #<%= reserveSidechainId %>). - <% if (enforcer.ok) { %> -
Spendable now: <%= fmtSats(enforcer.confirmed_sats) %> - <% if (enforcer.pending_sats > 0) { %> - (+<%= fmtSats(enforcer.pending_sats) %> pending) - <% } %> - <% } %> -

- - - - - - - -
- -
-
- -
-

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 (CreateNewAddress) from your - operator wallet. -

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

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

- <% } %> -
- -
-

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) { %>