@orb44/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,11 +13,11 @@ npx @orb44/cli login --url https://orb44.com
13
13
  Open the printed link in a browser where you are already signed in, confirm the domain, then optionally install the systemd daemon (default no). After a global install the command is `orb44`.
14
14
 
15
15
  ```bash
16
- npx @orb44/cli@0.1.5 status
17
- npx @orb44/cli@0.1.5 pulse
18
- npx @orb44/cli@0.1.5 install # later: background pulse, starts after reboot
16
+ npx @orb44/cli@0.1.7 status
17
+ npx @orb44/cli@0.1.7 pulse
18
+ npx @orb44/cli@0.1.7 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.5 logout
20
+ npx @orb44/cli@0.1.7 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
@@ -9,7 +9,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
11
  import { SYSTEM_DEVICE, parseDeviceJson, hasExistingInstall, pickLiveDevice, deviceSearchPaths, loadFirstDevice, stripDevicePath } from "../server/sat-local.mjs";
12
- import { CLI_PACKAGE, cmpVer, readCliVersion, pickSatRoots, applyUpdateTree, fetchLatestMeta, unpackTarball } from "../server/sat-update.mjs";
12
+ import { CLI_PACKAGE, cmpVer, readCliVersion, pickSatRoots, staleSatRoots, applyUpdateTree, fetchLatestMeta, unpackTarball } from "../server/sat-update.mjs";
13
+ import { cabinetRequest, apiFailText } from "../server/sat-http.mjs";
13
14
 
14
15
  const DEVICE_FILE = process.env.ORB44_DEVICE_FILE || path.join(os.homedir(), ".config", "orb44", "device.json");
15
16
  const CLI_FILE = process.env.ORB44_CLI_FILE || path.join(path.dirname(DEVICE_FILE), "cli.json");
@@ -82,16 +83,12 @@ function clearDevice() {
82
83
  }
83
84
  }
84
85
 
85
- async function api(url, pathname, { method = "GET", json, secret } = {}) {
86
- const headers = { "Content-Type": "application/json", Accept: "application/json" };
87
- if (secret) headers.Authorization = `Bearer ${secret}`;
88
- const r = await fetch(`${url.replace(/\/$/, "")}${pathname}`, {
89
- method,
90
- headers,
91
- body: json ? JSON.stringify(json) : undefined,
92
- });
93
- const body = await r.json().catch(() => ({}));
94
- return { ok: r.ok, status: r.status, body };
86
+ function api(url, pathname, opts) {
87
+ return cabinetRequest(url, pathname, opts);
88
+ }
89
+
90
+ function failLine(out, fallbackKey = "pulse_fail") {
91
+ return "⚠️ " + apiFailText(lang, out, t, fallbackKey);
95
92
  }
96
93
 
97
94
  function dim(s) {
@@ -125,7 +122,7 @@ async function ensureLang(opts) {
125
122
  return lang;
126
123
  }
127
124
  lang = detectLang();
128
- if (opts.yes || !input.isTTY || !output.isTTY) {
125
+ if (opts.yes || opts.url || !input.isTTY || !output.isTTY) {
129
126
  saveCli({ ...loadCli(), lang });
130
127
  return lang;
131
128
  }
@@ -245,9 +242,10 @@ async function revokeLocalDevices(devices, fallbackApi) {
245
242
  }
246
243
 
247
244
  async function cmdLogin(opts) {
245
+ const url = String(opts.url || process.env.ORB44_API || "http://127.0.0.1:8787").replace(/\/$/, "");
246
+ process.stdout.write(`orb44 login → ${url}\n`);
248
247
  await ensureLang(opts);
249
248
  banner();
250
- const url = String(opts.url || process.env.ORB44_API || "http://127.0.0.1:8787").replace(/\/$/, "");
251
249
  const found = existingOnBox();
252
250
  if (hasExistingInstall(found)) {
253
251
  const replace = await wantReplace(found, opts);
@@ -259,7 +257,7 @@ async function cmdLogin(opts) {
259
257
  }
260
258
  const { out } = await sendPulse(live);
261
259
  if (!out.ok) {
262
- console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
260
+ console.error(failLine(out));
263
261
  process.exit(1);
264
262
  }
265
263
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -276,7 +274,7 @@ async function cmdLogin(opts) {
276
274
  json: { hostname, name },
277
275
  });
278
276
  if (!begin.ok || !begin.body.pollToken) {
279
- console.error(`⚠️ ${begin.body.error || t(lang, "pair_fail")}`);
277
+ console.error(failLine(begin, "pair_fail"));
280
278
  process.exit(1);
281
279
  }
282
280
  console.log(`🔗 API ${url}`);
@@ -320,7 +318,7 @@ async function cmdLogin(opts) {
320
318
 
321
319
  const { out } = await sendPulse(device);
322
320
  if (!out.ok) {
323
- console.error(`⚠️ ${out.body.error || t(lang, "pulse_fail")}`);
321
+ console.error(failLine(out));
324
322
  process.exit(1);
325
323
  }
326
324
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -350,10 +348,7 @@ async function cmdPulse() {
350
348
  banner();
351
349
  const { out } = await sendPulse(device);
352
350
  if (!out.ok) {
353
- console.error(
354
- "⚠️ " +
355
- (out.body.error === "bad_secret" || out.body.error === "not_found" ? t(lang, "key_revoked") : out.body.error || t(lang, "pulse_fail"))
356
- );
351
+ console.error(failLine(out));
357
352
  process.exit(1);
358
353
  }
359
354
  console.log("\n📡 " + t(lang, "pulse_ok"));
@@ -370,14 +365,17 @@ async function cmdDaemon(opts) {
370
365
  const sec = intervalSec(opts.interval);
371
366
  console.log(`🛰️ ${t(lang, "daemon_run", { sec, host: device.host })}`);
372
367
  const tick = async () => {
373
- const { out, pulse } = await sendPulse(device, { preview: false });
374
368
  const hh = new Date().toISOString().slice(11, 19);
375
- if (!out.ok) {
376
- const gone = out.body.error === "bad_secret" || out.body.error === "not_found";
377
- console.error(`⚠️ ${hh} ${gone ? t(lang, "daemon_revoked") : out.body.error || t(lang, "pulse_fail")}`);
378
- return;
369
+ try {
370
+ const { out, pulse } = await sendPulse(device, { preview: false });
371
+ if (!out.ok) {
372
+ console.error(`⚠️ ${hh} ${apiFailText(lang, out, t)}`);
373
+ return;
374
+ }
375
+ console.log(`📡 ${hh} ok load ${pulse.load1} ${pulse.gradeInside || "—"}`);
376
+ } catch (e) {
377
+ console.error(`⚠️ ${hh} ${String(e?.message || e).split("\n")[0].slice(0, 200)}`);
379
378
  }
380
- console.log(`📡 ${hh} ok load ${pulse.load1} ${pulse.gradeInside || "—"}`);
381
379
  };
382
380
  await tick();
383
381
  const id = setInterval(tick, sec * 1000);
@@ -646,23 +644,24 @@ async function cmdUpdate() {
646
644
  process.exit(1);
647
645
  }
648
646
  const pin = t(lang, "update_npx", { version: meta.version });
649
- if (cmpVer(current, meta.version) >= 0 && !opts.force) {
650
- console.log("✅ " + t(lang, "update_same", { version: current }));
651
- console.log(dim(pin));
652
- return;
653
- }
654
647
  const roots = pickSatRoots(SCRIPT);
648
+ const stale = staleSatRoots(roots, meta.version, { force: Boolean(opts.force) });
655
649
  if (!roots.length) {
656
650
  console.error("⚠️ " + t(lang, "update_no_tree"));
657
651
  console.log(dim(pin));
658
652
  process.exit(1);
659
653
  }
654
+ if (!stale.length) {
655
+ console.log("✅ " + t(lang, "update_same", { version: current || meta.version }));
656
+ console.log(dim(pin));
657
+ return;
658
+ }
660
659
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "orb44-upd-"));
661
660
  let wrote = [];
662
661
  try {
663
662
  const pkg = await unpackTarball(meta.tarball, tmp);
664
663
  const blocked = [];
665
- for (const root of roots) {
664
+ for (const root of stale) {
666
665
  try {
667
666
  applyUpdateTree(pkg, root);
668
667
  wrote.push(root);
@@ -713,7 +712,7 @@ async function cmdLogout() {
713
712
  applyLang(opts);
714
713
  const device = loadDevice();
715
714
  if (device?.secret) {
716
- await api(device.api, "/api/satellites/logout", { method: "POST", json: { secret: device.secret } });
715
+ await api(device.api, "/api/satellites/logout", { method: "POST", json: { secret: device.secret } }).catch(() => {});
717
716
  }
718
717
  clearDevice();
719
718
  console.log("✅ " + t(lang, "logged_out"));
@@ -769,4 +768,9 @@ if (!fn) {
769
768
  help();
770
769
  process.exit(1);
771
770
  }
772
- await fn();
771
+ try {
772
+ await fn();
773
+ } catch (e) {
774
+ console.error("⚠️ " + String(e?.message || e).split("\n")[0].slice(0, 200));
775
+ process.exit(1);
776
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": {
@@ -12,6 +12,7 @@
12
12
  "server/sat-i18n.mjs",
13
13
  "server/cli-menu.mjs",
14
14
  "server/sat-local.mjs",
15
+ "server/sat-http.mjs",
15
16
  "server/sat-update.mjs"
16
17
  ],
17
18
  "engines": {
package/server/pulse.mjs CHANGED
@@ -78,17 +78,26 @@ function run(cmd, args) {
78
78
 
79
79
  const SKIP_TOP = new Set(["ps", "ss", "lsof"]);
80
80
 
81
+ const STAT_RE = /^[DRSTZIX][A-Za-z+<]*$/;
82
+
81
83
  export function parsePsTop(text) {
82
84
  const rows = [];
83
85
  for (const line of String(text || "").split("\n")) {
84
- const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)/);
86
+ const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)(?:\s+(\S+))?$/);
85
87
  if (!m) continue;
86
- const comm = sanitizeComm(m[4]);
88
+ let stat = null;
89
+ let commRaw = m[4];
90
+ if (m[5] && STAT_RE.test(m[4]) && m[4].length <= 8) {
91
+ stat = m[4].slice(0, 4);
92
+ commRaw = m[5];
93
+ }
94
+ const comm = sanitizeComm(commRaw);
87
95
  if (!comm || SKIP_TOP.has(comm)) continue;
88
96
  rows.push({
89
97
  comm,
90
98
  cpuPct: Math.round(Number(m[2]) * 10) / 10,
91
99
  rssMb: Math.round(Number(m[3]) / 1024),
100
+ stat,
92
101
  });
93
102
  }
94
103
  rows.sort((a, b) => b.cpuPct - a.cpuPct || b.rssMb - a.rssMb);
@@ -168,8 +177,10 @@ function listenTable() {
168
177
  }
169
178
 
170
179
  function topTable() {
171
- const text = run("ps", ["-axo", "pid=,pcpu=,rss=,comm="]);
172
- return parsePsTop(text);
180
+ const withStat = run("ps", ["-axo", "pid=,pcpu=,rss=,stat=,comm="]);
181
+ const parsed = parsePsTop(withStat);
182
+ if (parsed.length) return parsed;
183
+ return parsePsTop(run("ps", ["-axo", "pid=,pcpu=,rss=,comm="]));
173
184
  }
174
185
 
175
186
  function okListenAddr(a) {
@@ -180,23 +191,29 @@ function okListenAddr(a) {
180
191
  }
181
192
 
182
193
  export function collapseListen(rows = []) {
183
- const groups = new Map();
194
+ const world = [];
195
+ const rest = [];
184
196
  for (const r of rows) {
185
- const key = `${Number(r.port)}|${r.comm || ""}`;
186
- if (!groups.has(key)) groups.set(key, []);
187
- groups.get(key).push(r);
197
+ if (r.addr === "0.0.0.0" || r.addr === "::" || r.addr === "*") world.push(r);
198
+ else rest.push(r);
199
+ }
200
+ const byPort = new Map();
201
+ for (const r of world) {
202
+ const port = Number(r.port);
203
+ if (!byPort.has(port)) byPort.set(port, []);
204
+ byPort.get(port).push(r);
188
205
  }
189
206
  const out = [];
190
- for (const list of groups.values()) {
207
+ for (const list of byPort.values()) {
191
208
  const addrs = new Set(list.map((x) => x.addr));
192
- const rest = list.filter((x) => x.addr !== "0.0.0.0" && x.addr !== "::" && x.addr !== "*");
209
+ const comm = list.find((x) => x.comm)?.comm || list[0].comm;
193
210
  if (addrs.has("*") || (addrs.has("0.0.0.0") && addrs.has("::"))) {
194
- out.push({ addr: "*", port: list[0].port, comm: list[0].comm });
195
- out.push(...rest);
211
+ out.push({ addr: "*", port: list[0].port, comm });
196
212
  } else {
197
213
  out.push(...list);
198
214
  }
199
215
  }
216
+ out.push(...rest);
200
217
  return out;
201
218
  }
202
219
 
@@ -208,6 +225,7 @@ export function sanitizePulse(raw = {}) {
208
225
  comm: sanitizeComm(r.comm),
209
226
  cpuPct: Math.max(0, Math.min(100, Number(r.cpuPct) || 0)),
210
227
  rssMb: Math.max(0, Math.round(Number(r.rssMb) || 0)),
228
+ stat: STAT_RE.test(String(r.stat || "")) ? String(r.stat).slice(0, 4) : null,
211
229
  }))
212
230
  .filter((r) => r.comm)
213
231
  : [];
@@ -247,6 +265,7 @@ export function sanitizePulse(raw = {}) {
247
265
  };
248
266
  const limited = Boolean(raw.limited);
249
267
  const oom = Boolean(raw.oom) || hardening.oomKills > 0;
268
+ const pressure = sanitizePressure(raw.pressure || raw);
250
269
  const base = {
251
270
  ts: Number(raw.ts) || Date.now(),
252
271
  hostname: sanitizeComm(raw.hostname) || os.hostname().slice(0, 40),
@@ -261,6 +280,7 @@ export function sanitizePulse(raw = {}) {
261
280
  oom,
262
281
  limited,
263
282
  hardening,
283
+ pressure,
264
284
  };
265
285
  return { ...base, gradeInside: gradeInside(base) };
266
286
  }
@@ -345,6 +365,172 @@ export function parseVmstatOom(text) {
345
365
  return m ? Number(m[1]) : 0;
346
366
  }
347
367
 
368
+ export function parseSockstat(text) {
369
+ const tcp = /(?:^|\n)TCP:\s+inuse\s+(\d+)(?:\s+orphan\s+(\d+))?(?:\s+tw\s+(\d+))?/i.exec(String(text || ""));
370
+ if (!tcp) return { tcpInuse: null, tcpOrphan: null, tcpTw: null };
371
+ return {
372
+ tcpInuse: Number(tcp[1]),
373
+ tcpOrphan: tcp[2] != null ? Number(tcp[2]) : null,
374
+ tcpTw: tcp[3] != null ? Number(tcp[3]) : null,
375
+ };
376
+ }
377
+
378
+ export function parseFileNr(text) {
379
+ const p = String(text || "").trim().split(/[\s,]+/);
380
+ const used = Number(p[0]);
381
+ const max = Number(p[2] ?? p[1]);
382
+ if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return { fileUsed: null, fileMax: null, filePct: null };
383
+ return { fileUsed: used, fileMax: max, filePct: Math.max(0, Math.min(100, Math.round((used / max) * 100))) };
384
+ }
385
+
386
+ export function parseChronyTracking(text) {
387
+ const m =
388
+ /Last offset\s*:\s*([+-]?\d+(?:\.\d+)?)\s*seconds/i.exec(String(text || "")) ||
389
+ /System time\s*:\s*(\d+(?:\.\d+)?)\s*seconds\s+(slow|fast)/i.exec(String(text || ""));
390
+ if (!m) return null;
391
+ let n = Number(m[1]);
392
+ if (!Number.isFinite(n)) return null;
393
+ if (m[2] === "fast") n = Math.abs(n);
394
+ if (m[2] === "slow") n = -Math.abs(n);
395
+ return Math.round(n * 1000) / 1000;
396
+ }
397
+
398
+ export function parseCgroupEvents(text) {
399
+ const kill = /(?:^|\n)oom_kill\s+(\d+)/.exec(String(text || ""));
400
+ const oom = /(?:^|\n)oom\s+(\d+)/.exec(String(text || ""));
401
+ return {
402
+ cgroupOom: kill ? Number(kill[1]) : oom ? Number(oom[1]) : 0,
403
+ memFailcnt: 0,
404
+ };
405
+ }
406
+
407
+ export function parseNetstatListenOverflows(text) {
408
+ const lines = String(text || "").split("\n");
409
+ let header = "";
410
+ let values = "";
411
+ for (const line of lines) {
412
+ if (/^TcpExt:\s+/.test(line) && /ListenOverflows/.test(line)) header = line;
413
+ else if (/^TcpExt:\s+/.test(line) && header && !/ListenOverflows/.test(line)) values = line;
414
+ }
415
+ if (!header || !values) return { listenOverflows: null, listenDrops: null };
416
+ const keys = header.replace(/^TcpExt:\s+/, "").trim().split(/\s+/);
417
+ const nums = values.replace(/^TcpExt:\s+/, "").trim().split(/\s+/).map(Number);
418
+ const idx = (name) => keys.indexOf(name);
419
+ const n = (i) => (i >= 0 && Number.isFinite(nums[i]) ? nums[i] : null);
420
+ return { listenOverflows: n(idx("ListenOverflows")), listenDrops: n(idx("ListenDrops")) };
421
+ }
422
+
423
+ function readTrim(p) {
424
+ try {
425
+ return fs.readFileSync(p, "utf8").trim();
426
+ } catch {
427
+ return "";
428
+ }
429
+ }
430
+
431
+ function numFile(p) {
432
+ const n = Number(readTrim(p));
433
+ return Number.isFinite(n) ? n : null;
434
+ }
435
+
436
+ export function stuckFromTop(top = []) {
437
+ return (top || []).filter((r) => {
438
+ const s = String(r.stat || "")[0];
439
+ return s === "D" || s === "Z" || s === "T";
440
+ }).slice(0, 6);
441
+ }
442
+
443
+ export function hotFromTop(top = []) {
444
+ return (top || []).filter((r) => Number(r.cpuPct) >= 70 || Number(r.rssMb) >= 1024).slice(0, 6);
445
+ }
446
+
447
+ export function cpuCount(pulse) {
448
+ const n = Number(pulse?.pressure?.cpus);
449
+ return Number.isFinite(n) && n > 0 ? n : 2;
450
+ }
451
+
452
+ function collectPressure() {
453
+ const cpus = Math.max(1, os.cpus()?.length || 1);
454
+ const sock = parseSockstat(readTrim("/proc/net/sockstat"));
455
+ const files = parseFileNr(readTrim("/proc/sys/fs/file-nr"));
456
+ let conntrackUsed = numFile("/proc/sys/net/netfilter/nf_conntrack_count");
457
+ let conntrackMax = numFile("/proc/sys/net/netfilter/nf_conntrack_max");
458
+ if (conntrackUsed == null) conntrackUsed = numFile("/proc/sys/net/nf_conntrack_count");
459
+ if (conntrackMax == null) conntrackMax = numFile("/proc/sys/net/nf_conntrack_max");
460
+ const conntrackPct =
461
+ conntrackUsed != null && conntrackMax > 0 ? Math.max(0, Math.min(100, Math.round((conntrackUsed / conntrackMax) * 100))) : null;
462
+ const overflows = parseNetstatListenOverflows(readTrim("/proc/net/netstat"));
463
+ let clockOffsetSec = parseChronyTracking(runOut("chronyc", ["tracking"]));
464
+ let ntpSync = null;
465
+ if (clockOffsetSec != null) ntpSync = true;
466
+ else {
467
+ const td = runOut("timedatectl", ["show", "-p", "NTPSynchronized", "--value"]);
468
+ if (/^yes$/i.test(td.trim())) ntpSync = true;
469
+ else if (/^no$/i.test(td.trim())) ntpSync = false;
470
+ }
471
+ let cgroupOom = 0;
472
+ let memFailcnt = 0;
473
+ const ev = parseCgroupEvents(readTrim("/sys/fs/cgroup/memory.events") || readTrim("/sys/fs/cgroup/memory/memory.events"));
474
+ cgroupOom = ev.cgroupOom || 0;
475
+ memFailcnt = numFile("/sys/fs/cgroup/memory/memory.failcnt") ?? ev.memFailcnt ?? 0;
476
+ return {
477
+ cpus,
478
+ conntrackUsed,
479
+ conntrackMax,
480
+ conntrackPct,
481
+ tcpInuse: sock.tcpInuse,
482
+ tcpTw: sock.tcpTw,
483
+ tcpOrphan: sock.tcpOrphan,
484
+ listenOverflows: overflows.listenOverflows,
485
+ listenDrops: overflows.listenDrops,
486
+ fileUsed: files.fileUsed,
487
+ fileMax: files.fileMax,
488
+ filePct: files.filePct,
489
+ clockOffsetSec,
490
+ ntpSync,
491
+ memFailcnt: Math.max(0, Math.min(999999, Number(memFailcnt) || 0)),
492
+ cgroupOom: Math.max(0, Math.min(999, Number(cgroupOom) || 0)),
493
+ };
494
+ }
495
+
496
+ function sanitizePressure(raw = {}) {
497
+ const n = (v, lo, hi) => {
498
+ if (v == null || v === "") return null;
499
+ const x = Number(v);
500
+ if (!Number.isFinite(x)) return null;
501
+ return Math.max(lo, Math.min(hi, x));
502
+ };
503
+ const cpus = n(raw.cpus, 1, 512) || null;
504
+ const conntrackUsed = n(raw.conntrackUsed, 0, 1e9);
505
+ const conntrackMax = n(raw.conntrackMax, 0, 1e9);
506
+ const conntrackPct =
507
+ raw.conntrackPct != null
508
+ ? n(raw.conntrackPct, 0, 100)
509
+ : conntrackUsed != null && conntrackMax > 0
510
+ ? Math.max(0, Math.min(100, Math.round((conntrackUsed / conntrackMax) * 100)))
511
+ : null;
512
+ const clockOffsetSec = n(raw.clockOffsetSec, -86400, 86400);
513
+ const ntpSync = raw.ntpSync === true || raw.ntpSync === false ? raw.ntpSync : null;
514
+ return {
515
+ cpus,
516
+ conntrackUsed,
517
+ conntrackMax,
518
+ conntrackPct,
519
+ tcpInuse: n(raw.tcpInuse, 0, 1e9),
520
+ tcpTw: n(raw.tcpTw, 0, 1e9),
521
+ tcpOrphan: n(raw.tcpOrphan, 0, 1e9),
522
+ listenOverflows: n(raw.listenOverflows, 0, 1e12),
523
+ listenDrops: n(raw.listenDrops, 0, 1e12),
524
+ fileUsed: n(raw.fileUsed, 0, 1e12),
525
+ fileMax: n(raw.fileMax, 0, 1e12),
526
+ filePct: n(raw.filePct, 0, 100),
527
+ clockOffsetSec,
528
+ ntpSync,
529
+ memFailcnt: n(raw.memFailcnt, 0, 1e9) || 0,
530
+ cgroupOom: n(raw.cgroupOom, 0, 999) || 0,
531
+ };
532
+ }
533
+
348
534
  export function insideServiceWorldPorts(pulse) {
349
535
  return [...new Set((pulse?.listen || []).filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port)).map((r) => r.port))];
350
536
  }
@@ -419,19 +605,23 @@ export function parseFail2banJail(text) {
419
605
  export function gradeInside(pulse = {}) {
420
606
  const listen = pulse.listen || [];
421
607
  const h = pulse.hardening || {};
608
+ const pr = pulse.pressure || {};
422
609
  const exposedAdmin = listen.filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port));
423
610
  const fw = Boolean(h.firewall);
424
611
  const ban = Boolean(h.fail2ban);
425
612
  const updates = Boolean(h.updates);
426
613
  const diskHigh = Number(pulse.diskUsedPct) >= 85;
427
- const oom = Boolean(pulse.oom) || Number(h.oomKills) > 0;
614
+ const oom = Boolean(pulse.oom) || Number(h.oomKills) > 0 || Number(pr.cgroupOom) > 0;
615
+ const loadHigh = Number(pulse.load1) >= Math.max(2, cpuCount(pulse));
616
+ const connHot = Number(pr.conntrackPct) >= 90;
617
+ const stuck = stuckFromTop(pulse.top).length > 0;
428
618
  if (h.dockerApi) return "D";
429
619
  if (h.sshPassword && h.sshRoot && h.sshWorld) return "D";
430
620
  if (!fw && exposedAdmin.length) return "D";
431
621
  if (!fw && !ban) return "D";
432
622
  if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
433
623
  if (!fw || !ban) return "C";
434
- if (diskHigh || oom || h.rebootNeeded || !h.timesync) return "C";
624
+ if (diskHigh || oom || h.rebootNeeded || !h.timesync || loadHigh || connHot || stuck) return "C";
435
625
  if (fw && ban && updates && h.timesync && !h.sshPassword && !h.sshRoot && !exposedAdmin.length && !h.dockerApi) return "A";
436
626
  if (fw && ban && !exposedAdmin.length && !h.dockerApi) return "B";
437
627
  return "C";
@@ -442,7 +632,7 @@ function labelPort(r) {
442
632
  return svc ? `${svc} :${r.port}` : `:${r.port}`;
443
633
  }
444
634
 
445
- export function compareInside(pulse, rec = {}) {
635
+ export function compareInside(pulse, rec = {}, prev = null) {
446
636
  const notes = [];
447
637
  const listen = pulse?.listen || [];
448
638
  const streetIp = rec.ticket?.ip || rec.watch?.current?.ip || null;
@@ -482,8 +672,9 @@ export function compareInside(pulse, rec = {}) {
482
672
  do: "Сверьте DNS A с тем VPS, куда поставили сателлит. Если сайт за Cloudflare — это ожидаемо, не инцидент.",
483
673
  });
484
674
  }
485
- const loadHigh = Number(pulse?.load1) >= Math.max(2, (os.cpus() || []).length);
486
- const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.9;
675
+ const loadHigh = Number(pulse?.load1) >= Math.max(2, cpuCount(pulse));
676
+ const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.85;
677
+ const pr = pulse?.pressure || {};
487
678
  if (loadHigh || memHigh) {
488
679
  const top = pulse.top?.[0];
489
680
  const who = top ? `${top.comm} ${top.cpuPct}%` : "процесс в топе не виден";
@@ -599,6 +790,93 @@ export function compareInside(pulse, rec = {}) {
599
790
  });
600
791
  }
601
792
  }
793
+ const stuck = stuckFromTop(pulse?.top);
794
+ if (stuck.length) {
795
+ const who = stuck.map((r) => `${r.comm} (${r.stat || "?"})`).join(", ");
796
+ notes.push({
797
+ kind: "stuck-proc",
798
+ title: "Процессы зависли или зомби",
799
+ text: `Состояние D/Z/T: ${who}. Так машина стоит на диске или мёртвых воркерах, а не «просто высокая нагрузка».`,
800
+ do: "Не убивайте с кабинета — его нет. На сервере: ps и диск, не WAF. Orb44 процессы сам не трогает.",
801
+ });
802
+ }
803
+ const hot = hotFromTop(pulse?.top);
804
+ if (hot.length && !loadHigh) {
805
+ const who = hot.map((r) => `${r.comm} ${r.cpuPct}%/${r.rssMb}M`).join(", ");
806
+ notes.push({
807
+ kind: "hot-proc",
808
+ title: "Сервис жрёт CPU или RAM",
809
+ text: `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
810
+ do: "Смотрите этот процесс (php-fpm, node, mysql). Orb44 его не рестартит.",
811
+ });
812
+ }
813
+ if (Number(pr.conntrackPct) >= 80) {
814
+ notes.push({
815
+ kind: "conntrack",
816
+ title: "Таблица соединений почти полная",
817
+ text: `conntrack ${pr.conntrackUsed}/${pr.conntrackMax} (${pr.conntrackPct}%). Новые сессии начнут отбрасываться — с улицы это 502, изнутри это очередь.`,
818
+ do: "Ищите кто держит кучу TCP. Не открывайте порты «чтобы помогло».",
819
+ });
820
+ }
821
+ const prevPr = prev?.pressure || {};
822
+ const overDelta =
823
+ pr.listenOverflows != null && prevPr.listenOverflows != null
824
+ ? Number(pr.listenOverflows) - Number(prevPr.listenOverflows)
825
+ : 0;
826
+ const dropDelta =
827
+ pr.listenDrops != null && prevPr.listenDrops != null
828
+ ? Number(pr.listenDrops) - Number(prevPr.listenDrops)
829
+ : 0;
830
+ if (overDelta > 0 || dropDelta > 0) {
831
+ notes.push({
832
+ kind: "backlog",
833
+ title: "Очередь accept переполняется",
834
+ text: `С прошлого пульса ListenOverflows +${overDelta}, ListenDrops +${dropDelta}. Сервис не успевает брать соединения.`,
835
+ do: "Больше воркеров или меньше входа. Orb44 лимиты сам не поднимает.",
836
+ });
837
+ }
838
+ if (Number(pr.filePct) >= 85) {
839
+ notes.push({
840
+ kind: "files",
841
+ title: "Заканчиваются файловые дескрипторы",
842
+ text: `Открыто ${pr.filePct}% лимита (${pr.fileUsed}/${pr.fileMax}). Типичный «внезапно не открывается сокет».`,
843
+ do: "Кто держит файлы: воркер или утечка. Orb44 ulimit сам не меняет.",
844
+ });
845
+ }
846
+ if (pr.clockOffsetSec != null && Math.abs(pr.clockOffsetSec) >= 5) {
847
+ notes.push({
848
+ kind: "clock",
849
+ title: "Часы машины уехали",
850
+ text: `Смещение NTP ${pr.clockOffsetSec} с. TLS и метки Watch начнут врать.`,
851
+ do: "Почините chrony/timesyncd. Orb44 время сам не ставит.",
852
+ });
853
+ }
854
+ if (Number(pr.cgroupOom) > 0 || Number(pr.memFailcnt) > 20) {
855
+ notes.push({
856
+ kind: "cgroup-oom",
857
+ title: "Контейнер или cgroup упирается в память",
858
+ text: `cgroup oom=${pr.cgroupOom || 0}, failcnt=${pr.memFailcnt || 0}. Это не journal — счётчик ядра.`,
859
+ do: "Лимит памяти контейнера, не WAF. Orb44 лимиты сам не поднимает.",
860
+ });
861
+ }
862
+ if (prev?.listen && pulse?.listen) {
863
+ const face = (p) =>
864
+ (p.listen || [])
865
+ .filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
866
+ .map((r) => `${r.port}:${r.comm || "?"}`)
867
+ .sort()
868
+ .join(",");
869
+ const a = face(prev);
870
+ const b = face(pulse);
871
+ if (a && b && a !== b) {
872
+ notes.push({
873
+ kind: "listen-swap",
874
+ title: "На 80/443 сменился процесс",
875
+ text: `Было ${a}, стало ${b}. Бинарь витрины подменили или рядом встал другой сервер.`,
876
+ do: "Сверьте, кто должен слушать HTTPS. Orb44 процесс сам не откатывает.",
877
+ });
878
+ }
879
+ }
602
880
  return notes;
