@orb44/cli 0.1.6 → 0.1.8

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.6 status
17
- npx @orb44/cli@0.1.6 pulse
18
- npx @orb44/cli@0.1.6 install # later: background pulse, starts after reboot
16
+ npx @orb44/cli@0.1.8 status
17
+ npx @orb44/cli@0.1.8 pulse
18
+ npx @orb44/cli@0.1.8 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.6 logout
20
+ npx @orb44/cli@0.1.8 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
@@ -122,7 +122,7 @@ async function ensureLang(opts) {
122
122
  return lang;
123
123
  }
124
124
  lang = detectLang();
125
- if (opts.yes || !input.isTTY || !output.isTTY) {
125
+ if (opts.yes || opts.url || !input.isTTY || !output.isTTY) {
126
126
  saveCli({ ...loadCli(), lang });
127
127
  return lang;
128
128
  }
@@ -169,6 +169,8 @@ function printAdvice(notes) {
169
169
 
170
170
  async function sendPulse(device, { preview = true } = {}) {
171
171
  const pulse = collectPulse();
172
+ const ver = readCliVersion(SCRIPT);
173
+ if (ver && ver !== "0.0.0") pulse.cliVersion = ver;
172
174
  if (preview) {
173
175
  console.log(bold("📡 " + t(lang, "payload")));
174
176
  console.log(formatPulsePreview(pulse, lang));
@@ -242,9 +244,10 @@ async function revokeLocalDevices(devices, fallbackApi) {
242
244
  }
243
245
 
244
246
  async function cmdLogin(opts) {
247
+ const url = String(opts.url || process.env.ORB44_API || "http://127.0.0.1:8787").replace(/\/$/, "");
248
+ process.stdout.write(`orb44 login → ${url}\n`);
245
249
  await ensureLang(opts);
246
250
  banner();
247
- const url = String(opts.url || process.env.ORB44_API || "http://127.0.0.1:8787").replace(/\/$/, "");
248
251
  const found = existingOnBox();
249
252
  if (hasExistingInstall(found)) {
250
253
  const replace = await wantReplace(found, opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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
@@ -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) {
@@ -214,6 +225,7 @@ export function sanitizePulse(raw = {}) {
214
225
  comm: sanitizeComm(r.comm),
215
226
  cpuPct: Math.max(0, Math.min(100, Number(r.cpuPct) || 0)),
216
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,
217
229
  }))
218
230
  .filter((r) => r.comm)
219
231
  : [];
@@ -253,6 +265,8 @@ export function sanitizePulse(raw = {}) {
253
265
  };
254
266
  const limited = Boolean(raw.limited);
255
267
  const oom = Boolean(raw.oom) || hardening.oomKills > 0;
268
+ const pressure = sanitizePressure(raw.pressure || raw);
269
+ const cliVersion = sanitizeCliVersion(raw.cliVersion);
256
270
  const base = {
257
271
  ts: Number(raw.ts) || Date.now(),
258
272
  hostname: sanitizeComm(raw.hostname) || os.hostname().slice(0, 40),
@@ -267,10 +281,19 @@ export function sanitizePulse(raw = {}) {
267
281
  oom,
268
282
  limited,
269
283
  hardening,
284
+ pressure,
285
+ cliVersion,
270
286
  };
271
287
  return { ...base, gradeInside: gradeInside(base) };
272
288
  }
273
289
 
290
+ function sanitizeCliVersion(raw) {
291
+ const v = String(raw || "").trim();
292
+ if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}(-[a-z0-9.]+)?$/i.test(v)) return null;
293
+ if (v === "0.0.0") return null;
294
+ return v.slice(0, 32);
295
+ }
296
+
274
297
  const ADMIN_PORTS = new Set([2019, 2375, 2376, 3306, 5432, 6379, 27017, 9200, 11211, 15672, 8500, 2379, 6443, 9090, 5601, 7474, 7687, 1080, 6432]);
275
298
 
276
299
  const SERVICE_NAME = {
@@ -351,6 +374,172 @@ export function parseVmstatOom(text) {
351
374
  return m ? Number(m[1]) : 0;
352
375
  }
353
376
 
377
+ export function parseSockstat(text) {
378
+ const tcp = /(?:^|\n)TCP:\s+inuse\s+(\d+)(?:\s+orphan\s+(\d+))?(?:\s+tw\s+(\d+))?/i.exec(String(text || ""));
379
+ if (!tcp) return { tcpInuse: null, tcpOrphan: null, tcpTw: null };
380
+ return {
381
+ tcpInuse: Number(tcp[1]),
382
+ tcpOrphan: tcp[2] != null ? Number(tcp[2]) : null,
383
+ tcpTw: tcp[3] != null ? Number(tcp[3]) : null,
384
+ };
385
+ }
386
+
387
+ export function parseFileNr(text) {
388
+ const p = String(text || "").trim().split(/[\s,]+/);
389
+ const used = Number(p[0]);
390
+ const max = Number(p[2] ?? p[1]);
391
+ if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return { fileUsed: null, fileMax: null, filePct: null };
392
+ return { fileUsed: used, fileMax: max, filePct: Math.max(0, Math.min(100, Math.round((used / max) * 100))) };
393
+ }
394
+
395
+ export function parseChronyTracking(text) {
396
+ const m =
397
+ /Last offset\s*:\s*([+-]?\d+(?:\.\d+)?)\s*seconds/i.exec(String(text || "")) ||
398
+ /System time\s*:\s*(\d+(?:\.\d+)?)\s*seconds\s+(slow|fast)/i.exec(String(text || ""));
399
+ if (!m) return null;
400
+ let n = Number(m[1]);
401
+ if (!Number.isFinite(n)) return null;
402
+ if (m[2] === "fast") n = Math.abs(n);
403
+ if (m[2] === "slow") n = -Math.abs(n);
404
+ return Math.round(n * 1000) / 1000;
405
+ }
406
+
407
+ export function parseCgroupEvents(text) {
408
+ const kill = /(?:^|\n)oom_kill\s+(\d+)/.exec(String(text || ""));
409
+ const oom = /(?:^|\n)oom\s+(\d+)/.exec(String(text || ""));
410
+ return {
411
+ cgroupOom: kill ? Number(kill[1]) : oom ? Number(oom[1]) : 0,
412
+ memFailcnt: 0,
413
+ };
414
+ }
415
+
416
+ export function parseNetstatListenOverflows(text) {
417
+ const lines = String(text || "").split("\n");
418
+ let header = "";
419
+ let values = "";
420
+ for (const line of lines) {
421
+ if (/^TcpExt:\s+/.test(line) && /ListenOverflows/.test(line)) header = line;
422
+ else if (/^TcpExt:\s+/.test(line) && header && !/ListenOverflows/.test(line)) values = line;
423
+ }
424
+ if (!header || !values) return { listenOverflows: null, listenDrops: null };
425
+ const keys = header.replace(/^TcpExt:\s+/, "").trim().split(/\s+/);
426
+ const nums = values.replace(/^TcpExt:\s+/, "").trim().split(/\s+/).map(Number);
427
+ const idx = (name) => keys.indexOf(name);
428
+ const n = (i) => (i >= 0 && Number.isFinite(nums[i]) ? nums[i] : null);
429
+ return { listenOverflows: n(idx("ListenOverflows")), listenDrops: n(idx("ListenDrops")) };
430
+ }
431
+
432
+ function readTrim(p) {
433
+ try {
434
+ return fs.readFileSync(p, "utf8").trim();
435
+ } catch {
436
+ return "";
437
+ }
438
+ }
439
+
440
+ function numFile(p) {
441
+ const n = Number(readTrim(p));
442
+ return Number.isFinite(n) ? n : null;
443
+ }
444
+
445
+ export function stuckFromTop(top = []) {
446
+ return (top || []).filter((r) => {
447
+ const s = String(r.stat || "")[0];
448
+ return s === "D" || s === "Z" || s === "T";
449
+ }).slice(0, 6);
450
+ }
451
+
452
+ export function hotFromTop(top = []) {
453
+ return (top || []).filter((r) => Number(r.cpuPct) >= 70 || Number(r.rssMb) >= 1024).slice(0, 6);
454
+ }
455
+
456
+ export function cpuCount(pulse) {
457
+ const n = Number(pulse?.pressure?.cpus);
458
+ return Number.isFinite(n) && n > 0 ? n : 2;
459
+ }
460
+
461
+ function collectPressure() {
462
+ const cpus = Math.max(1, os.cpus()?.length || 1);
463
+ const sock = parseSockstat(readTrim("/proc/net/sockstat"));
464
+ const files = parseFileNr(readTrim("/proc/sys/fs/file-nr"));
465
+ let conntrackUsed = numFile("/proc/sys/net/netfilter/nf_conntrack_count");
466
+ let conntrackMax = numFile("/proc/sys/net/netfilter/nf_conntrack_max");
467
+ if (conntrackUsed == null) conntrackUsed = numFile("/proc/sys/net/nf_conntrack_count");
468
+ if (conntrackMax == null) conntrackMax = numFile("/proc/sys/net/nf_conntrack_max");
469
+ const conntrackPct =
470
+ conntrackUsed != null && conntrackMax > 0 ? Math.max(0, Math.min(100, Math.round((conntrackUsed / conntrackMax) * 100))) : null;
471
+ const overflows = parseNetstatListenOverflows(readTrim("/proc/net/netstat"));
472
+ let clockOffsetSec = parseChronyTracking(runOut("chronyc", ["tracking"]));
473
+ let ntpSync = null;
474
+ if (clockOffsetSec != null) ntpSync = true;
475
+ else {
476
+ const td = runOut("timedatectl", ["show", "-p", "NTPSynchronized", "--value"]);
477
+ if (/^yes$/i.test(td.trim())) ntpSync = true;
478
+ else if (/^no$/i.test(td.trim())) ntpSync = false;
479
+ }
480
+ let cgroupOom = 0;
481
+ let memFailcnt = 0;
482
+ const ev = parseCgroupEvents(readTrim("/sys/fs/cgroup/memory.events") || readTrim("/sys/fs/cgroup/memory/memory.events"));
483
+ cgroupOom = ev.cgroupOom || 0;
484
+ memFailcnt = numFile("/sys/fs/cgroup/memory/memory.failcnt") ?? ev.memFailcnt ?? 0;
485
+ return {
486
+ cpus,
487
+ conntrackUsed,
488
+ conntrackMax,
489
+ conntrackPct,
490
+ tcpInuse: sock.tcpInuse,
491
+ tcpTw: sock.tcpTw,
492
+ tcpOrphan: sock.tcpOrphan,
493
+ listenOverflows: overflows.listenOverflows,
494
+ listenDrops: overflows.listenDrops,
495
+ fileUsed: files.fileUsed,
496
+ fileMax: files.fileMax,
497
+ filePct: files.filePct,
498
+ clockOffsetSec,
499
+ ntpSync,
500
+ memFailcnt: Math.max(0, Math.min(999999, Number(memFailcnt) || 0)),
501
+ cgroupOom: Math.max(0, Math.min(999, Number(cgroupOom) || 0)),
502
+ };
503
+ }
504
+
505
+ function sanitizePressure(raw = {}) {
506
+ const n = (v, lo, hi) => {
507
+ if (v == null || v === "") return null;
508
+ const x = Number(v);
509
+ if (!Number.isFinite(x)) return null;
510
+ return Math.max(lo, Math.min(hi, x));
511
+ };
512
+ const cpus = n(raw.cpus, 1, 512) || null;
513
+ const conntrackUsed = n(raw.conntrackUsed, 0, 1e9);
514
+ const conntrackMax = n(raw.conntrackMax, 0, 1e9);
515
+ const conntrackPct =
516
+ raw.conntrackPct != null
517
+ ? n(raw.conntrackPct, 0, 100)
518
+ : conntrackUsed != null && conntrackMax > 0
519
+ ? Math.max(0, Math.min(100, Math.round((conntrackUsed / conntrackMax) * 100)))
520
+ : null;
521
+ const clockOffsetSec = n(raw.clockOffsetSec, -86400, 86400);
522
+ const ntpSync = raw.ntpSync === true || raw.ntpSync === false ? raw.ntpSync : null;
523
+ return {
524
+ cpus,
525
+ conntrackUsed,
526
+ conntrackMax,
527
+ conntrackPct,
528
+ tcpInuse: n(raw.tcpInuse, 0, 1e9),
529
+ tcpTw: n(raw.tcpTw, 0, 1e9),
530
+ tcpOrphan: n(raw.tcpOrphan, 0, 1e9),
531
+ listenOverflows: n(raw.listenOverflows, 0, 1e12),
532
+ listenDrops: n(raw.listenDrops, 0, 1e12),
533
+ fileUsed: n(raw.fileUsed, 0, 1e12),
534
+ fileMax: n(raw.fileMax, 0, 1e12),
535
+ filePct: n(raw.filePct, 0, 100),
536
+ clockOffsetSec,
537
+ ntpSync,
538
+ memFailcnt: n(raw.memFailcnt, 0, 1e9) || 0,
539
+ cgroupOom: n(raw.cgroupOom, 0, 999) || 0,
540
+ };
541
+ }
542
+
354
543
  export function insideServiceWorldPorts(pulse) {
355
544
  return [...new Set((pulse?.listen || []).filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port)).map((r) => r.port))];
356
545
  }
@@ -425,19 +614,23 @@ export function parseFail2banJail(text) {
425
614
  export function gradeInside(pulse = {}) {
426
615
  const listen = pulse.listen || [];
427
616
  const h = pulse.hardening || {};
617
+ const pr = pulse.pressure || {};
428
618
  const exposedAdmin = listen.filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port));
429
619
  const fw = Boolean(h.firewall);
430
620
  const ban = Boolean(h.fail2ban);
431
621
  const updates = Boolean(h.updates);
432
622
  const diskHigh = Number(pulse.diskUsedPct) >= 85;
433
- const oom = Boolean(pulse.oom) || Number(h.oomKills) > 0;
623
+ const oom = Boolean(pulse.oom) || Number(h.oomKills) > 0 || Number(pr.cgroupOom) > 0;
624
+ const loadHigh = Number(pulse.load1) >= Math.max(2, cpuCount(pulse));
625
+ const connHot = Number(pr.conntrackPct) >= 90;
626
+ const stuck = stuckFromTop(pulse.top).length > 0;
434
627
  if (h.dockerApi) return "D";
435
628
  if (h.sshPassword && h.sshRoot && h.sshWorld) return "D";
436
629
  if (!fw && exposedAdmin.length) return "D";
437
630
  if (!fw && !ban) return "D";
438
631
  if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
439
632
  if (!fw || !ban) return "C";
440
- if (diskHigh || oom || h.rebootNeeded || !h.timesync) return "C";
633
+ if (diskHigh || oom || h.rebootNeeded || !h.timesync || loadHigh || connHot || stuck) return "C";
441
634
  if (fw && ban && updates && h.timesync && !h.sshPassword && !h.sshRoot && !exposedAdmin.length && !h.dockerApi) return "A";
442
635
  if (fw && ban && !exposedAdmin.length && !h.dockerApi) return "B";
443
636
  return "C";
@@ -448,7 +641,7 @@ function labelPort(r) {
448
641
  return svc ? `${svc} :${r.port}` : `:${r.port}`;
449
642
  }
450
643
 
451
- export function compareInside(pulse, rec = {}) {
644
+ export function compareInside(pulse, rec = {}, prev = null) {
452
645
  const notes = [];
453
646
  const listen = pulse?.listen || [];
454
647
  const streetIp = rec.ticket?.ip || rec.watch?.current?.ip || null;
@@ -488,8 +681,9 @@ export function compareInside(pulse, rec = {}) {
488
681
  do: "Сверьте DNS A с тем VPS, куда поставили сателлит. Если сайт за Cloudflare — это ожидаемо, не инцидент.",
489
682
  });
490
683
  }
491
- const loadHigh = Number(pulse?.load1) >= Math.max(2, (os.cpus() || []).length);
492
- const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.9;
684
+ const loadHigh = Number(pulse?.load1) >= Math.max(2, cpuCount(pulse));
685
+ const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.85;
686
+ const pr = pulse?.pressure || {};
493
687
  if (loadHigh || memHigh) {
494
688
  const top = pulse.top?.[0];
495
689
  const who = top ? `${top.comm} ${top.cpuPct}%` : "процесс в топе не виден";
@@ -605,6 +799,93 @@ export function compareInside(pulse, rec = {}) {
605
799
  });
606
800
  }
607
801
  }
