@daemonitor/plugins 1.1.16 → 1.1.18

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,
@@ -5,6 +5,14 @@ import { hostname } from "os";
5
5
  const execAsync = promisify(exec);
6
6
  const EXEC_OPTS = { maxBuffer: 32 * 1024 * 1024 }; // containers.length can be large
7
7
  const HEALTH_RE = /\((healthy|unhealthy|health: starting|starting)\)/i;
8
+ // Previous cumulative NetIO/BlockIO totals per container id, to derive a
9
+ // per-second rate across polls. Module-level (one collect per host per cycle).
10
+ const ioPrev = {};
11
+ // docker stats renders "12MB / 7.02MB" (rx / tx, or read / write). Split + convert.
12
+ function pairToBytes(value) {
13
+ const [x, y] = (value || "").split("/").map((p) => p.trim());
14
+ return { a: memToBytes(x), b: memToBytes(y) };
15
+ }
8
16
  /** Convert docker's "83.86MiB" / "1.952GiB" memory strings to bytes. */
9
17
  function memToBytes(value) {
10
18
  if (!value)
@@ -52,13 +60,17 @@ async function collectContainers(bin) {
52
60
  }
53
61
  if (!containers.length)
54
62
  return containers;
55
- // 2. live stats for running containers (one batched call, best-effort)
63
+ // 2. live stats for running containers (one batched call, best-effort).
64
+ // NetIO/BlockIO are CUMULATIVE since container start, so we derive a per-second
65
+ // rate from the delta vs the previous poll (first poll → no rate).
56
66
  try {
57
- const { stdout: statsOut } = await execAsync(`${bin} stats --no-stream --format '{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}'`, EXEC_OPTS);
67
+ const { stdout: statsOut } = await execAsync(`${bin} stats --no-stream --format '{{.ID}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}'`, EXEC_OPTS);
68
+ const now = Date.now();
69
+ const seen = new Set();
58
70
  for (const line of statsOut.split("\n")) {
59
71
  if (!line.trim())
60
72
  continue;
61
- const [id, cpuPerc, memUsage, memPerc] = line.split("|");
73
+ const [id, cpuPerc, memUsage, memPerc, netIO, blkIO] = line.split("|");
62
74
  const c = byId.get(id);
63
75
  if (!c)
64
76
  continue;
@@ -67,7 +79,28 @@ async function collectContainers(bin) {
67
79
  c.mem = memToBytes(used);
68
80
  c.memLimit = memToBytes(limit);
69
81
  c.memPercent = parseFloat((memPerc || "").replace("%", "")) || 0;
82
+ const net = pairToBytes(netIO); // { a: rx, b: tx }
83
+ const blk = pairToBytes(blkIO); // { a: read, b: write }
84
+ seen.add(id);
85
+ const prev = ioPrev[id];
86
+ if (prev && now > prev.ts) {
87
+ const dt = (now - prev.ts) / 1000;
88
+ // Guard counter resets (container restart): negative delta → skip that field.
89
+ if (net.a >= prev.rx)
90
+ c.netIn = Math.round((net.a - prev.rx) / dt);
91
+ if (net.b >= prev.tx)
92
+ c.netOut = Math.round((net.b - prev.tx) / dt);
93
+ if (blk.a >= prev.rd)
94
+ c.blkRead = Math.round((blk.a - prev.rd) / dt);
95
+ if (blk.b >= prev.wr)
96
+ c.blkWrite = Math.round((blk.b - prev.wr) / dt);
97
+ }
98
+ ioPrev[id] = { rx: net.a, tx: net.b, rd: blk.a, wr: blk.b, ts: now };
70
99
  }
100
+ // Drop prev samples for containers no longer present.
101
+ for (const k of Object.keys(ioPrev))
102
+ if (!seen.has(k))
103
+ delete ioPrev[k];
71
104
  }
72
105
  catch (err) {
73
106
  console.error("docker: stats collection failed:", err.message);
@@ -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.16",
3
+ "version": "1.1.18",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",