@daemonitor/plugins 1.1.0 → 1.1.2
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/lib/processes.js +36 -0
- package/dist/plugins/index.js +6 -0
- package/dist/plugins/monitoring/LogWatchPlugin.js +212 -0
- package/dist/plugins/monitoring/NetScanPlugin.js +77 -0
- package/dist/plugins/monitoring/OSPlugin.js +134 -2
- package/dist/plugins/monitoring/WebsitePlugin.js +153 -16
- package/dist/plugins/monitoring/WordpressPlugin.js +248 -0
- package/package.json +16 -20
- package/dist/nuxt.config.js +0 -1
- package/dist/plugins/monitoring/CloudflarePlugin.js +0 -84
- package/dist/plugins/monitoring/EwelinkPlugin.js +0 -63
- package/dist/plugins/monitoring/MongoDBPlugin.js +0 -30
- package/index.ts +0 -11
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Aggregate named service processes (nginx, php-fpm, db, …) from `ps` output.
|
|
2
|
+
// Used for host processes (OSPlugin) and per-container processes (LxcPlugin via
|
|
3
|
+
// `lxc exec … ps`). MEM (rss) is exact; CPU is `ps` %cpu, which is the average
|
|
4
|
+
// over each process's lifetime — a rough indicator for long-running daemons.
|
|
5
|
+
// The command whose output parseProcesses() expects (headerless: comm rss pcpu).
|
|
6
|
+
export const PS_CMD = "ps -eo comm=,rss=,pcpu=";
|
|
7
|
+
const WATCH = [
|
|
8
|
+
{ key: "nginx", test: (c) => c.startsWith("nginx") },
|
|
9
|
+
{ key: "php-fpm", test: (c) => /^php-?fpm/i.test(c) },
|
|
10
|
+
{ key: "mysql", test: (c) => c.startsWith("mysqld") || c.startsWith("mariadbd") },
|
|
11
|
+
{ key: "postgres", test: (c) => c.startsWith("postgres") },
|
|
12
|
+
{ key: "redis", test: (c) => c.startsWith("redis-server") || c.startsWith("redis") },
|
|
13
|
+
{ key: "node", test: (c) => c.startsWith("node") },
|
|
14
|
+
];
|
|
15
|
+
export function parseProcesses(out) {
|
|
16
|
+
const agg = new Map();
|
|
17
|
+
for (const line of String(out || "").split("\n")) {
|
|
18
|
+
// Last two whitespace-separated tokens are rss + pcpu; the rest is comm
|
|
19
|
+
// (which may itself contain spaces/slashes, e.g. node process titles).
|
|
20
|
+
const m = line.trim().match(/^(.+?)\s+(\d+)\s+([\d.]+)$/);
|
|
21
|
+
if (!m)
|
|
22
|
+
continue;
|
|
23
|
+
const comm = m[1].trim();
|
|
24
|
+
const w = WATCH.find((x) => x.test(comm));
|
|
25
|
+
if (!w)
|
|
26
|
+
continue;
|
|
27
|
+
const cur = agg.get(w.key) || { name: w.key, count: 0, cpu: 0, mem: 0 };
|
|
28
|
+
cur.count++;
|
|
29
|
+
cur.cpu += Number(m[3]) || 0;
|
|
30
|
+
cur.mem += (Number(m[2]) || 0) * 1024; // rss KiB -> bytes
|
|
31
|
+
agg.set(w.key, cur);
|
|
32
|
+
}
|
|
33
|
+
return Array.from(agg.values())
|
|
34
|
+
.map((p) => ({ ...p, cpu: Math.round(p.cpu * 10) / 10 }))
|
|
35
|
+
.sort((a, b) => b.mem - a.mem);
|
|
36
|
+
}
|
package/dist/plugins/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { createDockerPlugin } from "./monitoring/DockerPlugin";
|
|
|
4
4
|
import { createLxcPlugin } from "./monitoring/LxcPlugin";
|
|
5
5
|
import { createOSPlugin } from "./monitoring/OSPlugin";
|
|
6
6
|
import { createWebsitePlugin } from "./monitoring/WebsitePlugin";
|
|
7
|
+
import { createWordpressPlugin } from "./monitoring/WordpressPlugin";
|
|
8
|
+
import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin";
|
|
9
|
+
import { createNetScanPlugin } from "./monitoring/NetScanPlugin";
|
|
7
10
|
// In future, implement other plugin factory functions using the same pattern
|
|
8
11
|
// import { createOSPlugin } from "./monitoring/OSPlugin"
|
|
9
12
|
// import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin"
|
|
@@ -19,6 +22,9 @@ export default {
|
|
|
19
22
|
Lxc: createLxcPlugin,
|
|
20
23
|
OS: createOSPlugin,
|
|
21
24
|
Website: createWebsitePlugin,
|
|
25
|
+
Wordpress: createWordpressPlugin,
|
|
26
|
+
Logwatch: createLogWatchPlugin,
|
|
27
|
+
Netscan: createNetScanPlugin,
|
|
22
28
|
// CloudflarePlugin: createCloudflarePlugin,
|
|
23
29
|
// EwelinkPlugin: createEwelinkPlugin,
|
|
24
30
|
// MongoDBPlugin: createMongoDBPlugin,
|
|
@@ -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,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,6 +1,87 @@
|
|
|
1
1
|
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
|
|
2
2
|
import { arch, cpus, freemem, hostname, loadavg, networkInterfaces, platform, release, totalmem, type, uptime, } from "os";
|
|
3
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 }));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
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
|
+
}
|
|
4
85
|
// True available memory: on Linux, os.freemem() returns MemFree, which excludes
|
|
5
86
|
// reclaimable page cache and makes hosts look near-full. MemAvailable is the
|
|
6
87
|
// kernel's estimate of memory obtainable without swapping. Fall back to freemem()
|
|
@@ -19,10 +100,39 @@ function availableMem() {
|
|
|
19
100
|
}
|
|
20
101
|
return freemem();
|
|
21
102
|
}
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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.
|
|
24
132
|
export function createOSPlugin() {
|
|
25
133
|
let refreshTimer = null;
|
|
134
|
+
// Previous cumulative net counters, to derive a per-second rate across polls.
|
|
135
|
+
let prevNet = null;
|
|
26
136
|
const collect = () => {
|
|
27
137
|
const interfaces = networkInterfaces();
|
|
28
138
|
const addresses = {};
|
|
@@ -33,11 +143,33 @@ export function createOSPlugin() {
|
|
|
33
143
|
if (found)
|
|
34
144
|
addresses[key] = { address: found.address, netmask: found.netmask, mac: found.mac };
|
|
35
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
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
prevNet = { rx: totals.rx, tx: totals.tx, ts: now };
|
|
164
|
+
}
|
|
36
165
|
return {
|
|
37
166
|
addresses,
|
|
38
167
|
totalmem: totalmem(),
|
|
39
168
|
freemem: freemem(),
|
|
40
169
|
available: availableMem(),
|
|
170
|
+
disks: collectDisks(),
|
|
171
|
+
processes: collectProcesses(),
|
|
172
|
+
network,
|
|
41
173
|
loadavg: loadavg(),
|
|
42
174
|
uptime: uptime(),
|
|
43
175
|
hostname: hostname(),
|
|
@@ -1,12 +1,95 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
1
4
|
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
|
|
5
|
+
// Low-level GET that can hit one host (`target`) while presenting a different
|
|
6
|
+
// Host header. fetch() can't do this — Host is a forbidden header per the
|
|
7
|
+
// Fetch spec and is silently dropped — so this uses Node's http/https
|
|
8
|
+
// directly, which honors it.
|
|
9
|
+
function rawGet(target, opts) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
let u;
|
|
13
|
+
try {
|
|
14
|
+
u = new URL(target);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
resolve({ status: 0, body: "", duration: 0, error: `Invalid origin URL: ${target}` });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const [k, v] of Object.entries(opts.query || {}))
|
|
21
|
+
u.searchParams.set(k, v);
|
|
22
|
+
const headers = { ...(opts.headers || {}) };
|
|
23
|
+
if (opts.host)
|
|
24
|
+
headers.Host = opts.host;
|
|
25
|
+
const lib = u.protocol === "http:" ? http : https;
|
|
26
|
+
const req = lib.request({
|
|
27
|
+
hostname: u.hostname,
|
|
28
|
+
port: u.port || (u.protocol === "http:" ? 80 : 443),
|
|
29
|
+
path: `${u.pathname}${u.search}`,
|
|
30
|
+
method: "GET",
|
|
31
|
+
headers,
|
|
32
|
+
// Explicit servername pins TLS/SNI to the real connection
|
|
33
|
+
// target. Without it, Node derives SNI from headers.Host,
|
|
34
|
+
// which would validate the cert against the WRONG hostname
|
|
35
|
+
// (the public one, not the origin actually being dialed).
|
|
36
|
+
...(u.protocol === "https:" ? { servername: u.hostname, rejectUnauthorized: !opts.insecure } : {}),
|
|
37
|
+
}, (res) => {
|
|
38
|
+
let body = "";
|
|
39
|
+
res.on("data", (c) => { body += c; });
|
|
40
|
+
res.on("end", () => resolve({ status: res.statusCode || 0, body, duration: Date.now() - start }));
|
|
41
|
+
});
|
|
42
|
+
req.on("error", (err) => resolve({ status: 0, body: "", duration: Date.now() - start, error: err.message }));
|
|
43
|
+
const timeoutMs = opts.timeout || 10000;
|
|
44
|
+
req.setTimeout(timeoutMs, () => {
|
|
45
|
+
req.destroy();
|
|
46
|
+
resolve({ status: 0, body: "", duration: Date.now() - start, error: "timeout" });
|
|
47
|
+
});
|
|
48
|
+
req.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function evaluate(body, status, expected, unexpected, expectedStatusCode) {
|
|
52
|
+
// Report WHICH strings matched/went missing, not just booleans. For security
|
|
53
|
+
// checks (injected-script IOCs, webshell markers, defacement canaries) the
|
|
54
|
+
// alert is only actionable if it names the offending string — "IOC _ea_s
|
|
55
|
+
// matched" vs. an opaque "check failed". `unexpectedStrings` doubles as an
|
|
56
|
+
// IOC list; `expectedStrings` doubles as an integrity canary (its absence =
|
|
57
|
+
// possible defacement). Kept as substring matches (no regex) to preserve the
|
|
58
|
+
// existing config contract.
|
|
59
|
+
const missingExpected = expected.filter((s) => body.indexOf(s) === -1);
|
|
60
|
+
const matchedUnexpected = unexpected.filter((s) => body.indexOf(s) !== -1);
|
|
61
|
+
const hasAllExpectedStrings = missingExpected.length === 0;
|
|
62
|
+
const hasAnyUnexpectedStrings = matchedUnexpected.length > 0;
|
|
63
|
+
const statusOk = expectedStatusCode ? status === expectedStatusCode : status >= 200 && status < 400;
|
|
64
|
+
return { ok: statusOk && hasAllExpectedStrings && !hasAnyUnexpectedStrings, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected };
|
|
65
|
+
}
|
|
66
|
+
async function checkOrigin(ep) {
|
|
67
|
+
const oc = ep.origin;
|
|
68
|
+
const r = await rawGet(oc.url, {
|
|
69
|
+
host: oc.host,
|
|
70
|
+
headers: oc.headers,
|
|
71
|
+
query: oc.query,
|
|
72
|
+
insecure: oc.insecure,
|
|
73
|
+
timeout: oc.timeout ?? ep.timeout,
|
|
74
|
+
});
|
|
75
|
+
if (r.error) {
|
|
76
|
+
return { url: oc.url, host: oc.host, status: r.status, duration: r.duration, ok: false, error: r.error };
|
|
77
|
+
}
|
|
78
|
+
const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(r.body, r.status, oc.expectedStrings ?? ep.expectedStrings ?? [], oc.unexpectedStrings ?? ep.unexpectedStrings ?? [], oc.expectedStatusCode ?? ep.expectedStatusCode);
|
|
79
|
+
return { url: oc.url, host: oc.host, status: r.status, duration: r.duration, ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected };
|
|
80
|
+
}
|
|
2
81
|
// HTTP uptime/content checks. Emits one client_state per endpoint, matching the
|
|
3
82
|
// frontend `web`/`website` fleetAdapter case ({ url, name, ok, status, duration }).
|
|
83
|
+
// Optionally two-tiered: the public URL (through Cloudflare/CDN) plus a direct
|
|
84
|
+
// origin check, nested under `origin`, so a CDN masking an origin outage still
|
|
85
|
+
// surfaces as a warning even while the public site keeps serving fine.
|
|
4
86
|
export function createWebsitePlugin() {
|
|
5
87
|
let refreshTimer = null;
|
|
6
88
|
const check = async (plugin, ep) => {
|
|
7
89
|
const start = Date.now();
|
|
8
90
|
const label = ep.name || ep.url;
|
|
9
91
|
const uid = `web-${(ep.name || ep.url).replace(/[^a-zA-Z0-9_.-]+/g, "-")}`;
|
|
92
|
+
let publicResult;
|
|
10
93
|
try {
|
|
11
94
|
const ctrl = new AbortController();
|
|
12
95
|
const t = ep.timeout ? setTimeout(() => ctrl.abort(), ep.timeout) : null;
|
|
@@ -15,44 +98,98 @@ export function createWebsitePlugin() {
|
|
|
15
98
|
clearTimeout(t);
|
|
16
99
|
const body = await res.text();
|
|
17
100
|
const duration = Date.now() - start;
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const hasAllExpected = expected.every((s) => body.indexOf(s) !== -1);
|
|
21
|
-
const hasAnyUnexpected = unexpected.some((s) => body.indexOf(s) !== -1);
|
|
22
|
-
const statusOk = ep.expectedStatusCode ? res.status === ep.expectedStatusCode : res.ok;
|
|
23
|
-
const ok = statusOk && hasAllExpected && !hasAnyUnexpected;
|
|
24
|
-
await plugin.send({
|
|
101
|
+
const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(body, res.status, ep.expectedStrings || [], ep.unexpectedStrings || [], ep.expectedStatusCode);
|
|
102
|
+
publicResult = {
|
|
25
103
|
name: label,
|
|
26
104
|
url: res.url || ep.url,
|
|
27
105
|
status: res.status,
|
|
28
106
|
duration,
|
|
29
107
|
ok,
|
|
30
108
|
redirected: res.redirected,
|
|
31
|
-
hasAllExpectedStrings
|
|
32
|
-
hasAnyUnexpectedStrings
|
|
33
|
-
|
|
109
|
+
hasAllExpectedStrings,
|
|
110
|
+
hasAnyUnexpectedStrings,
|
|
111
|
+
missingExpected,
|
|
112
|
+
matchedUnexpected,
|
|
113
|
+
};
|
|
34
114
|
}
|
|
35
115
|
catch (err) {
|
|
36
|
-
|
|
116
|
+
publicResult = {
|
|
37
117
|
name: label,
|
|
38
118
|
url: ep.url,
|
|
39
119
|
status: 0,
|
|
40
120
|
duration: Date.now() - start,
|
|
41
121
|
ok: false,
|
|
42
122
|
error: err.message,
|
|
43
|
-
}
|
|
123
|
+
};
|
|
44
124
|
}
|
|
125
|
+
const origin = ep.origin?.url ? await checkOrigin(ep) : undefined;
|
|
126
|
+
// `ok` stays the PUBLIC tier's own result — don't collapse it with
|
|
127
|
+
// origin.ok. The two tiers are different failure modes with different
|
|
128
|
+
// severity (public down = real outage; origin-only down = CDN masking
|
|
129
|
+
// a backend problem, still worth a warning). Collapsing them into one
|
|
130
|
+
// boolean loses that distinction downstream (ingest branches on
|
|
131
|
+
// `ok`/`origin.ok` separately to assign error vs warning).
|
|
132
|
+
await plugin.send({ ...publicResult, origin }, uid);
|
|
45
133
|
};
|
|
46
|
-
const
|
|
134
|
+
const localEndpointsOf = (plugin) => {
|
|
47
135
|
const cfg = plugin.config || {};
|
|
48
136
|
if (Array.isArray(cfg.endpoints))
|
|
49
137
|
return cfg.endpoints;
|
|
50
|
-
if (cfg.url)
|
|
51
|
-
return [{
|
|
138
|
+
if (cfg.url) {
|
|
139
|
+
return [{
|
|
140
|
+
name: cfg.name,
|
|
141
|
+
url: cfg.url,
|
|
142
|
+
expectedStrings: cfg.expectedStrings,
|
|
143
|
+
unexpectedStrings: cfg.unexpectedStrings,
|
|
144
|
+
expectedStatusCode: cfg.expectedStatusCode,
|
|
145
|
+
timeout: cfg.timeout,
|
|
146
|
+
origin: cfg.origin,
|
|
147
|
+
}];
|
|
148
|
+
}
|
|
52
149
|
return [];
|
|
53
150
|
};
|
|
151
|
+
// Server-managed endpoints: when the config carries a `configUrl` (the
|
|
152
|
+
// daemonitor /api/monitors/for-client endpoint) and this client's
|
|
153
|
+
// `systemKey`, monitors edited in the UI are pulled from there. The last
|
|
154
|
+
// successful fetch is cached so a transient network/API failure keeps the
|
|
155
|
+
// previous set running instead of silently dropping every check.
|
|
156
|
+
let lastRemote = null;
|
|
157
|
+
const remoteEndpointsOf = async (plugin) => {
|
|
158
|
+
const cfg = plugin.config || {};
|
|
159
|
+
const configUrl = cfg.configUrl;
|
|
160
|
+
const systemKey = cfg.systemKey;
|
|
161
|
+
if (!configUrl || !systemKey)
|
|
162
|
+
return null;
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(configUrl, { headers: { "x-system-key": systemKey } });
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
return lastRemote;
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
const eps = Array.isArray(body?.endpoints) ? body.endpoints : [];
|
|
169
|
+
lastRemote = eps;
|
|
170
|
+
return eps;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return lastRemote;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const endpointsOf = async (plugin) => {
|
|
177
|
+
const local = localEndpointsOf(plugin);
|
|
178
|
+
const remote = await remoteEndpointsOf(plugin);
|
|
179
|
+
if (remote == null)
|
|
180
|
+
return local;
|
|
181
|
+
// Merge by name/url: server-managed monitors win, local-only ones
|
|
182
|
+
// (defined directly in the config file) are kept alongside.
|
|
183
|
+
const key = (e) => `${e.name || ""}|${e.url}`;
|
|
184
|
+
const merged = new Map();
|
|
185
|
+
for (const e of local)
|
|
186
|
+
merged.set(key(e), e);
|
|
187
|
+
for (const e of remote)
|
|
188
|
+
merged.set(key(e), e);
|
|
189
|
+
return [...merged.values()];
|
|
190
|
+
};
|
|
54
191
|
const refreshFn = async (plugin) => {
|
|
55
|
-
const eps = endpointsOf(plugin);
|
|
192
|
+
const eps = await endpointsOf(plugin);
|
|
56
193
|
for (const ep of eps) {
|
|
57
194
|
if (ep?.url)
|
|
58
195
|
await check(plugin, ep);
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
|
|
6
|
+
// WordPress security-posture plugin. Runs on the WP host (where the daemonitor
|
|
7
|
+
// client already runs) and polls the state that the Archpaper intrusions moved
|
|
8
|
+
// through: admin accounts, page statuses, must-use plugins, and the web user's
|
|
9
|
+
// crontab. It emits raw observations only — severity and the new-admin /
|
|
10
|
+
// admin-demotion DIFFS are decided server-side in the ingest handler
|
|
11
|
+
// (clientstate/update.put.ts, case "wordpress"), so an agent restart can't reset
|
|
12
|
+
// the baseline and let a change slip through.
|
|
13
|
+
//
|
|
14
|
+
// Collection is via WP-CLI + filesystem + crontab (no DB credentials, no extra
|
|
15
|
+
// npm dependency) — mirroring MongoDBPlugin's shell-out pattern. Every collector
|
|
16
|
+
// is best-effort and isolated: one failing (e.g. crontab needs root) degrades
|
|
17
|
+
// that one field to null, it never kills the poll.
|
|
18
|
+
const pexec = promisify(execFile);
|
|
19
|
+
// Service-account naming patterns seen in the Archpaper intrusions. Any admin
|
|
20
|
+
// whose login/email contains one of these (case-insensitive substring) is
|
|
21
|
+
// flagged for escalation even before the new-admin diff fires.
|
|
22
|
+
const DEFAULT_SERVICE_PATTERNS = [
|
|
23
|
+
"wpsvc", "wpengine", "bot", "svc", "researchlabs", "wordpress-svc",
|
|
24
|
+
];
|
|
25
|
+
const DEFAULT_CRITICAL_PAGES = ["login", "home", "index", "front-page"];
|
|
26
|
+
function slug(s) {
|
|
27
|
+
return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
28
|
+
}
|
|
29
|
+
const MAXBUF = 8 * 1024 * 1024;
|
|
30
|
+
// Run a command in the site's context — inside its LXC container when `lxc` is
|
|
31
|
+
// set (via `sudo -n lxc exec <c> -- …`), else on the host (optionally as
|
|
32
|
+
// `runAs`). Returns stdout, or throws — callers isolate their own failures.
|
|
33
|
+
async function execInSite(site, argv, timeout = 15000) {
|
|
34
|
+
if (site.lxc) {
|
|
35
|
+
const lxcBin = site.lxcBin || "lxc";
|
|
36
|
+
const { stdout } = await pexec("sudo", ["-n", lxcBin, "exec", site.lxc, "--", ...argv], { timeout, maxBuffer: MAXBUF });
|
|
37
|
+
return stdout;
|
|
38
|
+
}
|
|
39
|
+
if (site.runAs) {
|
|
40
|
+
const { stdout } = await pexec("sudo", ["-n", "-u", site.runAs, ...argv], { timeout, maxBuffer: MAXBUF });
|
|
41
|
+
return stdout;
|
|
42
|
+
}
|
|
43
|
+
const { stdout } = await pexec(argv[0], argv.slice(1), { timeout, maxBuffer: MAXBUF });
|
|
44
|
+
return stdout;
|
|
45
|
+
}
|
|
46
|
+
// Run `wp <args>` in the site's context. Adds:
|
|
47
|
+
// --skip-plugins/--skip-themes so a plugin/theme's PHP notices don't get
|
|
48
|
+
// printed onto stdout ahead of the JSON and break the parse;
|
|
49
|
+
// --url when known (fixes wp-config reads of $_SERVER['SERVER_NAME']; also
|
|
50
|
+
// correct for multisite);
|
|
51
|
+
// --allow-root when running via `lxc exec` (that lands as root in the
|
|
52
|
+
// container, and WP-CLI refuses root without it).
|
|
53
|
+
async function wp(site, args, timeout = 15000) {
|
|
54
|
+
const bin = site.wpCli || "wp";
|
|
55
|
+
const full = [...args, `--path=${site.path}`, "--skip-plugins", "--skip-themes"];
|
|
56
|
+
if (site.url)
|
|
57
|
+
full.push(`--url=${site.url}`);
|
|
58
|
+
if (site.lxc)
|
|
59
|
+
full.push("--allow-root");
|
|
60
|
+
return execInSite(site, [bin, ...full], timeout);
|
|
61
|
+
}
|
|
62
|
+
function parseJsonSafe(s, fallback) {
|
|
63
|
+
try {
|
|
64
|
+
const v = JSON.parse(s);
|
|
65
|
+
return v == null ? fallback : v;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function collectSite(site) {
|
|
72
|
+
const label = site.name || path.basename(site.path || "") || "wordpress";
|
|
73
|
+
const servicePatterns = (site.servicePatterns || DEFAULT_SERVICE_PATTERNS).map((s) => s.toLowerCase());
|
|
74
|
+
const criticalPages = (site.criticalPages || DEFAULT_CRITICAL_PAGES).map((s) => s.toLowerCase());
|
|
75
|
+
const out = { name: label, site: label, url: site.url };
|
|
76
|
+
// 1. Current administrators (the rogue-admin / demotion signal set).
|
|
77
|
+
try {
|
|
78
|
+
const raw = await wp(site, ["user", "list", "--role=administrator", "--fields=ID,user_login,user_email,user_registered", "--format=json"]);
|
|
79
|
+
const admins = parseJsonSafe(raw, []).map((u) => ({
|
|
80
|
+
id: String(u.ID ?? ""),
|
|
81
|
+
login: String(u.user_login ?? ""),
|
|
82
|
+
email: String(u.user_email ?? ""),
|
|
83
|
+
registered: u.user_registered ?? null,
|
|
84
|
+
}));
|
|
85
|
+
out.admins = admins;
|
|
86
|
+
// Service-account-pattern admins — flagged locally so they surface even on
|
|
87
|
+
// the very first poll, before the server-side new-admin diff has a baseline.
|
|
88
|
+
out.suspiciousAdmins = admins
|
|
89
|
+
.filter((a) => servicePatterns.some((p) => a.login.toLowerCase().includes(p) || a.email.toLowerCase().includes(p)))
|
|
90
|
+
.map((a) => ({ login: a.login, email: a.email, reason: "service-account naming pattern" }));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
out.adminsError = String(e?.message || e).slice(0, 160);
|
|
94
|
+
}
|
|
95
|
+
// 2. WP core version (posture; latest-version comparison is done elsewhere).
|
|
96
|
+
try {
|
|
97
|
+
out.wpVersion = (await wp(site, ["core", "version"])).trim();
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
out.wpVersionError = String(e?.message || e).slice(0, 160);
|
|
101
|
+
}
|
|
102
|
+
// 3. Trashed pages/posts — attackers hid the login/home page by trashing it.
|
|
103
|
+
try {
|
|
104
|
+
const raw = await wp(site, ["post", "list", "--post_status=trash", "--post_type=page,post", "--fields=ID,post_title,post_name,post_type", "--format=json"]);
|
|
105
|
+
const trashed = parseJsonSafe(raw, []).map((p) => ({
|
|
106
|
+
id: String(p.ID ?? ""),
|
|
107
|
+
title: String(p.post_title ?? ""),
|
|
108
|
+
slug: String(p.post_name ?? ""),
|
|
109
|
+
type: String(p.post_type ?? ""),
|
|
110
|
+
}));
|
|
111
|
+
out.trashedPages = trashed;
|
|
112
|
+
out.trashedCriticalPages = trashed.filter((p) => criticalPages.includes(p.slug.toLowerCase()) || criticalPages.includes(p.title.toLowerCase()));
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
out.trashedError = String(e?.message || e).slice(0, 160);
|
|
116
|
+
}
|
|
117
|
+
// 4. mu-plugins auto-load with no UI to disable them — a favorite persistence
|
|
118
|
+
// spot. Report every .php and which ones aren't in the allowlist. Read via the
|
|
119
|
+
// container when the site is LXC (the path is inside the container's fs).
|
|
120
|
+
try {
|
|
121
|
+
const dir = path.join(site.path, "wp-content", "mu-plugins");
|
|
122
|
+
let entries;
|
|
123
|
+
if (site.lxc) {
|
|
124
|
+
// `ls -1` inside the container; empty/no-dir → treat as no mu-plugins.
|
|
125
|
+
const out2 = await execInSite(site, ["ls", "-1", dir], 8000).catch(() => "");
|
|
126
|
+
entries = out2.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
entries = await readdir(dir);
|
|
130
|
+
}
|
|
131
|
+
const php = entries.filter((f) => f.toLowerCase().endsWith(".php"));
|
|
132
|
+
out.muPlugins = php;
|
|
133
|
+
if (site.muPluginsAllowlist) {
|
|
134
|
+
const allow = new Set(site.muPluginsAllowlist);
|
|
135
|
+
out.unknownMuPlugins = php.filter((f) => !allow.has(f));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// no mu-plugins dir = fine; leave muPlugins undefined
|
|
140
|
+
}
|
|
141
|
+
// 5. Web user's crontab — www-data should almost never have one; the attacker
|
|
142
|
+
// used it to re-drop webshells every 5 min. Non-empty = alert. `crontab -u`
|
|
143
|
+
// needs privilege: try direct first, then `sudo -n` (the agent user typically
|
|
144
|
+
// has passwordless sudo). "no crontab for <user>" is the HEALTHY empty case,
|
|
145
|
+
// not an error — anything else (still denied) is a real collection failure.
|
|
146
|
+
const cronUser = site.cronUser || "www-data";
|
|
147
|
+
const readCron = async () => {
|
|
148
|
+
// LXC: the web user's crontab lives INSIDE the container (that's where the
|
|
149
|
+
// persistence would be dropped), so read it there.
|
|
150
|
+
if (site.lxc) {
|
|
151
|
+
try {
|
|
152
|
+
return await execInSite(site, ["crontab", "-l", "-u", cronUser], 8000);
|
|
153
|
+
}
|
|
154
|
+
catch (e2) {
|
|
155
|
+
const msg = String(e2?.stderr || e2?.message || "");
|
|
156
|
+
// "no crontab for" = empty (healthy). "cannot use this program" /
|
|
157
|
+
// "not allowed to use" = the user is denied crontab entirely
|
|
158
|
+
// (cron.deny / not in cron.allow) — that's GOOD posture (can't be used
|
|
159
|
+
// for persistence), not a collection failure. Treat both as clean.
|
|
160
|
+
if (/no crontab for|cannot use this program|not allowed to use/i.test(msg))
|
|
161
|
+
return "";
|
|
162
|
+
throw e2;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return (await pexec("crontab", ["-l", "-u", cronUser], { timeout: 8000 })).stdout;
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
const msg = String(e?.stderr || e?.message || "");
|
|
170
|
+
if (/no crontab for/i.test(msg))
|
|
171
|
+
return ""; // empty is fine
|
|
172
|
+
if (/privileged|not permitted|not allowed|denied/i.test(msg)) {
|
|
173
|
+
// Retry under sudo; may itself throw "no crontab for" (empty) → catch.
|
|
174
|
+
try {
|
|
175
|
+
return (await pexec("sudo", ["-n", "crontab", "-l", "-u", cronUser], { timeout: 8000 })).stdout;
|
|
176
|
+
}
|
|
177
|
+
catch (e2) {
|
|
178
|
+
if (/no crontab for/i.test(String(e2?.stderr || e2?.message || "")))
|
|
179
|
+
return "";
|
|
180
|
+
throw e2;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
const stdout = await readCron();
|
|
188
|
+
const lines = stdout.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
189
|
+
out.webUserCrontab = lines.join("\n") || null;
|
|
190
|
+
out.hasWebUserCrontab = lines.length > 0;
|
|
191
|
+
out.cronUser = cronUser;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
out.cronError = String(e?.stderr || e?.message || e).slice(0, 160);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
export function createWordpressPlugin() {
|
|
199
|
+
let refreshTimer = null;
|
|
200
|
+
const sitesOf = (plugin) => {
|
|
201
|
+
const cfg = (plugin.config || {});
|
|
202
|
+
if (Array.isArray(cfg.sites) && cfg.sites.length)
|
|
203
|
+
return cfg.sites;
|
|
204
|
+
if (cfg.path) {
|
|
205
|
+
return [{
|
|
206
|
+
name: cfg.name,
|
|
207
|
+
path: cfg.path,
|
|
208
|
+
url: cfg.url,
|
|
209
|
+
wpCli: cfg.wpCli,
|
|
210
|
+
runAs: cfg.runAs,
|
|
211
|
+
lxc: cfg.lxc,
|
|
212
|
+
lxcBin: cfg.lxcBin,
|
|
213
|
+
cronUser: cfg.cronUser,
|
|
214
|
+
muPluginsAllowlist: cfg.muPluginsAllowlist,
|
|
215
|
+
criticalPages: cfg.criticalPages,
|
|
216
|
+
servicePatterns: cfg.servicePatterns,
|
|
217
|
+
}];
|
|
218
|
+
}
|
|
219
|
+
return [];
|
|
220
|
+
};
|
|
221
|
+
const refreshFn = async (plugin) => {
|
|
222
|
+
const sites = sitesOf(plugin);
|
|
223
|
+
for (const site of sites) {
|
|
224
|
+
if (!site?.path)
|
|
225
|
+
continue;
|
|
226
|
+
try {
|
|
227
|
+
const data = await collectSite(site);
|
|
228
|
+
const uid = `wp-${slug(site.name || site.url || site.path)}`;
|
|
229
|
+
await plugin.send(data, uid);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
const uid = `wp-${slug(site.name || site.url || site.path)}`;
|
|
233
|
+
await plugin.send({ name: site.name || site.path, error: String(e?.message || e).slice(0, 160) }, uid);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const monitorFn = async (plugin) => {
|
|
238
|
+
await refreshFn(plugin);
|
|
239
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 300000);
|
|
240
|
+
};
|
|
241
|
+
const teardownFn = async () => {
|
|
242
|
+
if (refreshTimer)
|
|
243
|
+
clearInterval(refreshTimer);
|
|
244
|
+
refreshTimer = null;
|
|
245
|
+
};
|
|
246
|
+
return createMonitoringPlugin("wordpress", "wordpress", "WordPress security-posture monitoring plugin", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
247
|
+
}
|
|
248
|
+
export default createWordpressPlugin;
|
package/package.json
CHANGED
|
@@ -1,54 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daemonitor/plugins",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "daemonitor plugins",
|
|
5
5
|
"types": "types/index.d.ts",
|
|
6
|
-
"main": "index.
|
|
6
|
+
"main": "dist/index.js",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc",
|
|
10
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"dist",
|
|
14
15
|
"types"
|
|
15
16
|
],
|
|
16
17
|
"exports": {
|
|
17
|
-
".":
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./types/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./lib/*": "./dist/lib/*.js",
|
|
23
|
+
"./plugins/*": "./dist/plugins/*.js"
|
|
20
24
|
},
|
|
21
25
|
"publishConfig": {
|
|
22
|
-
"access": "public"
|
|
23
|
-
"main": "dist/index.js",
|
|
24
|
-
"types": "types/index.d.ts",
|
|
25
|
-
"exports": {
|
|
26
|
-
".": {
|
|
27
|
-
"types": "./types/index.d.ts",
|
|
28
|
-
"default": "./dist/index.js"
|
|
29
|
-
},
|
|
30
|
-
"./lib/*": "./dist/lib/*.js",
|
|
31
|
-
"./plugins/*": "./dist/plugins/*.js"
|
|
32
|
-
}
|
|
26
|
+
"access": "public"
|
|
33
27
|
},
|
|
34
28
|
"author": "",
|
|
35
29
|
"license": "ISC",
|
|
36
30
|
"packageManager": "pnpm@8.2.0",
|
|
37
31
|
"devDependencies": {
|
|
32
|
+
"@types/node": "^26.1.1",
|
|
38
33
|
"nuxt": "^3.15.4",
|
|
34
|
+
"typescript": "^5.8.3",
|
|
39
35
|
"vue": "^3.5.13",
|
|
40
36
|
"vue-router": "^4.5.0"
|
|
41
37
|
},
|
|
42
38
|
"dependencies": {
|
|
39
|
+
"@daemonitor/common": "^1.0.0",
|
|
43
40
|
"dotenv": "^16.0.3",
|
|
44
41
|
"ewelink-api": "^3.1.1",
|
|
45
42
|
"node-machine-id": "^1.1.12",
|
|
46
43
|
"ohmyfetch": "^0.4.21",
|
|
44
|
+
"pm2": "^6.0.5",
|
|
47
45
|
"string-to-color": "^2.2.2",
|
|
48
46
|
"ts-node": "^10.9.1",
|
|
49
|
-
"
|
|
50
|
-
"tsconfig-paths": "^4.2.0",
|
|
51
|
-
"pm2": "^6.0.5"
|
|
47
|
+
"tsconfig-paths": "^4.2.0"
|
|
52
48
|
},
|
|
53
49
|
"repository": {
|
|
54
50
|
"type": "git",
|
package/dist/nuxt.config.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export default defineNuxtConfig({});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
-
/**
|
|
3
|
-
* CloudflarePlugin - Receives push updates from Cloudflare Workers
|
|
4
|
-
*
|
|
5
|
-
* This plugin receives data directly from a client's Cloudflare Worker
|
|
6
|
-
* rather than polling for it. The plugin sets up a monitoring endpoint,
|
|
7
|
-
* and the Cloudflare Worker pushes data to this endpoint on a schedule.
|
|
8
|
-
*/
|
|
9
|
-
export class CloudflarePlugin extends MonitoringPlugin {
|
|
10
|
-
lastReportedData = null;
|
|
11
|
-
lastReportTime = 0;
|
|
12
|
-
reportingKey;
|
|
13
|
-
maxDataAge;
|
|
14
|
-
constructor() {
|
|
15
|
-
super("cloudflare", "Cloudflare", "Cloudflare Deployment Monitoring Plugin (Push Mode)");
|
|
16
|
-
this.reportingKey = this.config.reportingKey;
|
|
17
|
-
this.maxDataAge = this.config.maxDataAge || 5 * 60 * 1000; // Default 5 minutes
|
|
18
|
-
}
|
|
19
|
-
async setup() {
|
|
20
|
-
if (!this.reportingKey) {
|
|
21
|
-
console.error("Cloudflare reporting key not provided");
|
|
22
|
-
return Promise.reject("Reporting key not provided");
|
|
23
|
-
}
|
|
24
|
-
// Register this instance to receive push updates
|
|
25
|
-
try {
|
|
26
|
-
// Normally this would make an API call to register this plugin instance
|
|
27
|
-
// to receive push updates from Cloudflare Workers with this reporting key
|
|
28
|
-
console.log(`Cloudflare plugin ready to receive push updates for key: ${this.reportingKey}`);
|
|
29
|
-
return Promise.resolve();
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
console.error("Failed to register for Cloudflare push updates:", error);
|
|
33
|
-
return Promise.reject("Registration failed");
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* This method is called when data is pushed from a Cloudflare Worker
|
|
38
|
-
* It would typically be called by a webhook or socket connection
|
|
39
|
-
*/
|
|
40
|
-
async receiveUpdate(data, key) {
|
|
41
|
-
// Verify the reporting key matches
|
|
42
|
-
if (key !== this.reportingKey) {
|
|
43
|
-
console.error("Invalid reporting key for Cloudflare update");
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
this.lastReportedData = {
|
|
47
|
-
...data,
|
|
48
|
-
receivedAt: Date.now()
|
|
49
|
-
};
|
|
50
|
-
this.lastReportTime = Date.now();
|
|
51
|
-
// Forward the data to the monitoring system
|
|
52
|
-
await this.send({
|
|
53
|
-
...data,
|
|
54
|
-
timestamp: Date.now(),
|
|
55
|
-
status: "ok"
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
// This method checks if data is still fresh
|
|
59
|
-
async refresh() {
|
|
60
|
-
const now = Date.now();
|
|
61
|
-
const dataAge = now - this.lastReportTime;
|
|
62
|
-
// If we have data and it's still fresh, do nothing
|
|
63
|
-
if (this.lastReportedData && dataAge < this.maxDataAge) {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
// If data is stale or doesn't exist, report error status
|
|
67
|
-
await this.send({
|
|
68
|
-
timestamp: now,
|
|
69
|
-
error: `No data received in the last ${dataAge / 60000} minutes`,
|
|
70
|
-
status: "error",
|
|
71
|
-
lastReportTime: this.lastReportTime
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
async monitor() {
|
|
75
|
-
// Set up interval just to check for stale data
|
|
76
|
-
this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 60000 // Default to 1 minute
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
async teardown() {
|
|
80
|
-
// Unregister from receiving push updates
|
|
81
|
-
clearInterval(this.refreshTimer);
|
|
82
|
-
return Promise.resolve();
|
|
83
|
-
}
|
|
84
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import ewelink from "ewelink-api";
|
|
2
|
-
import * as dotenv from "dotenv";
|
|
3
|
-
import { MonitoringPlugin } from "~/lib/MonitoringPlugin";
|
|
4
|
-
dotenv.config();
|
|
5
|
-
export class EwelinkPlugin extends MonitoringPlugin {
|
|
6
|
-
connection;
|
|
7
|
-
devices;
|
|
8
|
-
constructor() {
|
|
9
|
-
super("ewelink", "Ewelink", "Ewelink Addon");
|
|
10
|
-
this.name = "Ewelink";
|
|
11
|
-
this.description = "Ewelink Addon";
|
|
12
|
-
}
|
|
13
|
-
async setup() {
|
|
14
|
-
if (!process.env.EWELINK_EMAIL || !process.env.EWELINK_PASSWORD) {
|
|
15
|
-
console.error("EWELINK_EMAIL and EWELINK_PASSWORD environment variables must be set");
|
|
16
|
-
return Promise.reject();
|
|
17
|
-
}
|
|
18
|
-
this.connection = new ewelink({
|
|
19
|
-
email: process.env.EWELINK_EMAIL,
|
|
20
|
-
password: process.env.EWELINK_PASSWORD
|
|
21
|
-
});
|
|
22
|
-
this.refresh().then(() => {
|
|
23
|
-
});
|
|
24
|
-
return Promise.resolve();
|
|
25
|
-
}
|
|
26
|
-
async get(key) {
|
|
27
|
-
let [deviceName, propertiesString] = key.split(":");
|
|
28
|
-
deviceName = deviceName.toLowerCase();
|
|
29
|
-
const properties = propertiesString.split(",");
|
|
30
|
-
const device = this.devices?.find((d) => d.name.toLowerCase() === deviceName);
|
|
31
|
-
if (!device) {
|
|
32
|
-
return Promise.reject();
|
|
33
|
-
}
|
|
34
|
-
const values = {};
|
|
35
|
-
for (const property of properties) {
|
|
36
|
-
values[property] = device?.params[property];
|
|
37
|
-
}
|
|
38
|
-
return Promise.resolve(values);
|
|
39
|
-
}
|
|
40
|
-
async refresh() {
|
|
41
|
-
/* get all devices */
|
|
42
|
-
return this.connection.getDevices().then((devices) => {
|
|
43
|
-
if (Array.isArray(devices) && (Symbol.iterator in Object(devices))) {
|
|
44
|
-
this.devices = devices;
|
|
45
|
-
for (const device of this.devices) {
|
|
46
|
-
this.send(device, device.deviceid);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
console.error("EwelinkPlugin: Invalid response from getDevices()", devices);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
async monitor() {
|
|
55
|
-
this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 30000);
|
|
56
|
-
}
|
|
57
|
-
async teardown() {
|
|
58
|
-
clearInterval(this.refreshTimer);
|
|
59
|
-
}
|
|
60
|
-
async render() {
|
|
61
|
-
return this.devices;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
export class MongoDBPlugin extends MonitoringPlugin {
|
|
4
|
-
childProcess;
|
|
5
|
-
constructor() {
|
|
6
|
-
super("mongodb", "mongodb", "MongoDB Monitoring Plugin");
|
|
7
|
-
}
|
|
8
|
-
async setup() {
|
|
9
|
-
// no setup required
|
|
10
|
-
return Promise.resolve();
|
|
11
|
-
}
|
|
12
|
-
async refresh() {
|
|
13
|
-
// get mongodb db.serverStatus
|
|
14
|
-
}
|
|
15
|
-
async monitor() {
|
|
16
|
-
// this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 5000)
|
|
17
|
-
this.childProcess = spawn("mongostat", ["--json", "mongodb://localhost:27017", "10"]);
|
|
18
|
-
this.childProcess.stdout.on("data", (input) => {
|
|
19
|
-
const jsonObject = JSON.parse(input.toString());
|
|
20
|
-
if (!jsonObject["localhost:27017"]) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
const data = jsonObject["localhost:27017"];
|
|
24
|
-
this.send(data, "mongodb-localhost:27017");
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
async teardown() {
|
|
28
|
-
this.childProcess.kill();
|
|
29
|
-
}
|
|
30
|
-
}
|
package/index.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// Export core plugin utilities
|
|
2
|
-
export { PluginConfigProvider } from "./lib/PluginConfigProvider"
|
|
3
|
-
export { PluginManager } from "./lib/PluginManager"
|
|
4
|
-
export { PluginLoader } from "./lib/PluginLoader"
|
|
5
|
-
|
|
6
|
-
// Only export creation functions, not types
|
|
7
|
-
export { createBasePlugin } from "./lib/BasePlugin"
|
|
8
|
-
export { createMonitoringPlugin } from "./lib/MonitoringPlugin"
|
|
9
|
-
|
|
10
|
-
// Export the PM2 plugin factory function
|
|
11
|
-
export { createPM2Plugin } from "./plugins/monitoring/PM2Plugin"
|