802
+ const stuck = stuckFromTop(pulse?.top);
803
+ if (stuck.length) {
804
+ const who = stuck.map((r) => `${r.comm} (${r.stat || "?"})`).join(", ");
805
+ notes.push({
806
+ kind: "stuck-proc",
807
+ title: "Процессы зависли или зомби",
808
+ text: `Состояние D/Z/T: ${who}. Так машина стоит на диске или мёртвых воркерах, а не «просто высокая нагрузка».`,
809
+ do: "Не убивайте с кабинета — его нет. На сервере: ps и диск, не WAF. Orb44 процессы сам не трогает.",
810
+ });
811
+ }
812
+ const hot = hotFromTop(pulse?.top);
813
+ if (hot.length && !loadHigh) {
814
+ const who = hot.map((r) => `${r.comm} ${r.cpuPct}%/${r.rssMb}M`).join(", ");
815
+ notes.push({
816
+ kind: "hot-proc",
817
+ title: "Сервис жрёт CPU или RAM",
818
+ text: `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
819
+ do: "Смотрите этот процесс (php-fpm, node, mysql). Orb44 его не рестартит.",
820
+ });
821
+ }
822
+ if (Number(pr.conntrackPct) >= 80) {
823
+ notes.push({
824
+ kind: "conntrack",
825
+ title: "Таблица соединений почти полная",
826
+ text: `conntrack ${pr.conntrackUsed}/${pr.conntrackMax} (${pr.conntrackPct}%). Новые сессии начнут отбрасываться — с улицы это 502, изнутри это очередь.`,
827
+ do: "Ищите кто держит кучу TCP. Не открывайте порты «чтобы помогло».",
828
+ });
829
+ }
830
+ const prevPr = prev?.pressure || {};
831
+ const overDelta =
832
+ pr.listenOverflows != null && prevPr.listenOverflows != null
833
+ ? Number(pr.listenOverflows) - Number(prevPr.listenOverflows)
834
+ : 0;
835
+ const dropDelta =
836
+ pr.listenDrops != null && prevPr.listenDrops != null
837
+ ? Number(pr.listenDrops) - Number(prevPr.listenDrops)
838
+ : 0;
839
+ if (overDelta > 0 || dropDelta > 0) {
840
+ notes.push({
841
+ kind: "backlog",
842
+ title: "Очередь accept переполняется",
843
+ text: `С прошлого пульса ListenOverflows +${overDelta}, ListenDrops +${dropDelta}. Сервис не успевает брать соединения.`,
844
+ do: "Больше воркеров или меньше входа. Orb44 лимиты сам не поднимает.",
845
+ });
846
+ }
847
+ if (Number(pr.filePct) >= 85) {
848
+ notes.push({
849
+ kind: "files",
850
+ title: "Заканчиваются файловые дескрипторы",
851
+ text: `Открыто ${pr.filePct}% лимита (${pr.fileUsed}/${pr.fileMax}). Типичный «внезапно не открывается сокет».`,
852
+ do: "Кто держит файлы: воркер или утечка. Orb44 ulimit сам не меняет.",
853
+ });
854
+ }
855
+ if (pr.clockOffsetSec != null && Math.abs(pr.clockOffsetSec) >= 5) {
856
+ notes.push({
857
+ kind: "clock",
858
+ title: "Часы машины уехали",
859
+ text: `Смещение NTP ${pr.clockOffsetSec} с. TLS и метки Watch начнут врать.`,
860
+ do: "Почините chrony/timesyncd. Orb44 время сам не ставит.",
861
+ });
862
+ }
863
+ if (Number(pr.cgroupOom) > 0 || Number(pr.memFailcnt) > 20) {
864
+ notes.push({
865
+ kind: "cgroup-oom",
866
+ title: "Контейнер или cgroup упирается в память",
867
+ text: `cgroup oom=${pr.cgroupOom || 0}, failcnt=${pr.memFailcnt || 0}. Это не journal — счётчик ядра.`,
868
+ do: "Лимит памяти контейнера, не WAF. Orb44 лимиты сам не поднимает.",
869
+ });
870
+ }
871
+ if (prev?.listen && pulse?.listen) {
872
+ const face = (p) =>
873
+ (p.listen || [])
874
+ .filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
875
+ .map((r) => `${r.port}:${r.comm || "?"}`)
876
+ .sort()
877
+ .join(",");
878
+ const a = face(prev);
879
+ const b = face(pulse);
880
+ if (a && b && a !== b) {
881
+ notes.push({
882
+ kind: "listen-swap",
883
+ title: "На 80/443 сменился процесс",
884
+ text: `Было ${a}, стало ${b}. Бинарь витрины подменили или рядом встал другой сервер.`,
885
+ do: "Сверьте, кто должен слушать HTTPS. Orb44 процесс сам не откатывает.",
886
+ });
887
+ }
888
+ }
608
889
  return notes;
609
890
  }
610
891
 
@@ -618,7 +899,7 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
618
899
  const notes = [];
619
900
  const incident = String(streetIncidentCode(rec) || "");
620
901
  const streetHot = /auth_hot|auth_open|l7_exhaustion/.test(incident);
621
- const loadHigh = Number(pulse?.load1) >= 2;
902
+ const loadHigh = Number(pulse?.load1) >= Math.max(2, cpuCount(pulse));
622
903
  const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.85;
623
904
  const top = pulse?.top?.[0];
624
905
  const who = top ? `${top.comm} ${top.cpuPct}%` : "процесс в топе не виден";
@@ -657,6 +938,52 @@ export function insideWatchReasons(pulse, prev, rec = {}) {
657
938
  text: `Появились ${added.join(", ")}. С прошлого пульса их не было.`,
658
939
  });
659
940
  }
941
+ const face = (p) =>
942
+ (p?.listen || [])
943
+ .filter((r) => Number(r.port) === 80 || Number(r.port) === 443)
944
+ .map((r) => `${r.port}:${r.comm || "?"}`)
945
+ .sort()
946
+ .join(",");
947
+ const a = face(prev);
948
+ const b = face(pulse);
949
+ if (a && b && a !== b) {
950
+ notes.push({
951
+ kind: "listen-swap",
952
+ title: "На 80/443 сменился процесс",
953
+ text: `Было ${a}, стало ${b}.`,
954
+ });
955
+ }
956
+ }
957
+ if (stuckFromTop(pulse?.top).length) {
958
+ notes.push({
959
+ kind: "stuck-proc",
960
+ title: "Процессы зависли",
961
+ text: stuckFromTop(pulse.top)
962
+ .map((r) => `${r.comm} ${r.stat}`)
963
+ .join(", "),
964
+ });
965
+ }
966
+ if (Number(pulse?.pressure?.conntrackPct) >= 80) {
967
+ notes.push({
968
+ kind: "conntrack",
969
+ title: "conntrack почти полный",
970
+ text: `${pulse.pressure.conntrackPct}%`,
971
+ });
972
+ }
973
+ const overDelta =
974
+ pulse?.pressure?.listenOverflows != null && prev?.pressure?.listenOverflows != null
975
+ ? Number(pulse.pressure.listenOverflows) - Number(prev.pressure.listenOverflows)
976
+ : 0;
977
+ const dropDelta =
978
+ pulse?.pressure?.listenDrops != null && prev?.pressure?.listenDrops != null
979
+ ? Number(pulse.pressure.listenDrops) - Number(prev.pressure.listenDrops)
980
+ : 0;
981
+ if (overDelta > 0 || dropDelta > 0) {
982
+ notes.push({
983
+ kind: "backlog",
984
+ title: "Очередь accept растёт",
985
+ text: `+${overDelta} overflows, +${dropDelta} drops с прошлого пульса.`,
986
+ });
660
987
  }
661
988
  const streetIp = rec.ticket?.ip || rec.watch?.current?.ip || rec.watch?.last?.ip || null;
662
989
  if (pulse?.originA?.length && streetIp && !pulse.originA.includes(streetIp)) {
@@ -684,6 +1011,7 @@ export function collectPulse() {
684
1011
  const top = topTable();
685
1012
  const hardening = collectHardening(listen);
686
1013
  const limited = listen.length > 0 && !named;
1014
+ const pressure = collectPressure();
687
1015
  return sanitizePulse({
688
1016
  ts: Date.now(),
689
1017
  hostname: os.hostname(),
@@ -695,9 +1023,10 @@ export function collectPulse() {
695
1023
  listen,
696
1024
  originA: originAddrs(),
697
1025
  failedUnit: null,
698
- oom: Number(hardening.oomKills) > 0,
1026
+ oom: Number(hardening.oomKills) > 0 || Number(pressure.cgroupOom) > 0,
699
1027
  limited,
700
1028
  hardening,
1029
+ pressure,
701
1030
  });
702
1031
  }
703
1032
 
@@ -766,6 +1095,13 @@ export function formatPulsePreview(pulse, lang = "en") {
766
1095
  [t(lang, "preview_row_ssh"), ssh],
767
1096
  ]
768
1097
  );
769
- const extra = pulse.limited ? `\n${t(lang, "preview_limited")}` : "";
1098
+ const extraBits = [];
1099
+ if (pulse.limited) extraBits.push(t(lang, "preview_limited"));
1100
+ const stuck = stuckFromTop(pulse.top);
1101
+ if (stuck.length) extraBits.push(`D/Z/T ${stuck.map((r) => `${r.comm}:${r.stat}`).join(" ")}`);
1102
+ const pr = pulse.pressure || {};
1103
+ if (pr.conntrackPct != null) extraBits.push(`conntrack ${pr.conntrackPct}%`);
1104
+ if (pr.cpus) extraBits.push(`${pr.cpus} CPU`);
1105
+ const extra = extraBits.length ? `\n${extraBits.join(" · ")}` : "";
770
1106
  return `${machine}\n\n${listen}\n\n${top}\n\n${guard}${extra}`;
771
1107
  }
@@ -104,13 +104,35 @@ export async function fetchLatestMeta(fetchFn = fetch) {
104
104
  const version = j?.version;
105
105
  const tarball = j?.dist?.tarball;
106
106
  if (!version || !tarball) throw new Error("registry meta");
107
- return { version: String(version), tarball: String(tarball) };
107
+ return { version: String(version), tarball: canonicalTarballUrl(tarball) };
108
108
  }
109
109
 
110
- export async function unpackTarball(url, tmp, fetchFn = fetch) {
111
- const r = await fetchFn(url);
112
- if (!r?.ok) throw new Error(`tarball ${r?.status || "fail"}`);
113
- const buf = Buffer.from(await r.arrayBuffer());
110
+ /** npm metadata uses /@scope/name/; GET of the tarball is more reliable as /@scope%2fname/. */
111
+ export function canonicalTarballUrl(url) {
112
+ try {
113
+ const u = new URL(String(url));
114
+ u.pathname = u.pathname.replace(/^\/@([^/]+)\/([^/]+)\//, "/@$1%2f$2/");
115
+ return u.href;
116
+ } catch {
117
+ return String(url || "");
118
+ }
119
+ }
120
+
121
+ export async function unpackTarball(url, tmp, fetchFn = fetch, { retries = 4, delayMs = 1500 } = {}) {
122
+ const href = canonicalTarballUrl(url);
123
+ let last = "fail";
124
+ let buf = null;
125
+ for (let i = 0; i < retries; i++) {
126
+ const r = await fetchFn(href);
127
+ last = r?.status || "fail";
128
+ if (r?.ok) {
129
+ buf = Buffer.from(await r.arrayBuffer());
130
+ break;
131
+ }
132
+ if (Number(last) !== 404 && Number(last) !== 429) break;
133
+ if (i < retries - 1 && delayMs) await new Promise((ok) => setTimeout(ok, delayMs * (i + 1)));
134
+ }
135
+ if (!buf) throw new Error(`tarball ${last}`);
114
136
  const tgz = path.join(tmp, "pkg.tgz");
115
137
  fs.writeFileSync(tgz, buf);
116
138
  const unpack = path.join(tmp, "unpack");