@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.
@@ -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
- const status = cSt[i] != null ? Number(cSt[i]) : undefined;
261
+ let status = cSt[i] != null ? Number(cSt[i]) : undefined;
262
262
  const tempC = parseC(cTp[i] || null);
263
- 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 });
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",