@daemonitor/plugins 1.1.10 → 1.1.12

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.
@@ -8,6 +8,7 @@ import { createWordpressPlugin } from "./monitoring/WordpressPlugin";
8
8
  import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin";
9
9
  import { createNetScanPlugin } from "./monitoring/NetScanPlugin";
10
10
  import { createSnmpPlugin } from "./monitoring/SnmpPlugin";
11
+ import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin";
11
12
  // In future, implement other plugin factory functions using the same pattern
12
13
  // import { createOSPlugin } from "./monitoring/OSPlugin"
13
14
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin"
@@ -27,6 +28,7 @@ export default {
27
28
  Logwatch: createLogWatchPlugin,
28
29
  Netscan: createNetScanPlugin,
29
30
  Snmp: createSnmpPlugin,
31
+ ThreeCX: createThreeCXPlugin,
30
32
  // CloudflarePlugin: createCloudflarePlugin,
31
33
  // EwelinkPlugin: createEwelinkPlugin,
32
34
  // MongoDBPlugin: createMongoDBPlugin,
@@ -204,19 +204,20 @@ async function snmpWalkIndexed(t, oid, timeout = 10000) {
204
204
  async function collectQnap(t) {
205
205
  const tempWarn = t.tempWarnC ?? 70;
206
206
  const volWarn = t.volumeWarnPct ?? 90;
207
- const [model, cpuTemp, sysTemp, cpuLoads, storDescrs, memTotal, memFree, fanSpeeds] = await Promise.all([
208
- snmpGet(t, QNAP.model),
209
- snmpGet(t, QNAP.cpuTemp),
210
- snmpGet(t, QNAP.sysTemp),
211
- snmpWalk(t, "1.3.6.1.2.1.25.3.3.1.2"), // hrProcessorLoad (per core)
212
- snmpWalkIndexed(t, "1.3.6.1.2.1.25.2.3.1.3"), // hrStorageDescr
213
- snmpGet(t, QNAP.memTotal),
214
- snmpGet(t, QNAP.memFree),
215
- snmpWalk(t, "1.3.6.1.4.1.24681.1.2.15.1.3"), // SystemFanSpeed table
207
+ // The light scalar gets can run together, but the WALKS must be SERIALIZED:
208
+ // QNAP's SNMP agent silently drops rows (and whole tables) under concurrent
209
+ // sessions, producing partial reads that flip a failing NAS back to a false
210
+ // "healthy". 5-min cadence — sequential latency is fine.
211
+ const [model, cpuTemp, sysTemp, memTotal, memFree] = await Promise.all([
212
+ snmpGet(t, QNAP.model), snmpGet(t, QNAP.cpuTemp), snmpGet(t, QNAP.sysTemp),
213
+ snmpGet(t, QNAP.memTotal), snmpGet(t, QNAP.memFree),
216
214
  ]);
217
- if (model == null && cpuTemp == null && !cpuLoads.length) {
215
+ if (model == null && cpuTemp == null) {
218
216
  return { name: t.name, ip: t.ip, kind: "qnap", reachable: false };
219
217
  }
218
+ const cpuLoads = await snmpWalk(t, "1.3.6.1.2.1.25.3.3.1.2"); // hrProcessorLoad (per core)
219
+ const storDescrs = await snmpWalkIndexed(t, "1.3.6.1.2.1.25.2.3.1.3"); // hrStorageDescr
220
+ const fanSpeeds = await snmpWalk(t, "1.3.6.1.4.1.24681.1.2.15.1.3"); // SystemFanSpeed table
220
221
  // CPU: average per-core load.
221
222
  const cores = cpuLoads.map(Number).filter((n) => !isNaN(n));
222
223
  const cpuPct = cores.length ? Math.round(cores.reduce((a, b) => a + b, 0) / cores.length) : undefined;
@@ -233,10 +234,14 @@ async function collectQnap(t) {
233
234
  // Presence keys on MODEL/CAPACITY ("--" = empty bay), so a FAILING disk
234
235
  // (unreadable temp, negative status) is still reported, not mistaken for empty.
235
236
  const DT = "1.3.6.1.4.1.24681.1.2.11.1."; // disk table columns: 2=descr 3=temp 4=status 5=model 6=capacity 7=smart
236
- const [wStatus, wTemp, wSmart, wDescr, wModel, wCap] = await Promise.all([
237
- snmpWalkIndexed(t, DT + "4"), snmpWalkIndexed(t, DT + "3"), snmpWalkIndexed(t, DT + "7"),
238
- snmpWalkIndexed(t, DT + "2"), snmpWalkIndexed(t, DT + "5"), snmpWalkIndexed(t, DT + "6"),
239
- ]);
237
+ // Serialized (not Promise.all) — concurrent column walks are what make the
238
+ // agent drop rows.
239
+ const wStatus = await snmpWalkIndexed(t, DT + "4");
240
+ const wTemp = await snmpWalkIndexed(t, DT + "3");
241
+ const wSmart = await snmpWalkIndexed(t, DT + "7");
242
+ const wDescr = await snmpWalkIndexed(t, DT + "2");
243
+ const wModel = await snmpWalkIndexed(t, DT + "5");
244
+ const wCap = await snmpWalkIndexed(t, DT + "6");
240
245
  const col = (rows) => {
241
246
  const m = {};
242
247
  for (const r of rows)
@@ -253,9 +258,41 @@ async function collectQnap(t) {
253
258
  // Only a truly empty bay (no model AND no capacity) is skipped.
254
259
  if (!model && !capacity)
255
260
  continue;
256
- const status = cSt[i] != null ? Number(cSt[i]) : undefined;
261
+ let status = cSt[i] != null ? Number(cSt[i]) : undefined;
257
262
  const tempC = parseC(cTp[i] || null);
258
- disks.push({ slot: Number(i), descr: cDs[i] || undefined, model, capacity, tempC, status, smart: cSm[i] && cSm[i] !== "--" ? cSm[i] : undefined });
263
+ let smart = cSm[i] && cSm[i] !== "--" ? cSm[i] : undefined;
264
+ // A present disk MUST carry a health verdict (status or SMART). If the
265
+ // status/smart column walks came back empty for this row that cycle, the
266
+ // disk would look healthy purely for lack of a reading — the exact bug that
267
+ // flips a failing NAS green. Fall back to a targeted per-disk get before
268
+ // giving up on the row.
269
+ if (status == null) {
270
+ const s = await snmpGet(t, DT + "4." + i);
271
+ if (s != null && s !== "" && !isNaN(Number(s)))
272
+ status = Number(s);
273
+ }
274
+ if (smart == null) {
275
+ const s = await snmpGet(t, DT + "7." + i);
276
+ if (s != null && s !== "" && s !== "--")
277
+ smart = s;
278
+ }
279
+ disks.push({ slot: Number(i), descr: cDs[i] || undefined, model, capacity, tempC, status, smart });
280
+ }
281
+ // A reachable QNAP always has ≥1 disk. Zero means the disk-table walks came
282
+ // back empty (agent timed out) — treat as an INCOMPLETE read, not a clean
283
+ // bill of health, so a real disk error isn't falsely cleared to green.
284
+ if (!disks.length) {
285
+ return { name: t.name, ip: t.ip, kind: "qnap", reachable: true, error: "SNMP read incomplete — disk table empty" };
286
+ }
287
+ // Every present disk must have a health verdict after the fallback gets. If
288
+ // any disk still lacks BOTH a status and a SMART reading, we cannot certify it
289
+ // — the read is incomplete, so report that rather than a false all-clear.
290
+ const unverified = disks.filter((d) => d.status == null && d.smart == null);
291
+ if (unverified.length) {
292
+ return {
293
+ name: t.name, ip: t.ip, kind: "qnap", reachable: true,
294
+ error: `SNMP read incomplete — no health verdict for ${unverified.length} disk(s): ${unverified.map((d) => d.descr || `slot ${d.slot}`).join(", ")}`,
295
+ };
259
296
  }
260
297
  // A present disk is bad only on a real error status, an explicitly-bad SMART
261
298
  // verdict, or overheating — not a blank "--" or an empty bay.
@@ -0,0 +1,181 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { connect } from "node:net";
4
+ import { hostname } from "node:os";
5
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
6
+ // 3CX PhoneSystem monitor. Runs ON a 3CX appliance (Debian) and watches the
7
+ // signals that actually break a PBX, using only unprivileged, auth-free reads:
8
+ // - every 3CX* systemd unit is active (a crashed CallFlow/SIP/Media server is
9
+ // the real failure mode; `systemctl -o json` needs no root)
10
+ // - the PBX version (dpkg)
11
+ // - SIP (5060/5061) and the management console (443) are actually listening
12
+ //
13
+ // It deliberately avoids the 3CX management API — that needs a bearer token and
14
+ // per-appliance provisioning; service + port liveness catches an outage without
15
+ // any secret on the box. One client_state per instance, type "3cx".
16
+ //
17
+ // Two roles:
18
+ // - "pbx" (default): the PhoneSystem — 3CX* services, SIP listeners (5060/5061),
19
+ // management console (443).
20
+ // - "sbc": a Session Border Controller — the single 3cxsbc service plus its
21
+ // OUTBOUND tunnel to the PBX (an established TCP session to the PBX on 5090).
22
+ // An SBC doesn't listen for SIP locally, so its health is "service up AND
23
+ // tunnel to the PBX connected", not a listen-probe.
24
+ //
25
+ // Severity is decided server-side (clientstate/update.put.ts): any down service,
26
+ // a dead SIP port, or (SBC) a dropped tunnel → error; console down → warning.
27
+ const pexec = promisify(execFile);
28
+ function slug(s) {
29
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
30
+ }
31
+ // TCP connect probe — a listening SIP/console port answers the handshake.
32
+ function portListening(host, port, timeout = 4000) {
33
+ return new Promise((resolve) => {
34
+ const sock = connect({ host, port });
35
+ let done = false;
36
+ const finish = (ok) => {
37
+ if (done)
38
+ return;
39
+ done = true;
40
+ sock.destroy();
41
+ resolve(ok);
42
+ };
43
+ sock.setTimeout(timeout);
44
+ sock.once("connect", () => finish(true));
45
+ sock.once("timeout", () => finish(false));
46
+ sock.once("error", () => finish(false));
47
+ });
48
+ }
49
+ async function pbxVersion(pkg) {
50
+ try {
51
+ const { stdout } = await pexec("dpkg-query", ["-W", "-f=${Version}", pkg], { timeout: 6000 });
52
+ const v = stdout.trim();
53
+ return v || undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ // SBC tunnel: the SBC keeps an established outbound TCP session to the PBX on the
60
+ // tunnel port (5090). `ss -tn state established` needs no root and its absence is
61
+ // the real "SBC is cut off from the PBX" signal. Returns whether ≥1 such session
62
+ // exists and the PBX peer it points at.
63
+ async function tunnelStatus(port) {
64
+ try {
65
+ const { stdout } = await pexec("ss", ["-tn", "state", "established"], { timeout: 6000 });
66
+ const suffix = `:${port}`;
67
+ for (const line of stdout.split("\n")) {
68
+ const cols = line.trim().split(/\s+/);
69
+ const peer = cols[cols.length - 1]; // "Peer Address:Port" is the last column
70
+ if (peer && peer.endsWith(suffix)) {
71
+ return { up: true, peer: peer.slice(0, -suffix.length) };
72
+ }
73
+ }
74
+ return { up: false };
75
+ }
76
+ catch {
77
+ return { up: false };
78
+ }
79
+ }
80
+ async function collect3cx(inst) {
81
+ const role = inst.role === "sbc" ? "sbc" : "pbx";
82
+ const name = inst.name || hostname();
83
+ const host = inst.host || "127.0.0.1";
84
+ const unitGlob = inst.unitGlob || (role === "sbc" ? "3cxsbc*" : "3CX*");
85
+ const versionPkg = inst.versionPkg || (role === "sbc" ? "3cxsbc" : "3cxpbx");
86
+ const sipPorts = inst.sipPorts && inst.sipPorts.length ? inst.sipPorts : [5060, 5061];
87
+ const consolePort = inst.consolePort ?? 443;
88
+ const pbxTunnelPort = inst.pbxTunnelPort ?? 5090;
89
+ const systemctlBin = inst.systemctlBin || "systemctl";
90
+ // --- systemd unit health (unprivileged JSON) ---
91
+ let units = [];
92
+ let listErr;
93
+ try {
94
+ const { stdout } = await pexec(systemctlBin, ["list-units", unitGlob, "--all", "--type=service", "--no-pager", "--no-legend", "-o", "json"], { timeout: 10000 });
95
+ const parsed = JSON.parse(stdout || "[]");
96
+ units = (Array.isArray(parsed) ? parsed : []).map((u) => ({
97
+ unit: String(u.unit || ""),
98
+ active: String(u.active || ""),
99
+ sub: String(u.sub || ""),
100
+ }));
101
+ }
102
+ catch (e) {
103
+ listErr = String(e?.message || e).slice(0, 160);
104
+ }
105
+ // A reachable 3CX box always has its service units. Zero units + an error
106
+ // means we couldn't read systemd at all — report INCOMPLETE, not all-clear,
107
+ // so a real outage isn't hidden behind a false green.
108
+ if (!units.length) {
109
+ return {
110
+ kind: "3cx", role, name, host, reachable: !listErr,
111
+ error: listErr ? `systemctl read failed — ${listErr}` : `no ${role === "sbc" ? "3cxsbc" : "3CX"} services found`,
112
+ };
113
+ }
114
+ // A oneshot unit that has run and exited (e.g. 3CXFirewall) reports
115
+ // active/exited — still "active", so `active === "active"` is the clean
116
+ // predicate for both long-running and oneshot units.
117
+ const services = units.map((u) => ({ ...u, ok: u.active === "active" }));
118
+ const down = services.filter((s) => !s.ok);
119
+ const version = await pbxVersion(versionPkg);
120
+ const svcCommon = {
121
+ kind: "3cx", role, name, host, reachable: true, version,
122
+ services,
123
+ servicesTotal: services.length,
124
+ servicesOk: services.length - down.length,
125
+ down: down.map((d) => ({ unit: d.unit, active: d.active, sub: d.sub })),
126
+ };
127
+ // --- SBC: outbound tunnel to the PBX (no local SIP listener) ---
128
+ if (role === "sbc") {
129
+ const tun = await tunnelStatus(pbxTunnelPort);
130
+ return { ...svcCommon, tunnelUp: tun.up, tunnelPeer: tun.peer, tunnelPort: pbxTunnelPort };
131
+ }
132
+ // --- PBX: port liveness ---
133
+ const sip = await Promise.all(sipPorts.map(async (port) => ({ port, listening: await portListening(host, port) })));
134
+ const sipOk = sip.every((p) => p.listening);
135
+ const consoleProbe = { port: consolePort, listening: await portListening(host, consolePort) };
136
+ return { ...svcCommon, sip, sipOk, console: consoleProbe };
137
+ }
138
+ export function createThreeCXPlugin() {
139
+ let refreshTimer = null;
140
+ const instancesOf = (plugin) => {
141
+ const cfg = (plugin.config || {});
142
+ if (Array.isArray(cfg.instances) && cfg.instances.length)
143
+ return cfg.instances;
144
+ // No explicit instances → a single on-box instance from the flat config.
145
+ return [{
146
+ name: cfg.name && cfg.name !== "3cx" ? cfg.name : undefined,
147
+ role: cfg.role,
148
+ host: cfg.host,
149
+ unitGlob: cfg.unitGlob,
150
+ versionPkg: cfg.versionPkg,
151
+ sipPorts: cfg.sipPorts,
152
+ consolePort: cfg.consolePort,
153
+ pbxTunnelPort: cfg.pbxTunnelPort,
154
+ systemctlBin: cfg.systemctlBin,
155
+ uniqueId: cfg.uniqueId,
156
+ }];
157
+ };
158
+ const refreshFn = async (plugin) => {
159
+ await Promise.all(instancesOf(plugin).map(async (inst) => {
160
+ const uid = inst.uniqueId || `3cx-${slug(inst.name || hostname())}`;
161
+ try {
162
+ const data = await collect3cx(inst);
163
+ await plugin.send(data, uid);
164
+ }
165
+ catch (e) {
166
+ await plugin.send({ kind: "3cx", name: inst.name || hostname(), reachable: false, error: String(e?.message || e).slice(0, 160) }, uid);
167
+ }
168
+ }));
169
+ };
170
+ const monitorFn = async (plugin) => {
171
+ await refreshFn(plugin);
172
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
173
+ };
174
+ const teardownFn = async () => {
175
+ if (refreshTimer)
176
+ clearInterval(refreshTimer);
177
+ refreshTimer = null;
178
+ };
179
+ return createMonitoringPlugin("3cx", "3cx", "3CX PBX monitor (service health, version, SIP/console liveness)", async () => { }, monitorFn, refreshFn, teardownFn);
180
+ }
181
+ export default createThreeCXPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",