@orb44/cli 0.1.8 → 0.1.10
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/README.md +4 -4
- package/bin/orb44.mjs +45 -4
- package/package.json +3 -2
- package/server/pulse.mjs +214 -11
- package/server/sat-i18n.mjs +32 -12
- package/server/sat-logs.mjs +260 -0
- package/server/sat-update.mjs +1 -0
package/README.md
CHANGED
|
@@ -13,11 +13,11 @@ npx @orb44/cli login --url https://orb44.com
|
|
|
13
13
|
Open the printed link in a browser where you are already signed in, confirm the domain, then optionally install the systemd daemon (default no). After a global install the command is `orb44`.
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
npx @orb44/cli@0.1.
|
|
17
|
-
npx @orb44/cli@0.1.
|
|
18
|
-
npx @orb44/cli@0.1.
|
|
16
|
+
npx @orb44/cli@0.1.10 status
|
|
17
|
+
npx @orb44/cli@0.1.10 pulse
|
|
18
|
+
npx @orb44/cli@0.1.10 install # later: background pulse, starts after reboot
|
|
19
19
|
orb44 update # after install: replace /usr/lib/orb44-sat from npm
|
|
20
|
-
npx @orb44/cli@0.1.
|
|
20
|
+
npx @orb44/cli@0.1.10 logout
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
Do not run bare `npx @orb44/cli` — it can reuse an old cache. Pin the version or use `orb44` after install.
|
package/bin/orb44.mjs
CHANGED
|
@@ -24,6 +24,7 @@ function args() {
|
|
|
24
24
|
if (a === "--yes" || a === "-y") out.yes = true;
|
|
25
25
|
else if (a === "--system") out.system = true;
|
|
26
26
|
else if (a === "--daemon") out.daemon = true;
|
|
27
|
+
else if (a === "--logs") out.logs = true;
|
|
27
28
|
else if (a === "--force") out.force = true;
|
|
28
29
|
else if (a === "--purge") out.purge = true;
|
|
29
30
|
else if (a === "--url" || a === "--code" || a === "--name" || a === "--interval" || a === "--lang") {
|
|
@@ -167,8 +168,33 @@ function printAdvice(notes) {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
async function wantLogs(opts) {
|
|
172
|
+
if (opts.logs) return true;
|
|
173
|
+
if (opts.yes) return false;
|
|
174
|
+
if (!input.isTTY || !output.isTTY) return false;
|
|
175
|
+
const idx = await pickFromList({
|
|
176
|
+
title: t(lang, "ask_logs"),
|
|
177
|
+
items: [t(lang, "logs_no"), t(lang, "logs_yes")],
|
|
178
|
+
index: 0,
|
|
179
|
+
hint: "↑↓ Enter",
|
|
180
|
+
stdin: input,
|
|
181
|
+
stdout: output,
|
|
182
|
+
});
|
|
183
|
+
return idx === 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function applyGrants(device, extra = {}) {
|
|
187
|
+
const grants = {
|
|
188
|
+
process: true,
|
|
189
|
+
daemon: extra.daemon != null ? Boolean(extra.daemon) : Boolean(device.grants?.daemon),
|
|
190
|
+
logs: extra.logs != null ? Boolean(extra.logs) : Boolean(device.grants?.logs),
|
|
191
|
+
};
|
|
192
|
+
device.grants = grants;
|
|
193
|
+
return grants;
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
async function sendPulse(device, { preview = true } = {}) {
|
|
171
|
-
const pulse = collectPulse();
|
|
197
|
+
const pulse = collectPulse({ grants: device.grants });
|
|
172
198
|
const ver = readCliVersion(SCRIPT);
|
|
173
199
|
if (ver && ver !== "0.0.0") pulse.cliVersion = ver;
|
|
174
200
|
if (preview) {
|
|
@@ -318,6 +344,13 @@ async function cmdLogin(opts) {
|
|
|
318
344
|
console.log(`\n\n✅ ${t(lang, "paired", { host: device.host, name: device.name })}`);
|
|
319
345
|
console.log(dim(`🔑 ${t(lang, "key_file", { file: DEVICE_FILE })}\n`));
|
|
320
346
|
|
|
347
|
+
const daemon = await wantDaemon(opts);
|
|
348
|
+
const logs = await wantLogs(opts);
|
|
349
|
+
applyGrants(device, { daemon, logs });
|
|
350
|
+
saveDevice(device);
|
|
351
|
+
if (logs) console.log(dim(t(lang, "logs_on")));
|
|
352
|
+
else console.log(dim(t(lang, "logs_skip")));
|
|
353
|
+
|
|
321
354
|
const { out } = await sendPulse(device);
|
|
322
355
|
if (!out.ok) {
|
|
323
356
|
console.error(failLine(out));
|
|
@@ -325,7 +358,7 @@ async function cmdLogin(opts) {
|
|
|
325
358
|
}
|
|
326
359
|
console.log("\n📡 " + t(lang, "pulse_ok"));
|
|
327
360
|
printAdvice(out.body?.notes);
|
|
328
|
-
if (
|
|
361
|
+
if (daemon) {
|
|
329
362
|
if (cmdInstall(opts, { enable: true })) {
|
|
330
363
|
console.log("\n" + t(lang, "login_done"));
|
|
331
364
|
}
|
|
@@ -521,7 +554,15 @@ function cmdInstall(opts, { enable = false } = {}) {
|
|
|
521
554
|
const srcPath = rec.path || DEVICE_FILE;
|
|
522
555
|
const device = stripDevicePath(rec);
|
|
523
556
|
if (opts.url) device.api = String(opts.url).replace(/\/$/, "");
|
|
524
|
-
if (
|
|
557
|
+
if (opts.logs) applyGrants(device, { logs: true });
|
|
558
|
+
saveDevice(device);
|
|
559
|
+
if (srcPath !== DEVICE_FILE) {
|
|
560
|
+
try {
|
|
561
|
+
fs.writeFileSync(srcPath, JSON.stringify(device, null, 2), { mode: 0o600 });
|
|
562
|
+
} catch {
|
|
563
|
+
/* unreadable system key stays as-is */
|
|
564
|
+
}
|
|
565
|
+
}
|
|
525
566
|
const asSystem = Boolean(opts.system) || process.getuid?.() === 0;
|
|
526
567
|
const interval = intervalSec(opts.interval);
|
|
527
568
|
let deviceFile = DEVICE_FILE;
|
|
@@ -705,7 +746,7 @@ function cmdStatus() {
|
|
|
705
746
|
console.log(`📛 ${device.name}`);
|
|
706
747
|
console.log(`🆔 ${device.id}`);
|
|
707
748
|
console.log(dim(`🔑 ${DEVICE_FILE}`));
|
|
708
|
-
const pulse = collectPulse();
|
|
749
|
+
const pulse = collectPulse({ grants: device.grants });
|
|
709
750
|
console.log(`\n${bold("📡 " + t(lang, "now_on_box"))}`);
|
|
710
751
|
console.log(formatPulsePreview(pulse, lang));
|
|
711
752
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orb44/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Orb44: street snapshot of your site. This CLI is the Watch satellite — pulse from the host (load, listeners, hardening). The cabinet does not run commands on the machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
"server/cli-menu.mjs",
|
|
14
14
|
"server/sat-local.mjs",
|
|
15
15
|
"server/sat-http.mjs",
|
|
16
|
-
"server/sat-update.mjs"
|
|
16
|
+
"server/sat-update.mjs",
|
|
17
|
+
"server/sat-logs.mjs"
|
|
17
18
|
],
|
|
18
19
|
"engines": {
|
|
19
20
|
"node": ">=18"
|
package/server/pulse.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { t } from "./sat-i18n.mjs";
|
|
6
|
+
import { collectRuntime, sanitizeErrors, sanitizeGrants, sanitizeRuntime } from "./sat-logs.mjs";
|
|
5
7
|
|
|
6
8
|
const COMM_RE = /[^a-zA-Z0-9._+-]/g;
|
|
7
9
|
|
|
@@ -54,6 +56,63 @@ function diskUsedPct(root = "/") {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
export function publicIpv4(ip) {
|
|
60
|
+
const s = String(ip || "").replace(/^::ffff:/, "").trim();
|
|
61
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return null;
|
|
62
|
+
const p = s.split(".").map(Number);
|
|
63
|
+
if (p.some((n) => n > 255)) return null;
|
|
64
|
+
const [a, b] = p;
|
|
65
|
+
if (a === 10 || a === 127 || a === 0) return null;
|
|
66
|
+
if (a === 192 && b === 168) return null;
|
|
67
|
+
if (a === 172 && b >= 16 && b <= 31) return null;
|
|
68
|
+
if (a === 169 && b === 254) return null;
|
|
69
|
+
if (a === 100 && b >= 64 && b <= 127) return null;
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pickPingTarget(pulse, seenIp) {
|
|
74
|
+
const ips = [...(pulse?.originA || []), seenIp].map(publicIpv4).filter(Boolean);
|
|
75
|
+
const ip = ips[0] || null;
|
|
76
|
+
if (!ip) return null;
|
|
77
|
+
const world = (pulse?.listen || [])
|
|
78
|
+
.filter((r) => r.addr === "0.0.0.0" || r.addr === "*" || r.addr === "::")
|
|
79
|
+
.map((r) => Number(r.port))
|
|
80
|
+
.filter((p) => p === 443 || p === 80 || p === 22);
|
|
81
|
+
const ports = [...new Set([443, 80, 22, ...world])];
|
|
82
|
+
return { ip, ports };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function tcpRttMs(ip, port, timeoutMs = 700) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const t0 = performance.now();
|
|
88
|
+
const sock = net.connect({ host: ip, port: Number(port), timeout: timeoutMs }, () => {
|
|
89
|
+
const ms = Math.max(1, Math.round(performance.now() - t0));
|
|
90
|
+
sock.destroy();
|
|
91
|
+
resolve(ms);
|
|
92
|
+
});
|
|
93
|
+
const fail = () => {
|
|
94
|
+
sock.destroy();
|
|
95
|
+
resolve(null);
|
|
96
|
+
};
|
|
97
|
+
sock.on("error", fail);
|
|
98
|
+
sock.on("timeout", fail);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function measureHostRtt(pulse, seenIp) {
|
|
103
|
+
const target = pickPingTarget(pulse, seenIp);
|
|
104
|
+
if (!target) return null;
|
|
105
|
+
const hits = (
|
|
106
|
+
await Promise.all(target.ports.slice(0, 3).map(async (port) => {
|
|
107
|
+
const ms = await tcpRttMs(target.ip, port, 650);
|
|
108
|
+
return ms == null ? null : { ms, port, ip: target.ip };
|
|
109
|
+
}))
|
|
110
|
+
).filter(Boolean);
|
|
111
|
+
if (!hits.length) return null;
|
|
112
|
+
hits.sort((a, b) => a.ms - b.ms);
|
|
113
|
+
return hits[0];
|
|
114
|
+
}
|
|
115
|
+
|
|
57
116
|
export function originAddrs(ifaces = os.networkInterfaces()) {
|
|
58
117
|
const out = [];
|
|
59
118
|
for (const rows of Object.values(ifaces || {})) {
|
|
@@ -283,6 +342,9 @@ export function sanitizePulse(raw = {}) {
|
|
|
283
342
|
hardening,
|
|
284
343
|
pressure,
|
|
285
344
|
cliVersion,
|
|
345
|
+
grants: sanitizeGrants(raw.grants),
|
|
346
|
+
runtime: sanitizeRuntime(raw.runtime),
|
|
347
|
+
errors: sanitizeErrors(raw.errors),
|
|
286
348
|
};
|
|
287
349
|
return { ...base, gradeInside: gradeInside(base) };
|
|
288
350
|
}
|
|
@@ -294,7 +356,10 @@ function sanitizeCliVersion(raw) {
|
|
|
294
356
|
return v.slice(0, 32);
|
|
295
357
|
}
|
|
296
358
|
|
|
297
|
-
const ADMIN_PORTS = new Set([
|
|
359
|
+
const ADMIN_PORTS = new Set([
|
|
360
|
+
2019, 2375, 2376, 3306, 5432, 6379, 27017, 9200, 11211, 15672, 8500, 2379, 6443, 9090, 5601, 7474, 7687, 1080, 6432,
|
|
361
|
+
11434, 6333, 19530, 9229, 9222,
|
|
362
|
+
]);
|
|
298
363
|
|
|
299
364
|
const SERVICE_NAME = {
|
|
300
365
|
22: "SSH",
|
|
@@ -310,6 +375,7 @@ const SERVICE_NAME = {
|
|
|
310
375
|
3306: "MySQL",
|
|
311
376
|
5432: "Postgres",
|
|
312
377
|
5601: "Kibana",
|
|
378
|
+
6333: "Qdrant",
|
|
313
379
|
6379: "Redis",
|
|
314
380
|
6432: "PgBouncer",
|
|
315
381
|
6443: "kube-api",
|
|
@@ -319,8 +385,12 @@ const SERVICE_NAME = {
|
|
|
319
385
|
8787: "туннель",
|
|
320
386
|
9090: "Prometheus",
|
|
321
387
|
9200: "Elasticsearch",
|
|
388
|
+
9222: "Chrome CDP",
|
|
389
|
+
9229: "Node inspector",
|
|
322
390
|
11211: "memcached",
|
|
391
|
+
11434: "Ollama",
|
|
323
392
|
15672: "RabbitMQ",
|
|
393
|
+
19530: "Milvus",
|
|
324
394
|
27017: "Mongo",
|
|
325
395
|
};
|
|
326
396
|
|
|
@@ -449,8 +519,37 @@ export function stuckFromTop(top = []) {
|
|
|
449
519
|
}).slice(0, 6);
|
|
450
520
|
}
|
|
451
521
|
|
|
452
|
-
export function
|
|
453
|
-
|
|
522
|
+
export function ramHotFloorMb(memTotal) {
|
|
523
|
+
const n = Number(memTotal);
|
|
524
|
+
if (!Number.isFinite(n) || n <= 0) return 1024;
|
|
525
|
+
const mb = n > 10_000_000 ? n / (1024 * 1024) : n;
|
|
526
|
+
return Math.max(1024, Math.round(mb * 0.3));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function hotFromTop(top = [], memTotal) {
|
|
530
|
+
const floor = ramHotFloorMb(memTotal);
|
|
531
|
+
return (top || []).filter((r) => Number(r.cpuPct) >= 70 || Number(r.rssMb) >= floor).slice(0, 6);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const MINER_COMM = /^(xmrig|minerd|nbminer|t-rex|trex|ethminer|phoenixminer|lolminer|gminer|teamredminer|kdevtmpfsi|kinsing|sysupdate|networkservice)$/i;
|
|
535
|
+
const MINER_HEX = /^[a-f0-9]{8,32}$/i;
|
|
536
|
+
const LEGIT_HOT = /^(php-fpm|php|node|nodejs|java|mysqld|postgres|redis-server|nginx|caddy|apache2|httpd|python|python3|ruby|uwsgi|gunicorn|sidekiq|beanstalkd)$/i;
|
|
537
|
+
|
|
538
|
+
/** Cryptojacking heuristic: known miner names, or near-100% CPU with tiny RSS and non-legit name. */
|
|
539
|
+
export function looksLikeMiner(proc = {}) {
|
|
540
|
+
const comm = String(proc.comm || "").replace(/^.*\//, "").slice(0, 64);
|
|
541
|
+
if (!comm) return false;
|
|
542
|
+
if (MINER_COMM.test(comm)) return true;
|
|
543
|
+
if (LEGIT_HOT.test(comm)) return false;
|
|
544
|
+
if (MINER_HEX.test(comm) && Number(proc.cpuPct) >= 90) return true;
|
|
545
|
+
const cpu = Number(proc.cpuPct);
|
|
546
|
+
const rss = Number(proc.rssMb);
|
|
547
|
+
if (cpu >= 95 && Number.isFinite(rss) && rss > 0 && rss < 200) return true;
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function minersFromTop(top = []) {
|
|
552
|
+
return (top || []).filter((r) => looksLikeMiner(r)).slice(0, 4);
|
|
454
553
|
}
|
|
455
554
|
|
|
456
555
|
export function cpuCount(pulse) {
|
|
@@ -628,9 +727,10 @@ export function gradeInside(pulse = {}) {
|
|
|
628
727
|
if (h.sshPassword && h.sshRoot && h.sshWorld) return "D";
|
|
629
728
|
if (!fw && exposedAdmin.length) return "D";
|
|
630
729
|
if (!fw && !ban) return "D";
|
|
730
|
+
if (minersFromTop(pulse.top).length) return "D";
|
|
631
731
|
if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
|
|
632
732
|
if (!fw || !ban) return "C";
|
|
633
|
-
if (diskHigh || oom || h.rebootNeeded || !h.timesync || loadHigh || connHot || stuck) return "C";
|
|
733
|
+
if (diskHigh || oom || h.rebootNeeded || !h.timesync || loadHigh || connHot || stuck || pulse.failedUnit || (pulse.errors || []).length) return "C";
|
|
634
734
|
if (fw && ban && updates && h.timesync && !h.sshPassword && !h.sshRoot && !exposedAdmin.length && !h.dockerApi) return "A";
|
|
635
735
|
if (fw && ban && !exposedAdmin.length && !h.dockerApi) return "B";
|
|
636
736
|
return "C";
|
|
@@ -809,14 +909,38 @@ export function compareInside(pulse, rec = {}, prev = null) {
|
|
|
809
909
|
do: "Не убивайте с кабинета — его нет. На сервере: ps и диск, не WAF. Orb44 процессы сам не трогает.",
|
|
810
910
|
});
|
|
811
911
|
}
|
|
812
|
-
const
|
|
912
|
+
const miners = minersFromTop(pulse?.top);
|
|
913
|
+
if (miners.length) {
|
|
914
|
+
const who = miners.map((r) => `${r.comm} ${r.cpuPct}%`).join(", ");
|
|
915
|
+
notes.push({
|
|
916
|
+
kind: "crypto-miner",
|
|
917
|
+
title: "Похоже на майнер",
|
|
918
|
+
text: `${who}. Имя из известного списка криптомайнеров или почти 100% CPU при маленьком RSS — типичный признак чужого майнера, не «Перегруз» легитимным java.`,
|
|
919
|
+
do: "На сервере остановите процесс и проверьте, как он появился (crontab, docker, скомпрометированный SSH). Orb44 процессы сам не убивает.",
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
const hot = hotFromTop(pulse?.top, pulse?.memTotal).filter((r) => !looksLikeMiner(r));
|
|
813
923
|
if (hot.length && !loadHigh) {
|
|
814
|
-
const
|
|
924
|
+
const floor = ramHotFloorMb(pulse?.memTotal);
|
|
925
|
+
const ramOnly = hot.every((r) => Number(r.rssMb) >= floor && Number(r.cpuPct) < 70);
|
|
926
|
+
const who = hot
|
|
927
|
+
.map((r) => {
|
|
928
|
+
const ram = Number(r.rssMb) >= floor;
|
|
929
|
+
const cpu = Number(r.cpuPct) >= 70;
|
|
930
|
+
if (ram && !cpu) return `${r.comm} ${r.rssMb} МБ RAM (CPU ${r.cpuPct}%)`;
|
|
931
|
+
if (cpu && !ram) return `${r.comm} CPU ${r.cpuPct}%`;
|
|
932
|
+
return `${r.comm} ${r.cpuPct}%/${r.rssMb}M`;
|
|
933
|
+
})
|
|
934
|
+
.join(", ");
|
|
815
935
|
notes.push({
|
|
816
936
|
kind: "hot-proc",
|
|
817
|
-
title: "Сервис жрёт CPU или RAM",
|
|
818
|
-
text:
|
|
819
|
-
|
|
937
|
+
title: ramOnly ? "Процесс держит много RAM" : "Сервис жрёт CPU или RAM",
|
|
938
|
+
text: ramOnly
|
|
939
|
+
? `${who}. В «Перегруз» попадает процесс из топа от ${floor} МБ RSS (30% RAM, минимум 1 ГБ). CPU тут ни при чём: load может быть спокойным.`
|
|
940
|
+
: `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
|
|
941
|
+
do: ramOnly
|
|
942
|
+
? "Это не атака и не 100% процессора. Смотрите, что за процесс и сколько ему реально нужно. Orb44 его не рестартит и память не ограничивает."
|
|
943
|
+
: "Смотрите этот процесс (php-fpm, node, mysql). Orb44 его не рестартит.",
|
|
820
944
|
});
|
|
821
945
|
}
|
|
822
946
|
if (Number(pr.conntrackPct) >= 80) {
|
|
@@ -886,6 +1010,27 @@ export function compareInside(pulse, rec = {}, prev = null) {
|
|
|
886
1010
|
});
|
|
887
1011
|
}
|
|
888
1012
|
}
|
|
1013
|
+
if (pulse?.failedUnit) {
|
|
1014
|
+
notes.push({
|
|
1015
|
+
kind: "failed-unit",
|
|
1016
|
+
title: "Упал systemd-юнит",
|
|
1017
|
+
text: `${pulse.failedUnit}. Имя из systemctl --failed — не стек.`,
|
|
1018
|
+
do: "Откройте этот юнит на машине (systemctl status). Orb44 контейнеры не перезапускает.",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
const errs = pulse?.errors || [];
|
|
1022
|
+
if (errs.length) {
|
|
1023
|
+
const line = errs
|
|
1024
|
+
.slice(0, 3)
|
|
1025
|
+
.map((e) => `${e.source} ${e.name}: ${e.text}`)
|
|
1026
|
+
.join(" · ");
|
|
1027
|
+
notes.push({
|
|
1028
|
+
kind: "runtime-error",
|
|
1029
|
+
title: "Ошибки на машине",
|
|
1030
|
+
text: line,
|
|
1031
|
+
do: "Это хвост журнала / docker / kubectl / nginx, который вы разрешили при login. Orb44 ничего не чинит.",
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
889
1034
|
return notes;
|
|
890
1035
|
}
|
|
891
1036
|
|
|
@@ -954,6 +1099,28 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
|
|
|
954
1099
|
});
|
|
955
1100
|
}
|
|
956
1101
|
}
|
|
1102
|
+
if (pulse?.failedUnit) {
|
|
1103
|
+
notes.push({
|
|
1104
|
+
kind: "failed-unit",
|
|
1105
|
+
title: "Упал systemd-юнит",
|
|
1106
|
+
text: `${pulse.failedUnit}. Это имя из systemctl --failed, не стек.`,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
const errNow = (pulse?.errors || []).map((e) => `${e.source}:${e.name}`);
|
|
1110
|
+
if (errNow.length) {
|
|
1111
|
+
const prevErr = new Set((prev?.errors || []).map((e) => `${e.source}:${e.name}`));
|
|
1112
|
+
const fresh = errNow.filter((k) => !prevErr.has(k));
|
|
1113
|
+
if (!prev || fresh.length) {
|
|
1114
|
+
notes.push({
|
|
1115
|
+
kind: "runtime-error",
|
|
1116
|
+
title: "Новые ошибки runtime",
|
|
1117
|
+
text: (pulse.errors || [])
|
|
1118
|
+
.slice(0, 3)
|
|
1119
|
+
.map((e) => `${e.source} ${e.name}: ${e.text}`)
|
|
1120
|
+
.join(" · "),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
957
1124
|
if (stuckFromTop(pulse?.top).length) {
|
|
958
1125
|
notes.push({
|
|
959
1126
|
kind: "stuck-proc",
|
|
@@ -1005,13 +1172,38 @@ export function insideAlertDecision(row, reasons, now = Date.now(), debounceMs =
|
|
|
1005
1172
|
return { emit: true, key };
|
|
1006
1173
|
}
|
|
1007
1174
|
|
|
1008
|
-
export function
|
|
1175
|
+
export function parseFailedUnits(text) {
|
|
1176
|
+
const out = [];
|
|
1177
|
+
for (const line of String(text || "").split("\n")) {
|
|
1178
|
+
const hit = line.match(/\b([a-zA-Z0-9@._-]+\.(service|socket|mount|timer))\b/);
|
|
1179
|
+
if (hit && !out.includes(hit[1])) out.push(hit[1].slice(0, 60));
|
|
1180
|
+
if (out.length >= 6) break;
|
|
1181
|
+
}
|
|
1182
|
+
return out;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function collectFailedUnits() {
|
|
1186
|
+
try {
|
|
1187
|
+
const out = execFileSync("systemctl", ["--failed", "--no-legend", "--no-pager", "--plain"], {
|
|
1188
|
+
encoding: "utf8",
|
|
1189
|
+
timeout: 1500,
|
|
1190
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1191
|
+
});
|
|
1192
|
+
return parseFailedUnits(out);
|
|
1193
|
+
} catch {
|
|
1194
|
+
return [];
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
export function collectPulse(opts = {}) {
|
|
1009
1199
|
const { memUsed, memTotal } = memInfo();
|
|
1010
1200
|
const { listen, named } = listenTable();
|
|
1011
1201
|
const top = topTable();
|
|
1012
1202
|
const hardening = collectHardening(listen);
|
|
1013
1203
|
const limited = listen.length > 0 && !named;
|
|
1014
1204
|
const pressure = collectPressure();
|
|
1205
|
+
const grants = sanitizeGrants(opts.grants);
|
|
1206
|
+
const extra = collectRuntime({ listen, top, grants });
|
|
1015
1207
|
return sanitizePulse({
|
|
1016
1208
|
ts: Date.now(),
|
|
1017
1209
|
hostname: os.hostname(),
|
|
@@ -1022,11 +1214,14 @@ export function collectPulse() {
|
|
|
1022
1214
|
top,
|
|
1023
1215
|
listen,
|
|
1024
1216
|
originA: originAddrs(),
|
|
1025
|
-
failedUnit: null,
|
|
1217
|
+
failedUnit: collectFailedUnits().join(", ") || null,
|
|
1026
1218
|
oom: Number(hardening.oomKills) > 0 || Number(pressure.cgroupOom) > 0,
|
|
1027
1219
|
limited,
|
|
1028
1220
|
hardening,
|
|
1029
1221
|
pressure,
|
|
1222
|
+
grants,
|
|
1223
|
+
runtime: extra.runtime,
|
|
1224
|
+
errors: extra.errors,
|
|
1030
1225
|
});
|
|
1031
1226
|
}
|
|
1032
1227
|
|
|
@@ -1102,6 +1297,14 @@ export function formatPulsePreview(pulse, lang = "en") {
|
|
|
1102
1297
|
const pr = pulse.pressure || {};
|
|
1103
1298
|
if (pr.conntrackPct != null) extraBits.push(`conntrack ${pr.conntrackPct}%`);
|
|
1104
1299
|
if (pr.cpus) extraBits.push(`${pr.cpus} CPU`);
|
|
1300
|
+
if (pulse.grants?.logs) extraBits.push("logs");
|
|
1301
|
+
if (pulse.grants?.daemon) extraBits.push("daemon");
|
|
1302
|
+
const rt = pulse.runtime || {};
|
|
1303
|
+
for (const k of ["docker", "kube", "nginx"]) {
|
|
1304
|
+
if (rt[k] === "ok") extraBits.push(k);
|
|
1305
|
+
if (rt[k] === "denied") extraBits.push(`${k} denied`);
|
|
1306
|
+
}
|
|
1307
|
+
if ((pulse.errors || []).length) extraBits.push(`errors ${(pulse.errors || []).length}`);
|
|
1105
1308
|
const extra = extraBits.length ? `\n${extraBits.join(" · ")}` : "";
|
|
1106
1309
|
return `${machine}\n\n${listen}\n\n${top}\n\n${guard}${extra}`;
|
|
1107
1310
|
}
|
package/server/sat-i18n.mjs
CHANGED
|
@@ -44,6 +44,11 @@ const STR = {
|
|
|
44
44
|
daemon_no: "No",
|
|
45
45
|
daemon_yes: "Yes — background, starts after reboot",
|
|
46
46
|
daemon_skip: "Daemon not installed. Later: orb44 install or login --daemon",
|
|
47
|
+
ask_logs: "Collect last errors from journal, Docker, Kubernetes, nginx?\nLast lines only, secrets stripped. Needs read access — Orb44 will not sudo.",
|
|
48
|
+
logs_no: "No — processes and listeners only",
|
|
49
|
+
logs_yes: "Yes — last errors if readable",
|
|
50
|
+
logs_skip: "Log monitoring off. Later: orb44 login --logs or orb44 install --logs",
|
|
51
|
+
logs_on: "Log monitoring on. Docker/kube/nginx only if this user can read them.",
|
|
47
52
|
need_login: "First: orb44 login",
|
|
48
53
|
key_revoked: "Key revoked. Again: orb44 login",
|
|
49
54
|
daemon_run: "daemon every {sec}s · {host}",
|
|
@@ -113,17 +118,17 @@ const STR = {
|
|
|
113
118
|
preview_limited: "limited: cannot see which process owns the ports",
|
|
114
119
|
help: `Orb44 satellite — admin device.
|
|
115
120
|
|
|
116
|
-
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
|
|
121
|
+
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
117
122
|
orb44 pulse
|
|
118
123
|
orb44 daemon [--interval 300]
|
|
119
|
-
orb44 install [--system] [--interval 300]
|
|
124
|
+
orb44 install [--system] [--logs] [--interval 300]
|
|
120
125
|
orb44 uninstall [--purge]
|
|
121
126
|
orb44 lang [en|ru|ko|es]
|
|
122
127
|
orb44 status
|
|
123
128
|
orb44 update
|
|
124
129
|
orb44 logout
|
|
125
130
|
|
|
126
|
-
Login asks language (saved) then daemon (default no).
|
|
131
|
+
Login asks language (saved), then daemon and logs (default no).
|
|
127
132
|
Key: {file}
|
|
128
133
|
Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
129
134
|
},
|
|
@@ -152,6 +157,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
152
157
|
daemon_no: "Нет",
|
|
153
158
|
daemon_yes: "Да — в фоне и после перезагрузки",
|
|
154
159
|
daemon_skip: "Демон не ставили. Позже: orb44 install или login --daemon",
|
|
160
|
+
ask_logs: "Снимать последние ошибки journal, Docker, Kubernetes, nginx?\nТолько хвост, секреты вырезаем. Нужен доступ на чтение — sudo сателлит не берёт.",
|
|
161
|
+
logs_no: "Нет — только процессы и слушатели",
|
|
162
|
+
logs_yes: "Да — последние ошибки, если читаются",
|
|
163
|
+
logs_skip: "Логи не снимаем. Позже: orb44 login --logs или orb44 install --logs",
|
|
164
|
+
logs_on: "Логи включены. Docker/kube/nginx — только если этот пользователь их видит.",
|
|
155
165
|
need_login: "Сначала: orb44 login",
|
|
156
166
|
key_revoked: "Ключ отозван. Снова: orb44 login",
|
|
157
167
|
daemon_run: "демон каждые {sec}с · {host}",
|
|
@@ -221,17 +231,17 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
221
231
|
preview_limited: "ограничено: не видно, какой процесс слушает порты",
|
|
222
232
|
help: `Orb44 сателлит — устройство админа.
|
|
223
233
|
|
|
224
|
-
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
|
|
234
|
+
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
225
235
|
orb44 pulse
|
|
226
236
|
orb44 daemon [--interval 300]
|
|
227
|
-
orb44 install [--system] [--interval 300]
|
|
237
|
+
orb44 install [--system] [--logs] [--interval 300]
|
|
228
238
|
orb44 uninstall [--purge]
|
|
229
239
|
orb44 lang [en|ru|ko|es]
|
|
230
240
|
orb44 status
|
|
231
241
|
orb44 update
|
|
232
242
|
orb44 logout
|
|
233
243
|
|
|
234
|
-
После login спрашивает язык (запоминает), затем демон — по умолчанию нет.
|
|
244
|
+
После login спрашивает язык (запоминает), затем демон и логи — по умолчанию нет.
|
|
235
245
|
Ключ: {file}
|
|
236
246
|
Пульс исходящий. Кабинет не выполняет команды: отвечает текстом «что сделать».`,
|
|
237
247
|
},
|
|
@@ -260,6 +270,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
260
270
|
daemon_no: "아니요",
|
|
261
271
|
daemon_yes: "예 — 백그라운드, 재부팅 후 자동",
|
|
262
272
|
daemon_skip: "데몬을 설치하지 않았습니다. 나중에: orb44 install 또는 login --daemon",
|
|
273
|
+
ask_logs: "journal / Docker / Kubernetes / nginx 마지막 오류를 보낼까요?\n마지막 줄만, 비밀은 지웁니다. 읽기 권한이 필요합니다 — sudo 하지 않습니다.",
|
|
274
|
+
logs_no: "아니요 — 프로세스와 리스너만",
|
|
275
|
+
logs_yes: "예 — 읽을 수 있으면 마지막 오류",
|
|
276
|
+
logs_skip: "로그 수집 안 함. 나중에: orb44 login --logs 또는 orb44 install --logs",
|
|
277
|
+
logs_on: "로그 수집 켜짐. Docker/kube/nginx는 이 사용자가 읽을 수 있을 때만.",
|
|
263
278
|
need_login: "먼저: orb44 login",
|
|
264
279
|
key_revoked: "키가 취소되었습니다. 다시: orb44 login",
|
|
265
280
|
daemon_run: "데몬 {sec}초마다 · {host}",
|
|
@@ -329,17 +344,17 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
329
344
|
preview_limited: "제한: 포트를 연 프로세스를 볼 수 없음",
|
|
330
345
|
help: `Orb44 위성 — 관리자 장치.
|
|
331
346
|
|
|
332
|
-
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
|
|
347
|
+
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
333
348
|
orb44 pulse
|
|
334
349
|
orb44 daemon [--interval 300]
|
|
335
|
-
orb44 install [--system] [--interval 300]
|
|
350
|
+
orb44 install [--system] [--logs] [--interval 300]
|
|
336
351
|
orb44 uninstall [--purge]
|
|
337
352
|
orb44 lang [en|ru|ko|es]
|
|
338
353
|
orb44 status
|
|
339
354
|
orb44 update
|
|
340
355
|
orb44 logout
|
|
341
356
|
|
|
342
|
-
login에서 언어를 묻고 저장한 뒤,
|
|
357
|
+
login에서 언어를 묻고 저장한 뒤, 데몬과 로그는 기본값 아니오입니다.
|
|
343
358
|
키: {file}
|
|
344
359
|
나가는 펄스. 콘솔은 명령을 실행하지 않고 조언만 돌려줍니다.`,
|
|
345
360
|
},
|
|
@@ -368,6 +383,11 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
|
|
|
368
383
|
daemon_no: "No",
|
|
369
384
|
daemon_yes: "Sí — segundo plano y tras el reinicio",
|
|
370
385
|
daemon_skip: "Demonio no instalado. Luego: orb44 install o login --daemon",
|
|
386
|
+
ask_logs: "¿Enviar los últimos errores de journal, Docker, Kubernetes, nginx?\nSolo las últimas líneas, sin secretos. Hace falta lectura — Orb44 no usa sudo.",
|
|
387
|
+
logs_no: "No — solo procesos y listeners",
|
|
388
|
+
logs_yes: "Sí — últimos errores si se pueden leer",
|
|
389
|
+
logs_skip: "Sin logs. Luego: orb44 login --logs o orb44 install --logs",
|
|
390
|
+
logs_on: "Logs activos. Docker/kube/nginx solo si este usuario los ve.",
|
|
371
391
|
need_login: "Primero: orb44 login",
|
|
372
392
|
key_revoked: "Clave revocada. Otra vez: orb44 login",
|
|
373
393
|
daemon_run: "demonio cada {sec}s · {host}",
|
|
@@ -437,17 +457,17 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
|
|
|
437
457
|
preview_limited: "limitado: no se ve qué proceso tiene los puertos",
|
|
438
458
|
help: `Satélite Orb44 — dispositivo de admin.
|
|
439
459
|
|
|
440
|
-
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
|
|
460
|
+
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
441
461
|
orb44 pulse
|
|
442
462
|
orb44 daemon [--interval 300]
|
|
443
|
-
orb44 install [--system] [--interval 300]
|
|
463
|
+
orb44 install [--system] [--logs] [--interval 300]
|
|
444
464
|
orb44 uninstall [--purge]
|
|
445
465
|
orb44 lang [en|ru|ko|es]
|
|
446
466
|
orb44 status
|
|
447
467
|
orb44 update
|
|
448
468
|
orb44 logout
|
|
449
469
|
|
|
450
|
-
Login pregunta idioma (lo guarda)
|
|
470
|
+
Login pregunta idioma (lo guarda), luego demonio y logs (por defecto no).
|
|
451
471
|
Clave: {file}
|
|
452
472
|
Pulso saliente. El gabinete no ejecuta órdenes; responde con consejos.`,
|
|
453
473
|
},
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const SECRET_RE = /(password|passwd|secret|api[_-]?key|authorization|bearer|private[_-]?key|token)\s*[:=]/i;
|
|
5
|
+
const RUNTIME_STATES = new Set(["ok", "absent", "denied"]);
|
|
6
|
+
const ERROR_SOURCES = new Set(["journal", "docker", "kube", "nginx", "unit"]);
|
|
7
|
+
|
|
8
|
+
export function sanitizeGrants(raw = {}) {
|
|
9
|
+
return {
|
|
10
|
+
process: true,
|
|
11
|
+
daemon: Boolean(raw?.daemon),
|
|
12
|
+
logs: Boolean(raw?.logs),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function oneState(v) {
|
|
17
|
+
return RUNTIME_STATES.has(v) ? v : "absent";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sanitizeRuntime(raw = {}) {
|
|
21
|
+
return {
|
|
22
|
+
daemon: oneState(raw.daemon),
|
|
23
|
+
journal: oneState(raw.journal),
|
|
24
|
+
docker: oneState(raw.docker),
|
|
25
|
+
kube: oneState(raw.kube),
|
|
26
|
+
nginx: oneState(raw.nginx),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function redactLogLine(line) {
|
|
31
|
+
let s = String(line || "").replace(/\s+/g, " ").trim();
|
|
32
|
+
if (!s) return "";
|
|
33
|
+
if (SECRET_RE.test(s) || /-----BEGIN/.test(s)) return "[redacted]";
|
|
34
|
+
if (s.length > 160) s = `${s.slice(0, 159)}…`;
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sanitizeErrors(list) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
for (const e of Array.isArray(list) ? list : []) {
|
|
42
|
+
const source = ERROR_SOURCES.has(e?.source) ? e.source : null;
|
|
43
|
+
const text = redactLogLine(e?.text);
|
|
44
|
+
const name = String(e?.name || "")
|
|
45
|
+
.replace(/[^\w./:@-]/g, "")
|
|
46
|
+
.slice(0, 48);
|
|
47
|
+
if (!source || !text || text === "[redacted]") continue;
|
|
48
|
+
const key = `${source}:${name}:${text}`;
|
|
49
|
+
if (seen.has(key)) continue;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
out.push({
|
|
52
|
+
source,
|
|
53
|
+
name: name || source,
|
|
54
|
+
level: e.level === "warn" ? "warn" : "error",
|
|
55
|
+
text,
|
|
56
|
+
});
|
|
57
|
+
if (out.length >= 8) break;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function inferRuntime(listen = [], top = []) {
|
|
63
|
+
const names = [...listen, ...top].map((r) => String(r.comm || "").toLowerCase());
|
|
64
|
+
const ports = listen.map((r) => Number(r.port));
|
|
65
|
+
const has = (re) => names.some((n) => re.test(n));
|
|
66
|
+
return {
|
|
67
|
+
docker: has(/^(dockerd|containerd|docker-proxy)/) || ports.includes(2375) || ports.includes(2376) ? "ok" : "absent",
|
|
68
|
+
kube: has(/^(kubelet|kube-apiserver|k3s|k0s)$/) || ports.includes(6443) || ports.includes(10250) ? "ok" : "absent",
|
|
69
|
+
nginx: has(/^(nginx|httpd|apache2|caddy|openresty)$/) ? "ok" : "absent",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseDockerPs(text) {
|
|
74
|
+
const bad = [];
|
|
75
|
+
for (const line of String(text || "").split("\n")) {
|
|
76
|
+
const tab = line.indexOf("\t");
|
|
77
|
+
const name = (tab >= 0 ? line.slice(0, tab) : line).trim();
|
|
78
|
+
const status = (tab >= 0 ? line.slice(tab + 1) : "").trim();
|
|
79
|
+
if (!name) continue;
|
|
80
|
+
if (/restarting|unhealthy|dead|exited \((?!0\))|oom/i.test(status)) {
|
|
81
|
+
bad.push({ name, status });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return bad;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseKubePods(text) {
|
|
88
|
+
const bad = [];
|
|
89
|
+
for (const line of String(text || "").split("\n")) {
|
|
90
|
+
const parts = line.trim().split(/\s+/);
|
|
91
|
+
if (parts.length < 4) continue;
|
|
92
|
+
const [ns, name, ready, status] = parts;
|
|
93
|
+
if (/^(NAMESPACE|NAME)$/i.test(ns)) continue;
|
|
94
|
+
const notReady = /^0\/[1-9]/.test(ready);
|
|
95
|
+
if (/CrashLoop|Error|ImagePull|BackOff|OOMKilled|Evicted|Unknown|Failed|Terminating/i.test(status) || notReady) {
|
|
96
|
+
bad.push({ name: `${ns}/${name}`, text: status });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return bad.slice(0, 8);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseNginxErrorLog(text) {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const line of String(text || "").split("\n").reverse()) {
|
|
105
|
+
if (!/\[(error|crit|alert|emerg)\]|emerg|fatal/i.test(line)) continue;
|
|
106
|
+
const t = redactLogLine(line);
|
|
107
|
+
if (t && t !== "[redacted]") out.push(t);
|
|
108
|
+
if (out.length >= 4) break;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parseJournalErrors(text) {
|
|
114
|
+
return String(text || "")
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map(redactLogLine)
|
|
117
|
+
.filter((l) => l && l !== "[redacted]" && !/orb44/i.test(l))
|
|
118
|
+
.slice(0, 6);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function tryCmd(bin, args, timeout = 1600) {
|
|
122
|
+
try {
|
|
123
|
+
const out = execFileSync(bin, args, {
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
timeout,
|
|
126
|
+
maxBuffer: 48 * 1024,
|
|
127
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
128
|
+
});
|
|
129
|
+
return { ok: true, text: String(out || "") };
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e?.code === "ENOENT") return { absent: true };
|
|
132
|
+
const msg = `${e?.stderr || ""} ${e?.message || ""} ${e?.stdout || ""}`;
|
|
133
|
+
if (/permission denied|access denied|cannot connect|connect: permission|forbidden|unauthorized|dial unix|Got permission/i.test(msg)) {
|
|
134
|
+
return { denied: true };
|
|
135
|
+
}
|
|
136
|
+
if (/localhost:8080 was refused|no configuration|no such file|The connection to the server/i.test(msg)) {
|
|
137
|
+
return { absent: true };
|
|
138
|
+
}
|
|
139
|
+
if (e?.stdout) return { ok: true, text: String(e.stdout) };
|
|
140
|
+
return { fail: true };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function probeDaemon() {
|
|
145
|
+
const sys = tryCmd("systemctl", ["is-active", "orb44-satellite"]);
|
|
146
|
+
if (sys.ok && String(sys.text).trim() === "active") return "ok";
|
|
147
|
+
const user = tryCmd("systemctl", ["--user", "is-active", "orb44-satellite"]);
|
|
148
|
+
if (user.ok && String(user.text).trim() === "active") return "ok";
|
|
149
|
+
return "absent";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectJournal() {
|
|
153
|
+
const r = tryCmd("journalctl", ["-p", "err", "-n", "12", "--no-pager", "--output=cat"]);
|
|
154
|
+
if (r.absent) return { state: "absent", errors: [] };
|
|
155
|
+
if (r.denied) return { state: "denied", errors: [] };
|
|
156
|
+
if (!r.ok) return { state: "absent", errors: [] };
|
|
157
|
+
return {
|
|
158
|
+
state: "ok",
|
|
159
|
+
errors: parseJournalErrors(r.text).map((text) => ({ source: "journal", name: "journal", text })),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function collectDocker() {
|
|
164
|
+
const sock = "/var/run/docker.sock";
|
|
165
|
+
const sockHere = fs.existsSync(sock);
|
|
166
|
+
const ps = tryCmd("docker", ["ps", "-a", "--format", "{{.Names}}\t{{.Status}}"]);
|
|
167
|
+
if (ps.absent && !sockHere) return { state: "absent", errors: [] };
|
|
168
|
+
if (ps.denied || (ps.absent && sockHere)) return { state: "denied", errors: [] };
|
|
169
|
+
if (!ps.ok) return { state: sockHere ? "denied" : "absent", errors: [] };
|
|
170
|
+
const errors = [];
|
|
171
|
+
for (const c of parseDockerPs(ps.text).slice(0, 4)) {
|
|
172
|
+
const logs = tryCmd("docker", ["logs", "--tail", "10", "--since", "20m", c.name], 1800);
|
|
173
|
+
const lines = String(logs.text || "")
|
|
174
|
+
.split("\n")
|
|
175
|
+
.map(redactLogLine)
|
|
176
|
+
.filter((l) => l && l !== "[redacted]" && /error|fatal|panic|oom|emerg/i.test(l));
|
|
177
|
+
errors.push({
|
|
178
|
+
source: "docker",
|
|
179
|
+
name: c.name,
|
|
180
|
+
text: lines[0] || c.status,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return { state: "ok", errors };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function collectKube() {
|
|
187
|
+
const pods = tryCmd("kubectl", ["get", "pods", "-A", "--no-headers"], 2000);
|
|
188
|
+
if (pods.absent) return { state: "absent", errors: [] };
|
|
189
|
+
if (pods.denied) return { state: "denied", errors: [] };
|
|
190
|
+
if (!pods.ok) return { state: "absent", errors: [] };
|
|
191
|
+
return {
|
|
192
|
+
state: "ok",
|
|
193
|
+
errors: parseKubePods(pods.text).map((p) => ({ source: "kube", name: p.name, text: p.text })),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const WEB_LOGS = ["/var/log/nginx/error.log", "/var/log/httpd/error_log", "/var/log/apache2/error.log", "/var/log/caddy/error.log"];
|
|
198
|
+
|
|
199
|
+
function tailFile(file, max = 8192) {
|
|
200
|
+
const st = fs.statSync(file);
|
|
201
|
+
const fd = fs.openSync(file, "r");
|
|
202
|
+
try {
|
|
203
|
+
const buf = Buffer.alloc(Math.min(max, st.size));
|
|
204
|
+
fs.readSync(fd, buf, 0, buf.length, Math.max(0, st.size - max));
|
|
205
|
+
return buf.toString("utf8");
|
|
206
|
+
} finally {
|
|
207
|
+
fs.closeSync(fd);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function collectNginx() {
|
|
212
|
+
let denied = false;
|
|
213
|
+
let saw = false;
|
|
214
|
+
const errors = [];
|
|
215
|
+
for (const file of WEB_LOGS) {
|
|
216
|
+
if (!fs.existsSync(file)) continue;
|
|
217
|
+
saw = true;
|
|
218
|
+
try {
|
|
219
|
+
const lines = parseNginxErrorLog(tailFile(file));
|
|
220
|
+
for (const text of lines) errors.push({ source: "nginx", name: file.split("/").slice(-2).join("/"), text });
|
|
221
|
+
} catch {
|
|
222
|
+
denied = true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (errors.length) return { state: "ok", errors };
|
|
226
|
+
if (denied) return { state: "denied", errors: [] };
|
|
227
|
+
if (saw) return { state: "ok", errors: [] };
|
|
228
|
+
return { state: "absent", errors: [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function pickState(probed, inferred) {
|
|
232
|
+
if (probed && probed !== "absent") return probed;
|
|
233
|
+
return inferred || "absent";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function collectRuntime({ listen = [], top = [], grants } = {}) {
|
|
237
|
+
const g = sanitizeGrants(grants);
|
|
238
|
+
const inferred = inferRuntime(listen, top);
|
|
239
|
+
const daemon = probeDaemon();
|
|
240
|
+
if (!g.logs) {
|
|
241
|
+
return {
|
|
242
|
+
runtime: sanitizeRuntime({ ...inferred, daemon, journal: "absent" }),
|
|
243
|
+
errors: [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const journal = collectJournal();
|
|
247
|
+
const docker = collectDocker();
|
|
248
|
+
const kube = collectKube();
|
|
249
|
+
const nginx = collectNginx();
|
|
250
|
+
return {
|
|
251
|
+
runtime: sanitizeRuntime({
|
|
252
|
+
daemon,
|
|
253
|
+
journal: journal.state,
|
|
254
|
+
docker: pickState(docker.state, inferred.docker),
|
|
255
|
+
kube: pickState(kube.state, inferred.kube),
|
|
256
|
+
nginx: pickState(nginx.state, inferred.nginx),
|
|
257
|
+
}),
|
|
258
|
+
errors: sanitizeErrors([...journal.errors, ...docker.errors, ...kube.errors, ...nginx.errors]),
|
|
259
|
+
};
|
|
260
|
+
}
|