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.
This commit is contained in:
@@ -7,6 +7,7 @@ import demoRouter from "./demo.js";
|
||||
import devRouter from "./dev.js";
|
||||
import testkitRouter from "./testkit.js";
|
||||
import uiRouter from "./ui.js";
|
||||
import nodeDiagnosticsRouter from "./node-diagnostics.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -17,6 +18,7 @@ router.use(sessionsRouter);
|
||||
router.use(demoRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
router.use(nodeDiagnosticsRouter);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
router.use(devRouter);
|
||||
|
||||
169
artifacts/api-server/src/routes/node-diagnostics.ts
Normal file
169
artifacts/api-server/src/routes/node-diagnostics.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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()} ·
|
||||
<a href="/api/admin/node-status" style="color:#60a5fa">JSON</a> ·
|
||||
<a href="/api/ui" style="color:#60a5fa">Timmy UI</a>
|
||||
</p>
|
||||
</body></html>`);
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user