@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.
- package/dist/index.js +9 -6
- package/dist/lib/BasePlugin.js +26 -16
- package/dist/lib/MonitoringPlugin.js +38 -22
- package/dist/lib/PluginConfigProvider.js +14 -7
- package/dist/lib/PluginLoader.js +26 -11
- package/dist/lib/PluginManager.js +78 -44
- package/dist/lib/processes.js +36 -0
- package/dist/plugins/index.js +31 -10
- package/dist/plugins/monitoring/DockerPlugin.js +170 -0
- package/dist/plugins/monitoring/LogWatchPlugin.js +212 -0
- package/dist/plugins/monitoring/LxcPlugin.js +126 -0
- package/dist/plugins/monitoring/NetScanPlugin.js +77 -0
- package/dist/plugins/monitoring/OSPlugin.js +184 -25
- package/dist/plugins/monitoring/PM2Plugin.js +71 -43
- package/dist/plugins/monitoring/WebsitePlugin.js +205 -42
- package/dist/plugins/monitoring/WordpressPlugin.js +248 -0
- package/index.ts +11 -0
- package/package.json +32 -11
- package/dist/plugins/monitoring/EwelinkPlugin.js +0 -63
- package/dist/plugins/monitoring/MongoDBPlugin.js +0 -30
|
@@ -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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
return Promise.resolve();
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
10
13
|
}
|
|
11
|
-
|
|
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 (
|
|
15
|
-
if (value)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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;
|