#!/usr/bin/env node /** * Txoko Node - System Metrics Server (v2 — ligero) * Expone métricas del sistema via HTTP en localhost. * * Cambios v2 respecto a v1: * - CPU medida leyendo /proc/stat dos veces con delta (sin spawnear `top`) * - Por-núcleo con uso ACTUAL real (antes era el acumulado desde el arranque) * - RPC de Bitcoin Core por HTTP nativo (sin `curl` → credenciales fuera de `ps`) * - Peers contados con getnetworkinfo (sin getpeerinfo, que es enorme) * - Caché con TTL: mide una vez cada pocos segundos, sirva a N navegadores * - Todo asíncrono: nada bloquea el servidor * * Setup: ninguno. Sin dependencias — solo Node.js. * node system-metrics.js * * Acceso: http://127.0.0.1:4082 */ const { exec } = require("child_process"); const fs = require("fs"); const os = require("os"); const http = require("http"); // ── CONFIGURACIÓN LOCAL ───────────────────────────────────────────────────── const RPC_HOST = "127.0.0.1"; const RPC_PORT = 8332; const RPC_USER = "TU_RPC_USER"; // bitcoin.conf → rpcuser const RPC_PASS = "TU_RPC_PASSWORD"; // bitcoin.conf → rpcpassword const PORT = 4082; const HOST = "127.0.0.1"; // ── FIN CONFIGURACIÓN ─────────────────────────────────────────────────────── // ── Helpers asíncronos ────────────────────────────────────────────────────── const readFile = (path) => { try { return fs.readFileSync(path, "utf8").trim(); } catch { return null; } }; const sh = (cmd) => new Promise((resolve) => { exec(cmd, { timeout: 5000 }, (err, stdout) => resolve(err ? null : stdout.toString().trim())); }); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // ── Caché con TTL y coalescencia de peticiones ────────────────────────────── // Mide una sola vez por ventana; si llegan peticiones mientras se calcula, // esperan a la misma promesa en vez de lanzar otro cálculo. const cache = new Map(); // key -> {ts, data, inflight} async function cached(key, ttlMs, fn) { const now = Date.now(); const entry = cache.get(key); if (entry) { if (entry.inflight) return entry.inflight; if (now - entry.ts < ttlMs) return entry.data; } const inflight = fn().then(data => { cache.set(key, { ts: Date.now(), data, inflight: null }); return data; }).catch(err => { cache.delete(key); throw err; }); cache.set(key, { ts: now, data: entry?.data, inflight }); return inflight; } // ── CPU: /proc/stat con delta entre dos lecturas (sin procesos externos) ──── function readProcStat() { const stat = readFile("/proc/stat"); if (!stat) return null; const out = {}; for (const line of stat.split("\n")) { const m = line.match(/^(cpu\d*)\s+(.+)$/); if (!m) continue; const nums = m[2].trim().split(/\s+/).map(Number); const idle = nums[3] + (nums[4] || 0); // idle + iowait const total = nums.reduce((a, b) => a + b, 0); out[m[1]] = { idle, total }; } return out; } async function getCpuUsage() { const s1 = readProcStat(); await sleep(500); const s2 = readProcStat(); if (!s1 || !s2) return { total: 0, cores: [] }; const usage = (a, b) => { const dt = b.total - a.total; const di = b.idle - a.idle; if (dt <= 0) return 0; return Math.min(100, Math.max(0, Math.round(((dt - di) / dt) * 100))); }; const total = usage(s1.cpu, s2.cpu); const cores = Object.keys(s2) .filter(k => /^cpu\d+$/.test(k)) .sort((a, b) => parseInt(a.slice(3)) - parseInt(b.slice(3))) .map((k, i) => ({ core: i, usage: s1[k] ? usage(s1[k], s2[k]) : 0 })); return { total, cores }; } // ── Temperatura: solo lecturas de /sys (sin `sensors`) ───────────────────── function getCpuTemp() { const paths = [ "/sys/class/thermal/thermal_zone0/temp", "/sys/class/thermal/thermal_zone1/temp", "/sys/class/hwmon/hwmon0/temp1_input", "/sys/class/hwmon/hwmon1/temp1_input", "/sys/class/hwmon/hwmon2/temp1_input", ]; for (const path of paths) { const val = readFile(path); if (val) { const temp = parseInt(val) / 1000; if (temp > 0 && temp < 150) return parseFloat(temp.toFixed(1)); } } return null; } // ── RAM desde /proc/meminfo ───────────────────────────────────────────────── function getRam() { try { const meminfo = readFile("/proc/meminfo"); if (!meminfo) throw new Error("no meminfo"); const get = key => { const m = meminfo.match(new RegExp(`^${key}:\\s+(\\d+)`, "m")); return m ? parseInt(m[1]) * 1024 : 0; }; const total = get("MemTotal"); const free = get("MemFree"); const buffers = get("Buffers"); const cached_ = get("Cached"); const reclaimable = get("SReclaimable"); const swapTotal = get("SwapTotal"); const swapFree = get("SwapFree"); const used = total - free - buffers - cached_ - reclaimable; const swapUsed = swapTotal - swapFree; const gb = v => parseFloat((v / 1e9).toFixed(1)); return { total_gb: gb(total), used_gb: gb(used), free_gb: gb(free), cached_gb: gb(cached_ + buffers + reclaimable), percent: Math.round((used / total) * 100), swap_total_gb: gb(swapTotal), swap_used_gb: gb(swapUsed), swap_percent: swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0, }; } catch { const total = os.totalmem(), free = os.freemem(), used = total - free; return { total_gb: parseFloat((total / 1e9).toFixed(1)), used_gb: parseFloat((used / 1e9).toFixed(1)), free_gb: parseFloat((free / 1e9).toFixed(1)), percent: Math.round((used / total) * 100), }; } } // ── Disco ─────────────────────────────────────────────────────────────────── async function getDisk() { const result = await sh("df -h / | tail -1"); if (!result) return null; const parts = result.split(/\s+/); return { total: parts[1], used: parts[2], free: parts[3], percent: parts[4] }; } // ── Procesos destacados (nombre limpio del binario) ──────────────────────── async function getProcesses() { const result = await sh("ps aux --no-headers --sort=-%mem | head -8"); if (!result) return []; return result.split("\n").map(line => { const parts = line.trim().split(/\s+/); // Nombre limpio: basename del ejecutable; si es un intérprete (node, // python...), añade el basename del script que ejecuta. const exe = (parts[10] || "").split("/").pop(); const interp = /^(node|python\d?|bash|sh|perl|ruby|java)$/.test(exe); let command = exe; if (interp) { // Saltar flags (--max-old-space-size=…, -e, etc.) y quedarse con el // primer argumento "real": el script (suele acabar en .js/.py o ser ruta) const args = parts.slice(11); const script = args.find(a => a && !a.startsWith("-")); if (script) { const name = script.split("/").pop(); command = `${exe} (${name})`; } } command = command.slice(0, 24); return { user: parts[0], cpu: parseFloat(parts[2]), mem: parseFloat(parts[3]), command, }; }).filter(p => p.mem > 0.5); } // ── Load / Uptime ─────────────────────────────────────────────────────────── function getLoad() { const load = os.loadavg(); return { "1m": load[0].toFixed(2), "5m": load[1].toFixed(2), "15m": load[2].toFixed(2) }; } function getUptime() { const s = os.uptime(); return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h ${Math.floor((s % 3600) / 60)}m`; } // ── Bitcoin Core RPC por HTTP nativo (sin curl, sin credenciales en ps) ──── function rpcCall(method, params = []) { const body = JSON.stringify({ jsonrpc: "1.0", id: "txoko", method, params }); const auth = Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString("base64"); return new Promise((resolve, reject) => { const req = http.request({ host: RPC_HOST, port: RPC_PORT, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Basic ${auth}` }, timeout: 8000, }, res => { let data = ""; res.on("data", c => data += c); res.on("end", () => { try { const parsed = JSON.parse(data); if (parsed.error) reject(new Error(parsed.error.message || "RPC error")); else resolve(parsed.result); } catch (e) { reject(e); } }); }); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("RPC timeout")); }); req.write(body); req.end(); }); } async function getBitcoinInfo() { try { const [info, netinfo, uptime] = await Promise.all([ rpcCall("getblockchaininfo"), rpcCall("getnetworkinfo"), rpcCall("uptime"), ]); if (!info || !netinfo) return null; // connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no let inbound = netinfo.connections_in; let outbound = netinfo.connections_out; if (inbound === undefined || outbound === undefined) { const peers = await rpcCall("getpeerinfo").catch(() => null); inbound = peers ? peers.filter(p => p.inbound).length : 0; outbound = peers ? peers.filter(p => !p.inbound).length : 0; } return { version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`, blocks: info.blocks, headers: info.headers, synced: info.verificationprogress > 0.9999, progress: (info.verificationprogress * 100).toFixed(2), size_gb: (info.size_on_disk / 1e9).toFixed(1), peers: netinfo.connections, inbound, outbound, uptime_sec: uptime, chain: info.chain, }; } catch { return null; } } // ── Logs ──────────────────────────────────────────────────────────────────── async function getLogs(service, lines = 50) { const result = await sh(`journalctl -u ${service} -n ${lines} --no-pager --output=short-iso 2>/dev/null`); if (!result) return []; return result.split("\n").filter(Boolean).reverse().map(line => { let level = "info"; if (/error/i.test(line)) level = "error"; else if (/warn/i.test(line)) level = "warn"; return { line, level }; }); } // ── Handlers (caché por ruta: el trabajo se hace 1 vez por ventana) ──────── const routes = { "/system/info": async () => cached("info", 3000, async () => { const [cpu, disk, processes] = await Promise.all([ getCpuUsage(), getDisk(), getProcesses(), ]); return { cpu: { usage: cpu.total, cores: cpu.cores, temp: getCpuTemp(), count: os.cpus().length, model: os.cpus()[0]?.model?.trim() || "—", }, ram: getRam(), disk, load: getLoad(), processes, uptime: getUptime(), os: `${os.type()} ${os.release()}`, }; }), "/system/bitcoin": async () => { const info = await cached("bitcoin", 5000, getBitcoinInfo); if (!info) return { __status: 503, error: "No se pudo conectar a Bitcoin Core" }; return info; }, "/system/logs/bitcoin": async (query) => { const lines = parseInt(query.get("lines")) || 50; const logs = await cached(`logs-btc-${lines}`, 10000, () => getLogs("bitcoind", lines)); return { logs }; }, "/system/logs/fulcrum": async (query) => { const lines = parseInt(query.get("lines")) || 50; const logs = await cached(`logs-ful-${lines}`, 10000, () => getLogs("fulcrum", lines)); return { logs }; }, "/system/health": async () => ({ status: "ok", timestamp: Date.now() }), }; // ── Servidor HTTP nativo (sin dependencias) ───────────────────────────────── const server = http.createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET"); res.setHeader("Content-Type", "application/json"); const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); const handler = routes[url.pathname]; if (!handler) { res.writeHead(404); return res.end(JSON.stringify({ error: "Not found" })); } try { const data = await handler(url.searchParams); if (data && data.__status) { const { __status, ...body } = data; res.writeHead(__status); return res.end(JSON.stringify(body)); } res.writeHead(200); res.end(JSON.stringify(data)); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message || "Internal error" })); } }); // ── Start ─────────────────────────────────────────────────────────────────── server.listen(PORT, HOST, () => { console.log(` ₿ Txoko System Metrics v2 ───────────────────────────────── Escuchando en http://${HOST}:${PORT} Solo accesible desde localhost Caché: info 3s · bitcoin 5s · logs 10s ───────────────────────────────── GET /system/info → CPU, RAM, disco, procesos GET /system/bitcoin → Bitcoin Core info GET /system/logs/bitcoin → logs bitcoind GET /system/logs/fulcrum → logs fulcrum `); });