@daemonitor/plugins 1.1.44 → 1.1.46
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.js";
|
|
|
8
8
|
import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin.js";
|
|
9
9
|
import { createNetScanPlugin } from "./monitoring/NetScanPlugin.js";
|
|
10
10
|
import { createSnmpPlugin } from "./monitoring/SnmpPlugin.js";
|
|
11
|
+
import { createSynologyPlugin } from "./monitoring/SynologyPlugin.js";
|
|
11
12
|
import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
|
|
12
13
|
import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
|
|
13
14
|
import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
|
|
@@ -36,6 +37,7 @@ export default {
|
|
|
36
37
|
Logwatch: createLogWatchPlugin,
|
|
37
38
|
Netscan: createNetScanPlugin,
|
|
38
39
|
Snmp: createSnmpPlugin,
|
|
40
|
+
Synology: createSynologyPlugin,
|
|
39
41
|
ThreeCX: createThreeCXPlugin,
|
|
40
42
|
Tplink: createTplinkPlugin,
|
|
41
43
|
Patroni: createPatroniPlugin,
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as https from "node:https";
|
|
2
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
3
|
+
const REAUTH_AFTER_MS = 3_600_000;
|
|
4
|
+
const AUTH_ERRORS = {
|
|
5
|
+
400: "wrong account or password",
|
|
6
|
+
401: "account disabled",
|
|
7
|
+
402: "account may not use this service",
|
|
8
|
+
403: "account has 2FA on, which an agent can't answer",
|
|
9
|
+
404: "2FA code rejected",
|
|
10
|
+
407: "this host is blocked by DSM's auto block",
|
|
11
|
+
};
|
|
12
|
+
/** A login DSM refused. Retrying every pass would trip its auto block. */
|
|
13
|
+
export const authErrorText = (code) => AUTH_ERRORS[Number(code)];
|
|
14
|
+
const apiErrorText = (api, code) => Number(code) === 105 ? `${api}: this account lacks permission, it may need to be an administrator` : `${api}: DSM error ${code}`;
|
|
15
|
+
function post(url, form, cfg) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const body = new URLSearchParams(form).toString();
|
|
18
|
+
const u = new URL("/webapi/entry.cgi", url);
|
|
19
|
+
const req = https.request({
|
|
20
|
+
hostname: u.hostname,
|
|
21
|
+
port: u.port || 443,
|
|
22
|
+
path: u.pathname,
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(body) },
|
|
25
|
+
rejectUnauthorized: !cfg.insecure,
|
|
26
|
+
}, (res) => {
|
|
27
|
+
let text = "";
|
|
28
|
+
res.on("data", (c) => { text += c; });
|
|
29
|
+
res.on("end", () => {
|
|
30
|
+
try {
|
|
31
|
+
resolve(JSON.parse(text));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
reject(new Error(`DSM answered HTTP ${res.statusCode} with something other than JSON`));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
req.on("error", reject);
|
|
39
|
+
req.setTimeout(cfg.requestTimeout || 20000, () => req.destroy(new Error("DSM request timed out")));
|
|
40
|
+
req.end(body);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Turn DSM's answers into the NAS card's payload. Exported for the check. */
|
|
44
|
+
export function summarise(storage, usb, system, util, cfg = {}) {
|
|
45
|
+
const tempWarn = cfg.tempWarnC ?? 70;
|
|
46
|
+
const volWarn = cfg.volumeWarnPct ?? 85;
|
|
47
|
+
const problems = [];
|
|
48
|
+
const pct = (used, total) => (total > 0 ? Math.round((used / total) * 100) : 0);
|
|
49
|
+
const disks = (storage?.disks || []).map((d, i) => ({
|
|
50
|
+
slot: i + 1,
|
|
51
|
+
descr: d.name || d.device,
|
|
52
|
+
model: d.model,
|
|
53
|
+
tempC: typeof d.temp === "number" ? d.temp : undefined,
|
|
54
|
+
status: d.status === "normal" ? 0 : 1,
|
|
55
|
+
smart: Number(d.unc) > 0 ? `${d.unc} uncorrectable` : d.smart_status,
|
|
56
|
+
}));
|
|
57
|
+
const volumes = (storage?.volumes || []).map((v) => {
|
|
58
|
+
const total = Number(v.size?.total) || 0;
|
|
59
|
+
const used = Number(v.size?.used) || 0;
|
|
60
|
+
if (v.status !== "normal")
|
|
61
|
+
problems.push(`${v.vol_path} ${v.status}`);
|
|
62
|
+
return { mount: v.vol_path, totalGB: Math.round(total / 1e9), usedGB: Math.round(used / 1e9), pct: pct(used, total) };
|
|
63
|
+
});
|
|
64
|
+
for (const p of storage?.storagePools || []) {
|
|
65
|
+
if (p.status !== "normal")
|
|
66
|
+
problems.push(`pool ${p.device_type || p.id} ${p.status}`);
|
|
67
|
+
}
|
|
68
|
+
// DSM states USB sizes in MiB. Its USB disks report no SMART or temperature here.
|
|
69
|
+
for (const dev of usb?.devices || []) {
|
|
70
|
+
disks.push({ slot: disks.length + 1, descr: `USB ${dev.dev_id}`, model: dev.product_name, status: dev.status === "normal" ? 0 : 1 });
|
|
71
|
+
for (const p of dev.partitions || []) {
|
|
72
|
+
const total = Number(p.total_size_mb) || 0;
|
|
73
|
+
const used = Number(p.used_size_mb) || 0;
|
|
74
|
+
if (p.status !== "normal")
|
|
75
|
+
problems.push(`${p.share_name} ${p.status}`);
|
|
76
|
+
volumes.push({ mount: p.share_name, totalGB: Math.round((total * 1.048576) / 1000), usedGB: Math.round((used * 1.048576) / 1000), pct: pct(used, total) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const mounts = volumes.map((v) => String(v.mount).split("/").pop());
|
|
80
|
+
for (const want of cfg.expectedVolumes || []) {
|
|
81
|
+
if (!mounts.includes(want))
|
|
82
|
+
problems.push(`${want} missing`);
|
|
83
|
+
}
|
|
84
|
+
if (!disks.length)
|
|
85
|
+
problems.push("DSM listed no disks");
|
|
86
|
+
if (system?.temperature_warning === true)
|
|
87
|
+
problems.push("DSM reports overheating");
|
|
88
|
+
const badDisks = disks.filter((d) => d.status !== 0 || (d.smart && d.smart !== "normal") || (d.tempC != null && d.tempC >= tempWarn));
|
|
89
|
+
const cpu = util?.cpu;
|
|
90
|
+
const mem = util?.memory;
|
|
91
|
+
return {
|
|
92
|
+
kind: "qnap",
|
|
93
|
+
reachable: true,
|
|
94
|
+
model: system?.model,
|
|
95
|
+
cpuPct: cpu ? (Number(cpu.user_load) || 0) + (Number(cpu.system_load) || 0) + (Number(cpu.other_load) || 0) : undefined,
|
|
96
|
+
sysTempC: typeof system?.sys_temp === "number" ? system.sys_temp : undefined,
|
|
97
|
+
memPct: typeof mem?.real_usage === "number" ? mem.real_usage : undefined,
|
|
98
|
+
memTotalMB: mem?.total_real ? Math.round(mem.total_real / 1024) : undefined,
|
|
99
|
+
disks,
|
|
100
|
+
diskCount: disks.length,
|
|
101
|
+
badDisks: badDisks.map(({ slot, descr, model, status, smart, tempC }) => ({ slot, descr, model, status, smart, tempC })),
|
|
102
|
+
volumes,
|
|
103
|
+
fullVolumes: volumes.filter((v) => v.pct >= volWarn),
|
|
104
|
+
tempWarnC: tempWarn,
|
|
105
|
+
volumeWarnPct: volWarn,
|
|
106
|
+
...(problems.length ? { error: problems.join(", ").slice(0, 120) } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async function collect(call, cfg) {
|
|
110
|
+
const storage = await call("SYNO.Storage.CGI.Storage", "load_info", 1);
|
|
111
|
+
if (!storage.success)
|
|
112
|
+
throw new Error(apiErrorText("storage", storage.error?.code));
|
|
113
|
+
// Extras: the card is still worth drawing without them.
|
|
114
|
+
const [usb, system, util] = await Promise.all([
|
|
115
|
+
call("SYNO.Core.ExternalDevice.Storage.USB", "list", 1, { additional: '["all"]' }).catch(() => null),
|
|
116
|
+
call("SYNO.Core.System", "info", 3).catch(() => null),
|
|
117
|
+
call("SYNO.Core.System.Utilization", "get", 1).catch(() => null),
|
|
118
|
+
]);
|
|
119
|
+
return summarise(storage.data, usb?.success ? usb.data : null, system?.success ? system.data : null, util?.success ? util.data : null, cfg);
|
|
120
|
+
}
|
|
121
|
+
export function createSynologyPlugin() {
|
|
122
|
+
let refreshTimer = null;
|
|
123
|
+
let lockedUntil = 0;
|
|
124
|
+
let lockedReason = "";
|
|
125
|
+
const refreshFn = async (plugin) => {
|
|
126
|
+
const cfg = (plugin.config || {});
|
|
127
|
+
const name = cfg.name || "synology";
|
|
128
|
+
let ip = "";
|
|
129
|
+
const report = (body) => plugin.send({ name, ip, ...body, timestamp: Date.now() }, `synology-${ip}`);
|
|
130
|
+
try {
|
|
131
|
+
const u = new URL(String(cfg.url));
|
|
132
|
+
ip = u.hostname;
|
|
133
|
+
if (u.protocol !== "https:")
|
|
134
|
+
throw new Error("url must be https, the password travels in the request");
|
|
135
|
+
if (!cfg.account || !cfg.password)
|
|
136
|
+
throw new Error("config needs account and password");
|
|
137
|
+
if (Date.now() < lockedUntil)
|
|
138
|
+
throw new Error(lockedReason);
|
|
139
|
+
const login = await post(cfg.url, { api: "SYNO.API.Auth", version: "6", method: "login", account: cfg.account, passwd: cfg.password, format: "sid" }, cfg);
|
|
140
|
+
if (!login.success) {
|
|
141
|
+
const code = login.error?.code;
|
|
142
|
+
const refused = authErrorText(code);
|
|
143
|
+
if (refused) {
|
|
144
|
+
lockedUntil = Date.now() + REAUTH_AFTER_MS;
|
|
145
|
+
lockedReason = `login refused: ${refused}. Next try within the hour, or restart the agent`;
|
|
146
|
+
throw new Error(lockedReason);
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`login failed: DSM error ${code}`);
|
|
149
|
+
}
|
|
150
|
+
const sid = String(login.data?.sid);
|
|
151
|
+
const call = (api, method, version, extra = {}) => post(cfg.url, { api, method, version: String(version), _sid: sid, ...extra }, cfg);
|
|
152
|
+
try {
|
|
153
|
+
await report(await collect(call, cfg));
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await call("SYNO.API.Auth", "logout", 6).catch(() => { });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
await report({ kind: "qnap", reachable: false, error: String(e?.message || e).slice(0, 120) });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const monitorFn = async (plugin) => {
|
|
164
|
+
await refreshFn(plugin);
|
|
165
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 300000);
|
|
166
|
+
};
|
|
167
|
+
const teardownFn = async () => {
|
|
168
|
+
if (refreshTimer)
|
|
169
|
+
clearInterval(refreshTimer);
|
|
170
|
+
refreshTimer = null;
|
|
171
|
+
};
|
|
172
|
+
const plugin = createMonitoringPlugin("synology", "snmp", "Synology NAS disks, pools and volumes through the DSM web API", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
173
|
+
// `name` is the x-type sent to the server, and PluginManager picks plugins from
|
|
174
|
+
// the config list by getName(). Without this, listing "synology" runs nothing.
|
|
175
|
+
plugin.getName = () => "synology";
|
|
176
|
+
return plugin;
|
|
177
|
+
}
|
|
178
|
+
export default createSynologyPlugin;
|