@glassmkr/crucible 0.7.1 → 0.8.1

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.
Files changed (103) hide show
  1. package/dist/alerts/__tests__/rules.test.d.ts +1 -0
  2. package/dist/alerts/__tests__/rules.test.js +437 -0
  3. package/dist/alerts/__tests__/rules.test.js.map +1 -0
  4. package/dist/alerts/rules.d.ts +8 -0
  5. package/dist/alerts/rules.js +175 -34
  6. package/dist/alerts/rules.js.map +1 -1
  7. package/dist/api.d.ts +2 -0
  8. package/dist/api.js +7 -0
  9. package/dist/api.js.map +1 -0
  10. package/dist/collect/__tests__/dmi.test.d.ts +1 -0
  11. package/dist/collect/__tests__/dmi.test.js +133 -0
  12. package/dist/collect/__tests__/dmi.test.js.map +1 -0
  13. package/dist/collect/__tests__/ipmi.test.js +47 -1
  14. package/dist/collect/__tests__/ipmi.test.js.map +1 -1
  15. package/dist/collect/__tests__/thermal.test.d.ts +1 -0
  16. package/dist/collect/__tests__/thermal.test.js +224 -0
  17. package/dist/collect/__tests__/thermal.test.js.map +1 -0
  18. package/dist/collect/dmi.d.ts +19 -0
  19. package/dist/collect/dmi.js +118 -0
  20. package/dist/collect/dmi.js.map +1 -0
  21. package/dist/collect/ipmi.d.ts +27 -2
  22. package/dist/collect/ipmi.js +90 -2
  23. package/dist/collect/ipmi.js.map +1 -1
  24. package/dist/collect/thermal.d.ts +10 -0
  25. package/dist/collect/thermal.js +232 -0
  26. package/dist/collect/thermal.js.map +1 -0
  27. package/dist/config.d.ts +10 -0
  28. package/dist/config.js +2 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/index.js +51 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/lib/__tests__/capability.test.d.ts +1 -0
  33. package/dist/lib/__tests__/capability.test.js +87 -0
  34. package/dist/lib/__tests__/capability.test.js.map +1 -0
  35. package/dist/lib/__tests__/vendor-sensors.test.d.ts +1 -0
  36. package/dist/lib/__tests__/vendor-sensors.test.js +49 -0
  37. package/dist/lib/__tests__/vendor-sensors.test.js.map +1 -0
  38. package/dist/lib/capability.d.ts +21 -0
  39. package/dist/lib/capability.js +110 -0
  40. package/dist/lib/capability.js.map +1 -0
  41. package/dist/lib/cpu-thermal-chips.d.ts +2 -0
  42. package/dist/lib/cpu-thermal-chips.js +28 -0
  43. package/dist/lib/cpu-thermal-chips.js.map +1 -0
  44. package/dist/lib/types.d.ts +58 -0
  45. package/dist/lib/vendor-sensors.d.ts +27 -0
  46. package/dist/lib/vendor-sensors.js +63 -0
  47. package/dist/lib/vendor-sensors.js.map +1 -0
  48. package/dist/notify/telegram.js +1 -1
  49. package/dist/notify/telegram.js.map +1 -1
  50. package/package.json +16 -1
  51. package/rule-ids.json +29 -0
  52. package/.dockerignore +0 -13
  53. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -24
  54. package/.github/ISSUE_TEMPLATE/no_data.md +0 -26
  55. package/.github/workflows/docker.yml +0 -53
  56. package/.github/workflows/publish.yml +0 -25
  57. package/Dockerfile +0 -59
  58. package/config/collector.example.yaml +0 -43
  59. package/docker-compose.yml +0 -26
  60. package/scripts/sign-release.sh +0 -29
  61. package/src/__tests__/cli.test.ts +0 -74
  62. package/src/__tests__/reboot-marker.test.ts +0 -122
  63. package/src/alerts/evaluator.ts +0 -15
  64. package/src/alerts/rules.ts +0 -283
  65. package/src/alerts/state.ts +0 -92
  66. package/src/cli.ts +0 -112
  67. package/src/collect/__tests__/ipmi.test.ts +0 -96
  68. package/src/collect/__tests__/smart.test.ts +0 -68
  69. package/src/collect/__tests__/system.test.ts +0 -29
  70. package/src/collect/__tests__/zfs.test.ts +0 -72
  71. package/src/collect/conntrack.ts +0 -27
  72. package/src/collect/cpu.ts +0 -92
  73. package/src/collect/disks.ts +0 -91
  74. package/src/collect/fd.ts +0 -31
  75. package/src/collect/io-errors.ts +0 -23
  76. package/src/collect/io-latency.ts +0 -103
  77. package/src/collect/ipmi.ts +0 -207
  78. package/src/collect/memory.ts +0 -30
  79. package/src/collect/network.ts +0 -193
  80. package/src/collect/ntp.ts +0 -114
  81. package/src/collect/os-alerts.ts +0 -43
  82. package/src/collect/raid.ts +0 -40
  83. package/src/collect/security.ts +0 -268
  84. package/src/collect/smart.ts +0 -72
  85. package/src/collect/system.ts +0 -32
  86. package/src/collect/systemd.ts +0 -33
  87. package/src/collect/zfs.ts +0 -66
  88. package/src/config.ts +0 -65
  89. package/src/index.ts +0 -221
  90. package/src/lib/__tests__/parse.test.ts +0 -28
  91. package/src/lib/exec.ts +0 -16
  92. package/src/lib/parse.ts +0 -29
  93. package/src/lib/reboot-marker.ts +0 -88
  94. package/src/lib/types.ts +0 -226
  95. package/src/lib/version-check.ts +0 -39
  96. package/src/lib/version.ts +0 -33
  97. package/src/metrics-server.ts +0 -123
  98. package/src/notify/email.ts +0 -69
  99. package/src/notify/slack.ts +0 -47
  100. package/src/notify/telegram.ts +0 -65
  101. package/src/push/forge.ts +0 -109
  102. package/tsconfig.json +0 -15
  103. package/vitest.config.ts +0 -12
