@daemonitor/plugins 1.1.24 → 1.1.27

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  // Export core plugin utilities
2
- export { PluginConfigProvider } from "./lib/PluginConfigProvider";
3
- export { PluginManager } from "./lib/PluginManager";
4
- export { PluginLoader } from "./lib/PluginLoader";
2
+ export { PluginConfigProvider } from "./lib/PluginConfigProvider.js";
3
+ export { PluginManager } from "./lib/PluginManager.js";
4
+ export { PluginLoader } from "./lib/PluginLoader.js";
5
5
  // Only export creation functions, not types
6
- export { createBasePlugin } from "./lib/BasePlugin";
7
- export { createMonitoringPlugin } from "./lib/MonitoringPlugin";
6
+ export { createBasePlugin } from "./lib/BasePlugin.js";
7
+ export { createMonitoringPlugin } from "./lib/MonitoringPlugin.js";
8
8
  // Export the PM2 plugin factory function
9
- export { createPM2Plugin } from "./plugins/monitoring/PM2Plugin";
9
+ export { createPM2Plugin } from "./plugins/monitoring/PM2Plugin.js";
@@ -1,4 +1,4 @@
1
- import { PluginConfigProvider } from "./PluginConfigProvider";
1
+ import { PluginConfigProvider } from "./PluginConfigProvider.js";
2
2
  let currentPluginIndex = 0;
