@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.
package/dist/plugins/index.js
CHANGED
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
261
|
+
let status = cSt[i] != null ? Number(cSt[i]) : undefined;
|
|
257
262
|
const tempC = parseC(cTp[i] || null);
|
|
258
|
-
|
|
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;
|