@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 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.8 status
17
- npx @orb44/cli@0.1.8 pulse
18
- npx @orb44/cli@0.1.8 install # later: background pulse, starts after reboot
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.8 logout
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 (await wantDaemon(opts)) {
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 (srcPath !== DEVICE_FILE) saveDevice(device);
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.8",
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([2019, 2375, 2376, 3306, 5432, 6379, 27017, 9200, 11211, 15672, 8500, 2379, 6443, 9090, 5601, 7474, 7687, 1080, 6432]);
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 hotFromTop(top = []) {
453
- return (top || []).filter((r) => Number(r.cpuPct) >= 70 || Number(r.rssMb) >= 1024).slice(0, 6);
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 hot = hotFromTop(pulse?.top);
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 who = hot.map((r) => `${r.comm} ${r.cpuPct}%/${r.rssMb}M`).join(", ");
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: `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
819
- do: "Смотрите этот процесс (php-fpm, node, mysql). Orb44 его не рестартит.",
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 collectPulse() {
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
  }
@@ -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) y luego demonio (por defecto no).
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
+ }
@@ -11,6 +11,7 @@ export const SAT_TREE_FILES = [
11
11
  "server/sat-local.mjs",
12
12
  "server/sat-http.mjs",
13
13
  "server/sat-update.mjs",
14
+ "server/sat-logs.mjs",
14
15
  "package.json",
15
16
  ];
16
17