Files
timmy-tower/artifacts/api-server/src/routes/node-diagnostics.ts
alexpaynex ca94c0a9e5 Add Bitcoin/LND/LNbits local node setup scripts and node diagnostics endpoint
- scripts/bitcoin-ln-node/setup.sh: one-shot installer for Bitcoin Core (pruned mainnet), LND, and LNbits on Apple Silicon Mac. Generates secrets, writes configs, installs launchd plists for auto-start.
- scripts/bitcoin-ln-node/start.sh: start all services via launchctl; waits for RPC readiness and auto-unlocks LND wallet.
- scripts/bitcoin-ln-node/stop.sh: graceful shutdown (lncli stop → bitcoin-cli stop).
- scripts/bitcoin-ln-node/status.sh: full health check (Bitcoin sync %, LND channels/balance, LNbits HTTP, bore tunnel). Supports --json mode for machine consumption.
- scripts/bitcoin-ln-node/expose.sh: opens bore tunnel from LNbits port 5000 to bore.pub for Replit access.
- scripts/bitcoin-ln-node/get-lnbits-key.sh: fetches LNbits admin API key and prints Replit secret values.
- artifacts/api-server/src/routes/node-diagnostics.ts: GET /api/admin/node-status (JSON) and /api/admin/node-status/html — Timmy self-diagnoses its LNbits/LND connectivity and reports issues.
2026-03-18 21:58:41 +00:00

170 lines
5.4 KiB
TypeScript

import { Router, type IRouter } from "express";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
const router: IRouter = Router();
interface NodeStatus {
timestamp: string;
lnbits: LNbitsStatus;
lnd?: LNDStatus;
bitcoin?: BitcoinStatus;
overall: "ok" | "degraded" | "down";
issues: string[];
}
interface LNbitsStatus {
reachable: boolean;
url: string;
walletBalance?: number;
error?: string;
}
interface LNDStatus {
synced: boolean;
blockHeight: number;
activeChannels: number;
walletBalance: number;
channelBalance: number;
}
interface BitcoinStatus {
blocks: number;
headers: number;
syncPct: number;
pruned: boolean;
peers: number;
}
async function probeLNbits(): Promise<LNbitsStatus> {
const url = process.env.LNBITS_URL ?? "";
const key = process.env.LNBITS_API_KEY ?? "";
if (!url || !key) {
return { reachable: false, url: url || "(not configured)", error: "LNBITS_URL or LNBITS_API_KEY not set" };
}
try {
const res = await fetch(`${url}/api/v1/wallet`, {
headers: { "X-Api-Key": key },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { reachable: false, url, error: `HTTP ${res.status}` };
}
const data = (await res.json()) as { balance: number };
return { reachable: true, url, walletBalance: Math.round((data.balance ?? 0) / 1000) };
} catch (e) {
return { reachable: false, url, error: String(e) };
}
}
async function probeLNbitsNodeInfo(): Promise<{ lnd?: LNDStatus; bitcoin?: BitcoinStatus }> {
const url = process.env.LNBITS_URL ?? "";
const key = process.env.LNBITS_API_KEY ?? "";
if (!url || !key) return {};
try {
const res = await fetch(`${url}/api/v1/extension/lndhub/api/v1/getinfo`, {
headers: { "X-Api-Key": key },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return {};
const data = (await res.json()) as Record<string, unknown>;
const lnd: LNDStatus = {
synced: Boolean(data["synced_to_chain"]),
blockHeight: Number(data["block_height"] ?? 0),
activeChannels: Number(data["num_active_channels"] ?? 0),
walletBalance: 0,
channelBalance: 0,
};
return { lnd };
} catch {
return {};
}
}
router.get("/api/admin/node-status", async (_req, res) => {
const issues: string[] = [];
const [lnbits, nodeInfo] = await Promise.all([
probeLNbits(),
probeLNbitsNodeInfo(),
]);
if (!lnbits.reachable) {
issues.push(`LNbits unreachable: ${lnbits.error ?? "unknown"}`);
}
if (nodeInfo.lnd && !nodeInfo.lnd.synced) {
issues.push("LND not yet synced to chain tip");
}
const overall: NodeStatus["overall"] =
!lnbits.reachable ? "down" :
issues.length > 0 ? "degraded" :
"ok";
const status: NodeStatus = {
timestamp: new Date().toISOString(),
lnbits,
...nodeInfo,
overall,
issues,
};
res.json(status);
});
router.get("/api/admin/node-status/html", async (_req, res) => {
const issues: string[] = [];
const [lnbits, nodeInfo] = await Promise.all([probeLNbits(), probeLNbitsNodeInfo()]);
if (!lnbits.reachable) issues.push(`LNbits unreachable: ${lnbits.error ?? "unknown"}`);
if (nodeInfo.lnd && !nodeInfo.lnd.synced) issues.push("LND not yet synced");
const overall = !lnbits.reachable ? "down" : issues.length > 0 ? "degraded" : "ok";
const color = overall === "ok" ? "#22c55e" : overall === "degraded" ? "#f59e0b" : "#ef4444";
res.setHeader("Content-Type", "text/html");
res.send(`<!DOCTYPE html><html lang="en">
<head><meta charset="UTF-8"><title>Timmy — Node Status</title>
<style>
body { font-family: monospace; background:#0f172a; color:#e2e8f0; padding:2rem; }
h1 { color:#f8fafc; } .badge { padding:4px 12px; border-radius:4px; font-weight:bold; }
table { border-collapse:collapse; margin-top:1rem; }
td,th { padding:6px 16px; border-bottom:1px solid #1e293b; text-align:left; }
th { color:#94a3b8; } .ok{color:#22c55e} .warn{color:#f59e0b} .bad{color:#ef4444}
pre { background:#1e293b; padding:1rem; border-radius:6px; overflow:auto; }
</style></head><body>
<h1>⚡ Timmy Node Status</h1>
<p>Overall: <span class="badge" style="background:${color}20;color:${color}">${overall.toUpperCase()}</span></p>
<h2>LNbits</h2>
<table>
<tr><th>URL</th><td>${lnbits.url}</td></tr>
<tr><th>Reachable</th><td class="${lnbits.reachable ? "ok" : "bad"}">${lnbits.reachable ? "✓ yes" : "✗ no"}</td></tr>
${lnbits.walletBalance !== undefined ? `<tr><th>Wallet Balance</th><td>${lnbits.walletBalance} sats</td></tr>` : ""}
${lnbits.error ? `<tr><th>Error</th><td class="bad">${lnbits.error}</td></tr>` : ""}
</table>
${nodeInfo.lnd ? `<h2>LND</h2>
<table>
<tr><th>Synced</th><td class="${nodeInfo.lnd.synced ? "ok" : "warn"}">${nodeInfo.lnd.synced ? "✓ yes" : "⏳ syncing"}</td></tr>
<tr><th>Block Height</th><td>${nodeInfo.lnd.blockHeight.toLocaleString()}</td></tr>
<tr><th>Active Channels</th><td>${nodeInfo.lnd.activeChannels}</td></tr>
</table>` : ""}
${issues.length > 0 ? `<h2>Issues</h2><ul>${issues.map(i => `<li class="bad">${i}</li>`).join("")}</ul>` : ""}
<p style="color:#475569;margin-top:2rem;font-size:0.8rem">
Refreshed at ${new Date().toISOString()} &nbsp;·&nbsp;
<a href="/api/admin/node-status" style="color:#60a5fa">JSON</a> &nbsp;·&nbsp;
<a href="/api/ui" style="color:#60a5fa">Timmy UI</a>
</p>
</body></html>`);
});
export default router;