@orb44/cli 0.1.1 → 0.1.4

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/bin/orb44.mjs CHANGED
@@ -8,6 +8,7 @@ import { stdin as input, stdout as output } from "node:process";
8
8
  import { collectPulse, formatPulsePreview } from "../server/pulse.mjs";
9
9
  import { LANGS, LANG_LABEL, detectLang, normalizeLang, t } from "../server/sat-i18n.mjs";
10
10
  import { pickFromList } from "../server/cli-menu.mjs";
11
+ import { SYSTEM_DEVICE, parseDeviceJson, hasExistingInstall, pickLiveDevice, deviceSearchPaths, loadFirstDevice, stripDevicePath } from "../server/sat-local.mjs";
11
12
 
12
13
  const DEVICE_FILE = process.env.ORB44_DEVICE_FILE || path.join(os.homedir(), ".config", "orb44", "device.json");
13
14
  const CLI_FILE = process.env.ORB44_CLI_FILE || path.join(path.dirname(DEVICE_FILE), "cli.json");
@@ -21,6 +22,8 @@ function args() {
21
22
  if (a === "--yes" || a === "-y") out.yes = true;
22
23
  else if (a === "--system") out.system = true;
23
24
  else if (a === "--daemon") out.daemon = true;
25
+ else if (a === "--force") out.force = true;
26
+ else if (a === "--purge") out.purge = true;
24
27
  else if (a === "--url" || a === "--code" || a === "--name" || a === "--interval" || a === "--lang") {
25
28
  out[a.slice(2)] = argv[++i];
26
29
  } else if (!a.startsWith("-")) out._.push(a);
@@ -57,12 +60,12 @@ function storedOrFlag(opts) {
57
60
  return normalizeLang(opts.lang) || storedLang() || detectLang();
58
61
  }
59
62
 
63
+ function loadDeviceRecord() {
64
+ return loadFirstDevice((p) => fs.readFileSync(p, "utf8"), deviceSearchPaths(DEVICE_FILE));
65
+ }
66
+
60
67
  function loadDevice() {
61
- try {
62
- return JSON.parse(fs.readFileSync(DEVICE_FILE, "utf8"));
63
- } catch {
64
- return null;
65
- }
68
+ return stripDevicePath(loadDeviceRecord());
66
69
  }
67
70
 
68
71
  function saveDevice(row) {
@@ -180,14 +183,90 @@ async function sendPulse(device, { preview = true } = {}) {
180
183
  return { out, pulse };
181
184
  }
182
185
 
186
+ async function wantReplace(found, opts) {
187
+ if (opts.force) return true;
188
+ if (opts.yes || !input.isTTY || !output.isTTY) return false;
189
+ const lines = [t(lang, "already_title")];
190
+ for (const d of found.devices) {
191
+ lines.push(t(lang, "already_host", { host: d.host || "—", name: d.name || "—" }));
192
+ lines.push(t(lang, "already_key", { file: d.path }));
193
+ }
194
+ if (found.daemon) lines.push(t(lang, "already_daemon"));
195
+ else if (found.unit) lines.push(t(lang, "already_unit"));
196
+ const idx = await pickFromList({
197
+ title: lines.join("\n"),
198
+ items: [t(lang, "already_keep"), t(lang, "already_replace")],
199
+ index: 0,
200
+ hint: "↑↓ Enter",
201
+ stdin: input,
202
+ stdout: output,
203
+ });
204
+ return idx === 1;
205
+ }
206
+
207
+ function existingOnBox() {
208
+ const paths = [...new Set([DEVICE_FILE, SYSTEM_DEVICE])];
209
+ const devices = [];
210
+ for (const p of paths) {
211
+ try {
212
+ const row = parseDeviceJson(fs.readFileSync(p, "utf8"), p);
213
+ if (row) devices.push(row);
214
+ } catch {
215
+ /* missing or unreadable */
216
+ }
217
+ }
218
+ const unit =
219
+ fs.existsSync("/etc/systemd/system/orb44-satellite.service") ||
220
+ fs.existsSync(path.join(os.homedir(), ".config", "systemd", "user", "orb44-satellite.service"));
221
+ let daemon = false;
222
+ try {
223
+ daemon = serviceIsActive(true) || serviceIsActive(false);
224
+ } catch {
225
+ daemon = false;
226
+ }
227
+ return { devices, daemon, unit };
228
+ }
229
+
230
+ async function revokeLocalDevices(devices, fallbackApi) {
231
+ for (const d of devices) {
232
+ const apiBase = d.api || fallbackApi;
233
+ if (!apiBase || !d.secret) continue;
234
+ await api(apiBase, "/api/satellites/logout", { method: "POST", json: { secret: d.secret } }).catch(() => {});
235
+ }
236
+ clearDevice();
237
+ if (SYSTEM_DEVICE !== DEVICE_FILE) {
238
+ try {
239
+ fs.unlinkSync(SYSTEM_DEVICE);
240
+ } catch {
241
+ /* missing */
242
+ }
243
+ }
244
+ }
245
+
183
246
  async function cmdLogin(opts) {
184
247
  await ensureLang(opts);
185
248
  banner();
186
249
  const url = String(opts.url || process.env.ORB44_API || "http://127.0.0.1:8787").replace(/\/$/, "");
187
- const prev = loadDevice();
188
- if (prev?.secret) {
189
- await api(prev.api, "/api/satellites/logout", { method: "POST", json: { secret: prev.secret } }).catch(() => {});
190
- clearDevice();
250
+ const found = existingOnBox();
251
+ if (hasExistingInstall(found)) {
252
+ const replace = await wantReplace(found, opts);
253
+ if (!replace) {
254
+ const live = pickLiveDevice(found.devices, { daemon: found.daemon });
255
+ if (!live?.secret) {
256
+ console.log(t(lang, "already_no_key"));
257
+ process.exit(0);
258
+ }
259
+ const { out } = await sendPulse(live);
260
+ if (!out.ok) {
261
+ console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
262
+ process.exit(1);
263
+ }
264
+ console.log("\n📡 " + t(lang, "pulse_ok"));
265
+ printAdvice(out.body?.notes);
266
+ console.log(dim("\n" + t(lang, "already_kept")));
267
+ process.exit(0);
268
+ }
269
+ await revokeLocalDevices(found.devices, url);
191
270
  }
192
271
  const hostname = os.hostname();
193
272
  const name = opts.name || hostname;
@@ -336,6 +415,13 @@ function systemdUnit({ node, script, deviceFile, interval, user }) {
336
415
  return `${lines.join("\n")}\n`;
337
416
  }
338
417
 
418
+ const CLI_LINK = "/usr/local/bin/orb44";
419
+
420
+ function linkSystemCli(node, script) {
421
+ const body = `#!/bin/sh\nexec ${quote(node)} ${quote(script)} "$@"\n`;
422
+ fs.writeFileSync(CLI_LINK, body, { mode: 0o755 });
423
+ }
424
+
339
425
  function satServerDir() {
340
426
  return path.join(path.dirname(SCRIPT), "..", "server");
341
427
  }
@@ -350,6 +436,7 @@ function installSystemTree() {
350
436
  [path.join(serverSrc, "pulse.mjs"), path.join(lib, "server", "pulse.mjs")],
351
437
  [path.join(serverSrc, "sat-i18n.mjs"), path.join(lib, "server", "sat-i18n.mjs")],
352
438
  [path.join(serverSrc, "cli-menu.mjs"), path.join(lib, "server", "cli-menu.mjs")],
439
+ [path.join(serverSrc, "sat-local.mjs"), path.join(lib, "server", "sat-local.mjs")],
353
440
  ];
354
441
  for (const [from, to] of copies) {
355
442
  fs.copyFileSync(from, to);
@@ -377,7 +464,9 @@ function ensureSystemServiceAccount(srcDeviceFile) {
377
464
  }
378
465
  }
379
466
  fs.mkdirSync(home, { recursive: true, mode: 0o750 });
380
- fs.copyFileSync(srcDeviceFile, dest);
467
+ if (path.resolve(srcDeviceFile) !== path.resolve(dest)) {
468
+ fs.copyFileSync(srcDeviceFile, dest);
469
+ }
381
470
  fs.chmodSync(dest, 0o600);
382
471
  const chown = spawnSync("chown", ["-R", "orb44:orb44", home], { encoding: "utf8" });
383
472
  if (chown.status !== 0) {
@@ -429,18 +518,22 @@ function tryEnable(asSystem) {
429
518
 
430
519
  function cmdInstall(opts, { enable = false } = {}) {
431
520
  applyLang(opts);
432
- const device = loadDevice();
433
- if (!device?.secret) {
521
+ const rec = loadDeviceRecord();
522
+ if (!rec?.secret) {
434
523
  console.error("⚠️ " + t(lang, "need_login"));
435
524
  process.exit(1);
436
525
  }
526
+ const srcPath = rec.path || DEVICE_FILE;
527
+ const device = stripDevicePath(rec);
528
+ if (opts.url) device.api = String(opts.url).replace(/\/$/, "");
529
+ if (srcPath !== DEVICE_FILE) saveDevice(device);
437
530
  const asSystem = Boolean(opts.system) || process.getuid?.() === 0;
438
531
  const interval = intervalSec(opts.interval);
439
532
  let deviceFile = DEVICE_FILE;
440
533
  let user = null;
441
534
  let script = SCRIPT;
442
535
  if (asSystem) {
443
- const acct = ensureSystemServiceAccount(DEVICE_FILE);
536
+ const acct = ensureSystemServiceAccount(srcPath);
444
537
  if (acct.ok) {
445
538
  deviceFile = acct.deviceFile;
446
539
  user = acct.user;
@@ -463,6 +556,14 @@ function cmdInstall(opts, { enable = false } = {}) {
463
556
  fs.mkdirSync(path.dirname(unitPath), { recursive: true });
464
557
  fs.writeFileSync(unitPath, unit, { mode: 0o644 });
465
558
  console.log("⚙️ " + t(lang, "unit_written", { path: unitPath }));
559
+ if (asSystem) {
560
+ try {
561
+ linkSystemCli(process.execPath, script);
562
+ console.log(dim(t(lang, "cli_link", { path: CLI_LINK })));
563
+ } catch {
564
+ /* /usr/local/bin missing */
565
+ }
566
+ }
466
567
  if (enable) {
467
568
  const on = tryEnable(asSystem);
468
569
  if (on.ok) {
@@ -482,6 +583,63 @@ function cmdInstall(opts, { enable = false } = {}) {
482
583
  return false;
483
584
  }
484
585
 
586
+ function tryDisable(asSystem) {
587
+ const args = asSystem
588
+ ? [
589
+ ["disable", "--now", "orb44-satellite"],
590
+ ["daemon-reload"],
591
+ ]
592
+ : [
593
+ ["--user", "disable", "--now", "orb44-satellite"],
594
+ ["--user", "daemon-reload"],
595
+ ];
596
+ for (const a of args) {
597
+ spawnSync("systemctl", a, { encoding: "utf8" });
598
+ }
599
+ }
600
+
601
+ function rmTree(p) {
602
+ try {
603
+ fs.rmSync(p, { recursive: true, force: true });
604
+ } catch {
605
+ /* missing */
606
+ }
607
+ }
608
+
609
+ async function cmdUninstall(opts) {
610
+ applyLang(opts);
611
+ const asSystem = Boolean(opts.system) || process.getuid?.() === 0;
612
+ tryDisable(asSystem);
613
+ tryDisable(!asSystem);
614
+ const unitPaths = [
615
+ "/etc/systemd/system/orb44-satellite.service",
616
+ path.join(os.homedir(), ".config", "systemd", "user", "orb44-satellite.service"),
617
+ ];
618
+ for (const p of unitPaths) {
619
+ try {
620
+ fs.unlinkSync(p);
621
+ } catch {
622
+ /* missing */
623
+ }
624
+ }
625
+ rmTree("/usr/lib/orb44-sat");
626
+ try {
627
+ fs.unlinkSync(CLI_LINK);
628
+ } catch {
629
+ /* missing */
630
+ }
631
+ if (opts.purge) {
632
+ const found = existingOnBox();
633
+ await revokeLocalDevices(found.devices, found.devices[0]?.api);
634
+ rmTree("/var/lib/orb44");
635
+ spawnSync("userdel", ["orb44"], { encoding: "utf8" });
636
+ console.log("✅ " + t(lang, "uninstall_purged"));
637
+ return;
638
+ }
639
+ console.log("✅ " + t(lang, "uninstall_ok"));
640
+ console.log(dim(t(lang, "uninstall_key_kept", { file: SYSTEM_DEVICE })));
641
+ }
642
+
485
643
  function cmdStatus() {
486
644
  applyLang(opts);
487
645
  const device = loadDevice();
@@ -548,6 +706,7 @@ const run = {
548
706
  pulse: cmdPulse,
549
707
  daemon: () => cmdDaemon(opts),
550
708
  install: () => cmdInstall(opts, { enable: true }),
709
+ uninstall: () => cmdUninstall(opts),
551
710
  status: cmdStatus,
552
711
  logout: cmdLogout,
553
712
  lang: () => cmdLang(opts),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
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": {
@@ -10,7 +10,8 @@
10
10
  "bin/orb44.mjs",
11
11
  "server/pulse.mjs",
12
12
  "server/sat-i18n.mjs",
13
- "server/cli-menu.mjs"
13
+ "server/cli-menu.mjs",
14
+ "server/sat-local.mjs"
14
15
  ],
15
16
  "engines": {
16
17
  "node": ">=18"
package/server/pulse.mjs CHANGED
@@ -672,33 +672,70 @@ export function collectPulse() {
672
672
  });
673
673
  }
674
674
 
675
+ function clip(s, n) {
676
+ const t = String(s ?? "");
677
+ if (t.length <= n) return t;
678
+ return `${t.slice(0, Math.max(1, n - 1))}…`;
679
+ }
680
+
681
+ function asciiTable(headers, rows) {
682
+ const cells = [headers, ...rows].map((r) => r.map((c) => String(c ?? "")));
683
+ const widths = headers.map((_, i) => Math.max(1, ...cells.map((r) => [...(r[i] || "")].length)));
684
+ const fill = (ch) => widths.map((w) => ch.repeat(w + 2)).join("┼");
685
+ const line = (row) =>
686
+ "│ " +
687
+ row
688
+ .map((c, i) => {
689
+ const s = clip(c, widths[i]);
690
+ return s + " ".repeat(Math.max(0, widths[i] - [...s].length));
691
+ })
692
+ .join(" │ ") +
693
+ " │";
694
+ return [`┌${fill("─").replaceAll("┼", "┬")}┐`, line(headers), `├${fill("─")}┤`, ...rows.map(line), `└${fill("─").replaceAll("┼", "┴")}┘`].join("\n");
695
+ }
696
+
675
697
  export function formatPulsePreview(pulse, lang = "en") {
676
698
  const gb = (n) => (Number(n) / 1024 / 1024 / 1024).toFixed(1);
677
699
  const none = t(lang, "preview_none");
678
- const lines = [
679
- t(lang, "preview_host", { h: pulse.hostname || "—" }),
680
- t(lang, "preview_load", { load: pulse.load1, used: gb(pulse.memUsed), total: gb(pulse.memTotal), disk: pulse.diskUsedPct ?? "—" }),
681
- t(lang, "preview_listen", {
682
- list: (pulse.listen || []).slice(0, 12).map((r) => `${r.addr}:${r.port}${r.comm ? ` ${r.comm}` : ""}`).join(", ") || none,
683
- }),
684
- t(lang, "preview_top", {
685
- list: (pulse.top || []).slice(0, 5).map((r) => `${r.comm} ${r.cpuPct}% ${r.rssMb}M`).join(", ") || none,
686
- }),
687
- ];
688
- const h = pulse.hardening;
689
- if (h) {
690
- const ssh = h.sshPassword ? t(lang, "preview_ssh_pass") : h.sshPassword === false ? t(lang, "preview_ssh_key") : "?";
691
- lines.push(
692
- t(lang, "preview_guard", {
693
- fw: h.firewall || t(lang, "preview_fw_no"),
694
- ban: h.fail2ban || t(lang, "preview_ban_no"),
695
- upd: h.updates ? t(lang, "preview_yes") : t(lang, "preview_no"),
696
- sync: h.timesync || t(lang, "preview_no"),
697
- ssh,
698
- grade: pulse.gradeInside || "—",
699
- })
700
- );
701
- }
702
- if (pulse.limited) lines.push(t(lang, "preview_limited"));
703
- return lines.join("\n");
700
+ const h = pulse.hardening || {};
701
+ const grade = pulse.gradeInside || gradeInside(pulse) || "—";
702
+ const ssh = h.sshPassword ? t(lang, "preview_ssh_pass") : h.sshPassword === false ? t(lang, "preview_ssh_key") : "—";
703
+ const machine = asciiTable(
704
+ [t(lang, "preview_col_check"), t(lang, "preview_col_value")],
705
+ [
706
+ [t(lang, "preview_row_host"), pulse.hostname || "—"],
707
+ [t(lang, "preview_row_load"), Number(pulse.load1).toFixed(2)],
708
+ [t(lang, "preview_row_ram"), `${gb(pulse.memUsed)} / ${gb(pulse.memTotal)}`],
709
+ [t(lang, "preview_row_disk"), `${pulse.diskUsedPct ?? "—"}%`],
710
+ [t(lang, "preview_row_grade"), grade],
711
+ ]
712
+ );
713
+ const listenRows = [...(pulse.listen || [])]
714
+ .sort((a, b) => {
715
+ const rank = (addr) => (addr === "0.0.0.0" || addr === "::" ? 0 : String(addr).startsWith("127.") || addr === "::1" ? 2 : 1);
716
+ return rank(a.addr) - rank(b.addr) || Number(a.port) - Number(b.port);
717
+ })
718
+ .slice(0, 16)
719
+ .map((r) => [r.addr || "—", String(r.port ?? ""), r.comm || "—"]);
720
+ const listen = asciiTable(
721
+ [t(lang, "preview_col_bind"), t(lang, "preview_col_port"), t(lang, "preview_col_proc")],
722
+ listenRows.length ? listenRows : [[none, "", ""]]
723
+ );
724
+ const topRows = (pulse.top || []).slice(0, 6).map((r) => [r.comm || "", `${r.cpuPct}%`, `${r.rssMb}M`]);
725
+ const top = asciiTable(
726
+ [t(lang, "preview_col_proc"), t(lang, "preview_col_cpu"), t(lang, "preview_col_rss")],
727
+ topRows.length ? topRows : [[none, "", ""]]
728
+ );
729
+ const guard = asciiTable(
730
+ [t(lang, "preview_col_check"), t(lang, "preview_col_value")],
731
+ [
732
+ [t(lang, "preview_row_fw"), h.firewall || t(lang, "preview_fw_no")],
733
+ [t(lang, "preview_row_ban"), h.fail2ban || t(lang, "preview_ban_no")],
734
+ [t(lang, "preview_row_upd"), h.updates ? t(lang, "preview_yes") : t(lang, "preview_no")],
735
+ [t(lang, "preview_row_sync"), h.timesync || t(lang, "preview_no")],
736
+ [t(lang, "preview_row_ssh"), ssh],
737
+ ]
738
+ );
739
+ const extra = pulse.limited ? `\n${t(lang, "preview_limited")}` : "";
740
+ return `${machine}\n\n${listen}\n\n${top}\n\n${guard}${extra}`;
704
741
  }
@@ -21,7 +21,7 @@ export function detectLang(env = process.env) {
21
21
  const STR = {
22
22
  en: {
23
23
  banner_title: "Orb44 satellite",
24
- banner_sub: "Device key is pulse only, not a shell. Advice comes back on the pulse; you run commands.",
24
+ banner_sub: "Inside analysis of this host: load, listeners, hardening.",
25
25
  lang_pick: "Language · Язык · 언어 · Idioma",
26
26
  lang_saved: "Language saved: {label}",
27
27
  lang_now: "CLI language: {label} (--lang en|ru|ko|es)",
@@ -45,6 +45,7 @@ const STR = {
45
45
  daemon_run: "daemon every {sec}s · {host}",
46
46
  daemon_revoked: "key revoked",
47
47
  unit_written: "unit {path}",
48
+ cli_link: "command {path}",
48
49
  daemon_on: "daemon enabled: background pulse, starts again after reboot.",
49
50
  login_done: "Done. You can close this session — the daemon is already in the background.",
50
51
  daemon_user: "service user {user} · key {file}",
@@ -55,6 +56,18 @@ const STR = {
55
56
  not_paired: "Not paired. orb44 login",
56
57
  now_on_box: "On the machine now",
57
58
  logged_out: "Disconnected. Cabinet dropped the device.",
59
+ uninstall_ok: "Daemon stopped and unit removed. Pairing key kept.",
60
+ uninstall_purged: "Daemon, key, and service user orb44 removed. Cabinet dropped the device.",
61
+ uninstall_key_kept: "Key still at {file}. Later: npx @orb44/cli install",
62
+ already_title: "Already on this machine",
63
+ already_host: "{host} ({name})",
64
+ already_key: "key {file}",
65
+ already_daemon: "Watch daemon is already running",
66
+ already_unit: "systemd unit is already installed",
67
+ already_keep: "Keep — do not pair again",
68
+ already_replace: "Replace — new login, old device is revoked",
69
+ already_kept: "Left as is. Pulse sent. Same device in the cabinet.",
70
+ already_no_key: "Daemon is on, but this user cannot read the key. Try: sudo orb44 status",
58
71
  advice_title: "What to do on this machine",
59
72
  advice_sub: "The cabinet will not run this — a leaked key must not become a remote shell.",
60
73
  preview_host: "host {h}",
@@ -62,6 +75,23 @@ const STR = {
62
75
  preview_listen: "listening: {list}",
63
76
  preview_top: "top: {list}",
64
77
  preview_none: "not visible",
78
+ preview_col_check: "check",
79
+ preview_col_value: "value",
80
+ preview_col_bind: "bind",
81
+ preview_col_port: "port",
82
+ preview_col_proc: "process",
83
+ preview_col_cpu: "CPU",
84
+ preview_col_rss: "RSS",
85
+ preview_row_host: "host",
86
+ preview_row_load: "load",
87
+ preview_row_ram: "RAM",
88
+ preview_row_disk: "disk",
89
+ preview_row_grade: "grade",
90
+ preview_row_fw: "firewall",
91
+ preview_row_ban: "fail2ban",
92
+ preview_row_upd: "updates",
93
+ preview_row_sync: "time",
94
+ preview_row_ssh: "SSH",
65
95
  preview_guard: "guard: firewall {fw} · {ban} · unattended-upgrades {upd} · time {sync} · SSH {ssh} · grade {grade}",
66
96
  preview_fw_no: "none",
67
97
  preview_ban_no: "no fail2ban",
@@ -72,10 +102,11 @@ const STR = {
72
102
  preview_limited: "limited: cannot see which process owns the ports",
73
103
  help: `Orb44 satellite — admin device.
74
104
 
75
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
105
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
76
106
  orb44 pulse
77
107
  orb44 daemon [--interval 300]
78
108
  orb44 install [--system] [--interval 300]
109
+ orb44 uninstall [--purge]
79
110
  orb44 lang [en|ru|ko|es]
80
111
  orb44 status
81
112
  orb44 logout
@@ -86,7 +117,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
86
117
  },
87
118
  ru: {
88
119
  banner_title: "Orb44 сателлит",
89
- banner_sub: "Ключ устройства только пульс, не шелл. Советы приходят ответом, команды выполняете вы.",
120
+ banner_sub: "Анализ хоста изнутри: нагрузка, слушатели, hardening.",
90
121
  lang_pick: "Language · Язык · 언어 · Idioma",
91
122
  lang_saved: "Язык сохранён: {label}",
92
123
  lang_now: "Язык консоли: {label} (--lang en|ru|ko|es)",
@@ -110,6 +141,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
110
141
  daemon_run: "демон каждые {sec}с · {host}",
111
142
  daemon_revoked: "ключ отозван",
112
143
  unit_written: "unit {path}",
144
+ cli_link: "command {path}",
113
145
  daemon_on: "демон включён: пульс в фоне, поднимается после перезагрузки.",
114
146
  login_done: "Готово. Эту сессию можно закрыть — демон уже в фоне.",
115
147
  daemon_user: "пользователь службы {user} · ключ {file}",
@@ -120,6 +152,18 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
120
152
  not_paired: "Не связано. orb44 login",
121
153
  now_on_box: "Сейчас на машине",
122
154
  logged_out: "Отключено. В кабинете устройство снято.",
155
+ uninstall_ok: "Демон остановлен, unit снят. Ключ пары оставлен.",
156
+ uninstall_purged: "Демон, ключ и пользователь orb44 сняты. В кабинете устройство отозвано.",
157
+ uninstall_key_kept: "Ключ на месте: {file}. Потом: npx @orb44/cli install",
158
+ already_title: "На этой машине уже стоит",
159
+ already_host: "{host} ({name})",
160
+ already_key: "ключ {file}",
161
+ already_daemon: "Демон Watch уже работает",
162
+ already_unit: "systemd unit уже установлен",
163
+ already_keep: "Оставить — не логиниться заново",
164
+ already_replace: "Заменить — новый login, старое устройство отзовётся",
165
+ already_kept: "Оставили как есть. Пульс ушёл. В кабинете то же устройство.",
166
+ already_no_key: "Демон работает, но этот пользователь не читает ключ. Попробуйте: sudo orb44 status",
123
167
  advice_title: "Что сделать на этой машине",
124
168
  advice_sub: "Кабинет это не выполнит — иначе утечка ключа была бы удалённым шеллом.",
125
169
  preview_host: "хост {h}",
@@ -127,6 +171,23 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
127
171
  preview_listen: "слушает: {list}",
128
172
  preview_top: "топ: {list}",
129
173
  preview_none: "не видно",
174
+ preview_col_check: "проверка",
175
+ preview_col_value: "значение",
176
+ preview_col_bind: "адрес",
177
+ preview_col_port: "порт",
178
+ preview_col_proc: "процесс",
179
+ preview_col_cpu: "CPU",
180
+ preview_col_rss: "RSS",
181
+ preview_row_host: "хост",
182
+ preview_row_load: "load",
183
+ preview_row_ram: "RAM",
184
+ preview_row_disk: "диск",
185
+ preview_row_grade: "грейд",
186
+ preview_row_fw: "файрвол",
187
+ preview_row_ban: "fail2ban",
188
+ preview_row_upd: "обновления",
189
+ preview_row_sync: "время",
190
+ preview_row_ssh: "SSH",
130
191
  preview_guard: "защита: файрвол {fw} · {ban} · автообновления {upd} · время {sync} · SSH {ssh} · грейд {grade}",
131
192
  preview_fw_no: "нет",
132
193
  preview_ban_no: "fail2ban нет",
@@ -137,10 +198,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
137
198
  preview_limited: "ограничено: не видно, какой процесс слушает порты",
138
199
  help: `Orb44 сателлит — устройство админа.
139
200
 
140
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
201
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
141
202
  orb44 pulse
142
203
  orb44 daemon [--interval 300]
143
204
  orb44 install [--system] [--interval 300]
205
+ orb44 uninstall [--purge]
144
206
  orb44 lang [en|ru|ko|es]
145
207
  orb44 status
146
208
  orb44 logout
@@ -151,7 +213,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
151
213
  },
152
214
  ko: {
153
215
  banner_title: "Orb44 위성",
154
- banner_sub: "장치 키는 펄스만입니다. 셸이 아닙니다. 조언은 펄스 응답으로 오고, 명령은 직접 실행합니다.",
216
+ banner_sub: " 호스트를 안에서 분석합니다: 부하, 리스너, hardening.",
155
217
  lang_pick: "Language · Язык · 언어 · Idioma",
156
218
  lang_saved: "언어 저장됨: {label}",
157
219
  lang_now: "CLI 언어: {label} (--lang en|ru|ko|es)",
@@ -175,6 +237,7 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
175
237
  daemon_run: "데몬 {sec}초마다 · {host}",
176
238
  daemon_revoked: "키 취소됨",
177
239
  unit_written: "unit {path}",
240
+ cli_link: "command {path}",
178
241
  daemon_on: "데몬이 켜졌습니다: 백그라운드 펄스, 재부팅 후에도 올라옵니다.",
179
242
  login_done: "끝났습니다. 이 세션을 닫아도 됩니다 — 데몬은 이미 백그라운드에 있습니다.",
180
243
  daemon_user: "서비스 사용자 {user} · 키 {file}",
@@ -185,6 +248,18 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
185
248
  not_paired: "연결되지 않음. orb44 login",
186
249
  now_on_box: "지금 이 기기",
187
250
  logged_out: "해제됨. 콘솔에서 장치가 제거되었습니다.",
251
+ uninstall_ok: "데몬을 멈추고 unit을 제거했습니다. 페어링 키는 남겼습니다.",
252
+ uninstall_purged: "데몬, 키, 사용자 orb44를 제거했습니다. 콘솔에서 장치를 취소했습니다.",
253
+ uninstall_key_kept: "키는 그대로입니다: {file}. 나중에: npx @orb44/cli install",
254
+ already_title: "이 기기에 이미 있습니다",
255
+ already_host: "{host} ({name})",
256
+ already_key: "키 {file}",
257
+ already_daemon: "Watch 데몬이 이미 실행 중입니다",
258
+ already_unit: "systemd unit이 이미 설치되어 있습니다",
259
+ already_keep: "유지 — 다시 페어링하지 않음",
260
+ already_replace: "교체 — 새 login, 이전 장치는 취소됨",
261
+ already_kept: "그대로 두었습니다. 펄스를 보냈습니다. 콘솔의 장치는 같습니다.",
262
+ already_no_key: "데몬은 켜져 있지만 이 사용자는 키를 읽지 못합니다. sudo orb44 status",
188
263
  advice_title: "이 기기에서 할 일",
189
264
  advice_sub: "콘솔은 실행하지 않습니다. 키 유출이 원격 셸이 되면 안 됩니다.",
190
265
  preview_host: "호스트 {h}",
@@ -192,6 +267,23 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
192
267
  preview_listen: "수신: {list}",
193
268
  preview_top: "top: {list}",
194
269
  preview_none: "없음",
270
+ preview_col_check: "항목",
271
+ preview_col_value: "값",
272
+ preview_col_bind: "주소",
273
+ preview_col_port: "포트",
274
+ preview_col_proc: "프로세스",
275
+ preview_col_cpu: "CPU",
276
+ preview_col_rss: "RSS",
277
+ preview_row_host: "호스트",
278
+ preview_row_load: "load",
279
+ preview_row_ram: "RAM",
280
+ preview_row_disk: "디스크",
281
+ preview_row_grade: "등급",
282
+ preview_row_fw: "방화벽",
283
+ preview_row_ban: "fail2ban",
284
+ preview_row_upd: "업데이트",
285
+ preview_row_sync: "시간",
286
+ preview_row_ssh: "SSH",
195
287
  preview_guard: "보호: 방화벽 {fw} · {ban} · 자동업데이트 {upd} · 시간 {sync} · SSH {ssh} · 등급 {grade}",
196
288
  preview_fw_no: "없음",
197
289
  preview_ban_no: "fail2ban 없음",
@@ -202,10 +294,11 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
202
294
  preview_limited: "제한: 포트를 연 프로세스를 볼 수 없음",
203
295
  help: `Orb44 위성 — 관리자 장치.
204
296
 
205
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
297
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
206
298
  orb44 pulse
207
299
  orb44 daemon [--interval 300]
208
300
  orb44 install [--system] [--interval 300]
301
+ orb44 uninstall [--purge]
209
302
  orb44 lang [en|ru|ko|es]
210
303
  orb44 status
211
304
  orb44 logout
@@ -216,7 +309,7 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
216
309
  },
217
310
  es: {
218
311
  banner_title: "Satélite Orb44",
219
- banner_sub: "La clave del dispositivo es solo pulso, no un shell. Los consejos vuelven en la respuesta; las órdenes las ejecuta usted.",
312
+ banner_sub: "Análisis del host desde dentro: carga, listeners, hardening.",
220
313
  lang_pick: "Language · Язык · 언어 · Idioma",
221
314
  lang_saved: "Idioma guardado: {label}",
222
315
  lang_now: "Idioma de la consola: {label} (--lang en|ru|ko|es)",
@@ -240,6 +333,7 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
240
333
  daemon_run: "demonio cada {sec}s · {host}",
241
334
  daemon_revoked: "clave revocada",
242
335
  unit_written: "unit {path}",
336
+ cli_link: "command {path}",
243
337
  daemon_on: "demonio activado: pulso en segundo plano, arranca de nuevo tras el reinicio.",
244
338
  login_done: "Listo. Puede cerrar esta sesión: el demonio ya está en segundo plano.",
245
339
  daemon_user: "usuario del servicio {user} · clave {file}",
@@ -250,6 +344,18 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
250
344
  not_paired: "Sin vincular. orb44 login",
251
345
  now_on_box: "Ahora en la máquina",
252
346
  logged_out: "Desconectado. El gabinete quitó el dispositivo.",
347
+ uninstall_ok: "Demonio parado y unit quitado. Se guarda la clave de emparejamiento.",
348
+ uninstall_purged: "Demonio, clave y usuario orb44 eliminados. El gabinete revocó el dispositivo.",
349
+ uninstall_key_kept: "La clave sigue en {file}. Luego: npx @orb44/cli install",
350
+ already_title: "Ya está en esta máquina",
351
+ already_host: "{host} ({name})",
352
+ already_key: "clave {file}",
353
+ already_daemon: "El demonio Watch ya está en marcha",
354
+ already_unit: "El unit systemd ya está instalado",
355
+ already_keep: "Dejar — no volver a emparejar",
356
+ already_replace: "Sustituir — login nuevo, se revoca el dispositivo anterior",
357
+ already_kept: "Se dejó igual. Pulso enviado. El mismo dispositivo en el gabinete.",
358
+ already_no_key: "El demonio está activo, pero este usuario no lee la clave. Pruebe: sudo orb44 status",
253
359
  advice_title: "Qué hacer en esta máquina",
254
360
  advice_sub: "El gabinete no lo ejecutará: una clave filtrada no debe ser un shell remoto.",
255
361
  preview_host: "host {h}",
@@ -257,6 +363,23 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
257
363
  preview_listen: "escucha: {list}",
258
364
  preview_top: "top: {list}",
259
365
  preview_none: "no se ve",
366
+ preview_col_check: "dato",
367
+ preview_col_value: "valor",
368
+ preview_col_bind: "bind",
369
+ preview_col_port: "puerto",
370
+ preview_col_proc: "proceso",
371
+ preview_col_cpu: "CPU",
372
+ preview_col_rss: "RSS",
373
+ preview_row_host: "host",
374
+ preview_row_load: "load",
375
+ preview_row_ram: "RAM",
376
+ preview_row_disk: "disco",
377
+ preview_row_grade: "grado",
378
+ preview_row_fw: "cortafuegos",
379
+ preview_row_ban: "fail2ban",
380
+ preview_row_upd: "updates",
381
+ preview_row_sync: "hora",
382
+ preview_row_ssh: "SSH",
260
383
  preview_guard: "defensa: cortafuegos {fw} · {ban} · actualizaciones {upd} · hora {sync} · SSH {ssh} · grado {grade}",
261
384
  preview_fw_no: "no",
262
385
  preview_ban_no: "sin fail2ban",
@@ -267,10 +390,11 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
267
390
  preview_limited: "limitado: no se ve qué proceso tiene los puertos",
268
391
  help: `Satélite Orb44 — dispositivo de admin.
269
392
 
270
- orb44 login [--url http://127.0.0.1:8787] [--daemon] [--lang en|ru|ko|es]
393
+ orb44 login [--url http://127.0.0.1:8787] [--daemon] [--force] [--lang en|ru|ko|es]
271
394
  orb44 pulse
272
395
  orb44 daemon [--interval 300]
273
396
  orb44 install [--system] [--interval 300]
397
+ orb44 uninstall [--purge]
274
398
  orb44 lang [en|ru|ko|es]
275
399
  orb44 status
276
400
  orb44 logout
@@ -0,0 +1,46 @@
1
+ export const SYSTEM_DEVICE = "/var/lib/orb44/device.json";
2
+
3
+ export function parseDeviceJson(raw, filePath) {
4
+ try {
5
+ const row = JSON.parse(raw);
6
+ if (!row?.secret) return null;
7
+ return { ...row, path: filePath };
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ export function deviceSearchPaths(homeFile, systemdPath = SYSTEM_DEVICE) {
14
+ return [...new Set([homeFile, systemdPath].filter(Boolean))];
15
+ }
16
+
17
+ export function loadFirstDevice(readFile, paths) {
18
+ for (const p of paths) {
19
+ try {
20
+ const row = parseDeviceJson(readFile(p), p);
21
+ if (row) return row;
22
+ } catch {
23
+ /* missing or unreadable */
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function stripDevicePath(row) {
30
+ if (!row) return null;
31
+ const { path: _p, ...rest } = row;
32
+ return rest;
33
+ }
34
+
35
+ export function hasExistingInstall({ devices = [], daemon = false, unit = false } = {}) {
36
+ return Boolean((devices && devices.length) || daemon || unit);
37
+ }
38
+
39
+ export function pickLiveDevice(devices, { daemon = false, systemdPath = SYSTEM_DEVICE } = {}) {
40
+ const list = Array.isArray(devices) ? devices.filter((d) => d?.secret) : [];
41
+ if (daemon) {
42
+ const sys = list.find((d) => d.path === systemdPath);
43
+ if (sys) return sys;
44
+ }
45
+ return list[0] || null;
46
+ }