@daemonitor/plugins 1.1.43 → 1.1.45

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,9 +8,11 @@ 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";
15
+ import { createAppActivityPlugin } from "./monitoring/AppActivityPlugin.js";
14
16
  import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
15
17
  import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
16
18
  import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
@@ -35,9 +37,11 @@ export default {
35
37
  Logwatch: createLogWatchPlugin,
36
38
  Netscan: createNetScanPlugin,
37
39
  Snmp: createSnmpPlugin,
40
+ Synology: createSynologyPlugin,
38
41
  ThreeCX: createThreeCXPlugin,
39
42
  Tplink: createTplinkPlugin,
40
43
  Patroni: createPatroniPlugin,
44
+ Appactivity: createAppActivityPlugin,
41
45
  Adsense: createAdsensePlugin,
42
46
  Ga4: createGa4Plugin,
43
47
  Nagios: createNagiosPlugin,
@@ -0,0 +1,152 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
4
+ const execFileAsync = promisify(execFile);
5
+ const DAY_MS = 86_400_000;
6
+ const RECENT_LIMIT = 10;
7
+ const MAX_USER_PAGES = 20;
8
+ const USER_PAGE_SIZE = 1000;
9
+ export function maskEmail(email) {
10
+ const s = String(email ?? "");
11
+ const at = s.indexOf("@");
12
+ if (at < 1)
13
+ return "unknown";
14
+ return `${s[0]}***${s.slice(at)}`;
15
+ }
16
+ // One row per person, action and minute: a Google sign-in writes two audit lines
17
+ // a moment apart. An account made through the admin API names service_role as the
18
+ // actor and the new user in traits.user_id, as in admin_auth_events().
19
+ export const AUDIT_SQL = `
20
+ with e as (
21
+ select distinct on (person, action, date_trunc('minute', created_at))
22
+ created_at as at, action, who, provider
23
+ from (
24
+ select created_at,
25
+ payload->>'action' as action,
26
+ coalesce(payload->'traits'->>'user_id', payload->>'actor_id') as person,
27
+ coalesce(payload->'traits'->>'user_email', payload->>'actor_username') as who,
28
+ coalesce(payload->'traits'->>'provider', payload->'traits'->>'provider_type') as provider
29
+ from auth.audit_log_entries
30
+ where created_at >= now() - interval '30 days'
31
+ and payload->>'action' in ('user_signedup', 'login')
32
+ ) raw
33
+ order by person, action, date_trunc('minute', created_at), created_at
34
+ )
35
+ select json_build_object(
36
+ 'users', (select count(*) from auth.users where deleted_at is null),
37
+ 'events', coalesce((select json_agg(e) from e), '[]'::json)
38
+ )`;
39
+ const windows = (times, now) => ({
40
+ d1: times.filter((t) => t >= now - DAY_MS).length,
41
+ d7: times.filter((t) => t >= now - 7 * DAY_MS).length,
42
+ d30: times.filter((t) => t >= now - 30 * DAY_MS).length,
43
+ });
44
+ const newestFirst = (events) => [...events].sort((a, b) => Date.parse(b.at) - Date.parse(a.at)).slice(0, RECENT_LIMIT);
45
+ /** Turn audit rows into the report. Exported for the check. */
46
+ export function summariseAudit(users, rows, now = Date.now()) {
47
+ const events = rows.map((r) => ({
48
+ at: new Date(r.at).toISOString(),
49
+ action: r.action === "user_signedup" ? "signup" : "login",
50
+ who: maskEmail(r.who),
51
+ ...(r.provider ? { provider: String(r.provider) } : {}),
52
+ }));
53
+ const times = (action) => events.filter((e) => e.action === action).map((e) => Date.parse(e.at));
54
+ return {
55
+ users,
56
+ signups: windows(times("signup"), now),
57
+ logins: windows(times("login"), now),
58
+ loginsArePeople: false,
59
+ recent: newestFirst(events),
60
+ };
61
+ }
62
+ /** Turn GoTrue admin users into the report. Exported for the check. */
63
+ export function summariseUsers(list, now = Date.now()) {
64
+ const stamp = (v) => {
65
+ const t = Date.parse(String(v ?? ""));
66
+ return Number.isFinite(t) ? t : null;
67
+ };
68
+ const events = [];
69
+ const signups = [];
70
+ const logins = [];
71
+ for (const u of list) {
72
+ const who = maskEmail(u?.email);
73
+ const provider = u?.app_metadata?.provider ? String(u.app_metadata.provider) : undefined;
74
+ const created = stamp(u?.created_at);
75
+ const seen = stamp(u?.last_sign_in_at);
76
+ if (created != null && created >= now - 30 * DAY_MS) {
77
+ signups.push(created);
78
+ events.push({ at: new Date(created).toISOString(), action: "signup", who, ...(provider ? { provider } : {}) });
79
+ }
80
+ if (seen != null && seen >= now - 30 * DAY_MS) {
81
+ logins.push(seen);
82
+ events.push({ at: new Date(seen).toISOString(), action: "login", who, ...(provider ? { provider } : {}) });
83
+ }
84
+ }
85
+ return {
86
+ users: list.length,
87
+ signups: windows(signups, now),
88
+ logins: windows(logins, now),
89
+ loginsArePeople: true,
90
+ recent: newestFirst(events),
91
+ };
92
+ }
93
+ async function collectPostgres(src, timeout) {
94
+ if (!src.database)
95
+ throw new Error("source has no database");
96
+ // ponytail: reads through one local container, so this agent's host going down
97
+ // silences every source. Move to HAProxy with a read-only role if that bites.
98
+ const { stdout } = await execFileAsync("docker", ["exec", src.container || "supacluster-patroni-1", "psql", "-U", "postgres", "-d", src.database, "-tAc", AUDIT_SQL], { timeout, killSignal: "SIGKILL", maxBuffer: 8 * 1024 * 1024 });
99
+ const parsed = JSON.parse(stdout);
100
+ return summariseAudit(Number(parsed.users) || 0, parsed.events || []);
101
+ }
102
+ async function collectSupabase(src, timeout) {
103
+ if (!src.url || !src.serviceKey)
104
+ throw new Error("source needs url and serviceKey");
105
+ const base = src.url.replace(/\/+$/, "");
106
+ const list = [];
107
+ for (let page = 1; page <= MAX_USER_PAGES; page++) {
108
+ const res = await fetch(`${base}/auth/v1/admin/users?page=${page}&per_page=${USER_PAGE_SIZE}`, {
109
+ headers: { apikey: src.serviceKey, Authorization: `Bearer ${src.serviceKey}` },
110
+ signal: AbortSignal.timeout(timeout),
111
+ });
112
+ if (!res.ok)
113
+ throw new Error(`admin users HTTP ${res.status}`);
114
+ const users = (await res.json())?.users || [];
115
+ list.push(...users);
116
+ if (users.length < USER_PAGE_SIZE)
117
+ break;
118
+ }
119
+ return summariseUsers(list);
120
+ }
121
+ export function sourceUid(src) {
122
+ return src.uniqueId || `activity-${src.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
123
+ }
124
+ export function createAppActivityPlugin() {
125
+ let refreshTimer = null;
126
+ const refreshFn = async (plugin) => {
127
+ const cfg = (plugin.config || {});
128
+ const timeout = cfg.requestTimeout || 20000;
129
+ for (const src of cfg.sources || []) {
130
+ const uid = sourceUid(src);
131
+ const head = { kind: "appactivity", name: src.name, source: src.kind || "postgres" };
132
+ try {
133
+ const body = src.kind === "supabase" ? await collectSupabase(src, timeout) : await collectPostgres(src, timeout);
134
+ await plugin.send({ ...head, reachable: true, ...body, timestamp: Date.now() }, uid);
135
+ }
136
+ catch (e) {
137
+ await plugin.send({ ...head, reachable: false, error: String(e?.message || e).slice(0, 200), timestamp: Date.now() }, uid);
138
+ }
139
+ }
140
+ };
141
+ const monitorFn = async (plugin) => {
142
+ await refreshFn(plugin);
143
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 300000);
144
+ };
145
+ const teardownFn = async () => {
146
+ if (refreshTimer)
147
+ clearInterval(refreshTimer);
148
+ refreshTimer = null;
149
+ };
150
+ return createMonitoringPlugin("appactivity", "appactivity", "Sign-ups and logins for apps on Supabase, self-hosted or cloud", async () => { }, monitorFn, refreshFn, teardownFn);
151
+ }
152
+ export default createAppActivityPlugin;
@@ -0,0 +1,174 @@
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
+ return createMonitoringPlugin("synology", "snmp", "Synology NAS disks, pools and volumes through the DSM web API", async () => { }, monitorFn, refreshFn, teardownFn);
173
+ }
174
+ export default createSynologyPlugin;
@@ -68,6 +68,13 @@ function parseJsonSafe(s, fallback) {
68
68
  return fallback;
69
69
  }
70
70
  }
71
+ // WP-CLI prints user_registered as UTC with no zone marker.
72
+ export function registrationCounts(registered, now = Date.now()) {
73
+ const day = 86_400_000;
74
+ const times = registered.map((r) => Date.parse(`${String(r).replace(" ", "T")}Z`)).filter(Number.isFinite);
75
+ const since = (days) => times.filter((t) => t >= now - days * day).length;
76
+ return { users: registered.length, signups: { d1: since(1), d7: since(7), d30: since(30) } };
77
+ }
71
78
  async function collectSite(site) {
72
79
  const label = site.name || path.basename(site.path || "") || "wordpress";
73
80
  const servicePatterns = (site.servicePatterns || DEFAULT_SERVICE_PATTERNS).map((s) => s.toLowerCase());
@@ -92,6 +99,14 @@ async function collectSite(site) {
92
99
  catch (e) {
93
100
  out.adminsError = String(e?.message || e).slice(0, 160);
94
101
  }
102
+ // Registrations, for the activity view. Counts only: nothing here names anyone.
103
+ try {
104
+ const raw = await wp(site, ["user", "list", "--fields=user_registered", "--format=json"], 30000);
105
+ out.activity = registrationCounts(parseJsonSafe(raw, []).map((u) => u.user_registered));
106
+ }
107
+ catch (e) {
108
+ out.activityError = String(e?.message || e).slice(0, 160);
109
+ }
95
110
  // 2. WP core version (posture; latest-version comparison is done elsewhere).
96
111
  try {
97
112
  out.wpVersion = (await wp(site, ["core", "version"])).trim();
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.43",
3
+ "version": "1.1.45",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",
7
7
  "type": "module",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "echo \"Error: no test specified\" && exit 1",
11
+ "prepublishOnly": "npm run build"
12
+ },
8
13
  "files": [
9
14
  "dist",
10
15
  "types"
@@ -22,6 +27,7 @@
22
27
  },
23
28
  "author": "",
24
29
  "license": "ISC",
30
+ "packageManager": "pnpm@9.15.9",
25
31
  "devDependencies": {
26
32
  "@types/node": "^26.1.1",
27
33
  "nuxt": "^3.15.4",
@@ -53,9 +59,5 @@
53
59
  },
54
60
  "engines": {
55
61
  "node": ">=18.17"
56
- },
57
- "scripts": {
58
- "build": "tsc",
59
- "test": "echo \"Error: no test specified\" && exit 1"
60
62
  }
61
- }
63
+ }