@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 +4 -4
- package/bin/orb44.mjs +45 -4
- package/package.json +3 -2
- package/server/pulse.mjs +86 -3
- 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
|
@@ -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
|
|
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
|
}
|
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
|
+
}
|