- 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.
170 lines
5.4 KiB
TypeScript
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()} ·
|
|
<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;
|