603
881
  }
604
882
 
@@ -612,7 +890,7 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
612
890
  const notes = [];
613
891
  const incident = String(streetIncidentCode(rec) || "");
614
892
  const streetHot = /auth_hot|auth_open|l7_exhaustion/.test(incident);
615
- const loadHigh = Number(pulse?.load1) >= 2;
893
+ const loadHigh = Number(pulse?.load1) >= Math.max(2, cpuCount(pulse));
616
894
  const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.85;
617
895
  const top = pulse?.top?.[0];
618
896
  const who = top ? `${top.comm} ${top.cpuPct}%` : "процесс в топе не виден";
@@ -651,6 +929,52 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
651
929
  text: `Появились ${added.join(", ")}. С прошлого пульса их не было.`,
652
930
  });
653
931
  }
932
+ const face = (p) =>
933
+ (p?.listen || [])
934
+ .filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
935
+ .map((r) => `${r.port}:${r.comm || "?"}`)
936
+ .sort()
937
+ .join(",");
938
+ const a = face(prev);
939
+ const b = face(pulse);
940
+ if (a && b && a !== b) {
941
+ notes.push({
942
+ kind: "listen-swap",
943
+ title: "На 80/443 сменился процесс",
944
+ text: `Было ${a}, стало ${b}.`,
945
+ });
946
+ }
947
+ }
948
+ if (stuckFromTop(pulse?.top).length) {
949
+ notes.push({
950
+ kind: "stuck-proc",
951
+ title: "Процессы зависли",
952
+ text: stuckFromTop(pulse.top)
953
+ .map((r) => `${r.comm} ${r.stat}`)
954
+ .join(", "),
955
+ });
956
+ }
957
+ if (Number(pulse?.pressure?.conntrackPct) >= 80) {
958
+ notes.push({
959
+ kind: "conntrack",
960
+ title: "conntrack почти полный",
961
+ text: `${pulse.pressure.conntrackPct}%`,
962
+ });
963
+ }
964
+ const overDelta =
965
+ pulse?.pressure?.listenOverflows != null && prev?.pressure?.listenOverflows != null
966
+ ? Number(pulse.pressure.listenOverflows) - Number(prev.pressure.listenOverflows)
967
+ : 0;
968
+ const dropDelta =
969
+ pulse?.pressure?.listenDrops != null && prev?.pressure?.listenDrops != null
970
+ ? Number(pulse.pressure.listenDrops) - Number(prev.pressure.listenDrops)
971
+ : 0;
972
+ if (overDelta > 0 || dropDelta > 0) {
973
+ notes.push({
974
+ kind: "backlog",
975
+ title: "Очередь accept растёт",
976
+ text: `+${overDelta} overflows, +${dropDelta} drops с прошлого пульса.`,
977
+ });
654
978
  }
