From c71309844beba9f3e83096bb55a55578d95ddd3b Mon Sep 17 00:00:00 2001 From: satoshi Date: Sun, 10 May 2026 17:24:39 +0300 Subject: [PATCH] Add Pool Status action, rename from Stratum Smoke Test Adds a comprehensive status action showing pool health, connected miners, block history with chain tags, and debug memory-vs-DB comparison. Removes the clear-network-orphans action (now handled automatically by the reconcile loop). Updates manifest description to accurately reflect capabilities. --- manifest.yaml | 19 +++ scripts/embassy.ts | 1 + scripts/procedures/actions.ts | 290 ++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 scripts/procedures/actions.ts diff --git a/manifest.yaml b/manifest.yaml index 5a03332..9883a5b 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -148,6 +148,25 @@ backup: BACKUP: /mnt/backup main: /root/.kamado +actions: + stratum-smoke-test: + name: "Pool Status" + description: "Displays a full status snapshot: Bitcoin Core sync state, ckpool health, connected miners, hashrate, found blocks, and submit-gap diagnostics." + warning: ~ + allowed-statuses: + - running + implementation: + type: script + regen-tls-cert: + name: "Regenerate TLS Certificate" + description: "Clears the current stratum TLS certificate so a fresh one is generated on the next service start. Use this to rotate an expired or untrusted certificate." + warning: "Miners connected via TLS will be disconnected on restart and will need to accept or re-pin the new certificate fingerprint." + allowed-statuses: + - running + - stopped + implementation: + type: script + migrations: from: "*": diff --git a/scripts/embassy.ts b/scripts/embassy.ts index bbdc5f7..6e8adf9 100644 --- a/scripts/embassy.ts +++ b/scripts/embassy.ts @@ -4,3 +4,4 @@ export { dependencies } from "./procedures/dependencies.ts"; export { health } from "./procedures/healthChecks.ts"; export { migration } from "./procedures/migrations.ts"; export { properties } from "./procedures/properties.ts"; +export { action } from "./procedures/actions.ts"; diff --git a/scripts/procedures/actions.ts b/scripts/procedures/actions.ts new file mode 100644 index 0000000..47b3733 --- /dev/null +++ b/scripts/procedures/actions.ts @@ -0,0 +1,290 @@ +import { types as T } from "../deps.ts"; + +const POOL_HOST = "kamado-pool.embassy"; +const STRATUM_PORT = 3333; // shown in output; direct TCP not testable from sandbox +const API_BASE = `http://${POOL_HOST}:8080`; + +// ── formatting helpers ──────────────────────────────────────────────────────── + +function ts(): string { return new Date().toISOString().slice(11, 22); } + +function fmtHR(hs: number): string { + if (hs <= 0) return "0 H/s"; + if (hs >= 1e18) return `${(hs / 1e18).toFixed(2)} EH/s`; + if (hs >= 1e15) return `${(hs / 1e15).toFixed(2)} PH/s`; + if (hs >= 1e12) return `${(hs / 1e12).toFixed(2)} TH/s`; + if (hs >= 1e9) return `${(hs / 1e9 ).toFixed(2)} GH/s`; + if (hs >= 1e6) return `${(hs / 1e6 ).toFixed(2)} MH/s`; + if (hs >= 1e3) return `${(hs / 1e3 ).toFixed(2)} kH/s`; + return `${hs.toFixed(0)} H/s`; +} + +function fmtDiff(d: number): string { + if (d <= 0) return "0"; + if (d >= 1e12) return `${(d / 1e12).toFixed(2)}T`; + if (d >= 1e9) return `${(d / 1e9 ).toFixed(2)}G`; + if (d >= 1e6) return `${(d / 1e6 ).toFixed(2)}M`; + if (d >= 1e3) return `${(d / 1e3 ).toFixed(2)}K`; + return d.toFixed(2); +} + +function fmtUptime(s: number): string { + if (s <= 0) return "0m"; + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function fmtBTC(btc: number): string { return `${btc.toFixed(8)} BTC`; } +function pad(s: string, w: number): string { return s.padEnd(w); } + +const chainNames: Record = { main: "mainnet" }; +function displayChain(c: string): string { return chainNames[c] ?? c; } + +// ── snapshot shape ──────────────────────────────────────────────────────────── + +interface PoolStats { + workers: number; users: number; accepted: number; rejected: number; + shares: number; dsps1: number; dsps5: number; dsps60: number; dsps1440: number; +} +interface Chain { + chain: string; blocks: number; headers: number; difficulty: number; + initialblockdownload: boolean; verificationprogress: number; bestblockhash: string; +} +interface Worker { worker: string; dsps1: number; bestdiff: number; bestever: number; idle: boolean; } +interface Client { id: number; workername: string; diff: number; dsps1: number; useragent: string; } +interface BlockRecord { + height: number; hash?: string; reward_btc?: number; found_at: string; + orphaned_at?: string; chain?: string; +} +interface Snapshot { + pool: PoolStats | null; + uptime_seconds: number; + hashrate_hs_1m: number; hashrate_hs_5m: number; + hashrate_hs_1h: number; hashrate_hs_24h: number; + best_diff: number; cumulative_shares: number; + next_block_reward_btc: number; next_difficulty_percent: number; + chain: Chain | null; network_hashrate_hs: number; + recent_blocks: BlockRecord[]; + ckpool_ok: boolean; bitcoin_ok: boolean; last_error?: string; + block_submit_attempts: number; block_submits_confirmed: number; + zmq_enabled: boolean; zmq_stale: boolean; + has_last_zmq_event: boolean; last_zmq_event_age: number; + workers: Worker[]; clients: Client[]; +} + +// ── actions ─────────────────────────────────────────────────────────────────── + +export const action: T.ExpectedExports.action = { + + // ── Pool status ───────────────────────────────────────────────────────────── + "stratum-smoke-test": async (effects) => { + const lines: string[] = []; + const log = (s: string) => lines.push(`[${ts()}] ${s}`); + const sep = () => log("──────────────────────────────────────────"); + let allPass = true; + + log("Kamado Pool — Status"); + sep(); + + // ── 1. Full snapshot ──────────────────────────────────────────────────── + log("● Pool & Bitcoin Core status"); + let snap: Snapshot | null = null; + try { + const res = await effects.fetch(`${API_BASE}/api/snapshot`); + snap = await res.json() as Snapshot; + } catch (e) { + log(` API unreachable: ${(e as Error).message}`); + allPass = false; + } + + if (snap) { + const chain = snap.chain; + const pool = snap.pool; + + log(` Bitcoin Core: ${snap.bitcoin_ok ? "OK" : "FAIL"}`); + if (chain) { + const ibd = chain.initialblockdownload; + const syncPct = (chain.verificationprogress * 100).toFixed(2); + log(` Network: ${chain.chain} height=${chain.blocks} headers=${chain.headers}`); + log(` Sync: ${ibd ? `IBD — ${syncPct}% (pool will not mine until synced)` : `${syncPct}% — fully synced`}`); + log(` Block hash: ${chain.bestblockhash}`); + log(` Difficulty: ${fmtDiff(chain.difficulty)}`); + log(` Network hashrate: ${fmtHR(snap.network_hashrate_hs)}`); + if (ibd) allPass = false; + } + if (!snap.bitcoin_ok) allPass = false; + + if (snap.next_block_reward_btc > 0) + log(` Next block reward: ${fmtBTC(snap.next_block_reward_btc)}`); + if (snap.next_difficulty_percent !== 0) { + const sign = snap.next_difficulty_percent > 0 ? "+" : ""; + log(` Next diff adjust: ${sign}${snap.next_difficulty_percent.toFixed(1)}%`); + } + + if (snap.zmq_enabled) { + const age = snap.has_last_zmq_event + ? `last event ${snap.last_zmq_event_age.toFixed(0)}s ago` + : "no event yet since startup"; + log(` ZMQ: ${snap.zmq_stale ? "STALE — " : "OK — "}${age}`); + if (snap.zmq_stale) allPass = false; + } else { + log(` ZMQ: disabled`); + } + + log(""); + log(` ckpool process: ${snap.ckpool_ok ? "OK" : "FAIL"}`); + if (!snap.ckpool_ok) allPass = false; + if (snap.last_error) log(` last_error: ${snap.last_error}`); + if (pool) { + log(` Uptime: ${fmtUptime(snap.uptime_seconds)}`); + log(` Workers online: ${pool.workers} (${pool.users} user${pool.users !== 1 ? "s" : ""})`); + log(` Shares accepted: ${pool.accepted} rejected: ${pool.rejected}`); + log(` Hashrate 1m: ${fmtHR(snap.hashrate_hs_1m)}`); + log(` Hashrate 5m: ${fmtHR(snap.hashrate_hs_5m)}`); + log(` Hashrate 1h: ${fmtHR(snap.hashrate_hs_1h)}`); + log(` Hashrate 24h: ${fmtHR(snap.hashrate_hs_24h)}`); + log(` Best share ever: diff ${fmtDiff(snap.best_diff)}`); + log(` Cumul. work: ${fmtDiff(snap.cumulative_shares)} diff-1 shares`); + } + + const gap = snap.block_submit_attempts - snap.block_submits_confirmed; + log(` Submit attempts: ${snap.block_submit_attempts} confirmed: ${snap.block_submits_confirmed}`); + log(` Submit gap: ${gap > 0 ? `WARN — ${gap} unconfirmed` : "OK (all confirmed)"}`); + if (gap > 0) allPass = false; + + if (snap.clients && snap.clients.length > 0) { + log(""); + log(`● Connected miners (${snap.clients.length})`); + for (const c of snap.clients) { + const tag = c.useragent ? ` [${c.useragent}]` : ""; + log(` ${pad(c.workername || `client#${c.id}`, 30)} diff=${fmtDiff(c.diff)} ${fmtHR(c.dsps1 * 4294967296)}${tag}`); + } + } else { + log(""); log("● Connected miners: none"); + } + + if (snap.workers && snap.workers.length > 0) { + log(""); log("● Workers — best shares"); + for (const w of snap.workers) { + const ever = w.bestever > 0 ? w.bestever : w.bestdiff; + log(` ${pad(w.worker, 30)} best=${fmtDiff(ever)}${w.idle ? " (idle)" : ""}`); + } + } + + log(""); + if (snap.recent_blocks && snap.recent_blocks.length > 0) { + const currentNetwork = chain?.chain ?? ""; + log(`● Blocks found (${snap.recent_blocks.length})`); + for (const b of snap.recent_blocks) { + let status = ""; + if (b.orphaned_at) { + status = " ORPHANED"; + } else if (b.chain && currentNetwork && b.chain !== currentNetwork) { + status = ` [${displayChain(b.chain)}]`; + } + const reward = b.reward_btc ? ` ${fmtBTC(b.reward_btc)}` : ""; + const hash = b.hash ? ` ${b.hash.slice(0, 16)}…` : ""; + log(` height=${b.height} ${b.found_at.slice(0, 19)}${reward}${hash}${status}`); + log(` chain="${b.chain ?? ""}" orphaned_at="${b.orphaned_at ?? ""}" hash="${b.hash ?? ""}"`); + } + } else { + log("● Blocks found: none yet"); + } + } + + // ── 1b. Debug: memory vs DB comparison ───────────────────────────────── + try { + const dbgRes = await effects.fetch(`${API_BASE}/api/admin/debug-blocks`); + const dbg = await dbgRes.json() as { + memory: { height: number; hash: string; chain: string; orphaned_at: string; found_at: string }[]; + db: { height: number; hash: string; chain: string; orphaned_at: string; found_at: string }[]; + }; + log(""); log("● Debug: memory vs DB"); + log(` Memory blocks: ${dbg.memory?.length ?? 0} DB blocks: ${dbg.db?.length ?? 0}`); + if (dbg.memory && dbg.memory.length > 0) { + for (const m of dbg.memory) { + const dbRow = dbg.db?.find(d => d.height === m.height); + const memOrph = m.orphaned_at || "none"; + const dbOrph = dbRow?.orphaned_at || "none"; + const match = memOrph === dbOrph ? "✓" : "MISMATCH"; + log(` h=${m.height} mem_orphan="${memOrph}" db_orphan="${dbOrph}" ${match}`); + if (memOrph !== dbOrph) { + log(` mem: chain="${m.chain}" hash="${(m.hash || "").slice(0, 20)}…"`); + log(` db: chain="${dbRow?.chain ?? "?"}" hash="${(dbRow?.hash || "").slice(0, 20)}…"`); + } + } + } + } catch (e) { + log(` (debug endpoint unavailable: ${(e as Error).message})`); + } + + // ── 2. Stratum ─────────────────────────────────────────────────────────── + log(""); sep(); + log(`● Stratum (port ${STRATUM_PORT})`); + if (snap?.ckpool_ok) { + log(" ckpool process: healthy — stratum port is served by the same process"); + } else { + log(" FAIL — ckpool is not running, stratum port will not accept connections"); + } + + // ── 3. Overall ────────────────────────────────────────────────────────── + log(""); sep(); + const summary = allPass && (snap?.ckpool_ok ?? false) + ? "PASS — pool is healthy" + : "FAIL — see details above"; + log(`Overall: ${summary}`); + + return { + result: { + version: "0", + message: summary, + value: lines.join("\n"), + copyable: true, + qr: false, + }, + }; + }, + + // ── Regenerate TLS certificate ────────────────────────────────────────────── + "regen-tls-cert": async (effects) => { + const VOLUME = "main"; + const TLS_PATH = "tls"; + const certFiles = ["stratum.crt", "stratum.key", "stratum.pem", "cert_version", "fingerprint.txt"]; + + const removed: string[] = []; + const missing: string[] = []; + for (const f of certFiles) { + try { + await effects.removeFile({ volumeId: VOLUME, path: `${TLS_PATH}/${f}` }); + removed.push(f); + } catch { + missing.push(f); + } + } + + const hadCert = removed.length > 0; + const message = hadCert + ? "TLS certificate cleared — restart the service to generate a new one" + : "No TLS certificate files found (TLS may not have been enabled)"; + + const detail = [ + hadCert ? `Removed: ${removed.join(", ")}` : "No certificate files were present.", + missing.length > 0 ? `Already absent: ${missing.join(", ")}` : "", + "", + "Next steps:", + " 1. Restart Kamado Pool.", + " 2. The new certificate fingerprint will appear in the service logs", + ' (search for "stratum TLS SHA256 fingerprint").', + " 3. Provide the new fingerprint to miners that pin the certificate.", + ].filter(Boolean).join("\n"); + + return { + result: { version: "0", message, value: detail, copyable: false, qr: false }, + }; + }, + +};