@daemonitor/plugins 1.1.33 → 1.1.34

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.
@@ -13,6 +13,8 @@ import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
13
13
  import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
14
14
  import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
15
15
  import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
16
+ import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
17
+ import { createTplinkSwitchPlugin } from "./monitoring/TplinkSwitchPlugin.js";
16
18
  // In future, implement other plugin factory functions using the same pattern
17
19
  // import { createOSPlugin } from "./monitoring/OSPlugin.js"
18
20
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
@@ -37,6 +39,8 @@ export default {
37
39
  Patroni: createPatroniPlugin,
38
40
  Adsense: createAdsensePlugin,
39
41
  Ga4: createGa4Plugin,
42
+ Nagios: createNagiosPlugin,
43
+ TplinkSwitch: createTplinkSwitchPlugin,
40
44
  // CloudflarePlugin: createCloudflarePlugin,
41
45
  // EwelinkPlugin: createEwelinkPlugin,
42
46
  // MongoDBPlugin: createMongoDBPlugin,
@@ -0,0 +1,138 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
+ const STATES = ["ok", "warning", "critical", "unknown"];
4
+ // Output is capped: a check printing a megabyte would otherwise be sent every
5
+ // minute and stored in history.
6
+ const MAX_OUTPUT = 4000;
7
+ export function stateOf(exitCode) {
8
+ return exitCode != null && exitCode >= 0 && exitCode <= 3 ? STATES[exitCode] : "unknown";
9
+ }
10
+ const num = (s) => (s == null || s === "" || Number.isNaN(Number(s)) ? undefined : Number(s));
11
+ /**
12
+ * Parse performance data: space-separated `'label'=value[UOM];[warn];[crit];[min];[max]`.
13
+ * Labels may be quoted to contain spaces, with '' standing for a literal quote.
14
+ * Malformed items are skipped rather than failing the whole check.
15
+ */
16
+ export function parsePerfdata(raw) {
17
+ const out = [];
18
+ const re = /\s*('(?:[^']|'')+'|[^\s=']+)=(\S+)/g;
19
+ let m;
20
+ while ((m = re.exec(raw))) {
21
+ const label = m[1].startsWith("'") ? m[1].slice(1, -1).replace(/''/g, "'") : m[1];
22
+ const [valueUom, warn, crit, min, max] = m[2].split(";");
23
+ const vm = valueUom.match(/^(-?[\d.]+(?:[eE][-+]?\d+)?)([a-zA-Z%]*)$/);
24
+ if (!vm)
25
+ continue; // "U" (undetermined) or garbage
26
+ out.push({
27
+ label,
28
+ value: Number(vm[1]),
29
+ ...(vm[2] ? { uom: vm[2] } : {}),
30
+ ...(warn ? { warn } : {}),
31
+ ...(crit ? { crit } : {}),
32
+ ...(num(min) != null ? { min: num(min) } : {}),
33
+ ...(num(max) != null ? { max: num(max) } : {}),
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Split plugin output into the status line, the long output and perfdata.
40
+ * Perfdata can follow "|" on the first line and on any long-output line after
41
+ * a "|" there (the Nagios 3 multi-line form).
42
+ */
43
+ export function parseOutput(stdout) {
44
+ const lines = stdout.replace(/\r/g, "").replace(/\n+$/, "").split("\n");
45
+ const [first = "", ...rest] = lines;
46
+ const [output, firstPerf = ""] = splitPipe(first);
47
+ let perf = firstPerf;
48
+ const long = [];
49
+ let inPerf = false;
50
+ for (const line of rest) {
51
+ if (inPerf) {
52
+ perf += " " + line;
53
+ continue;
54
+ }
55
+ const [text, more] = splitPipe(line);
56
+ if (text.trim())
57
+ long.push(text.trimEnd());
58
+ if (more != null) {
59
+ perf += " " + more;
60
+ inPerf = true;
61
+ }
62
+ }
63
+ return {
64
+ output: output.trim(),
65
+ ...(long.length ? { longOutput: long.join("\n").slice(0, MAX_OUTPUT) } : {}),
66
+ perfdata: parsePerfdata(perf),
67
+ };
68
+ }
69
+ function splitPipe(line) {
70
+ const i = line.indexOf("|");
71
+ return i < 0 ? [line] : [line.slice(0, i), line.slice(i + 1)];
72
+ }
73
+ export async function runCheck(check, defaults = {}) {
74
+ const [bin, ...args] = check.command;
75
+ const resolved = defaults.pluginDir && !bin.includes("/") ? `${defaults.pluginDir.replace(/\/$/, "")}/${bin}` : bin;
76
+ const timeoutMs = (check.timeout ?? defaults.timeout ?? 30) * 1000;
77
+ const started = Date.now();
78
+ return new Promise((resolve) => {
79
+ execFile(resolved, args, { timeout: timeoutMs, maxBuffer: 256 * 1024, env: { ...process.env, ...(check.env || {}) }, killSignal: "SIGKILL" }, (err, stdout, stderr) => {
80
+ const durationMs = Date.now() - started;
81
+ const base = { name: check.name, command: bin.split("/").pop() || bin, durationMs };
82
+ // A non-zero exit is the plugin reporting a state, not a failure to run.
83
+ const exitCode = err ? (typeof err.code === "number" ? err.code : null) : 0;
84
+ if (err && err.killed) {
85
+ return resolve({ ...base, state: "unknown", exitCode: null, output: `Check timed out after ${timeoutMs / 1000}s`, perfdata: [], timedOut: true });
86
+ }
87
+ if (err && exitCode == null) {
88
+ // Couldn't start it at all: ENOENT, EACCES and so on.
89
+ return resolve({ ...base, state: "unknown", exitCode: null, output: `Could not run ${base.command}: ${err.code || err.message}`, perfdata: [], error: String(err.message || err).slice(0, 300) });
90
+ }
91
+ const parsed = parseOutput(String(stdout || stderr || "").slice(0, MAX_OUTPUT));
92
+ resolve({ ...base, state: stateOf(exitCode), exitCode, ...parsed, output: parsed.output || "(no output)" });
93
+ });
94
+ });
95
+ }
96
+ function slug(s) {
97
+ return s.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
98
+ }
99
+ export function createNagiosPlugin() {
100
+ let refreshTimer = null;
101
+ let running = false;
102
+ const checksOf = (plugin) => {
103
+ const cfg = (plugin.config || {});
104
+ return (Array.isArray(cfg.checks) ? cfg.checks : []).filter((c) => c?.name && Array.isArray(c.command) && c.command.length && c.command.every((a) => typeof a === "string"));
105
+ };
106
+ const refreshFn = async (plugin) => {
107
+ // A slow check must not pile up overlapping runs of itself.
108
+ if (running)
109
+ return;
110
+ running = true;
111
+ try {
112
+ const cfg = (plugin.config || {});
113
+ await Promise.all(checksOf(plugin).map(async (check) => {
114
+ const result = await runCheck(check, cfg);
115
+ // The interval tells the dashboard how long silence is normal, so a
116
+ // check that runs every 10 minutes doesn't read OFFLINE in between.
117
+ await plugin.send({ ...result, intervalMs: cfg.refreshInterval || 60000 }, `nagios-${slug(check.name)}`);
118
+ }));
119
+ }
120
+ finally {
121
+ running = false;
122
+ }
123
+ };
124
+ const monitorFn = async (plugin) => {
125
+ const n = checksOf(plugin).length;
126
+ if (!n)
127
+ console.warn("nagios: no valid checks configured (each needs a name and a command array)");
128
+ await refreshFn(plugin);
129
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
130
+ };
131
+ const teardownFn = async () => {
132
+ if (refreshTimer)
133
+ clearInterval(refreshTimer);
134
+ refreshTimer = null;
135
+ };
136
+ return createMonitoringPlugin("nagios", "nagios", "Nagios-compatible check commands", async () => { }, monitorFn, refreshFn, teardownFn);
137
+ }
138
+ export default createNagiosPlugin;
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
+ const HEADERS = { "Content-Type": "application/json;charset=utf-8", "X-Requested-With": "XMLHttpRequest", SourceType: "switch_web" };
4
+ const LOOP = { "0": "normal", "1": "blocked", "2": "loop" };
5
+ export const md5Upper = (s) => createHash("md5").update(s).digest("hex").toUpperCase();
6
+ /** Build the report from the raw API results. Pure, so the check can drive it. */
7
+ export function buildReport(target, raw, rates) {
8
+ const info = raw.info || {};
9
+ const trafficById = new Map((raw.traffic?.port_list || []).map((p) => [p.port_id, p]));
10
+ const loopById = new Map((raw.loops?.port_list || []).map((p) => [p.port_id, p]));
11
+ const interfaces = (raw.ports?.port_list || []).map((p) => {
12
+ const speed = String(p.port_speed_actual || "");
13
+ const up = !!speed && speed !== "Link Down";
14
+ const t = trafficById.get(p.port_id);
15
+ const r = rates.get(p.port_id) || {};
16
+ return {
17
+ name: `port ${p.port_id}`,
18
+ up,
19
+ adminUp: p.port_state_cfg !== "0",
20
+ ...(up ? { speed, duplex: p.port_duplex_actual } : {}),
21
+ ...(t ? { rxBytes: t.rx_bytes, txBytes: t.tx_bytes } : {}),
22
+ ...(r.inBps != null ? { inBps: r.inBps, outBps: r.outBps } : {}),
23
+ loop: LOOP[String(loopById.get(p.port_id)?.port_state ?? "0")] || "normal",
24
+ };
25
+ });
26
+ const portIds = (raw.ports?.port_list || []).map((p) => p.port_id);
27
+ const downUplinks = (target.uplinkPorts || []).filter((id) => {
28
+ const i = portIds.indexOf(id);
29
+ return i < 0 || !interfaces[i].up;
30
+ });
31
+ return {
32
+ ip: target.host,
33
+ name: target.name || info.des || target.host,
34
+ kind: "switch",
35
+ model: info.hw_ver || info.des,
36
+ serial: info.serial_number,
37
+ mac: info.sys_mac_addr,
38
+ firmware: info.fw_ver,
39
+ sysDescr: [info.hw_ver || info.des, info.fw_ver && `firmware ${info.fw_ver}`].filter(Boolean).join(", "),
40
+ reachable: true,
41
+ portsUp: interfaces.filter((i) => i.up).length,
42
+ portsTotal: interfaces.length,
43
+ interfaces,
44
+ loops: interfaces.filter((i) => i.loop === "loop").map((i) => i.name),
45
+ blocked: interfaces.filter((i) => i.loop === "blocked").map((i) => i.name),
46
+ downUplinks: downUplinks.map((id) => `port ${id}`),
47
+ };
48
+ }
49
+ /** Byte rates from two counter readings. A counter that went backwards (reboot, clear) gives no rate. */
50
+ export function ratesBetween(prev, now) {
51
+ const out = new Map();
52
+ if (!prev)
53
+ return out;
54
+ const dt = (now.at - prev.at) / 1000;
55
+ if (dt <= 0)
56
+ return out;
57
+ for (const [id, c] of now.ports) {
58
+ const p = prev.ports.get(id);
59
+ if (!p || c.rx < p.rx || c.tx < p.tx)
60
+ continue;
61
+ out.set(id, { inBps: Math.round((c.rx - p.rx) / dt), outBps: Math.round((c.tx - p.tx) / dt) });
62
+ }
63
+ return out;
64
+ }
65
+ export class LoginRefused extends Error {
66
+ }
67
+ async function call(host, path, opts) {
68
+ const res = await fetch(`http://${host}${path}`, {
69
+ method: opts.body !== undefined ? "POST" : "GET",
70
+ headers: opts.token ? { ...HEADERS, Authorization: opts.token } : HEADERS,
71
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
72
+ signal: AbortSignal.timeout(opts.timeoutMs),
73
+ });
74
+ if (!res.ok)
75
+ throw new Error(`${path} returned HTTP ${res.status}`);
76
+ const json = await res.json();
77
+ if (json?.error_code)
78
+ throw Object.assign(new Error(`${path} error_code ${json.error_code}`), { code: json.error_code });
79
+ return json?.result || {};
80
+ }
81
+ export const newSwitchState = () => ({ counters: new Map(), refusedUntil: new Map() });
82
+ /** Log in, read one switch, log out. Throws LoginRefused or a network error. */
83
+ export async function readSwitch(target, cfg, state) {
84
+ const { counters, refusedUntil } = state;
85
+ const timeoutMs = cfg.timeoutMs || 8000;
86
+ const wait = refusedUntil.get(target.host);
87
+ if (wait && Date.now() < wait) {
88
+ throw new LoginRefused(`login refused earlier; not retrying until ${new Date(wait).toISOString()} so the switch doesn't lock the account`);
89
+ }
90
+ let login;
91
+ try {
92
+ login = await call(target.host, "/login/status", { body: { login_user: target.username, login_pwd: md5Upper(target.password) }, timeoutMs });
93
+ }
94
+ catch (e) {
95
+ if (e?.code) {
96
+ refusedUntil.set(target.host, Date.now() + (cfg.loginBackoffMs || 30 * 60 * 1000));
97
+ throw new LoginRefused(`login refused (error_code ${e.code}); check the username and password`);
98
+ }
99
+ throw e;
100
+ }
101
+ refusedUntil.delete(target.host);
102
+ const token = login.token;
103
+ try {
104
+ const [info, ports, traffic, loops] = await Promise.all([
105
+ call(target.host, "/system_summary/system_information_status", { token, timeoutMs }),
106
+ call(target.host, "/switching/port_settings_status", { token, timeoutMs }),
107
+ call(target.host, "/traffic_summary/traffic_summary_port_status", { token, timeoutMs }),
108
+ call(target.host, "/loop_detection/loop_detection_status", { token, timeoutMs }).catch(() => ({})),
109
+ ]);
110
+ const now = {
111
+ at: Date.now(),
112
+ ports: new Map((traffic.port_list || []).map((p) => [p.port_id, { rx: Number(p.rx_bytes) || 0, tx: Number(p.tx_bytes) || 0 }])),
113
+ };
114
+ const rates = ratesBetween(counters.get(target.host), now);
115
+ counters.set(target.host, now);
116
+ return buildReport(target, { info, ports, traffic, loops }, rates);
117
+ }
118
+ finally {
119
+ // Log out every time: these switches allow few admin sessions, and an
120
+ // idle one held by the agent could lock someone out of the web UI.
121
+ await call(target.host, "/show_up/logout_status", { token, body: {}, timeoutMs }).catch(() => { });
122
+ }
123
+ }
124
+ export function createTplinkSwitchPlugin() {
125
+ let refreshTimer = null;
126
+ const state = newSwitchState();
127
+ const refreshFn = async (plugin) => {
128
+ const cfg = (plugin.config || {});
129
+ const targets = (cfg.switches || []).filter((s) => s?.host && s.username && s.password);
130
+ await Promise.all(targets.map(async (target) => {
131
+ let report;
132
+ try {
133
+ report = await readSwitch(target, cfg, state);
134
+ }
135
+ catch (e) {
136
+ report = {
137
+ ip: target.host,
138
+ name: target.name || target.host,
139
+ kind: "switch",
140
+ reachable: e instanceof LoginRefused,
141
+ error: String(e?.message || e).slice(0, 200),
142
+ };
143
+ }
144
+ await plugin.send(report, `tplinkswitch-${target.host.replace(/[^0-9a-zA-Z]+/g, "-")}`);
145
+ }));
146
+ };
147
+ const monitorFn = async (plugin) => {
148
+ const cfg = (plugin.config || {});
149
+ if (!(cfg.switches || []).length)
150
+ console.warn("tplinkswitch: no switches configured");
151
+ await refreshFn(plugin);
152
+ refreshTimer = setInterval(() => refreshFn(plugin), cfg.refreshInterval || 60000);
153
+ };
154
+ const teardownFn = async () => {
155
+ if (refreshTimer)
156
+ clearInterval(refreshTimer);
157
+ refreshTimer = null;
158
+ };
159
+ return createMonitoringPlugin("tplinkswitch", "tplinkswitch", "TP-Link Omada Easy Managed switch (web API)", async () => { }, monitorFn, refreshFn, teardownFn);
160
+ }
161
+ export default createTplinkSwitchPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.33",
3
+ "version": "1.1.34",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",