@daemonitor/plugins 1.0.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ import { stat, open } from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
4
+ // Cap a single tail read so a rotated-in giant file (or fromStart on a large
5
+ // log) can't blow up memory — read at most the last MAX_READ bytes.
6
+ const MAX_READ = 5 * 1024 * 1024;
7
+ // Field-position TOLERANT parsing. Log layouts vary a lot across these hosts:
8
+ // • standard combined: `1.2.3.4 - - [t] "GET / HTTP/1.1" 200 ...`
9
+ // • vhost-first: `example.com - 1.2.3.4 - - [t] "GET / HTTP/1.1" 200`
10
+ // • CF combined_with_host: `host - <edge_ip> - <cf_connecting_ip> - - [t] "GET .." "200"`
11
+ // So neither the IP column NOR the status quoting is fixed. We therefore:
12
+ // 1. pull the request + status from the quoted "$request" run (status may be
13
+ // bare or quoted → allow an optional leading quote), and
14
+ // 2. take the LAST IP token BEFORE the `[timestamp]` as the client. That is
15
+ // the real requester in every layout here: when Cloudflare's
16
+ // $http_cf_connecting_ip is present it is logged AFTER $remote_addr (the CF
17
+ // edge), so "last IP" is the visitor, not the edge; when it's absent there
18
+ // is a single IP ($remote_addr) and "last" == "only".
19
+ const REQ = /"([A-Z]+)\s+([^ "]+)[^"]*"\s+"?(\d{3})/;
20
+ const IP_TOKEN = /^(?:(?:\d{1,3}\.){3}\d{1,3}|(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4})$/;
21
+ function parseLine(line) {
22
+ const rq = REQ.exec(line);
23
+ if (!rq)
24
+ return null;
25
+ const br = line.indexOf("[");
26
+ const pre = br > 0 ? line.slice(0, br) : line;
27
+ const tokens = pre.split(/\s+/);
28
+ const ips = tokens.filter((t) => IP_TOKEN.test(t));
29
+ const ip = ips.length ? ips[ips.length - 1] : (tokens.find(Boolean) || "");
30
+ return { ip, method: rq[1], path: rq[2], status: Number(rq[3]), ua: "" };
31
+ }
32
+ const SUSPICIOUS_QUERY = /(?:^|[?&])(c|k|cmd|exec|e|q|f|file|dir|download|0|1|a)=/i;
33
+ const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
34
+ const UPLOADS_PHP = /\/wp-content\/uploads\/.*\.php$/i;
35
+ const PLUGIN_PHP = /\/wp-content\/(?:plugins|mu-plugins)\/.*\.php$/i;
36
+ const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
37
+ // Read new bytes since last offset, advancing state. Handles rotation (file
38
+ // shrank → restart at 0) and partial trailing lines (buffered in remainder).
39
+ async function readNew(source, st) {
40
+ let size;
41
+ try {
42
+ size = (await stat(source.path)).size;
43
+ }
44
+ catch {
45
+ return []; // file missing this cycle; try again next tick
46
+ }
47
+ if (!st.inited) {
48
+ st.offset = source.fromStart ? 0 : size;
49
+ st.remainder = "";
50
+ st.inited = true;
51
+ }
52
+ if (size < st.offset) {
53
+ // Truncated or rotated in place.
54
+ st.offset = 0;
55
+ st.remainder = "";
56
+ }
57
+ if (size <= st.offset)
58
+ return []; // nothing new
59
+ let start = st.offset;
60
+ if (size - start > MAX_READ)
61
+ start = size - MAX_READ; // bound the read
62
+ const len = size - start;
63
+ const buf = Buffer.allocUnsafe(len);
64
+ const fh = await open(source.path, "r");
65
+ try {
66
+ await fh.read(buf, 0, len, start);
67
+ }
68
+ finally {
69
+ await fh.close();
70
+ }
71
+ st.offset = size;
72
+ const text = st.remainder + buf.toString("utf8");
73
+ const lines = text.split("\n");
74
+ st.remainder = lines.pop() ?? ""; // last element is a partial line (no newline)
75
+ return lines;
76
+ }
77
+ function analyze(lines, cfg, windowSec) {
78
+ const burstThreshold = cfg.xmlrpcBurstThreshold ?? 20;
79
+ const batchIps = new Map(); // ip → count of batch/v1 → 207
80
+ const loginOkIps = new Set(); // ip with POST wp-login → 302
81
+ const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
82
+ const webshell = new Map(); // pathNoQuery → sample
83
+ let total = 0;
84
+ for (const line of lines) {
85
+ const e = parseLine(line);
86
+ if (!e)
87
+ continue;
88
+ total++;
89
+ const qIdx = e.path.indexOf("?");
90
+ const pathNoQuery = qIdx === -1 ? e.path : e.path.slice(0, qIdx);
91
+ const query = qIdx === -1 ? "" : e.path.slice(qIdx + 1);
92
+ if ((BATCH_PATH.test(e.path) || pathNoQuery === "/wp-json/batch/v1") && e.status === 207) {
93
+ batchIps.set(e.ip, (batchIps.get(e.ip) || 0) + 1);
94
+ }
95
+ if (pathNoQuery === "/wp-login.php" && e.method === "POST" && e.status === 302) {
96
+ loginOkIps.add(e.ip);
97
+ }
98
+ if (pathNoQuery === "/xmlrpc.php" && e.method === "POST") {
99
+ xmlrpcByIp.set(e.ip, (xmlrpcByIp.get(e.ip) || 0) + 1);
100
+ }
101
+ // A .php under uploads is never legitimate; under a plugin dir it's only
102
+ // suspicious with a command-style or base64 query (webshell invocation).
103
+ // BUT only a SUCCESSFUL (2xx) response means the PHP actually executed —
104
+ // every WP site is probed constantly for my.php / academy.php / etc., and
105
+ // those hits 404 (not there) or 403 (WAF-blocked). Alerting on the probes
106
+ // is pure noise; alert only when the shell actually RAN.
107
+ const executed = e.status >= 200 && e.status < 300;
108
+ const isUploadsPhp = UPLOADS_PHP.test(pathNoQuery);
109
+ const isPluginShell = PLUGIN_PHP.test(pathNoQuery) && (SUSPICIOUS_QUERY.test(query) || BASE64_BLOB.test(query));
110
+ if ((isUploadsPhp || isPluginShell) && executed) {
111
+ const cur = webshell.get(pathNoQuery) || { ip: e.ip, count: 0 };
112
+ cur.count++;
113
+ webshell.set(pathNoQuery, cur);
114
+ }
115
+ }
116
+ const findings = [];
117
+ // Correlation is the highest-signal event — flag it before the standalone
118
+ // batch/v1 finding so it leads.
119
+ for (const [ip, count] of batchIps) {
120
+ if (loginOkIps.has(ip)) {
121
+ findings.push({
122
+ rule: "batch-then-login", key: `batch-login:${ip}`, severity: "error", ip,
123
+ path: "/wp-json/batch/v1",
124
+ title: "WP2Shell: exploit + admin login",
125
+ detail: `${ip}: batch/v1 exploit (${count}×) followed by a successful wp-login (HTTP 302) — likely account takeover`,
126
+ });
127
+ }
128
+ else {
129
+ findings.push({
130
+ rule: "wp-batch-207", key: `batchv1:${ip}`, severity: "error", ip,
131
+ path: "/wp-json/batch/v1",
132
+ title: "WP2Shell batch/v1 exploit",
133
+ detail: `${ip}: POST /wp-json/batch/v1 → 207 (${count}×) — near-zero false positives`,
134
+ });
135
+ }
136
+ }
137
+ for (const [p, v] of webshell) {
138
+ findings.push({
139
+ rule: "webshell-exec", key: `webshell:${p}`, severity: "error", ip: v.ip, path: p,
140
+ title: "Webshell execution",
141
+ detail: `${v.ip} invoked ${p} (${v.count}×) — executable PHP in an upload/plugin path`,
142
+ });
143
+ }
144
+ for (const [ip, count] of xmlrpcByIp) {
145
+ if (count >= burstThreshold) {
146
+ findings.push({
147
+ rule: "xmlrpc-burst", key: `xmlrpc:${ip}`, severity: count >= burstThreshold * 5 ? "error" : "warning", ip,
148
+ path: "/xmlrpc.php",
149
+ title: "xmlrpc.php brute-force / amplification",
150
+ detail: `${ip}: ${count} POST /xmlrpc.php in ~${windowSec}s (threshold ${burstThreshold})`,
151
+ });
152
+ }
153
+ }
154
+ const counts = {
155
+ total,
156
+ batchV1: [...batchIps.values()].reduce((a, b) => a + b, 0),
157
+ xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
158
+ webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
159
+ loginSuccess: loginOkIps.size,
160
+ };
161
+ return { findings, counts };
162
+ }
163
+ function slug(s) {
164
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
165
+ }
166
+ export function createLogWatchPlugin() {
167
+ let refreshTimer = null;
168
+ const states = new Map();
169
+ const sourcesOf = (plugin) => {
170
+ const cfg = (plugin.config || {});
171
+ if (Array.isArray(cfg.sources) && cfg.sources.length)
172
+ return cfg.sources;
173
+ if (cfg.path)
174
+ return [{ name: cfg.name, path: cfg.path, fromStart: cfg.fromStart }];
175
+ return [];
176
+ };
177
+ const refreshFn = async (plugin) => {
178
+ const cfg = (plugin.config || {});
179
+ const windowSec = Math.round((cfg.refreshInterval || 60000) / 1000);
180
+ for (const source of sourcesOf(plugin)) {
181
+ if (!source?.path)
182
+ continue;
183
+ const label = source.name || path.basename(source.path);
184
+ const uid = `log-${slug(source.name || source.path)}`;
185
+ let st = states.get(source.path);
186
+ if (!st) {
187
+ st = { offset: 0, remainder: "", inited: false };
188
+ states.set(source.path, st);
189
+ }
190
+ try {
191
+ const lines = await readNew(source, st);
192
+ const { findings, counts } = analyze(lines, cfg, windowSec);
193
+ await plugin.send({ name: label, source: label, path: source.path, window: windowSec, counts, findings }, uid);
194
+ }
195
+ catch (e) {
196
+ await plugin.send({ name: label, source: label, path: source.path, error: String(e?.message || e).slice(0, 160), findings: [] }, uid);
197
+ }
198
+ }
199
+ };
200
+ const monitorFn = async (plugin) => {
201
+ await refreshFn(plugin);
202
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
203
+ };
204
+ const teardownFn = async () => {
205
+ if (refreshTimer)
206
+ clearInterval(refreshTimer);
207
+ refreshTimer = null;
208
+ states.clear();
209
+ };
210
+ return createMonitoringPlugin("logwatch", "logwatch", "Access-log security watcher (WP2Shell / webshell / xmlrpc)", async () => { }, monitorFn, refreshFn, teardownFn);
211
+ }
212
+ export default createLogWatchPlugin;
@@ -0,0 +1,126 @@
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
2
+ import { exec } from "child_process";
3
+ import { promisify } from "util";
4
+ import { hostname } from "os";
5
+ const execAsync = promisify(exec);
6
+ const EXEC_OPTS = { maxBuffer: 32 * 1024 * 1024 };
7
+ /** Parse LXD memory-limit strings like "1GB", "512MiB", "2GiB" to bytes. */
8
+ function limitToBytes(value) {
9
+ if (!value)
10
+ return 0;
11
+ const unit = String(value).replace(/[0-9.\s]/g, "").toUpperCase();
12
+ const num = parseFloat(String(value).replace(/[^0-9.]/g, "")) || 0;
13
+ const mult = {
14
+ "": 1, B: 1, KB: 1e3, KIB: 1024, MB: 1e6, MIB: 1024 ** 2,
15
+ GB: 1e9, GIB: 1024 ** 3, TB: 1e12, TIB: 1024 ** 4,
16
+ };
17
+ return Math.round(num * (mult[unit] ?? 1));
18
+ }
19
+ export function createLxcPlugin() {
20
+ let refreshTimer = null;
21
+ let available = false;
22
+ let lxcBin = "lxc";
23
+ // cumulative CPU usage (ns) + wall clock (ms) per instance, for %-of-core calc
24
+ const prevCpu = new Map();
25
+ const setupFn = async (plugin) => {
26
+ lxcBin = plugin.config?.lxcBin || plugin.config?.bin || "lxc";
27
+ try {
28
+ await execAsync(`${lxcBin} version`, EXEC_OPTS);
29
+ available = true;
30
+ }
31
+ catch (err) {
32
+ available = false;
33
+ console.error(`lxc: "${lxcBin}" not available — plugin idle:`, err.message);
34
+ }
35
+ };
36
+ const refreshFn = async (plugin) => {
37
+ if (!available)
38
+ return;
39
+ try {
40
+ const { stdout } = await execAsync(`${lxcBin} list --format json`, EXEC_OPTS);
41
+ const instances = JSON.parse(stdout || "[]");
42
+ const only = plugin.config?.instances || [];
43
+ const exclude = plugin.config?.exclude || [];
44
+ const host = hostname();
45
+ const now = Date.now();
46
+ const groups = new Map();
47
+ for (const inst of instances) {
48
+ const name = inst.name;
49
+ if (!name)
50
+ continue;
51
+ if (only.length && !only.includes(name))
52
+ continue;
53
+ if (exclude.includes(name))
54
+ continue;
55
+ const running = String(inst.status || "").toLowerCase() === "running";
56
+ const st = inst.state || {};
57
+ // CPU %: delta of cumulative usage(ns) over wall time -> % of one core
58
+ const usage = Number(st.cpu?.usage) || 0;
59
+ let cpu = 0;
60
+ const p = prevCpu.get(name);
61
+ if (running && p && now > p.t) {
62
+ const dUsage = usage - p.usage;
63
+ const dWallNs = (now - p.t) * 1e6;
64
+ if (dWallNs > 0 && dUsage >= 0)
65
+ cpu = Math.round((dUsage / dWallNs) * 1000) / 10;
66
+ }
67
+ prevCpu.set(name, { usage, t: now });
68
+ const mem = Number(st.memory?.usage) || 0;
69
+ const memLimit = limitToBytes(inst.config?.["limits.memory"] || inst.expanded_config?.["limits.memory"]);
70
+ const memPercent = memLimit ? Math.round((mem / memLimit) * 1000) / 10 : 0;
71
+ const container = {
72
+ id: name,
73
+ name,
74
+ service: "",
75
+ image: inst.config?.["image.description"] || inst.type || "lxc",
76
+ state: String(inst.status || "").toLowerCase(),
77
+ status: inst.status || "",
78
+ running,
79
+ health: "",
80
+ cpu,
81
+ mem,
82
+ memLimit,
83
+ memPercent,
84
+ restarts: 0,
85
+ exitCode: 0,
86
+ };
87
+ // Grouping: a user.daemonitor.group config key, else the host.
88
+ const group = inst.config?.["user.daemonitor.group"] ||
89
+ inst.expanded_config?.["user.daemonitor.group"] || host;
90
+ const arr = groups.get(group) || [];
91
+ arr.push(container);
92
+ groups.set(group, arr);
93
+ }
94
+ for (const [project, containers] of groups) {
95
+ const running = containers.filter((c) => c.running).length;
96
+ const payload = {
97
+ name: project,
98
+ engine: "lxc",
99
+ host,
100
+ project,
101
+ running,
102
+ total: containers.length,
103
+ containers,
104
+ timestamp: now,
105
+ };
106
+ await plugin.send(payload, `lxc-${project}`);
107
+ }
108
+ }
109
+ catch (err) {
110
+ console.error("lxc: refresh error:", err.message);
111
+ }
112
+ };
113
+ const monitorFn = async (plugin) => {
114
+ if (!available)
115
+ return;
116
+ await refreshFn(plugin);
117
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
118
+ };
119
+ const teardownFn = async () => {
120
+ if (refreshTimer)
121
+ clearInterval(refreshTimer);
122
+ refreshTimer = null;
123
+ };
124
+ return createMonitoringPlugin("lxc", "lxc", "LXC / LXD / Incus container monitoring plugin", setupFn, monitorFn, refreshFn, teardownFn);
125
+ }
126
+ export default createLxcPlugin;
@@ -0,0 +1,77 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
4
+ // LAN device reachability monitor. Pings a configured list of devices (the
5
+ // office infrastructure — switch, printers, APs, extenders, PBX, router, NAS,
6
+ // phones — plus any hosts worth watching) and reports up/down + latency for
7
+ // each. No credentials needed: pure ICMP. Richer per-device metrics (printer
8
+ // toner/pages, switch ports) come from the companion SNMP plugin.
9
+ //
10
+ // One client_state per device (type "netdevice", uid net-<slug>), so each shows
11
+ // as its own service and can be grouped (e.g. under "New York Office").
12
+ const pexec = promisify(execFile);
13
+ function slug(s) {
14
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
15
+ }
16
+ // Ping via the system `ping` (works unprivileged in a host-network container).
17
+ // Returns reachability + average RTT in ms. Never throws — an unreachable host
18
+ // exits non-zero, which we read as down.
19
+ async function pingHost(ip, count, timeout) {
20
+ try {
21
+ const { stdout } = await pexec("ping", ["-c", String(count), "-W", String(timeout), ip], {
22
+ timeout: (timeout * count + 2) * 1000,
23
+ });
24
+ // "rtt min/avg/max/mdev = 0.3/0.5/0.7/0.1 ms" (iputils) or a single "time=x ms"
25
+ const avg = stdout.match(/=\s*[\d.]+\/([\d.]+)\//);
26
+ const single = stdout.match(/time[=<]\s*([\d.]+)/);
27
+ const lossM = stdout.match(/([\d.]+)%\s*packet loss/);
28
+ const latency = avg ? parseFloat(avg[1]) : single ? parseFloat(single[1]) : undefined;
29
+ const loss = lossM ? parseFloat(lossM[1]) : undefined;
30
+ // 100% loss can still exit 0 on some ping builds — treat as down.
31
+ if (loss === 100)
32
+ return { up: false, latency: undefined, loss };
33
+ return { up: true, latency: latency != null ? Math.round(latency * 10) / 10 : undefined, loss };
34
+ }
35
+ catch {
36
+ return { up: false };
37
+ }
38
+ }
39
+ export function createNetScanPlugin() {
40
+ let refreshTimer = null;
41
+ const devicesOf = (plugin) => {
42
+ const cfg = (plugin.config || {});
43
+ return Array.isArray(cfg.devices) ? cfg.devices.filter((d) => d?.ip) : [];
44
+ };
45
+ const refreshFn = async (plugin) => {
46
+ const cfg = (plugin.config || {});
47
+ const count = cfg.pingCount || 2;
48
+ const timeout = cfg.pingTimeout || 2;
49
+ const devices = devicesOf(plugin);
50
+ // Ping all devices concurrently — a scan of ~dozens stays well under the
51
+ // refresh interval.
52
+ await Promise.all(devices.map(async (d) => {
53
+ const r = await pingHost(d.ip, count, timeout);
54
+ await plugin.send({
55
+ name: d.name,
56
+ ip: d.ip,
57
+ type: d.type || "host",
58
+ mac: d.mac,
59
+ up: r.up,
60
+ latency: r.latency,
61
+ loss: r.loss,
62
+ warnMs: d.warnMs ?? 200,
63
+ }, `net-${slug(d.name || d.ip)}`);
64
+ }));
65
+ };
66
+ const monitorFn = async (plugin) => {
67
+ await refreshFn(plugin);
68
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 30000);
69
+ };
70
+ const teardownFn = async () => {
71
+ if (refreshTimer)
72
+ clearInterval(refreshTimer);
73
+ refreshTimer = null;
74
+ };
75
+ return createMonitoringPlugin("netscan", "netscan", "LAN device reachability (ping) monitor", async () => { }, monitorFn, refreshFn, teardownFn);
76
+ }
77
+ export default createNetScanPlugin;
@@ -1,29 +1,175 @@
1
- import { arch, cpus, freemem, hostname, loadavg, networkInterfaces, platform, release, totalmem, type, uptime } from "os";
2
- import { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
- export class OSPlugin extends MonitoringPlugin {
4
- constructor() {
5
- super("os", "OS", "OS Monitoring Plugin");
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
2
+ import { arch, cpus, freemem, hostname, loadavg, networkInterfaces, platform, release, totalmem, type, uptime, } from "os";
3
+ import { readFileSync } from "fs";
4
+ import { execSync } from "child_process";
5
+ import { parseProcesses, PS_CMD } from "../../lib/processes";
6
+ // Named service processes (nginx / php-fpm / db / …) running on the host.
7
+ function collectProcesses() {
8
+ try {
9
+ return parseProcesses(execSync(PS_CMD, { encoding: "utf8", timeout: 5000 }));
6
10
  }
7
- async setup() {
8
- // no setup required
9
- return Promise.resolve();
11
+ catch {
12
+ return [];
10
13
  }
11
- async refresh() {
14
+ }
15
+ // Pseudo / virtual filesystems that aren't real drives.
16
+ const PSEUDO_FS = new Set([
17
+ "tmpfs", "devtmpfs", "squashfs", "overlay", "overlayfs", "aufs", "proc", "sysfs",
18
+ "cgroup", "cgroup2", "mqueue", "debugfs", "tracefs", "devpts", "ramfs", "nsfs",
19
+ "binfmt_misc", "configfs", "pstore", "efivarfs", "autofs", "fusectl", "securityfs",
20
+ "hugetlbfs", "none", "fuse.gvfsd-fuse", "fuse.portal", "fuse.snapfuse",
21
+ ]);
22
+ // Real, physical/attached drives only. Shells out to `df` and filters out
23
+ // pseudo filesystems, loop/snap mounts, and bind-mount duplicates.
24
+ function collectDisks() {
25
+ try {
26
+ const darwin = platform() === "darwin";
27
+ // Linux: `-T` gives the fstype column; bytes via --block-size=1.
28
+ // macOS: no fstype flag, `-l` restricts to local filesystems.
29
+ const cmd = darwin ? "df -kl" : "df -PT --block-size=1";
30
+ const out = execSync(cmd, { encoding: "utf8", timeout: 5000 });
31
+ const raw = [];
32
+ for (const line of out.split("\n").slice(1)) {
33
+ const p = line.trim().split(/\s+/);
34
+ if (p.length < 6)
35
+ continue;
36
+ let device, fstype, total, used, free, mount;
37
+ if (darwin) {
38
+ // Filesystem 1K-blocks Used Avail Capacity iused ifree %iused Mounted-on
39
+ device = p[0];
40
+ fstype = "";
41
+ total = (Number(p[1]) || 0) * 1024;
42
+ used = (Number(p[2]) || 0) * 1024;
43
+ free = (Number(p[3]) || 0) * 1024;
44
+ mount = p.slice(8).join(" ");
45
+ if (!device.startsWith("/dev/"))
46
+ continue;
47
+ // macOS/APFS spawns many synthetic /dev/diskNsM volumes. The real
48
+ // main-disk usage lives on the Data volume (the "/" snapshot reads
49
+ // near-empty); real external drives mount under /Volumes. Drop the
50
+ // rest (/System/Volumes/*, CoreSimulator, etc.).
51
+ if (mount === "/System/Volumes/Data")
52
+ mount = "/";
53
+ else if (mount !== "/" && !mount.startsWith("/Volumes/"))
54
+ continue;
55
+ }
56
+ else {
57
+ // Filesystem Type 1B-blocks Used Available Capacity Mounted-on
58
+ device = p[0];
59
+ fstype = p[1];
60
+ total = Number(p[2]) || 0;
61
+ used = Number(p[3]) || 0;
62
+ free = Number(p[4]) || 0;
63
+ mount = p.slice(6).join(" ");
64
+ if (PSEUDO_FS.has(fstype))
65
+ continue;
66
+ }
67
+ if (total <= 0 || device === "overlay" || device === "none" || device.startsWith("/dev/loop"))
68
+ continue;
69
+ raw.push({ device, fstype, mount, total, used, free, pct: total ? Math.round((used / total) * 100) : 0 });
70
+ }
71
+ // Dedupe by mount, keeping the fullest entry — collapses bind mounts, and on
72
+ // macOS picks the real Data volume over the near-empty read-only "/" snapshot.
73
+ const byMount = new Map();
74
+ for (const d of raw) {
75
+ const cur = byMount.get(d.mount);
76
+ if (!cur || d.used > cur.used)
77
+ byMount.set(d.mount, d);
78
+ }
79
+ return Array.from(byMount.values()).sort((a, b) => b.pct - a.pct);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
85
+ // True available memory: on Linux, os.freemem() returns MemFree, which excludes
86
+ // reclaimable page cache and makes hosts look near-full. MemAvailable is the
87
+ // kernel's estimate of memory obtainable without swapping. Fall back to freemem()
88
+ // on non-Linux (or if /proc is unreadable).
89
+ function availableMem() {
90
+ if (platform() === "linux") {
91
+ try {
92
+ const info = readFileSync("/proc/meminfo", "utf8");
93
+ const m = info.match(/^MemAvailable:\s+(\d+)\s*kB/m);
94
+ if (m)
95
+ return parseInt(m[1], 10) * 1024;
96
+ }
97
+ catch {
98
+ // fall through to freemem()
99
+ }
100
+ }
101
+ return freemem();
102
+ }
103
+ // Cumulative rx/tx byte counters summed across real interfaces (Linux
104
+ // /proc/net/dev). Loopback and virtual bridges (docker/veth/lxc/br-) are skipped
105
+ // so the figure reflects actual host network traffic, not container-to-host
106
+ // chatter. Returns null off Linux (rate is then omitted, not faked).
107
+ function readNetTotals() {
108
+ if (platform() !== "linux")
109
+ return null;
110
+ try {
111
+ const data = readFileSync("/proc/net/dev", "utf8");
112
+ let rx = 0, tx = 0;
113
+ for (const line of data.split("\n")) {
114
+ const m = line.match(/^\s*([^:]+):\s*(.*)$/);
115
+ if (!m)
116
+ continue;
117
+ const iface = m[1].trim();
118
+ if (iface === "lo" || /^(veth|docker|br-|lxcbr|virbr|tap|tun)/.test(iface))
119
+ continue;
120
+ const cols = m[2].trim().split(/\s+/).map(Number);
121
+ rx += cols[0] || 0; // column 1 = rx bytes
122
+ tx += cols[8] || 0; // column 9 = tx bytes
123
+ }
124
+ return { rx, tx };
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ // Host OS metrics: cpu (from cpus().times), memory, disk, load, uptime,
131
+ // addresses, and network throughput. Matches the frontend `os` fleetAdapter case.
132
+ export function createOSPlugin() {
133
+ let refreshTimer = null;
134
+ // Previous cumulative net counters, to derive a per-second rate across polls.
135
+ let prevNet = null;
136
+ const collect = () => {
12
137
  const interfaces = networkInterfaces();
13
138
  const addresses = {};
14
- for (let [key, value] of Object.entries(interfaces)) {
15
- if (value) {
16
- let found = value.find(port => (port.family === "IPv4") && (port.internal !== true));
17
- if (found) {
18
- let { address, netmask, mac } = found;
19
- addresses[key] = { address, netmask, mac };
139
+ for (const [key, value] of Object.entries(interfaces)) {
140
+ if (!value)
141
+ continue;
142
+ const found = value.find((p) => p.family === "IPv4" && p.internal !== true);
143
+ if (found)
144
+ addresses[key] = { address: found.address, netmask: found.netmask, mac: found.mac };
145
+ }
146
+ // Network throughput: bytes/sec since the previous sample. The first poll
147
+ // has no baseline, so rate is omitted (undefined) rather than a bogus 0.
148
+ let network;
149
+ const totals = readNetTotals();
150
+ if (totals) {
151
+ const now = Date.now();
152
+ if (prevNet) {
153
+ const dt = (now - prevNet.ts) / 1000;
154
+ if (dt > 0) {
155
+ network = {
156
+ rxBytes: totals.rx,
157
+ txBytes: totals.tx,
158
+ rxRate: Math.max(0, Math.round((totals.rx - prevNet.rx) / dt)),
159
+ txRate: Math.max(0, Math.round((totals.tx - prevNet.tx) / dt)),
160
+ };
20
161
  }
21
162
  }
163
+ prevNet = { rx: totals.rx, tx: totals.tx, ts: now };
22
164
  }
23
- await this.send({
165
+ return {
24
166
  addresses,
25
167
  totalmem: totalmem(),
26
168
  freemem: freemem(),
169
+ available: availableMem(),
170
+ disks: collectDisks(),
171
+ processes: collectProcesses(),
172
+ network,
27
173
  loadavg: loadavg(),
28
174
  uptime: uptime(),
29
175
  hostname: hostname(),
@@ -31,13 +177,26 @@ export class OSPlugin extends MonitoringPlugin {
31
177
  release: release(),
32
178
  type: type(),
33
179
  arch: arch(),
34
- cpus: cpus()
35
- });
36
- }
37
- async monitor() {
38
- this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 5000);
39
- }
40
- async teardown() {
41
- clearInterval(this.refreshTimer);
42
- }
180
+ cpus: cpus(),
181
+ };
182
+ };
183
+ const refreshFn = async (plugin) => {
184
+ try {
185
+ await plugin.send({ name: hostname(), ...collect() }, "os");
186
+ }
187
+ catch (err) {
188
+ console.error("os: refresh error:", err.message);
189
+ }
190
+ };
191
+ const monitorFn = async (plugin) => {
192
+ await refreshFn(plugin);
193
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 30000);
194
+ };
195
+ const teardownFn = async () => {
196
+ if (refreshTimer)
197
+ clearInterval(refreshTimer);
198
+ refreshTimer = null;
199
+ };
200
+ return createMonitoringPlugin("os", "os", "Host OS monitoring plugin", async () => { }, monitorFn, refreshFn, teardownFn);
43
201
  }
202
+ export default createOSPlugin;