3
3
  export function createBasePlugin(alias, name, description, setupFn, monitorFn, refreshFn, teardownFn) {
4
4
  const config = PluginConfigProvider.get(alias);
@@ -1,4 +1,4 @@
1
- import { PluginManager } from "./PluginManager";
1
+ import { PluginManager } from "./PluginManager.js";
2
2
  let monitoringPluginIndex = 0;
3
3
  export function createMonitoringPlugin(alias, name, description, setupFn, monitorFn, refreshFn, teardownFn) {
4
4
  const config = PluginManager.getConfig ? PluginManager.getConfig(alias) : {};
@@ -1,4 +1,4 @@
1
- import pluginsFactory from "../plugins/index";
1
+ import pluginsFactory from "../plugins/index.js";
2
2
  export const PluginLoader = {
3
3
  // Load all plugins using the factories
4
4
  loadPlugins: async function () {
@@ -1,5 +1,5 @@
1
- import { PluginLoader } from "./PluginLoader";
2
- import { PluginConfigProvider } from "./PluginConfigProvider";
1
+ import { PluginLoader } from "./PluginLoader.js";
2
+ import { PluginConfigProvider } from "./PluginConfigProvider.js";
3
3
  // Singleton state
4
4
  const state = {
5
5
  availablePlugins: [],
@@ -1,21 +1,21 @@
1
1
  // Import plugin factory functions
2
- import { createPM2Plugin } from "./monitoring/PM2Plugin";
3
- import { createDockerPlugin } from "./monitoring/DockerPlugin";
4
- import { createLxcPlugin } from "./monitoring/LxcPlugin";
5
- import { createOSPlugin } from "./monitoring/OSPlugin";
6
- import { createWebsitePlugin } from "./monitoring/WebsitePlugin";
7
- import { createWordpressPlugin } from "./monitoring/WordpressPlugin";
8
- import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin";
9
- import { createNetScanPlugin } from "./monitoring/NetScanPlugin";
10
- import { createSnmpPlugin } from "./monitoring/SnmpPlugin";
11
- import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin";
12
- import { createTplinkPlugin } from "./monitoring/TplinkPlugin";
2
+ import { createPM2Plugin } from "./monitoring/PM2Plugin.js";
3
+ import { createDockerPlugin } from "./monitoring/DockerPlugin.js";
4
+ import { createLxcPlugin } from "./monitoring/LxcPlugin.js";
5
+ import { createOSPlugin } from "./monitoring/OSPlugin.js";
6
+ import { createWebsitePlugin } from "./monitoring/WebsitePlugin.js";
7
+ import { createWordpressPlugin } from "./monitoring/WordpressPlugin.js";
8
+ import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin.js";
9
+ import { createNetScanPlugin } from "./monitoring/NetScanPlugin.js";
10
+ import { createSnmpPlugin } from "./monitoring/SnmpPlugin.js";
11
+ import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
12
+ import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
13
13
  // In future, implement other plugin factory functions using the same pattern
14
- // import { createOSPlugin } from "./monitoring/OSPlugin"
15
- // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin"
16
- // import { createWebsitePlugin } from "./monitoring/WebsitePlugin"
17
- // import { createCloudflarePlugin } from "./monitoring/CloudflarePlugin"
18
- // import { createEwelinkPlugin } from "./monitoring/EwelinkPlugin"
14
+ // import { createOSPlugin } from "./monitoring/OSPlugin.js"
15
+ // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
16
+ // import { createWebsitePlugin } from "./monitoring/WebsitePlugin.js"
17
+ // import { createCloudflarePlugin } from "./monitoring/CloudflarePlugin.js"
18
+ // import { createEwelinkPlugin } from "./monitoring/EwelinkPlugin.js"
19
19
  // Export factory functions to create plugin instances.
20
20
  // (Keys are display-only; the manager activates a plugin when its getName()
21
21
  // lowercased matches an alias in the config `plugins` array.)
@@ -1,9 +1,42 @@
1
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
4
  import { hostname } from "os";
5
5
  const execAsync = promisify(exec);
6
6
  const EXEC_OPTS = { maxBuffer: 32 * 1024 * 1024 }; // containers.length can be large
7
+ /**
8
+ * Reduce a healthcheck's captured output to the part that says what went wrong.
9
+ *
10
+ * Healthchecks are overwhelmingly `curl`, and curl writes its transfer meter to
11
+ * the same buffer as the response — so the raw text is mostly columns of rates
12
+ * and `--:--:--` padding, with the answer somewhere inside. Taking the first 300
13
+ * characters, as this used to, returns the meter header every single time.
14
+ *
15
+ * curl also rewrites the meter line in place with \r while the body streams in,
16
+ * so the response can land INSIDE a rate row rather than on its own line. That
17
+ * rules out dropping noisy lines wholesale; the meter tokens have to come out
18
+ * and whatever is left is the message.
19
+ */
20
+ export function cleanHealthOutput(raw) {
21
+ const s = String(raw || "");
22
+ // When curl itself failed it says so in one line, and that line is the entire
23
+ // answer: exit 7 is connection refused, 28 is timeout, 22 is an HTTP error.
24
+ const curlErr = s.match(/curl:\s*\(\d+\)[^\r\n]*/);
25
+ if (curlErr)
26
+ return curlErr[0].trim().slice(0, 300);
27
+ const stripped = s
28
+ .replace(/%\s*Total[\s\S]*?Dload\s+Upload\s+Total\s+Spent\s+Left\s+Speed/g, " ")
29
+ .replace(/%\s*Total[^\r\n]*/g, " ")
30
+ .replace(/Dload[^\r\n]*Speed/g, " ")
31
+ .replace(/(?:--:--:--|\d+:\d{2}:\d{2})/g, " ")
32
+ .replace(/\s+/g, " ")
33
+ // what remains of the meter is runs of bare counters
34
+ .replace(/(?:\s\d+(?:\.\d+)?[kKMG]?){3,}(?=\s|$)/g, " ")
35
+ .replace(/\s+/g, " ")
36
+ .trim();
37
+ // A failing command puts its error last, after whatever it managed to print.
38
+ return stripped.length > 300 ? "…" + stripped.slice(-300) : stripped;
39
+ }
7
40
  const HEALTH_RE = /\((healthy|unhealthy|health: starting|starting)\)/i;
8
41
  // Previous cumulative NetIO/BlockIO totals per container id, to derive a
9
42
  // per-second rate across polls. Module-level (one collect per host per cycle).
@@ -148,13 +181,19 @@ async function collectContainers(bin) {
148
181
  if (!h)
149
182
  continue;
150
183
  c.healthFailingStreak = Number(h.FailingStreak) || 0;
151
- const last = Array.isArray(h.Log) && h.Log.length ? h.Log[h.Log.length - 1] : null;
152
- if (last && typeof last.ExitCode === "number")
153
- c.healthExitCode = last.ExitCode;
154
- if (last?.Output) {
155
- // Healthcheck output is often multi-line and ends in a newline; collapse
156
- // it so it fits an alert line, and keep the head where the error is.
157
- c.healthOutput = String(last.Output).replace(/\s+/g, " ").trim().slice(0, 300);
184
+ const log = Array.isArray(h.Log) ? h.Log : [];
185
+ // Docker keeps the last five probes, and the newest one can be a PASS while
186
+ // the container is still marked unhealthy — the status lags a recovery, and
187
+ // a flapping check alternates. Quoting that pass is how an alert ends up
188
+ // giving {"status":"ok"} as the reason something is broken. Report the most
189
+ // recent probe that actually failed.
190
+ const entry = [...log].reverse().find((e) => e && e.ExitCode !== 0) || log[log.length - 1] || null;
191
+ if (entry && typeof entry.ExitCode === "number")
192
+ c.healthExitCode = entry.ExitCode;
193
+ if (entry?.Output) {
194
+ const cleaned = cleanHealthOutput(entry.Output);
195
+ if (cleaned)
196
+ c.healthOutput = cleaned;
158
197
  }
159
198
  }
160
199
  catch (err) {
@@ -1,6 +1,6 @@
1
1
  import { stat, open } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
4
4
  // Cap a single tail read so a rotated-in giant file (or fromStart on a large
5
5
  // log) can't blow up memory — read at most the last MAX_READ bytes.
6
6
  const MAX_READ = 5 * 1024 * 1024;
@@ -24,16 +24,27 @@ function parseLine(line) {
24
24
  return null;
25
25
  const br = line.indexOf("[");
26
26
  const pre = br > 0 ? line.slice(0, br) : line;
27
- const tokens = pre.split(/\s+/);
27
+ const tokens = pre.split(/\s+/).filter(Boolean);
28
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: "" };
29
+ const ip = ips.length ? ips[ips.length - 1] : (tokens[0] || "");
30
+ // A host in the leading field is what lets a SHARED access log (one file for
31
+ // every vhost, as on the misc box) still say WHICH site was hit. A plain
32
+ // combined line leads with the client IP instead, so only a non-IP first
33
+ // token counts. When $host is itself an IP — a request made straight to the
34
+ // origin address — there is no site name to report and "" is correct.
35
+ const first = tokens[0] || "";
36
+ const host = first !== "-" && !IP_TOKEN.test(first) ? first : "";
37
+ return { host, ip, method: rq[1], path: rq[2], status: Number(rq[3]), ua: "" };
31
38
  }
32
39
  const SUSPICIOUS_QUERY = /(?:^|[?&])(c|k|cmd|exec|e|q|f|file|dir|download|0|1|a)=/i;
33
40
  const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
34
41
  const UPLOADS_PHP = /\/wp-content\/uploads\/.*\.php$/i;
35
42
  const PLUGIN_PHP = /\/wp-content\/(?:plugins|mu-plugins)\/.*\.php$/i;
36
43
  const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
44
+ // wp-admin paths that answer 2xx to anonymous callers, so a 2xx on them proves
45
+ // nothing about being logged in. Everything else under /wp-admin/ redirects a
46
+ // logged-out visitor to the login form.
47
+ const ANON_ADMIN = /^\/wp-admin\/(?:admin-ajax|admin-post|load-styles|load-scripts|install|setup-config)\.php$|^\/wp-admin\/(?:css|js|images|fonts)\//i;
37
48
  // Read new bytes since last offset, advancing state. Handles rotation (file
38
49
  // shrank → restart at 0) and partial trailing lines (buffered in remainder).
39
50
  async function readNew(source, st) {
@@ -74,10 +85,34 @@ async function readNew(source, st) {
74
85
  st.remainder = lines.pop() ?? ""; // last element is a partial line (no newline)
75
86
  return lines;
76
87
  }
77
- function analyze(lines, cfg, windowSec) {
88
+ export function analyze(lines, cfg, windowSec, st) {
78
89
  const burstThreshold = cfg.xmlrpcBurstThreshold ?? 20;
79
90
  const batchIps = new Map(); // ip → count of batch/v1 → 207
80
- const loginOkIps = new Set(); // ip with POST wp-login → 302
91
+ // key (ip, or path for webshells) → the vhosts it was seen against. Only
92
+ // populated when the log format carries $host; findings degrade to the old
93
+ // wording when it doesn't.
94
+ const hostsByKey = new Map();
95
+ const noteHost = (key, host) => {
96
+ if (!host)
97
+ return;
98
+ const set = hostsByKey.get(key) || new Set();
99
+ set.add(host);
100
+ hostsByKey.set(key, set);
101
+ };
102
+ // " on a.com, b.com" — capped so a scanner sweeping 30 vhosts can't blow past
103
+ // the 200-char detail limit the ingest applies.
104
+ const onSites = (key) => {
105
+ const set = hostsByKey.get(key);
106
+ if (!set?.size)
107
+ return "";
108
+ const list = [...set];
109
+ const shown = list.slice(0, 3).join(", ");
110
+ return ` on ${shown}${list.length > 3 ? ` +${list.length - 3} more` : ""}`;
111
+ };
112
+ let batchAttempts = 0; // every batch/v1 request, whatever the status
113
+ let batchBlocked = 0; // ...of which nginx/WAF rejected outright
114
+ const loginOkIps = new Set(); // ip with POST wp-login → 302 (WEAK on its own)
115
+ const adminOkIps = new Set(); // ip that then loaded an authed wp-admin page (2xx)
81
116
  const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
82
117
  const webshell = new Map(); // pathNoQuery → sample
83
118
  let total = 0;
@@ -89,14 +124,40 @@ function analyze(lines, cfg, windowSec) {
89
124
  const qIdx = e.path.indexOf("?");
90
125
  const pathNoQuery = qIdx === -1 ? e.path : e.path.slice(0, qIdx);
91
126
  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);
127
+ // Counting every batch/v1 attempt (not just the 207s) keeps attack pressure
128
+ // visible on the dashboard once an origin rule starts answering 403 — the
129
+ // ALERT still fires only on 207, which is the status that means WordPress
130
+ // actually executed the batch.
131
+ if (BATCH_PATH.test(e.path) || pathNoQuery === "/wp-json/batch/v1") {
132
+ batchAttempts++;
133
+ if (e.status === 207) {
134
+ batchIps.set(e.ip, (batchIps.get(e.ip) || 0) + 1);
135
+ noteHost(e.ip, e.host);
136
+ }
137
+ else if (e.status === 403 || e.status === 406 || e.status === 429) {
138
+ batchBlocked++;
139
+ }
140
+ }
141
+ // A 302 on POST /wp-login.php is the classic "login succeeded" tell, but it
142
+ // is only meaningful where a FAILED login looks different. On a site that
143
+ // hides or blocks wp-login.php every response is a 302 — right password,
144
+ // wrong password, no password, plain GET. Calibrate against that first.
145
+ if (pathNoQuery === "/wp-login.php" && e.method === "GET" && e.status >= 300 && e.status < 400) {
146
+ if (st)
147
+ st.hideLogin = true;
94
148
  }
95
149
  if (pathNoQuery === "/wp-login.php" && e.method === "POST" && e.status === 302) {
96
150
  loginOkIps.add(e.ip);
97
151
  }
152
+ // Corroboration: a session that actually authenticated goes on to load a
153
+ // wp-admin screen and gets a 2xx. An unauthenticated caller is bounced with
154
+ // a 3xx. Skip the wp-admin paths that answer 2xx to anonymous callers.
155
+ if (pathNoQuery.startsWith("/wp-admin/") && !ANON_ADMIN.test(pathNoQuery) && e.status >= 200 && e.status < 300) {
156
+ adminOkIps.add(e.ip);
157
+ }
98
158
  if (pathNoQuery === "/xmlrpc.php" && e.method === "POST") {
99
159
  xmlrpcByIp.set(e.ip, (xmlrpcByIp.get(e.ip) || 0) + 1);
160
+ noteHost(e.ip, e.host);
100
161
  }
101
162
  // A .php under uploads is never legitimate; under a plugin dir it's only
102
163
  // suspicious with a command-style or base64 query (webshell invocation).
@@ -111,18 +172,23 @@ function analyze(lines, cfg, windowSec) {
111
172
  const cur = webshell.get(pathNoQuery) || { ip: e.ip, count: 0 };
112
173
  cur.count++;
113
174
  webshell.set(pathNoQuery, cur);
175
+ noteHost(pathNoQuery, e.host);
114
176
  }
115
177
  }
116
178
  const findings = [];
117
179
  // Correlation is the highest-signal event — flag it before the standalone
118
180
  // batch/v1 finding so it leads.
181
+ // Only call it a takeover when the login status is trustworthy for this site
182
+ // AND the IP went on to load an admin screen successfully. Either half alone
183
+ // produced a false "account takeover" page on archpaper.com, 2026-08-23.
184
+ const authed = (ip) => !st?.hideLogin && loginOkIps.has(ip) && adminOkIps.has(ip);
119
185
  for (const [ip, count] of batchIps) {
120
- if (loginOkIps.has(ip)) {
186
+ if (authed(ip)) {
121
187
  findings.push({
122
188
  rule: "batch-then-login", key: `batch-login:${ip}`, severity: "error", ip,
123
189
  path: "/wp-json/batch/v1",
124
190
  title: "WP2Shell: exploit + admin login",
125
- detail: `${ip}: batch/v1 exploit (${count}×) followed by a successful wp-login (HTTP 302) — likely account takeover`,
191
+ detail: `${ip}: batch/v1 exploit (${count}×)${onSites(ip)}, then a wp-login 302 AND an authenticated wp-admin page (2xx) — likely account takeover`,
126
192
  });
127
193
  }
128
194
  else {
@@ -130,7 +196,7 @@ function analyze(lines, cfg, windowSec) {
130
196
  rule: "wp-batch-207", key: `batchv1:${ip}`, severity: "error", ip,
131
197
  path: "/wp-json/batch/v1",
132
198
  title: "WP2Shell batch/v1 exploit",
133
- detail: `${ip}: POST /wp-json/batch/v1 → 207 (${count}×) — near-zero false positives`,
199
+ detail: `${ip}: POST /wp-json/batch/v1 → 207 (${count}×)${onSites(ip)} — near-zero false positives`,
134
200
  });
135
201
  }
136
202
  }
@@ -138,7 +204,7 @@ function analyze(lines, cfg, windowSec) {
138
204
  findings.push({
139
205
  rule: "webshell-exec", key: `webshell:${p}`, severity: "error", ip: v.ip, path: p,
140
206
  title: "Webshell execution",
141
- detail: `${v.ip} invoked ${p} (${v.count}×) — executable PHP in an upload/plugin path`,
207
+ detail: `${v.ip} invoked ${p} (${v.count}×)${onSites(p)} — executable PHP in an upload/plugin path`,
142
208
  });
143
209
  }
144
210
  for (const [ip, count] of xmlrpcByIp) {
@@ -147,16 +213,19 @@ function analyze(lines, cfg, windowSec) {
147
213
  rule: "xmlrpc-burst", key: `xmlrpc:${ip}`, severity: count >= burstThreshold * 5 ? "error" : "warning", ip,
148
214
  path: "/xmlrpc.php",
149
215
  title: "xmlrpc.php brute-force / amplification",
150
- detail: `${ip}: ${count} POST /xmlrpc.php in ~${windowSec}s (threshold ${burstThreshold})`,
216
+ detail: `${ip}: ${count} POST /xmlrpc.php in ~${windowSec}s${onSites(ip)} (threshold ${burstThreshold})`,
151
217
  });
152
218
  }
153
219
  }
154
220
  const counts = {
155
221
  total,
156
222
  batchV1: [...batchIps.values()].reduce((a, b) => a + b, 0),
223
+ batchAttempts,
224
+ batchBlocked,
157
225
  xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
158
226
  webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
159
- loginSuccess: loginOkIps.size,
227
+ loginSuccess: st?.hideLogin ? 0 : [...loginOkIps].filter((ip) => adminOkIps.has(ip)).length,
228
+ loginRedirectsAlways: !!st?.hideLogin,
160
229
  };
161
230
  return { findings, counts };
162
231
  }
@@ -189,7 +258,7 @@ export function createLogWatchPlugin() {
189
258
  }
190
259
  try {
191
260
  const lines = await readNew(source, st);
192
- const { findings, counts } = analyze(lines, cfg, windowSec);
261
+ const { findings, counts } = analyze(lines, cfg, windowSec, st);
193
262
  await plugin.send({ name: label, source: label, path: source.path, window: windowSec, counts, findings }, uid);
194
263
  }
195
264
  catch (e) {
@@ -1,4 +1,4 @@
1
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
2
  import { exec } from "child_process";
3
3
  import { promisify } from "util";
4
4
  import { hostname } from "os";
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
4
4
  // LAN device reachability monitor. Pings a configured list of devices (the
5
5
  // office infrastructure — switch, printers, APs, extenders, PBX, router, NAS,
6
6
  // phones — plus any hosts worth watching) and reports up/down + latency for
@@ -1,8 +1,8 @@
1
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
2
  import { arch, cpus, freemem, hostname, loadavg, networkInterfaces, platform, release, totalmem, type, uptime, } from "os";
3
3
  import { readFileSync } from "fs";
4
4
  import { execSync } from "child_process";
5
- import { parseProcesses, PS_CMD } from "../../lib/processes";
5
+ import { parseProcesses, PS_CMD } from "../../lib/processes.js";
6
6
  // Named service processes (nginx / php-fpm / db / …) running on the host.
7
7
  function collectProcesses() {
8
8
  try {
@@ -1,4 +1,4 @@
1
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
1
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
2
  // Factory function to create PM2 plugin
3
3
  export function createPM2Plugin() {
4
4
  // Create plugin state
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
4
4
  // SNMP monitor. First target class: printers (standard Printer-MIB) — model,
5
5
  // serial, lifetime page count, and per-supply toner/consumable levels. Shells
6
6
  // out to net-snmp's snmpget/snmpwalk (add `snmp` to the client image) rather
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { connect } from "node:net";
4
4
  import { hostname } from "node:os";
5
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
5
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
6
6
  // 3CX PhoneSystem monitor. Runs ON a 3CX appliance (Debian) and watches the
7
7
  // signals that actually break a PBX, using only unprivileged, auth-free reads:
8
8
  // - every 3CX* systemd unit is active (a crashed CallFlow/SIP/Media server is
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
3
  // Live sessions per device IP — re-used across polls, rebuilt when they lapse.
4
4
  const sessions = {};
5
5
  function slug(s) {
@@ -1,7 +1,12 @@
1
1
  import * as http from "node:http";
2
2
  import * as https from "node:https";
3
+ import * as net from "node:net";
3
4
  import { URL } from "node:url";
4
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
5
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
6
+ // URL keeps IPv6 hosts bracketed ("[::1]"); net.isIP wants them bare.
7
+ function isIpLiteral(hostname) {
8
+ return net.isIP(hostname.replace(/^\[|\]$/g, "")) !== 0;
9
+ }
5
10
  // Low-level GET that can hit one host (`target`) while presenting a different
6
11
  // Host header. fetch() can't do this — Host is a forbidden header per the
7
12
  // Fetch spec and is silently dropped — so this uses Node's http/https
@@ -33,7 +38,20 @@ function rawGet(target, opts) {
33
38
  // target. Without it, Node derives SNI from headers.Host,
34
39
  // which would validate the cert against the WRONG hostname
35
40
  // (the public one, not the origin actually being dialed).
36
- ...(u.protocol === "https:" ? { servername: u.hostname, rejectUnauthorized: !opts.insecure } : {}),
41
+ //
42
+ // SNI must be a DNS name: RFC 6066 forbids IP literals, and
43
+ // Node/bun throw ERR_INVALID_ARG_VALUE rather than ignoring
44
+ // one, which kills the whole agent at startup. When the origin
45
+ // is dialed by IP there is no name to pin, so omit servername
46
+ // and let validation match the IP against the cert's IP SANs —
47
+ // which is the right check for an IP target, and is what the
48
+ // Host header override is deliberately kept out of.
49
+ ...(u.protocol === "https:"
50
+ ? {
51
+ ...(isIpLiteral(u.hostname) ? {} : { servername: u.hostname }),
52
+ rejectUnauthorized: !opts.insecure,
53
+ }
54
+ : {}),
37
55
  }, (res) => {
38
56
  let body = "";
39
57
  res.on("data", (c) => { body += c; });
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { readdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
5
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
6
6
  // WordPress security-posture plugin. Runs on the WP host (where the daemonitor
7
7
  // client already runs) and polls the state that the Archpaper intrusions moved
8
8
  // through: admin accounts, page statuses, must-use plugins, and the web user's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.24",
3
+ "version": "1.1.27",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,6 @@
22
22
  },
23
23
  "author": "",
24
24
  "license": "ISC",
25
- "packageManager": "pnpm@8.2.0",
26
25
  "devDependencies": {
27
26
  "@types/node": "^26.1.1",
28
27
  "nuxt": "^3.15.4",
@@ -49,6 +48,12 @@
49
48
  "url": "https://github.com/daemonitor/plugins/issues"
50
49
  },
51
50
  "homepage": "https://github.com/daemonitor/plugins#readme",
51
+ "overrides": {
52
+ "js-yaml": "^4.3.1"
53
+ },
54
+ "engines": {
55
+ "node": ">=18.17"
56
+ },
52
57
  "scripts": {
53
58
  "build": "tsc",
54
59
  "test": "echo \"Error: no test specified\" && exit 1"