@@ -1,114 +0,0 @@
1
- import { run } from "../lib/exec.js";
2
-
3
- export interface NtpData {
4
- synced: boolean;
5
- offset_seconds: number;
6
- source: string;
7
- daemon_running: boolean;
8
- daemon_name: string;
9
- }
10
-
11
- // Check whether a systemd unit is active. Returns false for missing units, not-found, etc.
12
- async function isUnitActive(unit: string): Promise<boolean> {
13
- const out = await run("systemctl", ["is-active", unit], 3000);
14
- return out?.trim() === "active";
15
- }
16
-
17
- // Detect which time-sync daemon unit is currently active on the host, if any.
18
- // Returns "" when none are. We check the common names in order of preference.
19
- async function detectActiveDaemon(): Promise<string> {
20
- const candidates = ["chrony", "chronyd", "systemd-timesyncd", "ntp", "ntpsec", "ntpd"];
21
- for (const unit of candidates) {
22
- if (await isUnitActive(unit)) return unit;
23
- }
24
- return "";
25
- }
26
-
27
- export async function collectNtp(): Promise<NtpData> {
28
- // The authoritative "is the daemon running" check is systemctl is-active,
29
- // not any derived flag from timedatectl. This catches daemon crashes and
30
- // manual stops where the kernel clock is still synced.
31
- const daemonName = await detectActiveDaemon();
32
- const daemonRunning = daemonName !== "";
33
-
34
- // Try timedatectl first (works for systemd-timesyncd and records the kernel
35
- // NTPSynchronized flag regardless of which daemon set it).
36
- const tdctl = await run("timedatectl", ["show", "--property=NTPSynchronized", "--value"]);
37
- if (tdctl !== null && (tdctl.trim() === "yes" || tdctl.trim() === "no")) {
38
- const synced = tdctl.trim() === "yes";
39
-
40
- let offset = 0;
41
- try {
42
- const tsStatus = await run("timedatectl", ["timesync-status"]);
43
- if (tsStatus) {
44
- const match = tsStatus.match(/Offset:\s*([+-]?\d+\.?\d*)(us|ms|s)/);
45
- if (match) {
46
- const val = parseFloat(match[1]);
47
- const unit = match[2];
48
- if (unit === "us") offset = val / 1_000_000;
49
- else if (unit === "ms") offset = val / 1000;
50
- else offset = val;
51
- }
52
- }
53
- } catch { /* offset stays 0 */ }
54
-
55
- // Prefer an explicitly detected daemon name; fall back to systemd-timesyncd
56
- // since timedatectl is most commonly the timesyncd frontend.
57
- const source = daemonName || "systemd-timesyncd";
58
- return { synced, offset_seconds: offset, source, daemon_running: daemonRunning, daemon_name: daemonName };
59
- }
60
-
61
- // Chrony tracking. Validate Leap status so we do not misread error text.
62
- const chronyOut = await run("chronyc", ["tracking"]);
63
- if (chronyOut) {
64
- const leapMatch = chronyOut.match(/Leap status\s*:\s*(.+)/);
65
- if (leapMatch) {
66
- const synced = leapMatch[1].trim() === "Normal";
67
- let offset = 0;
68
- const offsetMatch = chronyOut.match(/Last offset\s*:\s*([+-]?\d+\.?\d*)\s*seconds/);
69
- if (offsetMatch) offset = parseFloat(offsetMatch[1]);
70
- return {
71
- synced,
72
- offset_seconds: Math.abs(offset),
73
- source: daemonName || "chrony",
74
- daemon_running: daemonRunning,
75
- daemon_name: daemonName,
76
- };
77
- }
78
- }
79
-
80
- // ntpq peer table. Header check avoids false positives on error messages.
81
- const ntpqOut = await run("ntpq", ["-pn"]);
82
- if (ntpqOut) {
83
- const hasHeader = ntpqOut.split("\n").some((line) => line.includes("remote"));
84
- if (hasHeader) {
85
- const synced = ntpqOut.split("\n").some((line) => line.startsWith("*"));
86
- let offset = 0;
87
- const selectedLine = ntpqOut.split("\n").find((line) => line.startsWith("*"));
88
- if (selectedLine) {
89
- const fields = selectedLine.trim().split(/\s+/);
90
- if (fields.length >= 9) {
91
- const rawOffset = parseFloat(fields[8]);
92
- if (!isNaN(rawOffset)) offset = Math.abs(rawOffset) / 1000;
93
- }
94
- }
95
- return {
96
- synced,
97
- offset_seconds: offset,
98
- source: daemonName || "ntpd",
99
- daemon_running: daemonRunning,
100
- daemon_name: daemonName,
101
- };
102
- }
103
- }
104
-
105
- // No usable probe output. If systemd still reports a daemon as active, trust that;
106
- // otherwise report fully down.
107
- return {
108
- synced: false,
109
- offset_seconds: 0,
110
- source: daemonName || "none",
111
- daemon_running: daemonRunning,
112
- daemon_name: daemonName,
113
- };
114
- }
@@ -1,43 +0,0 @@
1
- import { run } from "../lib/exec.js";
2
- import { readProcFile } from "../lib/parse.js";
3
- import { readdirSync, readFileSync } from "fs";
4
- import type { OsAlerts } from "../lib/types.js";
5
-
6
- export async function collectOsAlerts(): Promise<OsAlerts> {
7
- // OOM kills
8
- let oomKills = 0;
9
- const dmesg = await run("dmesg", ["--level=err,crit", "--since", "5 min ago"]);
10
- if (dmesg) {
11
- oomKills = (dmesg.match(/Out of memory/gi) || []).length;
12
- }
13
-
14
- // Zombie processes
15
- let zombies = 0;
16
- try {
17
- const pids = readdirSync("/proc").filter((f) => /^\d+$/.test(f));
18
- for (const pid of pids) {
19
- try {
20
- const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
21
- // Field 3 is the state character
22
- const state = stat.split(" ")[2];
23
- if (state === "Z") zombies++;
24
- } catch { /* process disappeared */ }
25
- }
26
- } catch { /* /proc not readable */ }
27
-
28
- // Time drift (simple: check if chrony/ntp reports drift)
29
- let timeDriftMs = 0;
30
- const chrony = await run("chronyc", ["tracking"]);
31
- if (chrony) {
32
- const match = chrony.match(/System time\s*:\s*([\d.]+)\s*seconds\s*(slow|fast)/);
33
- if (match) {
34
- timeDriftMs = parseFloat(match[1]) * 1000;
35
- }
36
- }
37
-
38
- return {
39
- oom_kills_recent: oomKills,
40
- zombie_processes: zombies,
41
- time_drift_ms: Math.round(timeDriftMs * 100) / 100,
42
- };
43
- }
@@ -1,40 +0,0 @@
1
- import { readProcFile } from "../lib/parse.js";
2
- import type { RaidInfo } from "../lib/types.js";
3
-
4
- export async function collectRaid(): Promise<RaidInfo[]> {
5
- const raw = readProcFile("/proc/mdstat");
6
- if (!raw) return [];
7
-
8
- const results: RaidInfo[] = [];
9
- const lines = raw.split("\n");
10
-
11
- for (let i = 0; i < lines.length; i++) {
12
- const match = lines[i].match(/^(md\d+)\s*:\s*(\w+)\s+(\w+)\s+(.*)/);
13
- if (!match) continue;
14
-
15
- const device = match[1];
16
- const status = match[2]; // "active" or "inactive"
17
- const level = match[3]; // "raid1", "raid5", etc.
18
- const disksPart = match[4];
19
-
20
- // Parse component disks (e.g., "sda1[0] sdb1[1]")
21
- const disks = (disksPart.match(/\w+\[\d+\]/g) || []).map((d) => d.replace(/\[\d+\]/, ""));
22
-
23
- // Check next line for degraded status (e.g., "[UU_]" means one drive missing)
24
- const statusLine = lines[i + 1] || "";
25
- const bracketMatch = statusLine.match(/\[([U_]+)\]/);
26
- const degraded = bracketMatch ? bracketMatch[1].includes("_") : false;
27
-
28
- const failedDisks: string[] = [];
29
- if (degraded && bracketMatch) {
30
- const pattern = bracketMatch[1];
31
- pattern.split("").forEach((c, idx) => {
32
- if (c === "_" && disks[idx]) failedDisks.push(disks[idx]);
33
- });
34
- }
35
-
36
- results.push({ device, level, status, degraded, disks, failed_disks: failedDisks });
37
- }
38
-
39
- return results;
40
- }
@@ -1,268 +0,0 @@
1
- import { run } from "../lib/exec.js";
2
- import { readFileSync, existsSync, readdirSync } from "fs";
3
-
4
- export interface SshSecurityStatus {
5
- permitRootLogin: string;
6
- passwordAuthentication: string;
7
- rootPasswordExposed: boolean;
8
- }
9
-
10
- export interface FirewallStatus {
11
- active: boolean;
12
- source: string;
13
- details: string;
14
- }
15
-
16
- export interface SecurityUpdateStatus {
17
- distro: string;
18
- pendingCount: number;
19
- available: boolean;
20
- }
21
-
22
- export interface VulnerabilityStatus {
23
- name: string;
24
- status: string;
25
- mitigated: boolean;
26
- }
27
-
28
- export interface KernelRebootStatus {
29
- running: string;
30
- installed: string;
31
- needsReboot: boolean;
32
- }
33
-
34
- export interface AutoUpdateStatus {
35
- configured: boolean;
36
- mechanism: string;
37
- details: string;
38
- }
39
-
40
- export interface SecurityData {
41
- ssh: SshSecurityStatus | null;
42
- firewall: FirewallStatus;
43
- pending_updates: SecurityUpdateStatus | null;
44
- kernel_vulns: VulnerabilityStatus[];
45
- kernel_reboot: KernelRebootStatus | null;
46
- auto_updates: AutoUpdateStatus;
47
- }
48
-
49
- export async function collectSecurity(): Promise<SecurityData> {
50
- const [ssh, firewall, pendingUpdates, kernelVulns, kernelReboot, autoUpdates] = await Promise.all([
51
- checkSshConfig(),
52
- checkFirewall(),
53
- checkSecurityUpdates(),
54
- checkKernelVulnerabilities(),
55
- checkKernelReboot(),
56
- checkAutoUpdates(),
57
- ]);
58
-
59
- return { ssh, firewall, pending_updates: pendingUpdates, kernel_vulns: kernelVulns, kernel_reboot: kernelReboot, auto_updates: autoUpdates };
60
- }
61
-
62
- // === SSH ===
63
-
64
- async function checkSshConfig(): Promise<SshSecurityStatus | null> {
65
- // Prefer sshd -T (resolves includes and match blocks)
66
- const output = await run("sshd", ["-T"], 5000);
67
- if (output) {
68
- const getVal = (key: string): string => {
69
- const line = output.split("\n").find((l) => l.startsWith(key + " "));
70
- return line ? line.split(" ")[1].trim() : "";
71
- };
72
- const permitRootLogin = getVal("permitrootlogin");
73
- const passwordAuth = getVal("passwordauthentication");
74
- const rootPasswordExposed = permitRootLogin === "yes" && passwordAuth !== "no";
75
- return { permitRootLogin, passwordAuthentication: passwordAuth, rootPasswordExposed };
76
- }
77
-
78
- // Fallback: parse sshd_config directly
79
- try {
80
- const config = readFileSync("/etc/ssh/sshd_config", "utf-8");
81
- const lines = config.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
82
- const find = (key: string): string | null => {
83
- const line = lines.find((l) => l.toLowerCase().startsWith(key.toLowerCase()));
84
- return line ? line.split(/\s+/)[1] : null;
85
- };
86
- const permitRootLogin = find("PermitRootLogin") || "prohibit-password";
87
- const passwordAuth = find("PasswordAuthentication") || "yes";
88
- const rootPasswordExposed = permitRootLogin.toLowerCase() === "yes" && passwordAuth.toLowerCase() !== "no";
89
- return { permitRootLogin, passwordAuthentication: passwordAuth, rootPasswordExposed };
90
- } catch {
91
- return null;
92
- }
93
- }
94
-
95
- // === Firewall ===
96
-
97
- async function checkFirewall(): Promise<FirewallStatus> {
98
- // UFW: if installed, its status is authoritative (ignores Docker iptables chains)
99
- const ufw = await run("ufw", ["status"], 5000);
100
- if (ufw && ufw.includes("Status:")) {
101
- const active = ufw.includes("Status: active");
102
- return { active, source: "ufw", details: active ? "UFW is active" : "UFW is inactive" };
103
- }
104
-
105
- // firewalld: if installed, its status is authoritative
106
- const fwd = await run("firewall-cmd", ["--state"], 5000);
107
- if (fwd) {
108
- if (fwd.trim() === "running") {
109
- return { active: true, source: "firewalld", details: "firewalld is running" };
110
- }
111
- if (fwd.includes("not running") || fwd.includes("dead")) {
112
- return { active: false, source: "firewalld", details: "firewalld is not running" };
113
- }
114
- }
115
-
116
- // nftables (only if no managed firewall found)
117
- const nft = await run("nft", ["list", "ruleset"], 5000);
118
- if (nft) {
119
- const ruleLines = nft.split("\n").filter((l) => l.trim().match(/^\s*(meta|ip |ip6 |tcp |udp |ct |drop|reject|accept)/));
120
- if (ruleLines.length > 0) {
121
- return { active: true, source: "nftables", details: `${ruleLines.length} nftables rules` };
122
- }
123
- }
124
-
125
- // iptables fallback: filter out Docker/container chains to avoid false positives
126
- const ipt = await run("iptables", ["-L", "-n"], 5000);
127
- if (ipt) {
128
- const lines = ipt.split("\n").filter((l) =>
129
- l.trim() &&
130
- !l.startsWith("Chain ") &&
131
- !l.startsWith("target ") &&
132
- !l.includes("DOCKER") &&
133
- !l.includes("docker") &&
134
- !l.includes("br-") &&
135
- !l.includes("f2b-")
136
- );
137
- if (lines.length > 0) return { active: true, source: "iptables", details: `${lines.length} user iptables rules` };
138
- if (ipt.includes("policy DROP") || ipt.includes("policy REJECT")) {
139
- return { active: true, source: "iptables", details: "Default policy is DROP/REJECT" };
140
- }
141
- }
142
-
143
- return { active: false, source: "none", details: "No firewall detected (checked ufw, firewalld, nftables, iptables)" };
144
- }
145
-
146
- // === Pending Security Updates ===
147
-
148
- async function checkSecurityUpdates(): Promise<SecurityUpdateStatus | null> {
149
- let osRelease = "";
150
- try { osRelease = readFileSync("/etc/os-release", "utf-8").toLowerCase(); } catch { return null; }
151
-
152
- if (osRelease.includes("debian") || osRelease.includes("ubuntu") || osRelease.includes("mint")) {
153
- const output = await run("bash", ["-c", 'apt list --upgradable 2>/dev/null | grep -i "security" | wc -l'], 30000);
154
- if (output) {
155
- const count = parseInt(output.trim()) || 0;
156
- return { distro: osRelease.includes("ubuntu") ? "ubuntu" : "debian", pendingCount: count, available: true };
157
- }
158
- return { distro: "debian", pendingCount: 0, available: false };
159
- }
160
-
161
- if (osRelease.includes("rhel") || osRelease.includes("rocky") || osRelease.includes("alma") || osRelease.includes("fedora") || osRelease.includes("centos")) {
162
- const cmd = existsSync("/usr/bin/dnf") ? "dnf" : "yum";
163
- const output = await run("bash", ["-c", `${cmd} updateinfo list security --available 2>/dev/null | grep -c "/"`], 60000);
164
- if (output) {
165
- const count = parseInt(output.trim()) || 0;
166
- const distro = osRelease.includes("rocky") ? "rocky" : osRelease.includes("alma") ? "alma" : osRelease.includes("fedora") ? "fedora" : "rhel";
167
- return { distro, pendingCount: count, available: true };
168
- }
169
- return { distro: "rhel", pendingCount: 0, available: false };
170
- }
171
-
172
- return null;
173
- }
174
-
175
- // === Kernel Vulnerabilities ===
176
-
177
- function checkKernelVulnerabilities(): VulnerabilityStatus[] {
178
- const vulnDir = "/sys/devices/system/cpu/vulnerabilities";
179
- if (!existsSync(vulnDir)) return [];
180
-
181
- try {
182
- const files = readdirSync(vulnDir);
183
- return files.map((file) => {
184
- try {
185
- const status = readFileSync(`${vulnDir}/${file}`, "utf-8").trim();
186
- const mitigated = status.includes("Not affected") || status.includes("Mitigation:");
187
- return { name: file, status, mitigated };
188
- } catch {
189
- return { name: file, status: "unknown", mitigated: true };
190
- }
191
- });
192
- } catch {
193
- return [];
194
- }
195
- }
196
-
197
- // === Kernel Reboot ===
198
-
199
- async function checkKernelReboot(): Promise<KernelRebootStatus | null> {
200
- const running = (await run("uname", ["-r"]))?.trim();
201
- if (!running) return null;
202
-
203
- // Method 1: reboot-required flag (Debian/Ubuntu)
204
- if (existsSync("/var/run/reboot-required")) {
205
- // Filter to versioned images only (e.g. linux-image-6.8.0-107-generic),
206
- // excluding metapackages like linux-image-generic, linux-image-virtual.
207
- const installed = (await run("bash", ["-c", 'dpkg -l "linux-image-*" 2>/dev/null | grep "^ii" | awk \'{print $2}\' | grep "linux-image-[0-9]" | sed "s/linux-image-//" | sort -V | tail -1']))?.trim() || "unknown";
208
- return { running, installed, needsReboot: true };
209
- }
210
-
211
- // Method 2: Compare packages (Debian/Ubuntu)
212
- // Same filter: only versioned images, no metapackages.
213
- const debPkg = (await run("bash", ["-c", 'dpkg -l "linux-image-*" 2>/dev/null | grep "^ii" | awk \'{print $2}\' | grep "linux-image-[0-9]" | sed "s/linux-image-//" | sort -V | tail -1']))?.trim();
214
- if (debPkg) {
215
- return { running, installed: debPkg, needsReboot: debPkg !== running };
216
- }
217
-
218
- // Method 3: RPM-based
219
- const rpmPkg = (await run("bash", ["-c", 'rpm -q kernel --queryformat "%{VERSION}-%{RELEASE}.%{ARCH}\\n" 2>/dev/null | sort -V | tail -1']))?.trim();
220
- if (rpmPkg) {
221
- return { running, installed: rpmPkg, needsReboot: rpmPkg !== running };
222
- }
223
-
224
- return null;
225
- }
226
-
227
- // === Auto Updates ===
228
-
229
- async function checkAutoUpdates(): Promise<AutoUpdateStatus> {
230
- // Debian/Ubuntu: unattended-upgrades
231
- const uuInstalled = await run("bash", ["-c", 'dpkg -l unattended-upgrades 2>/dev/null | grep "^ii"'], 5000);
232
- if (uuInstalled) {
233
- // Check config file
234
- const autoConf = "/etc/apt/apt.conf.d/20auto-upgrades";
235
- let configEnabled = false;
236
- if (existsSync(autoConf)) {
237
- const content = readFileSync(autoConf, "utf-8");
238
- configEnabled = content.includes('Update-Package-Lists "1"') && content.includes('Unattended-Upgrade "1"');
239
- }
240
-
241
- // Check systemd service state
242
- const serviceEnabled = (await run("bash", ["-c", "systemctl is-enabled unattended-upgrades 2>/dev/null"], 5000))?.trim() === "enabled";
243
- const serviceActive = (await run("bash", ["-c", "systemctl is-active unattended-upgrades 2>/dev/null"], 5000))?.trim() === "active";
244
-
245
- if (configEnabled && serviceEnabled) {
246
- return { configured: true, mechanism: "unattended-upgrades", details: serviceActive ? "Installed, enabled, and running" : "Installed and enabled (service not active)" };
247
- }
248
- if (!configEnabled && !serviceEnabled) {
249
- return { configured: false, mechanism: "unattended-upgrades", details: "Installed but not configured and service disabled" };
250
- }
251
- if (!serviceEnabled) {
252
- return { configured: false, mechanism: "unattended-upgrades", details: "Installed and configured but service disabled" };
253
- }
254
- return { configured: false, mechanism: "unattended-upgrades", details: "Installed but not enabled in 20auto-upgrades" };
255
- }
256
-
257
- // RHEL/Rocky/Alma: dnf-automatic
258
- const dnfAuto = await run("bash", ["-c", "rpm -q dnf-automatic 2>/dev/null"], 5000);
259
- if (dnfAuto && !dnfAuto.includes("not installed")) {
260
- const timerActive = await run("bash", ["-c", "systemctl is-enabled dnf-automatic-install.timer 2>/dev/null || systemctl is-enabled dnf-automatic.timer 2>/dev/null"], 5000);
261
- if (timerActive && timerActive.includes("enabled")) {
262
- return { configured: true, mechanism: "dnf-automatic", details: "Installed and timer enabled" };
263
- }
264
- return { configured: false, mechanism: "dnf-automatic", details: "Installed but timer not enabled" };
265
- }
266
-
267
- return { configured: false, mechanism: "none", details: "No automatic security update mechanism detected" };
268
- }
@@ -1,72 +0,0 @@
1
- import { run } from "../lib/exec.js";
2
- import { readdirSync } from "fs";
3
- import type { SmartInfo } from "../lib/types.js";
4
-
5
- export async function collectSmart(): Promise<SmartInfo[]> {
6
- // Find block devices
7
- const devices: string[] = [];
8
- try {
9
- const entries = readdirSync("/sys/block");
10
- for (const entry of entries) {
11
- if (entry.startsWith("sd") || entry.startsWith("nvme") || entry.startsWith("hd")) {
12
- devices.push(`/dev/${entry}`);
13
- }
14
- }
15
- } catch {
16
- return [];
17
- }
18
-
19
- const results: SmartInfo[] = [];
20
- for (const device of devices) {
21
- const output = await run("smartctl", ["--json", "--all", device]);
22
- if (!output) continue;
23
-
24
- try {
25
- const info = parseSmartctlJson(JSON.parse(output), device);
26
- results.push(info);
27
- } catch {
28
- // Failed to parse, skip this device
29
- }
30
- }
31
-
32
- return results;
33
- }
34
-
35
- export function parseSmartctlJson(data: Record<string, unknown> & {
36
- model_name?: string;
37
- model_family?: string;
38
- smart_status?: { passed?: boolean };
39
- temperature?: { current?: number };
40
- power_on_time?: { hours?: number };
41
- nvme_smart_health_information_log?: { percentage_used?: number; temperature?: number };
42
- ata_smart_attributes?: { table?: Array<{ id?: number; name?: string; raw?: { value?: number } }> };
43
- }, device: string): SmartInfo {
44
- const info: SmartInfo = {
45
- device,
46
- model: data.model_name || data.model_family || "unknown",
47
- health: data.smart_status?.passed ? "PASSED" : "FAILED",
48
- temperature_c: data.temperature?.current,
49
- power_on_hours: data.power_on_time?.hours,
50
- };
51
-
52
- // NVMe specific
53
- if (data.nvme_smart_health_information_log) {
54
- const nvme = data.nvme_smart_health_information_log;
55
- info.percentage_used = nvme.percentage_used;
56
- info.temperature_c = nvme.temperature;
57
- }
58
-
59
- // SATA specific
60
- if (data.ata_smart_attributes?.table) {
61
- for (const attr of data.ata_smart_attributes.table) {
62
- if (attr.id === 5 || attr.name === "Reallocated_Sector_Ct") {
63
- info.reallocated_sectors = attr.raw?.value || 0;
64
- }
65
- if (attr.id === 197 || attr.name === "Current_Pending_Sector") {
66
- info.pending_sectors = attr.raw?.value || 0;
67
- }
68
- }
69
- }
70
-
71
- return info;
72
- }
@@ -1,32 +0,0 @@
1
- import { hostname } from "os";
2
- import { readProcFile } from "../lib/parse.js";
3
- import { run } from "../lib/exec.js";
4
- import type { SystemInfo } from "../lib/types.js";
5
-
6
- // Matches KEY=value with optional surrounding double quotes. Handles both
7
- // `ID=ubuntu` and `ID="rocky"` styles found in the wild.
8
- export function readOsReleaseField(osRelease: string, key: string): string | undefined {
9
- const m = osRelease.match(new RegExp(`^${key}=("?)(.+?)\\1$`, "m"));
10
- return m ? m[2].toLowerCase() : undefined;
11
- }
12
-
13
- export async function collectSystem(): Promise<SystemInfo> {
14
- const osRelease = readProcFile("/etc/os-release") || "";
15
- const osName = osRelease.match(/PRETTY_NAME="(.+?)"/)?.[1] || "Unknown";
16
- const os_id = readOsReleaseField(osRelease, "ID");
17
- const os_id_like = readOsReleaseField(osRelease, "ID_LIKE");
18
- const kernel = (await run("uname", ["-r"]))?.trim() || "unknown";
19
- const uptimeRaw = readProcFile("/proc/uptime") || "0";
20
- const uptimeSeconds = Math.floor(parseFloat(uptimeRaw.split(" ")[0]));
21
- const ip = (await run("hostname", ["-I"]))?.trim().split(" ")[0] || "unknown";
22
-
23
- return {
24
- hostname: hostname(),
25
- ip,
26
- os: osName,
27
- ...(os_id ? { os_id } : {}),
28
- ...(os_id_like ? { os_id_like } : {}),
29
- kernel,
30
- uptime_seconds: uptimeSeconds,
31
- };
32
- }
@@ -1,33 +0,0 @@
1
- import { run } from "../lib/exec.js";
2
-
3
- export interface SystemdData {
4
- failed_units: string[];
5
- failed_count: number;
6
- }
7
-
8
- // Units commonly in failed state by design or misconfiguration
9
- const DEFAULT_EXCLUDES = [
10
- "systemd-networkd-wait-online.service",
11
- ];
12
-
13
- export async function collectSystemd(extraExcludes: string[] = []): Promise<SystemdData> {
14
- const output = await run("systemctl", [
15
- "list-units", "--type=service", "--state=failed", "--no-legend", "--plain",
16
- ]);
17
-
18
- if (!output || output.trim() === "") {
19
- return { failed_units: [], failed_count: 0 };
20
- }
21
-
22
- const excludes = new Set([...DEFAULT_EXCLUDES, ...extraExcludes]);
23
- const units: string[] = [];
24
-
25
- for (const line of output.trim().split("\n")) {
26
- const unit = line.trim().split(/\s+/)[0];
27
- if (unit && unit.endsWith(".service") && !excludes.has(unit)) {
28
- units.push(unit);
29
- }
30
- }
31
-
32
- return { failed_units: units, failed_count: units.length };
33
- }
@@ -1,66 +0,0 @@
1
- import { run } from "../lib/exec.js";
2
- import type { ZfsData, ZfsPool } from "../lib/types.js";
3
-
4
- export async function collectZfs(): Promise<ZfsData | null> {
5
- // Check if zpool is installed
6
- const zpoolPath = await run("which", ["zpool"], 3000);
7
- if (!zpoolPath || !zpoolPath.trim()) return null;
8
-
9
- const zpoolStatus = await run("zpool", ["status"], 10000);
10
- if (!zpoolStatus || !zpoolStatus.trim()) return null;
11
-
12
- const pools = parseZpoolStatus(zpoolStatus);
13
- if (pools.length === 0) return null;
14
- return { pools };
15
- }
16
-
17
- export function parseZpoolStatus(zpoolStatus: string): ZfsPool[] {
18
- const pools: ZfsPool[] = [];
19
- let current: ZfsPool | null = null;
20
-
21
- for (const line of zpoolStatus.split("\n")) {
22
- const poolMatch = line.match(/^\s*pool:\s*(.+)/);
23
- if (poolMatch) {
24
- current = {
25
- name: poolMatch[1].trim(),
26
- state: "UNKNOWN",
27
- errors_text: "",
28
- };
29
- pools.push(current);
30
- continue;
31
- }
32
-
33
- if (!current) continue;
34
-
35
- const stateMatch = line.match(/^\s*state:\s*(.+)/);
36
- if (stateMatch) {
37
- current.state = stateMatch[1].trim();
38
- continue;
39
- }
40
-
41
- const errorsMatch = line.match(/^\s*errors:\s*(.+)/);
42
- if (errorsMatch) {
43
- current.errors_text = errorsMatch[1].trim();
44
- continue;
45
- }
46
-
47
- // Parse scrub info
48
- if (line.includes("scan:")) {
49
- if (line.includes("none requested")) {
50
- current.scrub_never_run = true;
51
- } else {
52
- const repairMatch = line.match(/scrub repaired (\S+) in .* with (\d+) errors/);
53
- if (repairMatch) {
54
- current.scrub_repaired = repairMatch[1];
55
- current.scrub_errors = parseInt(repairMatch[2]) || 0;
56
- }
57
- const dateMatch = line.match(/on (.+)$/);
58
- if (dateMatch) {
59
- current.last_scrub_date = dateMatch[1].trim();
60
- }
61
- }
62
- }
63
- }
64
-
65
- return pools;
66
- }