655
979
  const streetIp = rec.ticket?.ip || rec.watch?.current?.ip || rec.watch?.last?.ip || null;
656
980
  if (pulse?.originA?.length && streetIp && !pulse.originA.includes(streetIp)) {
@@ -678,6 +1002,7 @@ export function collectPulse() {
678
1002
  const top = topTable();
679
1003
  const hardening = collectHardening(listen);
680
1004
  const limited = listen.length > 0 && !named;
1005
+ const pressure = collectPressure();
681
1006
  return sanitizePulse({
682
1007
  ts: Date.now(),
683
1008
  hostname: os.hostname(),
@@ -689,9 +1014,10 @@ export function collectPulse() {
689
1014
  listen,
690
1015
  originA: originAddrs(),
691
1016
  failedUnit: null,
692
- oom: Number(hardening.oomKills) > 0,
1017
+ oom: Number(hardening.oomKills) > 0 || Number(pressure.cgroupOom) > 0,
693
1018
  limited,
694
1019
  hardening,
1020
+ pressure,
695
1021
  });
696
1022
  }
697
1023
 
@@ -760,6 +1086,13 @@ export function formatPulsePreview(pulse, lang = "en") {
760
1086
  [t(lang, "preview_row_ssh"), ssh],
761
1087
  ]
762
1088
  );
763
- const extra = pulse.limited ? `\n${t(lang, "preview_limited")}` : "";
1089
+ const extraBits = [];
1090
+ if (pulse.limited) extraBits.push(t(lang, "preview_limited"));
1091
+ const stuck = stuckFromTop(pulse.top);
1092
+ if (stuck.length) extraBits.push(`D/Z/T ${stuck.map((r) => `${r.comm}:${r.stat}`).join(" ")}`);
1093
+ const pr = pulse.pressure || {};
1094
+ if (pr.conntrackPct != null) extraBits.push(`conntrack ${pr.conntrackPct}%`);
1095
+ if (pr.cpus) extraBits.push(`${pr.cpus} CPU`);
1096
+ const extra = extraBits.length ? `\n${extraBits.join(" · ")}` : "";
764
1097
  return `${machine}\n\n${listen}\n\n${top}\n\n${guard}${extra}`;
765
1098
  }
