'use strict'; // Host/system sampling for the system-status page (panel-go/sysstat.go // mirrors this file). Samples are taken at most every ~30s (housekeeping // cadence and on-demand from /api/system when stale) and kept in a // memory-only ring buffer (240 entries ≈ 4h at 60s cadence, tens of KB). // Nothing here writes to the data file: the "today" traffic baseline is // also memory-only, so a panel restart restarts the day counter from // the traffic seen since boot (named trade-off, see UI-RESKIN-REPORT). const fs = require('node:fs'); const os = require('node:os'); const { customerOnline } = require('./presence'); const HISTORY_CAP = 240; const SAMPLE_MIN_INTERVAL_MS = 25000; function readCpuTimes() { try { const line = fs.readFileSync('/proc/stat', 'utf8').split('\n')[0]; const parts = line.trim().split(/\s+/).slice(1).map(Number); if (parts.length < 5 || parts.some((n) => !Number.isFinite(n))) return null; const idle = parts[3] + (parts[4] || 0); const total = parts.reduce((a, b) => a + b, 0); return { idle, total }; } catch { return null; } } function readMem() { try { const text = fs.readFileSync('/proc/meminfo', 'utf8'); const grab = (key) => { const match = new RegExp(`^${key}:\\s+(\\d+)`, 'm').exec(text); return match ? Number(match[1]) * 1024 : 0; }; const total = grab('MemTotal'); const available = grab('MemAvailable'); return { total, used: total > 0 ? total - available : 0 }; } catch { return { total: 0, used: 0 }; } } function isGlobalIPv4(ip) { const parts = String(ip).split('.').map(Number); if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; const [a, b] = parts; if (a === 0 || a === 10 || a === 127) return false; if (a === 172 && b >= 16 && b <= 31) return false; if (a === 192 && b === 168) return false; if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT if (a === 169 && b === 254) return false; // link-local if (a >= 224) return false; // multicast / reserved return true; } function isGlobalIPv6(ip) { const lower = String(ip).toLowerCase(); if (lower === '::1' || lower === '::') return false; if (/^f[cd]/.test(lower)) return false; // ULA fc00::/7 if (/^fe[89ab]/.test(lower)) return false; // link-local fe80::/10 if (lower.startsWith('ff')) return false; // multicast return lower.includes(':'); } // Pick the host's global addresses from the interface list (3x-ui // 口径: local interface addresses, no external lookup of any kind). function hostIPs() { let ipv4 = null; let ipv6 = null; const interfaces = os.networkInterfaces(); for (const list of Object.values(interfaces)) { for (const info of list || []) { if (info.internal) continue; if (info.family === 'IPv4' && !ipv4 && isGlobalIPv4(info.address)) ipv4 = info.address; if (info.family === 'IPv6' && !ipv6 && isGlobalIPv6(info.address)) ipv6 = info.address; } } return { ipv4, ipv6 }; } class SystemMonitor { constructor(dataDir) { this.dataDir = dataDir; this.startedAt = Date.now(); this.prevCpu = null; this.cpuPercent = null; this.history = []; this.lastSampleAt = 0; this.dayKey = null; this.dayBaseUp = 0; this.dayBaseDown = 0; this.todayUp = 0; this.todayDown = 0; this.current = null; } sample(data, force = false) { const now = Date.now(); if (!force && now - this.lastSampleAt < SAMPLE_MIN_INTERVAL_MS) return this.current; this.lastSampleAt = now; const cpu = readCpuTimes(); if (cpu && this.prevCpu) { const totalDelta = cpu.total - this.prevCpu.total; const idleDelta = cpu.idle - this.prevCpu.idle; if (totalDelta > 0) this.cpuPercent = Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100)); } if (cpu) this.prevCpu = cpu; const mem = readMem(); let disk = { total: 0, used: 0 }; try { const stat = fs.statfsSync(this.dataDir); disk = { total: stat.blocks * stat.bsize, used: (stat.blocks - stat.bfree) * stat.bsize }; } catch { /* keep zeros */ } let totalUp = 0; let totalDown = 0; let upBps = 0; let downBps = 0; for (const node of data.nodes || []) { if (node.meteredTraffic) { totalUp += Number(node.meteredTraffic.uplink || 0); totalDown += Number(node.meteredTraffic.downlink || 0); } if (node.speedAt && now - Date.parse(node.speedAt) <= 150000) { upBps += Number(node.speedUpBps || 0); downBps += Number(node.speedDownBps || 0); } } const dayKey = new Date(now).toLocaleDateString('en-CA'); if (this.dayKey !== dayKey) { this.dayKey = dayKey; this.dayBaseUp = totalUp; this.dayBaseDown = totalDown; } this.todayUp = Math.max(0, totalUp - this.dayBaseUp); this.todayDown = Math.max(0, totalDown - this.dayBaseDown); const connections = (data.customers || []).filter((c) => customerOnline(c, now)).length; const entry = { at: new Date(now).toISOString(), cpu: this.cpuPercent == null ? null : Math.round(this.cpuPercent * 10) / 10, mem: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null, upBps, downBps, connections, }; this.history.push(entry); if (this.history.length > HISTORY_CAP) this.history.splice(0, this.history.length - HISTORY_CAP); this.current = { at: entry.at, cpuPercent: entry.cpu, mem, disk, upBps, downBps, connections, totalUp, totalDown, }; return this.current; } status(data, version) { const current = this.sample(data) || { cpuPercent: null, mem: readMem(), disk: { total: 0, used: 0 }, upBps: 0, downBps: 0, connections: 0, totalUp: 0, totalDown: 0, }; const mem = current.mem; const disk = current.disk; const onlineNodes = (data.nodes || []).filter((n) => n.status === 'active').length; return { version, startedAt: new Date(this.startedAt).toISOString(), panelUptimeSec: Math.floor((Date.now() - this.startedAt) / 1000), hostUptimeSec: Math.floor(os.uptime()), panelMemBytes: process.memoryUsage().rss, host: { cpuPercent: current.cpuPercent, cpuCount: os.cpus().length, load1: os.loadavg()[0], memTotal: mem.total, memUsed: mem.used, memPercent: mem.total > 0 ? Math.round((mem.used / mem.total) * 1000) / 10 : null, diskTotal: disk.total, diskUsed: disk.used, diskPercent: disk.total > 0 ? Math.round((disk.used / disk.total) * 1000) / 10 : null, }, network: { upBps: current.upBps, downBps: current.downBps, connections: current.connections, onlineCustomers: current.connections, onlineNodes, nodesTotal: (data.nodes || []).length, customersTotal: (data.customers || []).length, }, today: { upBytes: this.todayUp, downBytes: this.todayDown }, totals: { upBytes: current.totalUp, downBytes: current.totalDown }, ips: hostIPs(), history: this.history, }; } } module.exports = { SystemMonitor, hostIPs, isGlobalIPv4, isGlobalIPv6, readCpuTimes, readMem };