@orb44/cli 0.1.9 → 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.9 status
17
- npx @orb44/cli@0.1.9 pulse
18
- npx @orb44/cli@0.1.9 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.9 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.9",
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
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import net from "node:net";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { t } from "./sat-i18n.mjs";
6
+ import { collectRuntime, sanitizeErrors, sanitizeGrants, sanitizeRuntime } from "./sat-logs.mjs";
6
7
 
7
8
  const COMM_RE = /[^a-zA-Z0-9._+-]/g;
8
9
 
@@ -341,6 +342,9 @@ export function sanitizePulse(raw = {}) {
341
342
  hardening,
342
343
  pressure,
343
344
  cliVersion,
345
+ grants: sanitizeGrants(raw.grants),
346
+ runtime: sanitizeRuntime(raw.runtime),
347
+ errors: sanitizeErrors(raw.errors),
344
348
  };
345
349
  return { ...base, gradeInside: gradeInside(base) };
346
350
  }
@@ -726,7 +730,7 @@ export function gradeInside(pulse = {}) {
726
730
  if (minersFromTop(pulse.top).length) return "D";
727
731
  if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
728
732
  if (!fw || !ban) return "C";
729
- 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";
730
734
  if (fw && ban && updates && h.timesync && !h.sshPassword && !h.sshRoot && !exposedAdmin.length && !h.dockerApi) return "A";
731
735
  if (fw && ban && !exposedAdmin.length && !h.dockerApi) return "B";
732
736
  return "C";
@@ -1006,6 +1010,27 @@ export function compareInside(pulse, rec = {}, prev = null) {
1006
1010
  });
1007
1011
  }
1008
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
+ }
1009
1034
  return notes;
1010
1035
  }
1011
1036
 
@@ -1074,6 +1099,28 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
1074
1099
  });
1075
1100
  }
1076
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
+ }
1077
1124
  if (stuckFromTop(pulse?.top).length) {
1078
1125
  notes.push({
1079
1126
  kind: "stuck-proc",
@@ -1125,13 +1172,38 @@ export function insideAlertDecision(row, reasons, now = Date.now(), debounceMs =
1125
1172
  return { emit: true, key };
1126
1173
  }
1127
1174
 
1128
- 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 = {}) {
1129
1199
  const { memUsed, memTotal } = memInfo();
1130
1200
  const { listen, named } = listenTable();
1131
1201
  const top = topTable();
1132
1202
  const hardening = collectHardening(listen);
1133
1203
  const limited = listen.length > 0 && !named;
1134
1204
  const pressure = collectPressure();
1205
+ const grants = sanitizeGrants(opts.grants);
1206
+ const extra = collectRuntime({ listen, top, grants });
1135
1207
  return sanitizePulse({
1136
1208
  ts: Date.now(),
1137
1209
  hostname: os.hostname(),
@@ -1142,11 +1214,14 @@ export function collectPulse() {
1142
1214
  top,
1143
1215
  listen,
1144
1216
  originA: originAddrs(),
1145
- failedUnit: null,
1217
+ failedUnit: collectFailedUnits().join(", ") || null,
1146
1218
  oom: Number(hardening.oomKills) > 0 || Number(pressure.cgroupOom) > 0,
1147
1219
  limited,
1148
1220
  hardening,
1149
1221
  pressure,
1222
+ grants,
1223
+ runtime: extra.runtime,
1224
+ errors: extra.errors,
1150
1225
  });
1151
1226
  }
1152
1227
 
@@ -1222,6 +1297,14 @@ export function formatPulsePreview(pulse, lang = "en") {
1222
1297
  const pr = pulse.pressure || {};
1223
1298
  if (pr.conntrackPct != null) extraBits.push(`conntrack ${pr.conntrackPct}%`);
1224
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}`);
1225
1308
  const extra = extraBits.length ? `\n${extraBits.join(" · ")}` : "";
1226
1309
  return `${machine}\n\n${listen}\n\n${top}\n\n${guard}${extra}`;
1227
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