@daemonitor/plugins 1.1.17 → 1.1.19

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.
@@ -9,6 +9,7 @@ import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin";
9
9
  import { createNetScanPlugin } from "./monitoring/NetScanPlugin";
10
10
  import { createSnmpPlugin } from "./monitoring/SnmpPlugin";
11
11
  import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin";
12
+ import { createTplinkPlugin } from "./monitoring/TplinkPlugin";
12
13
  // In future, implement other plugin factory functions using the same pattern
13
14
  // import { createOSPlugin } from "./monitoring/OSPlugin"
14
15
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin"
@@ -29,6 +30,7 @@ export default {
29
30
  Netscan: createNetScanPlugin,
30
31
  Snmp: createSnmpPlugin,
31
32
  ThreeCX: createThreeCXPlugin,
33
+ Tplink: createTplinkPlugin,
32
34
  // CloudflarePlugin: createCloudflarePlugin,
33
35
  // EwelinkPlugin: createEwelinkPlugin,
34
36
  // MongoDBPlugin: createMongoDBPlugin,
@@ -28,6 +28,7 @@ const SYS = {
28
28
  name: "1.3.6.1.2.1.1.5.0", // sysName
29
29
  ifOper: "1.3.6.1.2.1.2.2.1.8", // ifOperStatus (walk): 1=up 2=down
30
30
  ifAdmin: "1.3.6.1.2.1.2.2.1.7", // ifAdminStatus (walk): 1=up (enabled)
31
+ ifName: "1.3.6.1.2.1.31.1.1.1.1", // ifName (walk) — eth0/eth1/itf0/...
31
32
  ifHCIn: "1.3.6.1.2.1.31.1.1.1.6", // ifHCInOctets (walk, Counter64)
32
33
  ifHCOut: "1.3.6.1.2.1.31.1.1.1.10", // ifHCOutOctets (walk, Counter64)
33
34
  };
@@ -45,9 +46,9 @@ const UCD = {
45
46
  memCached: "1.3.6.1.4.1.2021.4.15.0", // memCached (KB)
46
47
  hrProcLoad: "1.3.6.1.2.1.25.3.3.1.2", // hrProcessorLoad (walk, per-core %) — CPU fallback
47
48
  };
48
- // Per-target previous interface-octet sample, for throughput (bps) rate calc
49
- // across polls. Module-level: one collectDevice call per target per cycle.
50
- const netPrev = {};
49
+ // Previous cumulative octet counters PER INTERFACE (target ip -> ifIndex), for
50
+ // per-link throughput rates across polls.
51
+ const netPrevIf = {};
51
52
  // QNAP enterprise MIB (NAS-MIB, 1.3.6.1.4.1.24681.1.2). A full walk of this
52
53
  // subtree times out on QTS, so everything here is fetched by exact OID.
53
54
  const QNAP = {
@@ -189,10 +190,11 @@ async function collectDevice(t) {
189
190
  // CPU / RAM / load / network — best-effort UCD-SNMP + IF-MIB counters. Absent
190
191
  // on gear without the UCD MIB (returns "No Such Object" fast → null), so every
191
192
  // field is optional and simply omitted when unsupported.
192
- const [cpuIdle, load1, load5, load15, memTotal, memAvail, memBuffer, memCached, procLoads, hcIn, hcOut] = await Promise.all([
193
+ const [cpuIdle, load1, load5, load15, memTotal, memAvail, memBuffer, memCached, procLoads, ifNames, hcInIdx, hcOutIdx] = await Promise.all([
193
194
  snmpGet(t, UCD.cpuIdle), snmpGet(t, UCD.load1), snmpGet(t, UCD.load5), snmpGet(t, UCD.load15),
194
195
  snmpGet(t, UCD.memTotal), snmpGet(t, UCD.memAvail), snmpGet(t, UCD.memBuffer), snmpGet(t, UCD.memCached),
195
- snmpWalk(t, UCD.hrProcLoad), snmpWalk(t, SYS.ifHCIn), snmpWalk(t, SYS.ifHCOut),
196
+ snmpWalk(t, UCD.hrProcLoad), snmpWalkIndexed(t, SYS.ifName),
197
+ snmpWalkIndexed(t, SYS.ifHCIn), snmpWalkIndexed(t, SYS.ifHCOut),
196
198
  ]);
197
199
  // CPU %: prefer ssCpuIdle (100 - idle); fall back to avg per-core hrProcessorLoad.
198
200
  let cpuPct;
@@ -216,24 +218,57 @@ async function collectDevice(t) {
216
218
  memPct = Math.round((usedKb / mTot) * 100);
217
219
  memTotalMB = Math.round(mTot / 1024);
218
220
  }
219
- // Network throughput (aggregate bytes/sec, to match the OS plugin's NET
220
- // metric): delta of summed HC octets vs the prior poll. First poll (or a
221
- // counter reset) yields no rate.
222
- let netIn, netOut;
223
- const sumIn = hcIn.map(Number).filter((n) => !isNaN(n)).reduce((a, b) => a + b, 0);
224
- const sumOut = hcOut.map(Number).filter((n) => !isNaN(n)).reduce((a, b) => a + b, 0);
225
- if (hcIn.length || hcOut.length) {
226
- const now = Date.now();
227
- const prev = netPrev[t.ip];
228
- if (prev && sumIn >= prev.in && sumOut >= prev.out && now > prev.ts) {
229
- const dt = (now - prev.ts) / 1000;
221
+ // Network throughput, PER INTERFACE (bytes/sec).
222
+ //
223
+ // Summing every interface is meaningless on a router: a transiting packet is
224
+ // counted once arriving on the WAN and again leaving on the LAN, so
225
+ // sum(in) === sum(out) by construction — and vendors add aggregate pseudo
226
+ // interfaces (EdgeOS `itf0`) that mirror the whole load a second time. The
227
+ // observed effect was ~5x inflation with in/out identical to the eye.
228
+ //
229
+ // So: rate each interface separately, skip loopback/pseudo, and let the target
230
+ // name its WAN (`wanInterface`) to headline the figure that actually means
231
+ // "internet traffic". Without one, the headline is left undefined rather than
232
+ // reporting a number we know to be wrong.
233
+ const PSEUDO = /^(lo|imq\d*|itf\d*|ifb\d*|sit\d*|gre\d*|teql\d*|tunl\d*)$/i;
234
+ const idxOf = (rows) => {
235
+ const m = {};
236
+ for (const r of rows)
237
+ m[r.idx] = r.val;
238
+ return m;
239
+ };
240
+ const nameByIdx = idxOf(ifNames);
241
+ const inByIdx = idxOf(hcInIdx);
242
+ const outByIdx = idxOf(hcOutIdx);
243
+ const now = Date.now();
244
+ const prevIf = netPrevIf[t.ip] || {};
245
+ const nextIf = {};
246
+ const interfaces = [];
247
+ for (const idx of Object.keys(inByIdx)) {
248
+ const name = (nameByIdx[idx] || `if${idx}`).replace(/^"|"$/g, "");
249
+ const cin = Number(inByIdx[idx]), cout = Number(outByIdx[idx]);
250
+ if (isNaN(cin) || isNaN(cout))
251
+ continue;
252
+ nextIf[idx] = { in: cin, out: cout, ts: now };
253
+ if (PSEUDO.test(name))
254
+ continue;
255
+ const p = prevIf[idx];
256
+ let inBps, outBps;
257
+ if (p && cin >= p.in && cout >= p.out && now > p.ts) {
258
+ const dt = (now - p.ts) / 1000;
230
259
  if (dt > 0) {
231
- netIn = Math.round((sumIn - prev.in) / dt);
232
- netOut = Math.round((sumOut - prev.out) / dt);
260
+ inBps = Math.round((cin - p.in) / dt);
261
+ outBps = Math.round((cout - p.out) / dt);
233
262
  }
234
263
  }
235
- netPrev[t.ip] = { in: sumIn, out: sumOut, ts: now };
264
+ interfaces.push({ name, inBps, outBps });
236
265
  }
266
+ netPrevIf[t.ip] = nextIf;
267
+ // Headline = the WAN link when the target names one.
268
+ const wanName = t.wanInterface;
269
+ const wan = wanName ? interfaces.find((i) => i.name === wanName) : undefined;
270
+ const netIn = wan?.inBps;
271
+ const netOut = wan?.outBps;
237
272
  return {
238
273
  name: t.name,
239
274
  ip: t.ip,
@@ -252,6 +287,11 @@ async function collectDevice(t) {
252
287
  memTotalMB,
253
288
  netIn,
254
289
  netOut,
290
+ wanInterface: wanName,
291
+ // Per-link rates (loopback/pseudo excluded), busiest first.
292
+ interfaces: interfaces
293
+ .slice()
294
+ .sort((a, b) => ((b.inBps || 0) + (b.outBps || 0)) - ((a.inBps || 0) + (a.outBps || 0))),
255
295
  };
256
296
  }
257
297
  // Walk that keeps each row's index (numeric OID suffix) — needed to resolve the
@@ -0,0 +1,151 @@
1
+ import crypto from "node:crypto";
2
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
3
+ // Live sessions per device IP — re-used across polls, rebuilt when they lapse.
4
+ const sessions = {};
5
+ function slug(s) {
6
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
7
+ }
8
+ const b64u = (hex) => Buffer.from(hex, "hex").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ function headers(ip, page, cookie) {
10
+ return {
11
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
12
+ Referer: `http://${ip}/webpages/${page}`,
13
+ "X-Requested-With": "XMLHttpRequest",
14
+ ...(cookie ? { Cookie: `sysauth=${cookie}` } : {}),
15
+ };
16
+ }
17
+ async function post(url, hdrs, body, timeout) {
18
+ const res = await fetch(url, { method: "POST", headers: hdrs, body, signal: AbortSignal.timeout(timeout) });
19
+ return await res.json();
20
+ }
21
+ /** TP-Link RSA login → { stok, sysauth }. Throws on bad credentials. */
22
+ async function login(d, timeout) {
23
+ const base = `http://${d.ip}/cgi-bin/luci/;stok=`;
24
+ const keyRes = await post(`${base}/login?form=login`, headers(d.ip, "login.html"), "operation=read", timeout);
25
+ const pair = keyRes?.data?.password;
26
+ if (!Array.isArray(pair) || pair.length < 2)
27
+ throw new Error("no RSA key from login form");
28
+ const pub = crypto.createPublicKey({
29
+ key: { kty: "RSA", n: b64u(pair[0]), e: b64u(pair[1]) },
30
+ format: "jwk",
31
+ });
32
+ const enc = crypto
33
+ .publicEncrypt({ key: pub, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(d.password))
34
+ .toString("hex");
35
+ const res = await fetch(`${base}/login?form=login`, {
36
+ method: "POST",
37
+ headers: headers(d.ip, "login.html"),
38
+ body: `operation=login&password=${enc}`,
39
+ signal: AbortSignal.timeout(timeout),
40
+ });
41
+ const setCookie = res.headers.get("set-cookie") || "";
42
+ const body = await res.json();
43
+ const stok = body?.data?.stok;
44
+ const sysauth = (setCookie.match(/sysauth=([^;]+)/) || [])[1];
45
+ if (!stok || !sysauth) {
46
+ throw new Error(body?.errorcode === "login failed" ? "login failed (bad password?)" : "login rejected");
47
+ }
48
+ return { stok, sysauth };
49
+ }
50
+ /** Authenticated read, re-logging in once if the session has lapsed. */
51
+ async function read(d, endpoint, timeout) {
52
+ const call = async (s) => post(`http://${d.ip}/cgi-bin/luci/;stok=${s.stok}/${endpoint}`, headers(d.ip, "index.html", s.sysauth), "operation=read", timeout);
53
+ let s = sessions[d.ip];
54
+ if (s) {
55
+ try {
56
+ const out = await call(s);
57
+ if (out?.success)
58
+ return out.data;
59
+ }
60
+ catch {
61
+ /* fall through to re-login */
62
+ }
63
+ }
64
+ s = await login(d, timeout);
65
+ sessions[d.ip] = s;
66
+ const out = await call(s);
67
+ if (!out?.success)
68
+ throw new Error(out?.errorcode || "read failed");
69
+ return out.data;
70
+ }
71
+ async function collect(d, timeout) {
72
+ try {
73
+ const status = await read(d, "admin/status?form=ap_status", timeout);
74
+ // Firmware is static — nice for drift, but never fail the poll over it.
75
+ let fw = {};
76
+ try {
77
+ fw = (await read(d, "admin/firmware?form=upgrade", timeout)) || {};
78
+ }
79
+ catch {
80
+ /* non-fatal */
81
+ }
82
+ const grid = status?.wirelessGrid;
83
+ const clients = Array.isArray(grid)
84
+ ? grid.map((c) => ({
85
+ name: c.name || c.hostname || "device",
86
+ mac: c.mac || "",
87
+ band: c.type || "",
88
+ }))
89
+ : [];
90
+ const radio2g = String(status?.wireless_2g_enable ?? "") === "on";
91
+ const radio5g = String(status?.wireless_5g_enable ?? "") === "on";
92
+ return {
93
+ name: d.name,
94
+ ip: d.ip,
95
+ kind: "extender",
96
+ reachable: true,
97
+ // The uplink — an extender with this down still pings but serves nobody.
98
+ internetStatus: status?.internet_status ?? undefined,
99
+ uplinkUp: status?.internet_status === "connected",
100
+ phyconn: status?.phyconn ?? undefined,
101
+ radio2g,
102
+ radio5g,
103
+ clientCount: typeof status?.wirelessCount === "number" ? status.wirelessCount : clients.length,
104
+ clients,
105
+ model: fw.model || undefined,
106
+ hardwareVersion: fw.hardware_version || undefined,
107
+ firmware: fw.firmware_version || undefined,
108
+ };
109
+ }
110
+ catch (e) {
111
+ delete sessions[d.ip]; // force a fresh login next pass
112
+ return {
113
+ name: d.name,
114
+ ip: d.ip,
115
+ kind: "extender",
116
+ reachable: false,
117
+ error: String(e?.message || e).slice(0, 160),
118
+ };
119
+ }
120
+ }
121
+ export function createTplinkPlugin() {
122
+ let refreshTimer = null;
123
+ const devicesOf = (plugin) => {
124
+ const cfg = (plugin.config || {});
125
+ return (Array.isArray(cfg.devices) ? cfg.devices : []).filter((d) => d?.ip && d?.password);
126
+ };
127
+ const refreshFn = async (plugin) => {
128
+ const cfg = (plugin.config || {});
129
+ const timeout = cfg.timeout || 8000;
130
+ await Promise.all(devicesOf(plugin).map(async (d) => {
131
+ const uid = d.uniqueId || `tplink-${slug(d.name || d.ip)}`;
132
+ try {
133
+ await plugin.send(await collect(d, timeout), uid);
134
+ }
135
+ catch (e) {
136
+ await plugin.send({ name: d.name, ip: d.ip, kind: "extender", reachable: false, error: String(e?.message || e).slice(0, 160) }, uid);
137
+ }
138
+ }));
139
+ };
140
+ const monitorFn = async (plugin) => {
141
+ await refreshFn(plugin);
142
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 120000);
143
+ };
144
+ const teardownFn = async () => {
145
+ if (refreshTimer)
146
+ clearInterval(refreshTimer);
147
+ refreshTimer = null;
148
+ };
149
+ return createMonitoringPlugin("tplink", "tplink", "TP-Link extender/AP monitor (uplink, radios, clients, firmware)", async () => { }, monitorFn, refreshFn, teardownFn);
150
+ }
151
+ export default createTplinkPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",