@daemonitor/plugins 1.1.11 → 1.1.13
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,
|
|
@@ -258,9 +258,25 @@ async function collectQnap(t) {
|
|
|
258
258
|
// Only a truly empty bay (no model AND no capacity) is skipped.
|
|
259
259
|
if (!model && !capacity)
|
|
260
260
|
continue;
|
|
261
|
-
|
|
261
|
+
let status = cSt[i] != null ? Number(cSt[i]) : undefined;
|
|
262
262
|
const tempC = parseC(cTp[i] || null);
|
|
263
|
-
|
|
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 });
|
|
264
280
|
}
|
|
265
281
|
// A reachable QNAP always has ≥1 disk. Zero means the disk-table walks came
|
|
266
282
|
// back empty (agent timed out) — treat as an INCOMPLETE read, not a clean
|
|
@@ -268,6 +284,16 @@ async function collectQnap(t) {
|
|
|
268
284
|
if (!disks.length) {
|
|
269
285
|
return { name: t.name, ip: t.ip, kind: "qnap", reachable: true, error: "SNMP read incomplete — disk table empty" };
|
|
270
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
|
+
};
|
|
296
|
+
}
|
|
271
297
|
// A present disk is bad only on a real error status, an explicitly-bad SMART
|
|
272
298
|
// verdict, or overheating — not a blank "--" or an empty bay.
|
|
273
299
|
const badDisks = disks.filter((d) => (d.status != null && d.status !== 0) ||
|
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
// Inbound tunnels: on the PBX, each connected SBC (and any tunnelled client) holds
|
|
81
|
+
// an established session whose LOCAL port is the tunnel port (5090). Returns the
|
|
82
|
+
// set of distinct peer IPs — matching a configured SBC's edge IP tells us that SBC
|
|
83
|
+
// is still reaching the PBX, observed entirely from the PBX side.
|
|
84
|
+
async function inboundTunnelPeers(port) {
|
|
85
|
+
try {
|
|
86
|
+
const { stdout } = await pexec("ss", ["-tn", "state", "established"], { timeout: 6000 });
|
|
87
|
+
const suffix = `:${port}`;
|
|
88
|
+
const peers = new Set();
|
|
89
|
+
for (const line of stdout.split("\n")) {
|
|
90
|
+
const cols = line.trim().split(/\s+/);
|
|
91
|
+
if (cols.length < 2)
|
|
92
|
+
continue;
|
|
93
|
+
const local = cols[cols.length - 2];
|
|
94
|
+
const peer = cols[cols.length - 1];
|
|
95
|
+
if (!local || !local.endsWith(suffix))
|
|
96
|
+
continue; // only sessions we LISTEN for on :port
|
|
97
|
+
const ip = peer.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").replace(/^::ffff:/, "");
|
|
98
|
+
if (ip)
|
|
99
|
+
peers.add(ip);
|
|
100
|
+
}
|
|
101
|
+
return [...peers];
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function collect3cx(inst) {
|
|
108
|
+
const role = inst.role === "sbc" ? "sbc" : "pbx";
|
|
109
|
+
const name = inst.name || hostname();
|
|
110
|
+
const host = inst.host || "127.0.0.1";
|
|
111
|
+
const unitGlob = inst.unitGlob || (role === "sbc" ? "3cxsbc*" : "3CX*");
|
|
112
|
+
const versionPkg = inst.versionPkg || (role === "sbc" ? "3cxsbc" : "3cxpbx");
|
|
113
|
+
const sipPorts = inst.sipPorts && inst.sipPorts.length ? inst.sipPorts : [5060, 5061];
|
|
114
|
+
const consolePort = inst.consolePort ?? 443;
|
|
115
|
+
const pbxTunnelPort = inst.pbxTunnelPort ?? 5090;
|
|
116
|
+
const systemctlBin = inst.systemctlBin || "systemctl";
|
|
117
|
+
// --- systemd unit health (unprivileged JSON) ---
|
|
118
|
+
let units = [];
|
|
119
|
+
let listErr;
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await pexec(systemctlBin, ["list-units", unitGlob, "--all", "--type=service", "--no-pager", "--no-legend", "-o", "json"], { timeout: 10000 });
|
|
122
|
+
const parsed = JSON.parse(stdout || "[]");
|
|
123
|
+
units = (Array.isArray(parsed) ? parsed : []).map((u) => ({
|
|
124
|
+
unit: String(u.unit || ""),
|
|
125
|
+
active: String(u.active || ""),
|
|
126
|
+
sub: String(u.sub || ""),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
listErr = String(e?.message || e).slice(0, 160);
|
|
131
|
+
}
|
|
132
|
+
// A reachable 3CX box always has its service units. Zero units + an error
|
|
133
|
+
// means we couldn't read systemd at all — report INCOMPLETE, not all-clear,
|
|
134
|
+
// so a real outage isn't hidden behind a false green.
|
|
135
|
+
if (!units.length) {
|
|
136
|
+
return {
|
|
137
|
+
kind: "3cx", role, name, host, reachable: !listErr,
|
|
138
|
+
error: listErr ? `systemctl read failed — ${listErr}` : `no ${role === "sbc" ? "3cxsbc" : "3CX"} services found`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// A oneshot unit that has run and exited (e.g. 3CXFirewall) reports
|
|
142
|
+
// active/exited — still "active", so `active === "active"` is the clean
|
|
143
|
+
// predicate for both long-running and oneshot units.
|
|
144
|
+
const services = units.map((u) => ({ ...u, ok: u.active === "active" }));
|
|
145
|
+
const down = services.filter((s) => !s.ok);
|
|
146
|
+
const version = await pbxVersion(versionPkg);
|
|
147
|
+
const svcCommon = {
|
|
148
|
+
kind: "3cx", role, name, host, reachable: true, version,
|
|
149
|
+
services,
|
|
150
|
+
servicesTotal: services.length,
|
|
151
|
+
servicesOk: services.length - down.length,
|
|
152
|
+
down: down.map((d) => ({ unit: d.unit, active: d.active, sub: d.sub })),
|
|
153
|
+
};
|
|
154
|
+
// --- SBC: outbound tunnel to the PBX (no local SIP listener) ---
|
|
155
|
+
if (role === "sbc") {
|
|
156
|
+
const tun = await tunnelStatus(pbxTunnelPort);
|
|
157
|
+
return { ...svcCommon, tunnelUp: tun.up, tunnelPeer: tun.peer, tunnelPort: pbxTunnelPort };
|
|
158
|
+
}
|
|
159
|
+
// --- PBX: port liveness ---
|
|
160
|
+
const sip = await Promise.all(sipPorts.map(async (port) => ({ port, listening: await portListening(host, port) })));
|
|
161
|
+
const sipOk = sip.every((p) => p.listening);
|
|
162
|
+
const consoleProbe = { port: consolePort, listening: await portListening(host, consolePort) };
|
|
163
|
+
// Watch branch SBCs from the PBX side, if any are configured.
|
|
164
|
+
let sbcs = undefined;
|
|
165
|
+
let sbcTunnelCount = undefined;
|
|
166
|
+
if (Array.isArray(inst.sbcs) && inst.sbcs.length) {
|
|
167
|
+
const peers = await inboundTunnelPeers(pbxTunnelPort);
|
|
168
|
+
sbcTunnelCount = peers.length;
|
|
169
|
+
sbcs = inst.sbcs.map((s) => ({
|
|
170
|
+
name: s.name,
|
|
171
|
+
peer: s.peer,
|
|
172
|
+
connected: s.peer ? peers.includes(s.peer) : peers.length > 0,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
return { ...svcCommon, sip, sipOk, console: consoleProbe, ...(sbcs ? { sbcs, sbcTunnelCount } : {}) };
|
|
176
|
+
}
|
|
177
|
+
export function createThreeCXPlugin() {
|
|
178
|
+
let refreshTimer = null;
|
|
179
|
+
const instancesOf = (plugin) => {
|
|
180
|
+
const cfg = (plugin.config || {});
|
|
181
|
+
if (Array.isArray(cfg.instances) && cfg.instances.length)
|
|
182
|
+
return cfg.instances;
|
|
183
|
+
// No explicit instances → a single on-box instance from the flat config.
|
|
184
|
+
return [{
|
|
185
|
+
name: cfg.name && cfg.name !== "3cx" ? cfg.name : undefined,
|
|
186
|
+
role: cfg.role,
|
|
187
|
+
host: cfg.host,
|
|
188
|
+
unitGlob: cfg.unitGlob,
|
|
189
|
+
versionPkg: cfg.versionPkg,
|
|
190
|
+
sipPorts: cfg.sipPorts,
|
|
191
|
+
consolePort: cfg.consolePort,
|
|
192
|
+
pbxTunnelPort: cfg.pbxTunnelPort,
|
|
193
|
+
sbcs: cfg.sbcs,
|
|
194
|
+
systemctlBin: cfg.systemctlBin,
|
|
195
|
+
uniqueId: cfg.uniqueId,
|
|
196
|
+
}];
|
|
197
|
+
};
|
|
198
|
+
const refreshFn = async (plugin) => {
|
|
199
|
+
await Promise.all(instancesOf(plugin).map(async (inst) => {
|
|
200
|
+
const uid = inst.uniqueId || `3cx-${slug(inst.name || hostname())}`;
|
|
201
|
+
try {
|
|
202
|
+
const data = await collect3cx(inst);
|
|
203
|
+
await plugin.send(data, uid);
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
await plugin.send({ kind: "3cx", name: inst.name || hostname(), reachable: false, error: String(e?.message || e).slice(0, 160) }, uid);
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
209
|
+
};
|
|
210
|
+
const monitorFn = async (plugin) => {
|
|
211
|
+
await refreshFn(plugin);
|
|
212
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
|
|
213
|
+
};
|
|
214
|
+
const teardownFn = async () => {
|
|
215
|
+
if (refreshTimer)
|
|
216
|
+
clearInterval(refreshTimer);
|
|
217
|
+
refreshTimer = null;
|
|
218
|
+
};
|
|
219
|
+
return createMonitoringPlugin("3cx", "3cx", "3CX PBX monitor (service health, version, SIP/console liveness)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
220
|
+
}
|
|
221
|
+
export default createThreeCXPlugin;
|