@orb44/cli 0.1.10 → 0.1.12
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 +114 -23
- package/package.json +1 -1
- package/server/pulse.mjs +94 -33
- package/server/sat-i18n.mjs +56 -8
- package/server/sat-update.mjs +53 -5
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.12 status
|
|
17
|
+
npx @orb44/cli@0.1.12 pulse
|
|
18
|
+
npx @orb44/cli@0.1.12 install # asks daemon (default yes) and logs (default no)
|
|
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.12 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
|
@@ -27,6 +27,7 @@ function args() {
|
|
|
27
27
|
else if (a === "--logs") out.logs = true;
|
|
28
28
|
else if (a === "--force") out.force = true;
|
|
29
29
|
else if (a === "--purge") out.purge = true;
|
|
30
|
+
else if (a === "--version" || a === "-V" || a === "-v") out.version = true;
|
|
30
31
|
else if (a === "--url" || a === "--code" || a === "--name" || a === "--interval" || a === "--lang") {
|
|
31
32
|
out[a.slice(2)] = argv[++i];
|
|
32
33
|
} else if (!a.startsWith("-")) out._.push(a);
|
|
@@ -105,8 +106,43 @@ function quote(p) {
|
|
|
105
106
|
return /[\s"$]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
function localCliVersion() {
|
|
110
|
+
const ver = readCliVersion(SCRIPT);
|
|
111
|
+
return ver && ver !== "0.0.0" ? ver : "—";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function latestCliVersion() {
|
|
115
|
+
try {
|
|
116
|
+
const ac = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => ac.abort(), 4000);
|
|
118
|
+
const meta = await fetchLatestMeta((url, init) => fetch(url, { ...init, signal: ac.signal }));
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
return meta.version;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function printVersions(current, latest) {
|
|
127
|
+
const here = current && current !== "0.0.0" ? current : localCliVersion();
|
|
128
|
+
console.log(dim(`${t(lang, "ver_here")}: ${here}`));
|
|
129
|
+
if (!latest) {
|
|
130
|
+
console.log(dim(`${t(lang, "ver_latest")}: ${t(lang, "ver_unknown")}`));
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
const behind = here !== "—" && cmpVer(here, latest) < 0;
|
|
134
|
+
const same = here !== "—" && cmpVer(here, latest) === 0;
|
|
135
|
+
console.log(dim(`${t(lang, "ver_latest")}: ${latest}${same ? " " + t(lang, "ver_ok") : ""}`));
|
|
136
|
+
if (behind) console.log(dim(t(lang, "ver_behind", { ver: latest })));
|
|
137
|
+
return behind;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function printVersionPair() {
|
|
141
|
+
return printVersions(localCliVersion(), await latestCliVersion());
|
|
142
|
+
}
|
|
143
|
+
|
|
108
144
|
function banner() {
|
|
109
|
-
console.log(`\n🛰️ ${bold(t(lang, "banner_title"))}`);
|
|
145
|
+
console.log(`\n🛰️ ${bold(t(lang, "banner_title"))} ${dim(localCliVersion())}`);
|
|
110
146
|
console.log(dim(`${t(lang, "banner_sub")}\n`));
|
|
111
147
|
}
|
|
112
148
|
|
|
@@ -142,14 +178,14 @@ async function ensureLang(opts) {
|
|
|
142
178
|
return lang;
|
|
143
179
|
}
|
|
144
180
|
|
|
145
|
-
async function wantDaemon(opts) {
|
|
181
|
+
async function wantDaemon(opts, { ifYes = false, index = 0 } = {}) {
|
|
146
182
|
if (opts.daemon) return true;
|
|
147
|
-
if (opts.yes) return
|
|
148
|
-
if (!input.isTTY || !output.isTTY) return
|
|
183
|
+
if (opts.yes) return ifYes;
|
|
184
|
+
if (!input.isTTY || !output.isTTY) return ifYes;
|
|
149
185
|
const idx = await pickFromList({
|
|
150
186
|
title: t(lang, "ask_daemon"),
|
|
151
187
|
items: [t(lang, "daemon_no"), t(lang, "daemon_yes")],
|
|
152
|
-
index
|
|
188
|
+
index,
|
|
153
189
|
hint: "↑↓ Enter",
|
|
154
190
|
stdin: input,
|
|
155
191
|
stdout: output,
|
|
@@ -346,8 +382,9 @@ async function cmdLogin(opts) {
|
|
|
346
382
|
|
|
347
383
|
const daemon = await wantDaemon(opts);
|
|
348
384
|
const logs = await wantLogs(opts);
|
|
349
|
-
applyGrants(device, { daemon, logs });
|
|
350
|
-
|
|
385
|
+
const grants = applyGrants(device, { daemon, logs });
|
|
386
|
+
persistDevice(device, [SYSTEM_DEVICE]);
|
|
387
|
+
printGrants(grants);
|
|
351
388
|
if (logs) console.log(dim(t(lang, "logs_on")));
|
|
352
389
|
else console.log(dim(t(lang, "logs_skip")));
|
|
353
390
|
|
|
@@ -359,9 +396,8 @@ async function cmdLogin(opts) {
|
|
|
359
396
|
console.log("\n📡 " + t(lang, "pulse_ok"));
|
|
360
397
|
printAdvice(out.body?.notes);
|
|
361
398
|
if (daemon) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
399
|
+
const installed = await cmdInstall({ ...opts, daemon: true, logs }, { enable: true, skipAsk: true });
|
|
400
|
+
if (installed) console.log("\n" + t(lang, "login_done"));
|
|
365
401
|
} else {
|
|
366
402
|
console.log(dim("\n" + t(lang, "daemon_skip")));
|
|
367
403
|
}
|
|
@@ -402,6 +438,8 @@ async function cmdDaemon(opts) {
|
|
|
402
438
|
const tick = async () => {
|
|
403
439
|
const hh = new Date().toISOString().slice(11, 19);
|
|
404
440
|
try {
|
|
441
|
+
const fresh = loadDevice();
|
|
442
|
+
if (fresh?.secret) Object.assign(device, fresh);
|
|
405
443
|
const { out, pulse } = await sendPulse(device, { preview: false });
|
|
406
444
|
if (!out.ok) {
|
|
407
445
|
console.error(`⚠️ ${hh} ${apiFailText(lang, out, t)}`);
|
|
@@ -544,8 +582,30 @@ function tryEnable(asSystem) {
|
|
|
544
582
|
return { ok: false, err: (st.stdout || st.stderr || "not active").trim().slice(0, 240) };
|
|
545
583
|
}
|
|
546
584
|
|
|
547
|
-
function
|
|
585
|
+
function persistDevice(device, extraPaths = []) {
|
|
586
|
+
saveDevice(device);
|
|
587
|
+
const body = JSON.stringify(device, null, 2);
|
|
588
|
+
for (const p of extraPaths) {
|
|
589
|
+
if (!p || path.resolve(p) === path.resolve(DEVICE_FILE)) continue;
|
|
590
|
+
try {
|
|
591
|
+
fs.writeFileSync(p, body, { mode: 0o600 });
|
|
592
|
+
} catch {
|
|
593
|
+
/* other key unreadable */
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function printGrants(grants) {
|
|
599
|
+
const on = t(lang, "grant_on");
|
|
600
|
+
const off = t(lang, "grant_off");
|
|
601
|
+
console.log(dim(t(lang, "grant_process") + ": " + on));
|
|
602
|
+
console.log(dim(t(lang, "grant_daemon") + ": " + (grants.daemon ? on : off)));
|
|
603
|
+
console.log(dim(t(lang, "grant_logs") + ": " + (grants.logs ? on : off)));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function cmdInstall(opts, { enable = false, skipAsk = false } = {}) {
|
|
548
607
|
applyLang(opts);
|
|
608
|
+
if (!skipAsk) await printVersionPair();
|
|
549
609
|
const rec = loadDeviceRecord();
|
|
550
610
|
if (!rec?.secret) {
|
|
551
611
|
console.error("⚠️ " + t(lang, "need_login"));
|
|
@@ -554,15 +614,20 @@ function cmdInstall(opts, { enable = false } = {}) {
|
|
|
554
614
|
const srcPath = rec.path || DEVICE_FILE;
|
|
555
615
|
const device = stripDevicePath(rec);
|
|
556
616
|
if (opts.url) device.api = String(opts.url).replace(/\/$/, "");
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
617
|
+
let daemon;
|
|
618
|
+
let logs;
|
|
619
|
+
if (skipAsk) {
|
|
620
|
+
daemon = opts.daemon != null ? Boolean(opts.daemon) : Boolean(device.grants?.daemon);
|
|
621
|
+
logs = opts.logs != null ? Boolean(opts.logs) : Boolean(device.grants?.logs);
|
|
622
|
+
} else {
|
|
623
|
+
daemon = await wantDaemon(opts, { ifYes: true, index: 1 });
|
|
624
|
+
logs = await wantLogs(opts);
|
|
565
625
|
}
|
|
626
|
+
const grants = applyGrants(device, { daemon, logs });
|
|
627
|
+
persistDevice(device, [srcPath, SYSTEM_DEVICE]);
|
|
628
|
+
printGrants(grants);
|
|
629
|
+
if (logs) console.log(dim(t(lang, "logs_on")));
|
|
630
|
+
else console.log(dim(t(lang, "logs_skip")));
|
|
566
631
|
const asSystem = Boolean(opts.system) || process.getuid?.() === 0;
|
|
567
632
|
const interval = intervalSec(opts.interval);
|
|
568
633
|
let deviceFile = DEVICE_FILE;
|
|
@@ -574,6 +639,12 @@ function cmdInstall(opts, { enable = false } = {}) {
|
|
|
574
639
|
deviceFile = acct.deviceFile;
|
|
575
640
|
user = acct.user;
|
|
576
641
|
script = acct.script;
|
|
642
|
+
persistDevice(device, [SYSTEM_DEVICE]);
|
|
643
|
+
try {
|
|
644
|
+
spawnSync("chown", ["orb44:orb44", SYSTEM_DEVICE], { encoding: "utf8" });
|
|
645
|
+
} catch {
|
|
646
|
+
/* key stays root-owned until next chown -R */
|
|
647
|
+
}
|
|
577
648
|
console.log(dim(t(lang, "daemon_user", { user, file: deviceFile })));
|
|
578
649
|
} else {
|
|
579
650
|
console.log(dim(t(lang, "daemon_user_fail", { err: acct.err ? `: ${acct.err}` : "" })));
|
|
@@ -600,13 +671,24 @@ function cmdInstall(opts, { enable = false } = {}) {
|
|
|
600
671
|
/* /usr/local/bin missing */
|
|
601
672
|
}
|
|
602
673
|
}
|
|
603
|
-
if (enable) {
|
|
674
|
+
if (enable && daemon) {
|
|
604
675
|
const on = tryEnable(asSystem);
|
|
605
676
|
if (on.ok) {
|
|
677
|
+
spawnSync("systemctl", asSystem ? ["try-restart", "orb44-satellite"] : ["--user", "try-restart", "orb44-satellite"], {
|
|
678
|
+
encoding: "utf8",
|
|
679
|
+
});
|
|
606
680
|
console.log("✅ " + t(lang, "daemon_on"));
|
|
681
|
+
try {
|
|
682
|
+
await sendPulse(device, { preview: false });
|
|
683
|
+
} catch {
|
|
684
|
+
/* next daemon tick */
|
|
685
|
+
}
|
|
607
686
|
return true;
|
|
608
687
|
}
|
|
609
688
|
console.log("⚠️ " + t(lang, "unit_enable_fail", { err: on.err ? `: ${on.err}` : "" }));
|
|
689
|
+
} else if (!daemon) {
|
|
690
|
+
console.log(dim(t(lang, "daemon_skip")));
|
|
691
|
+
return false;
|
|
610
692
|
}
|
|
611
693
|
if (asSystem) {
|
|
612
694
|
console.log(dim(" " + t(lang, "install_root")));
|
|
@@ -683,9 +765,11 @@ async function cmdUpdate() {
|
|
|
683
765
|
try {
|
|
684
766
|
meta = await fetchLatestMeta();
|
|
685
767
|
} catch (e) {
|
|
768
|
+
printVersions(current, null);
|
|
686
769
|
console.error("⚠️ " + t(lang, "update_fail", { err: `: ${String(e.message || e).slice(0, 160)}` }));
|
|
687
770
|
process.exit(1);
|
|
688
771
|
}
|
|
772
|
+
printVersions(current, meta.version);
|
|
689
773
|
const pin = t(lang, "update_npx", { version: meta.version });
|
|
690
774
|
const roots = pickSatRoots(SCRIPT);
|
|
691
775
|
const stale = staleSatRoots(roots, meta.version, { force: Boolean(opts.force) });
|
|
@@ -733,10 +817,16 @@ async function cmdUpdate() {
|
|
|
733
817
|
console.log(dim(pin));
|
|
734
818
|
}
|
|
735
819
|
|
|
736
|
-
function
|
|
820
|
+
async function cmdVersion() {
|
|
821
|
+
applyLang(opts);
|
|
822
|
+
await printVersionPair();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function cmdStatus() {
|
|
737
826
|
applyLang(opts);
|
|
738
827
|
const device = loadDevice();
|
|
739
828
|
banner();
|
|
829
|
+
await printVersionPair();
|
|
740
830
|
if (!device) {
|
|
741
831
|
console.log("⚠️ " + t(lang, "not_paired"));
|
|
742
832
|
return;
|
|
@@ -789,11 +879,11 @@ async function cmdLang(flags) {
|
|
|
789
879
|
|
|
790
880
|
function help() {
|
|
791
881
|
applyLang(opts);
|
|
792
|
-
console.log(t(lang, "help", { file: DEVICE_FILE }));
|
|
882
|
+
console.log(t(lang, "help", { file: DEVICE_FILE, version: localCliVersion() }));
|
|
793
883
|
}
|
|
794
884
|
|
|
795
885
|
const opts = args();
|
|
796
|
-
const cmd = opts._[0] || "help";
|
|
886
|
+
const cmd = opts.version ? "version" : opts._[0] || "help";
|
|
797
887
|
const run = {
|
|
798
888
|
login: () => cmdLogin(opts),
|
|
799
889
|
pulse: cmdPulse,
|
|
@@ -802,6 +892,7 @@ const run = {
|
|
|
802
892
|
uninstall: () => cmdUninstall(opts),
|
|
803
893
|
status: cmdStatus,
|
|
804
894
|
update: cmdUpdate,
|
|
895
|
+
version: cmdVersion,
|
|
805
896
|
logout: cmdLogout,
|
|
806
897
|
lang: () => cmdLang(opts),
|
|
807
898
|
help,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orb44/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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": {
|
package/server/pulse.mjs
CHANGED
|
@@ -336,7 +336,7 @@ export function sanitizePulse(raw = {}) {
|
|
|
336
336
|
top,
|
|
337
337
|
listen,
|
|
338
338
|
originA,
|
|
339
|
-
failedUnit:
|
|
339
|
+
failedUnit: cleanFailedUnits(raw.failedUnit).join(", ") || null,
|
|
340
340
|
oom,
|
|
341
341
|
limited,
|
|
342
342
|
hardening,
|
|
@@ -993,29 +993,23 @@ export function compareInside(pulse, rec = {}, prev = null) {
|
|
|
993
993
|
});
|
|
994
994
|
}
|
|
995
995
|
if (prev?.listen && pulse?.listen) {
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
.filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
|
|
999
|
-
.map((r) => `${r.port}:${r.comm || "?"}`)
|
|
1000
|
-
.sort()
|
|
1001
|
-
.join(",");
|
|
1002
|
-
const a = face(prev);
|
|
1003
|
-
const b = face(pulse);
|
|
1004
|
-
if (a && b && a !== b) {
|
|
996
|
+
const swap = httpListenSwapped(prev, pulse);
|
|
997
|
+
if (swap) {
|
|
1005
998
|
notes.push({
|
|
1006
999
|
kind: "listen-swap",
|
|
1007
1000
|
title: "На 80/443 сменился процесс",
|
|
1008
|
-
text: `Было ${
|
|
1001
|
+
text: `Было ${swap.from}, стало ${swap.to}. Бинарь витрины подменили или рядом встал другой сервер.`,
|
|
1009
1002
|
do: "Сверьте, кто должен слушать HTTPS. Orb44 процесс сам не откатывает.",
|
|
1010
1003
|
});
|
|
1011
1004
|
}
|
|
1012
1005
|
}
|
|
1013
|
-
|
|
1006
|
+
const failed = cleanFailedUnits(pulse?.failedUnit);
|
|
1007
|
+
if (failed.length) {
|
|
1014
1008
|
notes.push({
|
|
1015
1009
|
kind: "failed-unit",
|
|
1016
1010
|
title: "Упал systemd-юнит",
|
|
1017
|
-
text: `${
|
|
1018
|
-
do: "
|
|
1011
|
+
text: `${failed.join(", ")} не запустился.`,
|
|
1012
|
+
do: "На машине: systemctl status этого юнита. Orb44 его сам не поднимает.",
|
|
1019
1013
|
});
|
|
1020
1014
|
}
|
|
1021
1015
|
const errs = pulse?.errors || [];
|
|
@@ -1040,6 +1034,46 @@ function streetIncidentCode(rec = {}) {
|
|
|
1040
1034
|
return rec.incident?.code || rec.watch?.current?.incident || rec.watch?.last?.incident || "";
|
|
1041
1035
|
}
|
|
1042
1036
|
|
|
1037
|
+
function httpListenFace(p) {
|
|
1038
|
+
return (p?.listen || [])
|
|
1039
|
+
.filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
|
|
1040
|
+
.map((r) => ({ port: Number(r.port), comm: String(r.comm || "").replace(/^\?$/, "").trim() }))
|
|
1041
|
+
.sort((a, b) => a.port - b.port);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** True process swap on 80/443. `?` → `docker-proxy` is just ss starting to see names. */
|
|
1045
|
+
export function httpListenSwapped(prev, pulse) {
|
|
1046
|
+
const a = httpListenFace(prev);
|
|
1047
|
+
const b = httpListenFace(pulse);
|
|
1048
|
+
if (!a.length || !b.length) return null;
|
|
1049
|
+
const label = (rows) => rows.map((r) => `${r.port}:${r.comm || "?"}`).join(",");
|
|
1050
|
+
const from = label(a);
|
|
1051
|
+
const to = label(b);
|
|
1052
|
+
if (from === to) return null;
|
|
1053
|
+
const pa = new Map(a.map((r) => [r.port, r.comm]));
|
|
1054
|
+
const pb = new Map(b.map((r) => [r.port, r.comm]));
|
|
1055
|
+
let real = false;
|
|
1056
|
+
for (const port of new Set([...pa.keys(), ...pb.keys()])) {
|
|
1057
|
+
const ca = pa.get(port) || "";
|
|
1058
|
+
const cb = pb.get(port) || "";
|
|
1059
|
+
if (ca && cb && ca !== cb) real = true;
|
|
1060
|
+
}
|
|
1061
|
+
return real ? { from, to } : null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function fail2banActive(h) {
|
|
1065
|
+
return Boolean(h?.fail2ban);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Need fail2ban on both pulses. A default 0 on the first reading is not a brute-force spike. */
|
|
1069
|
+
export function fail2banBanJump(pulse, prev, minJump = 10) {
|
|
1070
|
+
if (!fail2banActive(prev?.hardening) || !fail2banActive(pulse?.hardening)) return null;
|
|
1071
|
+
const banned = Number(pulse.hardening.fail2banBanned) || 0;
|
|
1072
|
+
const prevBanned = Number(prev.hardening.fail2banBanned) || 0;
|
|
1073
|
+
if (banned < prevBanned + minJump) return null;
|
|
1074
|
+
return { from: prevBanned, to: banned };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1043
1077
|
export function insideWatchReasons(pulse, prev, rec = {}) {
|
|
1044
1078
|
const notes = [];
|
|
1045
1079
|
const incident = String(streetIncidentCode(rec) || "");
|
|
@@ -1063,13 +1097,12 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
|
|
|
1063
1097
|
});
|
|
1064
1098
|
}
|
|
1065
1099
|
}
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
if (prev && banned > prevBanned) {
|
|
1100
|
+
const banJump = fail2banBanJump(pulse, prev);
|
|
1101
|
+
if (banJump) {
|
|
1069
1102
|
notes.push({
|
|
1070
1103
|
kind: "stuffing-ssh",
|
|
1071
|
-
title: "fail2ban копит баны",
|
|
1072
|
-
text: `Было ${
|
|
1104
|
+
title: "fail2ban копит баны SSH",
|
|
1105
|
+
text: `Было ${banJump.from}, стало ${banJump.to}. Чужой перебор SSH, не вход на витрину. Пароли не подбираем.`,
|
|
1073
1106
|
});
|
|
1074
1107
|
}
|
|
1075
1108
|
if (prev) {
|
|
@@ -1083,27 +1116,21 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
|
|
|
1083
1116
|
text: `Появились ${added.join(", ")}. С прошлого пульса их не было.`,
|
|
1084
1117
|
});
|
|
1085
1118
|
}
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
.filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
|
|
1089
|
-
.map((r) => `${r.port}:${r.comm || "?"}`)
|
|
1090
|
-
.sort()
|
|
1091
|
-
.join(",");
|
|
1092
|
-
const a = face(prev);
|
|
1093
|
-
const b = face(pulse);
|
|
1094
|
-
if (a && b && a !== b) {
|
|
1119
|
+
const swap = httpListenSwapped(prev, pulse);
|
|
1120
|
+
if (swap) {
|
|
1095
1121
|
notes.push({
|
|
1096
1122
|
kind: "listen-swap",
|
|
1097
1123
|
title: "На 80/443 сменился процесс",
|
|
1098
|
-
text: `Было ${
|
|
1124
|
+
text: `Было ${swap.from}, стало ${swap.to}.`,
|
|
1099
1125
|
});
|
|
1100
1126
|
}
|
|
1101
1127
|
}
|
|
1102
|
-
|
|
1128
|
+
const freshFailed = freshFailedUnits(pulse, prev);
|
|
1129
|
+
if (freshFailed.length) {
|
|
1103
1130
|
notes.push({
|
|
1104
1131
|
kind: "failed-unit",
|
|
1105
1132
|
title: "Упал systemd-юнит",
|
|
1106
|
-
text: `${
|
|
1133
|
+
text: `${freshFailed.join(", ")} не запустился.`,
|
|
1107
1134
|
});
|
|
1108
1135
|
}
|
|
1109
1136
|
const errNow = (pulse?.errors || []).map((e) => `${e.source}:${e.name}`);
|
|
@@ -1172,14 +1199,48 @@ export function insideAlertDecision(row, reasons, now = Date.now(), debounceMs =
|
|
|
1172
1199
|
return { emit: true, key };
|
|
1173
1200
|
}
|
|
1174
1201
|
|
|
1202
|
+
/** First-boot / image leftovers that stay in `systemctl --failed` forever. Not a live outage. */
|
|
1203
|
+
export function isNoisyFailedUnit(name) {
|
|
1204
|
+
const n = String(name || "").toLowerCase();
|
|
1205
|
+
if (/^cloud-(init|config|final)/.test(n)) return true;
|
|
1206
|
+
if (/^snapd\.(seeded|autoimport)/.test(n)) return true;
|
|
1207
|
+
if (/^systemd-networkd-wait-online/.test(n)) return true;
|
|
1208
|
+
if (/^(plymouth|kmod-static-nodes|finalrd|open-iscsi|iscsid)\./.test(n)) return true;
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
export function failedUnitList(raw) {
|
|
1213
|
+
return String(raw || "")
|
|
1214
|
+
.split(",")
|
|
1215
|
+
.map((s) => s.trim())
|
|
1216
|
+
.filter(Boolean);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
export function cleanFailedUnits(raw) {
|
|
1220
|
+
const out = [];
|
|
1221
|
+
for (const name of failedUnitList(raw)) {
|
|
1222
|
+
const one = name.slice(0, 60);
|
|
1223
|
+
if (!one || isNoisyFailedUnit(one) || out.includes(one)) continue;
|
|
1224
|
+
out.push(one);
|
|
1225
|
+
if (out.length >= 6) break;
|
|
1226
|
+
}
|
|
1227
|
+
return out;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function freshFailedUnits(pulse, prev) {
|
|
1231
|
+
const now = cleanFailedUnits(pulse?.failedUnit);
|
|
1232
|
+
const old = new Set(cleanFailedUnits(prev?.failedUnit));
|
|
1233
|
+
return now.filter((u) => !old.has(u));
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1175
1236
|
export function parseFailedUnits(text) {
|
|
1176
1237
|
const out = [];
|
|
1177
1238
|
for (const line of String(text || "").split("\n")) {
|
|
1178
1239
|
const hit = line.match(/\b([a-zA-Z0-9@._-]+\.(service|socket|mount|timer))\b/);
|
|
1179
1240
|
if (hit && !out.includes(hit[1])) out.push(hit[1].slice(0, 60));
|
|
1180
|
-
if (out.length >=
|
|
1241
|
+
if (out.length >= 8) break;
|
|
1181
1242
|
}
|
|
1182
|
-
return out;
|
|
1243
|
+
return cleanFailedUnits(out.join(", "));
|
|
1183
1244
|
}
|
|
1184
1245
|
|
|
1185
1246
|
function collectFailedUnits() {
|
package/server/sat-i18n.mjs
CHANGED
|
@@ -49,6 +49,11 @@ const STR = {
|
|
|
49
49
|
logs_yes: "Yes — last errors if readable",
|
|
50
50
|
logs_skip: "Log monitoring off. Later: orb44 login --logs or orb44 install --logs",
|
|
51
51
|
logs_on: "Log monitoring on. Docker/kube/nginx only if this user can read them.",
|
|
52
|
+
grant_process: "System processes",
|
|
53
|
+
grant_daemon: "Restart after reboot",
|
|
54
|
+
grant_logs: "Log monitoring",
|
|
55
|
+
grant_on: "on",
|
|
56
|
+
grant_off: "off",
|
|
52
57
|
need_login: "First: orb44 login",
|
|
53
58
|
key_revoked: "Key revoked. Again: orb44 login",
|
|
54
59
|
daemon_run: "daemon every {sec}s · {host}",
|
|
@@ -64,6 +69,11 @@ const STR = {
|
|
|
64
69
|
copy_key: "and copy the key into ORB44_DEVICE_FILE that user orb44 can read.",
|
|
65
70
|
not_paired: "Not paired. orb44 login",
|
|
66
71
|
now_on_box: "On the machine now",
|
|
72
|
+
ver_here: "On this machine",
|
|
73
|
+
ver_latest: "Latest",
|
|
74
|
+
ver_ok: "up to date",
|
|
75
|
+
ver_unknown: "npm unreachable",
|
|
76
|
+
ver_behind: "update: npx @orb44/cli@{ver} update --force",
|
|
67
77
|
logged_out: "Disconnected. Cabinet dropped the device.",
|
|
68
78
|
uninstall_ok: "Daemon stopped and unit removed. Pairing key kept.",
|
|
69
79
|
uninstall_purged: "Daemon, key, and service user orb44 removed. Cabinet dropped the device.",
|
|
@@ -116,19 +126,21 @@ const STR = {
|
|
|
116
126
|
preview_ssh_pass: "password",
|
|
117
127
|
preview_ssh_key: "keys",
|
|
118
128
|
preview_limited: "limited: cannot see which process owns the ports",
|
|
119
|
-
help: `Orb44 satellite — admin device.
|
|
129
|
+
help: `Orb44 satellite {version} — admin device.
|
|
120
130
|
|
|
121
131
|
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
122
132
|
orb44 pulse
|
|
123
133
|
orb44 daemon [--interval 300]
|
|
124
|
-
orb44 install [--system] [--logs] [--interval 300]
|
|
134
|
+
orb44 install [--system] [--daemon] [--logs] [--interval 300]
|
|
125
135
|
orb44 uninstall [--purge]
|
|
126
136
|
orb44 lang [en|ru|ko|es]
|
|
127
137
|
orb44 status
|
|
138
|
+
orb44 version
|
|
128
139
|
orb44 update
|
|
129
140
|
orb44 logout
|
|
130
141
|
|
|
131
142
|
Login asks language (saved), then daemon and logs (default no).
|
|
143
|
+
Install asks the same two (daemon default yes, logs default no). --daemon / --logs skip the questions.
|
|
132
144
|
Key: {file}
|
|
133
145
|
Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
134
146
|
},
|
|
@@ -162,6 +174,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
162
174
|
logs_yes: "Да — последние ошибки, если читаются",
|
|
163
175
|
logs_skip: "Логи не снимаем. Позже: orb44 login --logs или orb44 install --logs",
|
|
164
176
|
logs_on: "Логи включены. Docker/kube/nginx — только если этот пользователь их видит.",
|
|
177
|
+
grant_process: "Системные процессы",
|
|
178
|
+
grant_daemon: "Рестарт после загрузки",
|
|
179
|
+
grant_logs: "Мониторинг логов",
|
|
180
|
+
grant_on: "да",
|
|
181
|
+
grant_off: "нет",
|
|
165
182
|
need_login: "Сначала: orb44 login",
|
|
166
183
|
key_revoked: "Ключ отозван. Снова: orb44 login",
|
|
167
184
|
daemon_run: "демон каждые {sec}с · {host}",
|
|
@@ -177,6 +194,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
177
194
|
copy_key: "и скопируйте ключ в ORB44_DEVICE_FILE, который читает orb44.",
|
|
178
195
|
not_paired: "Не связано. orb44 login",
|
|
179
196
|
now_on_box: "Сейчас на машине",
|
|
197
|
+
ver_here: "На машине",
|
|
198
|
+
ver_latest: "Актуальная",
|
|
199
|
+
ver_ok: "совпадает",
|
|
200
|
+
ver_unknown: "npm не ответил",
|
|
201
|
+
ver_behind: "обновить: npx @orb44/cli@{ver} update --force",
|
|
180
202
|
logged_out: "Отключено. В кабинете устройство снято.",
|
|
181
203
|
uninstall_ok: "Демон остановлен, unit снят. Ключ пары оставлен.",
|
|
182
204
|
uninstall_purged: "Демон, ключ и пользователь orb44 сняты. В кабинете устройство отозвано.",
|
|
@@ -229,19 +251,21 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
229
251
|
preview_ssh_pass: "пароль",
|
|
230
252
|
preview_ssh_key: "ключи",
|
|
231
253
|
preview_limited: "ограничено: не видно, какой процесс слушает порты",
|
|
232
|
-
help: `Orb44 сателлит — устройство админа.
|
|
254
|
+
help: `Orb44 сателлит {version} — устройство админа.
|
|
233
255
|
|
|
234
256
|
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
235
257
|
orb44 pulse
|
|
236
258
|
orb44 daemon [--interval 300]
|
|
237
|
-
orb44 install [--system] [--logs] [--interval 300]
|
|
259
|
+
orb44 install [--system] [--daemon] [--logs] [--interval 300]
|
|
238
260
|
orb44 uninstall [--purge]
|
|
239
261
|
orb44 lang [en|ru|ko|es]
|
|
240
262
|
orb44 status
|
|
263
|
+
orb44 version
|
|
241
264
|
orb44 update
|
|
242
265
|
orb44 logout
|
|
243
266
|
|
|
244
267
|
После login спрашивает язык (запоминает), затем демон и логи — по умолчанию нет.
|
|
268
|
+
install спрашивает то же (демон — да, логи — нет). --daemon / --logs пропускают вопросы.
|
|
245
269
|
Ключ: {file}
|
|
246
270
|
Пульс исходящий. Кабинет не выполняет команды: отвечает текстом «что сделать».`,
|
|
247
271
|
},
|
|
@@ -275,6 +299,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
275
299
|
logs_yes: "예 — 읽을 수 있으면 마지막 오류",
|
|
276
300
|
logs_skip: "로그 수집 안 함. 나중에: orb44 login --logs 또는 orb44 install --logs",
|
|
277
301
|
logs_on: "로그 수집 켜짐. Docker/kube/nginx는 이 사용자가 읽을 수 있을 때만.",
|
|
302
|
+
grant_process: "시스템 프로세스",
|
|
303
|
+
grant_daemon: "부팅 후 재시작",
|
|
304
|
+
grant_logs: "로그 모니터링",
|
|
305
|
+
grant_on: "켜짐",
|
|
306
|
+
grant_off: "꺼짐",
|
|
278
307
|
need_login: "먼저: orb44 login",
|
|
279
308
|
key_revoked: "키가 취소되었습니다. 다시: orb44 login",
|
|
280
309
|
daemon_run: "데몬 {sec}초마다 · {host}",
|
|
@@ -290,6 +319,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
290
319
|
copy_key: "그리고 orb44가 읽을 ORB44_DEVICE_FILE로 키를 복사하세요.",
|
|
291
320
|
not_paired: "연결되지 않음. orb44 login",
|
|
292
321
|
now_on_box: "지금 이 기기",
|
|
322
|
+
ver_here: "이 기기",
|
|
323
|
+
ver_latest: "최신",
|
|
324
|
+
ver_ok: "최신",
|
|
325
|
+
ver_unknown: "npm 응답 없음",
|
|
326
|
+
ver_behind: "업데이트: npx @orb44/cli@{ver} update --force",
|
|
293
327
|
logged_out: "해제됨. 콘솔에서 장치가 제거되었습니다.",
|
|
294
328
|
uninstall_ok: "데몬을 멈추고 unit을 제거했습니다. 페어링 키는 남겼습니다.",
|
|
295
329
|
uninstall_purged: "데몬, 키, 사용자 orb44를 제거했습니다. 콘솔에서 장치를 취소했습니다.",
|
|
@@ -342,19 +376,21 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
|
|
|
342
376
|
preview_ssh_pass: "비밀번호",
|
|
343
377
|
preview_ssh_key: "키",
|
|
344
378
|
preview_limited: "제한: 포트를 연 프로세스를 볼 수 없음",
|
|
345
|
-
help: `Orb44 위성 — 관리자 장치.
|
|
379
|
+
help: `Orb44 위성 {version} — 관리자 장치.
|
|
346
380
|
|
|
347
381
|
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
348
382
|
orb44 pulse
|
|
349
383
|
orb44 daemon [--interval 300]
|
|
350
|
-
orb44 install [--system] [--logs] [--interval 300]
|
|
384
|
+
orb44 install [--system] [--daemon] [--logs] [--interval 300]
|
|
351
385
|
orb44 uninstall [--purge]
|
|
352
386
|
orb44 lang [en|ru|ko|es]
|
|
353
387
|
orb44 status
|
|
388
|
+
orb44 version
|
|
354
389
|
orb44 update
|
|
355
390
|
orb44 logout
|
|
356
391
|
|
|
357
392
|
login에서 언어를 묻고 저장한 뒤, 데몬과 로그는 기본값 아니오입니다.
|
|
393
|
+
install도 같은 두 질문(데몬 기본 예, 로그 기본 아니오). --daemon / --logs는 질문을 건너뜁니다.
|
|
358
394
|
키: {file}
|
|
359
395
|
나가는 펄스. 콘솔은 명령을 실행하지 않고 조언만 돌려줍니다.`,
|
|
360
396
|
},
|
|
@@ -388,6 +424,11 @@ login에서 언어를 묻고 저장한 뒤, 데몬과 로그는 기본값 아니
|
|
|
388
424
|
logs_yes: "Sí — últimos errores si se pueden leer",
|
|
389
425
|
logs_skip: "Sin logs. Luego: orb44 login --logs o orb44 install --logs",
|
|
390
426
|
logs_on: "Logs activos. Docker/kube/nginx solo si este usuario los ve.",
|
|
427
|
+
grant_process: "Procesos del sistema",
|
|
428
|
+
grant_daemon: "Reinicio tras el arranque",
|
|
429
|
+
grant_logs: "Monitor de logs",
|
|
430
|
+
grant_on: "sí",
|
|
431
|
+
grant_off: "no",
|
|
391
432
|
need_login: "Primero: orb44 login",
|
|
392
433
|
key_revoked: "Clave revocada. Otra vez: orb44 login",
|
|
393
434
|
daemon_run: "demonio cada {sec}s · {host}",
|
|
@@ -403,6 +444,11 @@ login에서 언어를 묻고 저장한 뒤, 데몬과 로그는 기본값 아니
|
|
|
403
444
|
copy_key: "y copie la clave a ORB44_DEVICE_FILE que pueda leer orb44.",
|
|
404
445
|
not_paired: "Sin vincular. orb44 login",
|
|
405
446
|
now_on_box: "Ahora en la máquina",
|
|
447
|
+
ver_here: "En esta máquina",
|
|
448
|
+
ver_latest: "Actual",
|
|
449
|
+
ver_ok: "al día",
|
|
450
|
+
ver_unknown: "npm no respondió",
|
|
451
|
+
ver_behind: "actualizar: npx @orb44/cli@{ver} update --force",
|
|
406
452
|
logged_out: "Desconectado. El gabinete quitó el dispositivo.",
|
|
407
453
|
uninstall_ok: "Demonio parado y unit quitado. Se guarda la clave de emparejamiento.",
|
|
408
454
|
uninstall_purged: "Demonio, clave y usuario orb44 eliminados. El gabinete revocó el dispositivo.",
|
|
@@ -455,19 +501,21 @@ login에서 언어를 묻고 저장한 뒤, 데몬과 로그는 기본값 아니
|
|
|
455
501
|
preview_ssh_pass: "contraseña",
|
|
456
502
|
preview_ssh_key: "claves",
|
|
457
503
|
preview_limited: "limitado: no se ve qué proceso tiene los puertos",
|
|
458
|
-
help: `Satélite Orb44 — dispositivo de admin.
|
|
504
|
+
help: `Satélite Orb44 {version} — dispositivo de admin.
|
|
459
505
|
|
|
460
506
|
orb44 login [--url http://127.0.0.1:8787] [--daemon] [--logs] [--force] [--lang en|ru|ko|es]
|
|
461
507
|
orb44 pulse
|
|
462
508
|
orb44 daemon [--interval 300]
|
|
463
|
-
orb44 install [--system] [--logs] [--interval 300]
|
|
509
|
+
orb44 install [--system] [--daemon] [--logs] [--interval 300]
|
|
464
510
|
orb44 uninstall [--purge]
|
|
465
511
|
orb44 lang [en|ru|ko|es]
|
|
466
512
|
orb44 status
|
|
513
|
+
orb44 version
|
|
467
514
|
orb44 update
|
|
468
515
|
orb44 logout
|
|
469
516
|
|
|
470
517
|
Login pregunta idioma (lo guarda), luego demonio y logs (por defecto no).
|
|
518
|
+
install pregunta lo mismo (demonio sí, logs no). --daemon / --logs saltan las preguntas.
|
|
471
519
|
Clave: {file}
|
|
472
520
|
Pulso saliente. El gabinete no ejecuta órdenes; responde con consejos.`,
|
|
473
521
|
},
|
package/server/sat-update.mjs
CHANGED
|
@@ -54,8 +54,57 @@ export function readCliVersion(scriptFile) {
|
|
|
54
54
|
return "0.0.0";
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Files the running CLI expects, plus whatever the unpacked tarball actually ships. */
|
|
58
|
+
export function listSatPackageFiles(srcPackageDir) {
|
|
59
|
+
const src = path.resolve(srcPackageDir);
|
|
60
|
+
const out = new Set(SAT_TREE_FILES);
|
|
61
|
+
try {
|
|
62
|
+
const j = JSON.parse(fs.readFileSync(path.join(src, "package.json"), "utf8"));
|
|
63
|
+
for (const f of j.files || []) out.add(String(f));
|
|
64
|
+
const bin = j.bin;
|
|
65
|
+
if (typeof bin === "string") out.add(bin);
|
|
66
|
+
else if (bin && typeof bin === "object") {
|
|
67
|
+
for (const v of Object.values(bin)) if (v) out.add(String(v));
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
/* SAT_TREE_FILES only */
|
|
71
|
+
}
|
|
72
|
+
const serverDir = path.join(src, "server");
|
|
73
|
+
try {
|
|
74
|
+
for (const name of fs.readdirSync(serverDir)) {
|
|
75
|
+
if (name.endsWith(".mjs")) out.add(`server/${name}`);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
/* no server dir */
|
|
79
|
+
}
|
|
80
|
+
if (fs.existsSync(path.join(src, "bin", "orb44.mjs"))) out.add("bin/orb44.mjs");
|
|
81
|
+
if (fs.existsSync(path.join(src, "package.json"))) out.add("package.json");
|
|
82
|
+
return [...out];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function missingSatFiles(root) {
|
|
86
|
+
const r = path.resolve(root);
|
|
87
|
+
const miss = [];
|
|
88
|
+
for (const rel of SAT_TREE_FILES) {
|
|
89
|
+
if (rel === "package.json") continue;
|
|
90
|
+
if (!fs.existsSync(path.join(r, rel))) miss.push(rel);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const pulse = fs.readFileSync(path.join(r, "server", "pulse.mjs"), "utf8");
|
|
94
|
+
for (const m of pulse.matchAll(/from\s+["'](\.[^"']+)["']/g)) {
|
|
95
|
+
const spec = m[1];
|
|
96
|
+
const abs = path.join(r, "server", spec);
|
|
97
|
+
if (!fs.existsSync(abs)) miss.push(path.posix.join("server", path.basename(spec)));
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
miss.push("server/pulse.mjs");
|
|
101
|
+
}
|
|
102
|
+
return [...new Set(miss)];
|
|
103
|
+
}
|
|
104
|
+
|
|
57
105
|
export function treeNeedsRefresh(root, latest, { force = false } = {}) {
|
|
58
106
|
if (force) return true;
|
|
107
|
+
if (missingSatFiles(root).length) return true;
|
|
59
108
|
const ver = readCliVersion(path.join(root, "bin", "orb44.mjs"));
|
|
60
109
|
if (cmpVer(ver, latest) < 0) return true;
|
|
61
110
|
try {
|
|
@@ -84,17 +133,16 @@ export function pickSatRoots(scriptFile, { systemLib = "/usr/lib/orb44-sat" } =
|
|
|
84
133
|
export function applyUpdateTree(srcPackageDir, destRoot) {
|
|
85
134
|
const src = path.resolve(srcPackageDir);
|
|
86
135
|
const dest = path.resolve(destRoot);
|
|
87
|
-
for (const rel of
|
|
136
|
+
for (const rel of listSatPackageFiles(src)) {
|
|
88
137
|
const from = path.join(src, rel);
|
|
89
|
-
if (!fs.existsSync(from))
|
|
90
|
-
if (rel === "package.json") continue;
|
|
91
|
-
throw new Error(`missing ${rel}`);
|
|
92
|
-
}
|
|
138
|
+
if (!fs.existsSync(from) || !fs.statSync(from).isFile()) continue;
|
|
93
139
|
const to = path.join(dest, rel);
|
|
94
140
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
95
141
|
fs.copyFileSync(from, to);
|
|
96
142
|
fs.chmodSync(to, rel.endsWith("orb44.mjs") ? 0o755 : 0o644);
|
|
97
143
|
}
|
|
144
|
+
const miss = missingSatFiles(dest);
|
|
145
|
+
if (miss.length) throw new Error(`incomplete tree: ${miss.join(", ")}`);
|
|
98
146
|
return dest;
|
|
99
147
|
}
|
|
100
148
|
|