@@ -0,0 +1,65 @@
1
+ export const FETCH_MS = 15_000;
2
+
3
+ const NET_CODES = new Set([
4
+ "ECONNREFUSED",
5
+ "ECONNRESET",
6
+ "ECONNABORTED",
7
+ "EHOSTUNREACH",
8
+ "ENETUNREACH",
9
+ "EPIPE",
10
+ "EAI_AGAIN",
11
+ "ENOTFOUND",
12
+ ]);
13
+
14
+ export function classifyNetError(err) {
15
+ const cause = err?.cause && typeof err.cause === "object" ? err.cause : err;
16
+ const code = String(cause?.code || err?.code || "");
17
+ const name = String(err?.name || cause?.name || "");
18
+ const msg = `${cause?.message || ""} ${err?.message || ""}`;
19
+ if (name === "TimeoutError" || name === "AbortError" || code === "ABORT_ERR" || /timeout|aborted/i.test(msg)) {
20
+ return "timeout";
21
+ }
22
+ if (/CERT|UNABLE_TO_VERIFY|ERR_TLS|ERR_SSL/i.test(`${code} ${msg}`)) return "tls";
23
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN" || /getaddrinfo/i.test(msg)) return "dns";
24
+ if (NET_CODES.has(code) || /fetch failed|socket hang up/i.test(msg)) return "unreachable";
25
+ return "unreachable";
26
+ }
27
+
28
+ export function cabinetFail(url, err) {
29
+ return {
30
+ ok: false,
31
+ status: 0,
32
+ body: { error: classifyNetError(err), url: String(url || "").replace(/\/$/, "") },
33
+ };
34
+ }
35
+
36
+ export async function cabinetRequest(url, pathname, { method = "GET", json, secret, fetchImpl = fetch, timeoutMs = FETCH_MS } = {}) {
37
+ const base = String(url || "").replace(/\/$/, "");
38
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
39
+ if (secret) headers.Authorization = `Bearer ${secret}`;
40
+ const signal = timeoutMs > 0 && typeof AbortSignal !== "undefined" && AbortSignal.timeout ? AbortSignal.timeout(timeoutMs) : undefined;
41
+ try {
42
+ const r = await fetchImpl(`${base}${pathname}`, {
43
+ method,
44
+ headers,
45
+ body: json ? JSON.stringify(json) : undefined,
46
+ signal,
47
+ });
48
+ const body = await r.json().catch(() => ({}));
49
+ return { ok: Boolean(r.ok), status: r.status, body };
50
+ } catch (err) {
51
+ return cabinetFail(base, err);
52
+ }
53
+ }
54
+
55
+ export function apiFailText(lang, out, t, fallbackKey = "pulse_fail") {
56
+ const err = out?.body?.error;
57
+ const url = out?.body?.url || "";
58
+ if (err === "unreachable") return t(lang, "cabinet_down", { url });
59
+ if (err === "timeout") return t(lang, "cabinet_timeout", { url });
60
+ if (err === "tls") return t(lang, "cabinet_tls", { url });
61
+ if (err === "dns") return t(lang, "cabinet_dns", { url });
62
+ if (err === "bad_secret" || err === "not_found") return t(lang, "key_revoked");
63
+ if (typeof err === "string" && /^[a-z][a-z0-9_]{0,40}$/.test(err)) return err;
64
+ return t(lang, fallbackKey);
65
+ }
@@ -35,6 +35,10 @@ const STR = {
35
35
  payload: "What goes to the cabinet",
36
36
  pulse_ok: "Pulse is in the cabinet.",
37
37
  pulse_fail: "pulse not accepted",
38
+ cabinet_down: "Cabinet is not reachable ({url}). Start the cabinet or the tunnel to it.",
39
+ cabinet_timeout: "Cabinet did not answer ({url}). Try again later.",
40
+ cabinet_tls: "TLS to the cabinet failed ({url}).",
41
+ cabinet_dns: "Cannot resolve the cabinet host ({url}).",
38
42
  pair_fail: "could not start pairing",
39
43
  ask_daemon: "Install as a Watch daemon?\nRuns in the background and starts again after reboot. Pulse every 5 min.",
40
44
  daemon_no: "No",
@@ -139,6 +143,10 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
139
143
  payload: "Что уходит в кабинет",
140
144
  pulse_ok: "Пульс в кабинете.",
141
145
  pulse_fail: "пульс не принят",
146
+ cabinet_down: "Кабинет недоступен ({url}). Поднимите кабинет или туннель к нему.",
147
+ cabinet_timeout: "Кабинет не ответил ({url}). Повторите позже.",
148
+ cabinet_tls: "TLS к кабинету не сошёлся ({url}).",
149
+ cabinet_dns: "Не резолвится хост кабинета ({url}).",
142
150
  pair_fail: "не удалось начать пару",
143
151
  ask_daemon: "Поставить Watch-демоном?\nБудет работать в фоне и подниматься после перезагрузки. Пульс каждые 5 мин.",
144
152
  daemon_no: "Нет",
@@ -243,6 +251,10 @@ Outgoing pulse. Cabinet does not execute commands; it replies with advice.`,
243
251
  payload: "콘솔로 보내는 내용",
244
252
  pulse_ok: "펄스가 콘솔에 있습니다.",
245
253
  pulse_fail: "펄스 거부",
254
+ cabinet_down: "콘솔에 연결할 수 없습니다 ({url}). 콘솔 또는 터널을 올리세요.",
255
+ cabinet_timeout: "콘솔이 응답하지 않습니다 ({url}). 나중에 다시 시도하세요.",
256
+ cabinet_tls: "콘솔 TLS 실패 ({url}).",
257
+ cabinet_dns: "콘솔 호스트를 찾지 못했습니다 ({url}).",
246
258
  pair_fail: "페어링을 시작하지 못했습니다",
247
259
  ask_daemon: "Watch 데몬으로 설치할까요?\n백그라운드에서 돌고 재부팅 후에도 올라옵니다. 5분마다 펄스.",
248
260
  daemon_no: "아니요",
@@ -347,6 +359,10 @@ login에서 언어를 묻고 저장한 뒤, 데몬은 기본값 아니오입니
347
359
  payload: "Qué se envía al gabinete",
348
360
  pulse_ok: "Pulso en el gabinete.",
349
361
  pulse_fail: "pulso rechazado",
362
+ cabinet_down: "El gabinete no responde ({url}). Levante el gabinete o el túnel.",
363
+ cabinet_timeout: "El gabinete no contestó ({url}). Pruebe más tarde.",
364
+ cabinet_tls: "Falló TLS al gabinete ({url}).",
365
+ cabinet_dns: "No se resuelve el host del gabinete ({url}).",
350
366
  pair_fail: "no se pudo iniciar el emparejamiento",
351
367
  ask_daemon: "¿Instalar como demonio Watch?\nCorre en segundo plano y arranca de nuevo tras el reinicio. Pulso cada 5 min.",
352
368
  daemon_no: "No",
@@ -9,6 +9,7 @@ export const SAT_TREE_FILES = [
9
9
  "server/sat-i18n.mjs",
10
10
  "server/cli-menu.mjs",
11
11
  "server/sat-local.mjs",
12
+ "server/sat-http.mjs",
12
13
  "server/sat-update.mjs",
13
14
  "package.json",
14
15
  ];
@@ -52,6 +53,24 @@ export function readCliVersion(scriptFile) {
52
53
  return "0.0.0";
53
54
  }
54
55
 
56
+ export function treeNeedsRefresh(root, latest, { force = false } = {}) {
57
+ if (force) return true;
58
+ const ver = readCliVersion(path.join(root, "bin", "orb44.mjs"));
59
+ if (cmpVer(ver, latest) < 0) return true;
60
+ try {
61
+ const pulse = fs.readFileSync(path.join(root, "server", "pulse.mjs"), "utf8");
62
+ if (!pulse.includes("function collapseListen")) return true;
63
+ if (!fs.existsSync(path.join(root, "server", "sat-http.mjs"))) return true;
64
+ } catch {
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ export function staleSatRoots(roots, latest, opts = {}) {
71
+ return (roots || []).filter((r) => treeNeedsRefresh(r, latest, opts));
72
+ }
73
+
55
74
  export function pickSatRoots(scriptFile, { systemLib = "/usr/lib/orb44-sat" } = {}) {
56
75
  const here = path.resolve(path.dirname(scriptFile), "..");
57
76
  const out = [];