@orb44/cli 0.1.8 → 0.1.9

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.8 status
17
- npx @orb44/cli@0.1.8 pulse
18
- npx @orb44/cli@0.1.8 install # later: background pulse, starts after reboot
16
+ npx @orb44/cli@0.1.9 status
17
+ npx @orb44/cli@0.1.9 pulse
18
+ npx @orb44/cli@0.1.9 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.8 logout
20
+ npx @orb44/cli@0.1.9 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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
@@ -1,5 +1,6 @@
1
1
  import os from "node:os";
2
2
  import fs from "node:fs";
3
+ import net from "node:net";
3
4
  import { execFileSync } from "node:child_process";
4
5
  import { t } from "./sat-i18n.mjs";
5
6
 
@@ -54,6 +55,63 @@ function diskUsedPct(root = "/") {
54
55
  }
55
56
  }
56
57
 
58
+ export function publicIpv4(ip) {
59
+ const s = String(ip || "").replace(/^::ffff:/, "").trim();
60
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return null;
61
+ const p = s.split(".").map(Number);
62
+ if (p.some((n) => n > 255)) return null;
63
+ const [a, b] = p;
64
+ if (a === 10 || a === 127 || a === 0) return null;
65
+ if (a === 192 && b === 168) return null;
66
+ if (a === 172 && b >= 16 && b <= 31) return null;
67
+ if (a === 169 && b === 254) return null;
68
+ if (a === 100 && b >= 64 && b <= 127) return null;
69
+ return s;
70
+ }
71
+
72
+ export function pickPingTarget(pulse, seenIp) {
73
+ const ips = [...(pulse?.originA || []), seenIp].map(publicIpv4).filter(Boolean);
74
+ const ip = ips[0] || null;
75
+ if (!ip) return null;
76
+ const world = (pulse?.listen || [])
77
+ .filter((r) => r.addr === "0.0.0.0" || r.addr === "*" || r.addr === "::")
78
+ .map((r) => Number(r.port))
79
+ .filter((p) => p === 443 || p === 80 || p === 22);
80
+ const ports = [...new Set([443, 80, 22, ...world])];
81
+ return { ip, ports };
82
+ }
83
+
84
+ export function tcpRttMs(ip, port, timeoutMs = 700) {
85
+ return new Promise((resolve) => {
86
+ const t0 = performance.now();
87
+ const sock = net.connect({ host: ip, port: Number(port), timeout: timeoutMs }, () => {
88
+ const ms = Math.max(1, Math.round(performance.now() - t0));
89
+ sock.destroy();
90
+ resolve(ms);
91
+ });
92
+ const fail = () => {
93
+ sock.destroy();
94
+ resolve(null);
95
+ };
96
+ sock.on("error", fail);
97
+ sock.on("timeout", fail);
98
+ });
99
+ }
100
+
101
+ export async function measureHostRtt(pulse, seenIp) {
102
+ const target = pickPingTarget(pulse, seenIp);
103
+ if (!target) return null;
104
+ const hits = (
105
+ await Promise.all(target.ports.slice(0, 3).map(async (port) => {
106
+ const ms = await tcpRttMs(target.ip, port, 650);
107
+ return ms == null ? null : { ms, port, ip: target.ip };
108
+ }))
109
+ ).filter(Boolean);
110
+ if (!hits.length) return null;
111
+ hits.sort((a, b) => a.ms - b.ms);
112
+ return hits[0];
113
+ }
114
+
57
115
  export function originAddrs(ifaces = os.networkInterfaces()) {
58
116
  const out = [];
59
117
  for (const rows of Object.values(ifaces || {})) {
@@ -294,7 +352,10 @@ function sanitizeCliVersion(raw) {
294
352
  return v.slice(0, 32);
295
353
  }
296
354
 
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]);
355
+ const ADMIN_PORTS = new Set([
356
+ 2019, 2375, 2376, 3306, 5432, 6379, 27017, 9200, 11211, 15672, 8500, 2379, 6443, 9090, 5601, 7474, 7687, 1080, 6432,
357
+ 11434, 6333, 19530, 9229, 9222,
358
+ ]);
298
359
 
299
360
  const SERVICE_NAME = {
300
361
  22: "SSH",
@@ -310,6 +371,7 @@ const SERVICE_NAME = {
310
371
  3306: "MySQL",
311
372
  5432: "Postgres",
312
373
  5601: "Kibana",
374
+ 6333: "Qdrant",
313
375
  6379: "Redis",
314
376
  6432: "PgBouncer",
315
377
  6443: "kube-api",
@@ -319,8 +381,12 @@ const SERVICE_NAME = {
319
381
  8787: "туннель",
320
382
  9090: "Prometheus",
321
383
  9200: "Elasticsearch",
384
+ 9222: "Chrome CDP",
385
+ 9229: "Node inspector",
322
386
  11211: "memcached",
387
+ 11434: "Ollama",
323
388
  15672: "RabbitMQ",
389
+ 19530: "Milvus",
324
390
  27017: "Mongo",
325
391
  };
326
392
 
@@ -449,8 +515,37 @@ export function stuckFromTop(top = []) {
449
515
  }).slice(0, 6);
450
516
  }
451
517
 
452
- export function hotFromTop(top = []) {
453
- return (top || []).filter((r) => Number(r.cpuPct) >= 70 || Number(r.rssMb) >= 1024).slice(0, 6);
518
+ export function ramHotFloorMb(memTotal) {
519
+ const n = Number(memTotal);
520
+ if (!Number.isFinite(n) || n <= 0) return 1024;
521
+ const mb = n > 10_000_000 ? n / (1024 * 1024) : n;
522
+ return Math.max(1024, Math.round(mb * 0.3));
523
+ }
524
+
525
+ export function hotFromTop(top = [], memTotal) {
526
+ const floor = ramHotFloorMb(memTotal);
527
+ return (top || []).filter((r) => Number(r.cpuPct) >= 70 || Number(r.rssMb) >= floor).slice(0, 6);
528
+ }
529
+
530
+ const MINER_COMM = /^(xmrig|minerd|nbminer|t-rex|trex|ethminer|phoenixminer|lolminer|gminer|teamredminer|kdevtmpfsi|kinsing|sysupdate|networkservice)$/i;
531
+ const MINER_HEX = /^[a-f0-9]{8,32}$/i;
532
+ const LEGIT_HOT = /^(php-fpm|php|node|nodejs|java|mysqld|postgres|redis-server|nginx|caddy|apache2|httpd|python|python3|ruby|uwsgi|gunicorn|sidekiq|beanstalkd)$/i;
533
+
534
+ /** Cryptojacking heuristic: known miner names, or near-100% CPU with tiny RSS and non-legit name. */
535
+ export function looksLikeMiner(proc = {}) {
536
+ const comm = String(proc.comm || "").replace(/^.*\//, "").slice(0, 64);
537
+ if (!comm) return false;
538
+ if (MINER_COMM.test(comm)) return true;
539
+ if (LEGIT_HOT.test(comm)) return false;
540
+ if (MINER_HEX.test(comm) && Number(proc.cpuPct) >= 90) return true;
541
+ const cpu = Number(proc.cpuPct);
542
+ const rss = Number(proc.rssMb);
543
+ if (cpu >= 95 && Number.isFinite(rss) && rss > 0 && rss < 200) return true;
544
+ return false;
545
+ }
546
+
547
+ export function minersFromTop(top = []) {
548
+ return (top || []).filter((r) => looksLikeMiner(r)).slice(0, 4);
454
549
  }
455
550
 
456
551
  export function cpuCount(pulse) {
@@ -628,6 +723,7 @@ export function gradeInside(pulse = {}) {
628
723
  if (h.sshPassword && h.sshRoot && h.sshWorld) return "D";
629
724
  if (!fw && exposedAdmin.length) return "D";
630
725
  if (!fw && !ban) return "D";
726
+ if (minersFromTop(pulse.top).length) return "D";
631
727
  if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
632
728
  if (!fw || !ban) return "C";
633
729
  if (diskHigh || oom || h.rebootNeeded || !h.timesync || loadHigh || connHot || stuck) return "C";
@@ -809,14 +905,38 @@ export function compareInside(pulse, rec = {}, prev = null) {
809
905
  do: "Не убивайте с кабинета — его нет. На сервере: ps и диск, не WAF. Orb44 процессы сам не трогает.",
810
906
  });
811
907
  }
812
- const hot = hotFromTop(pulse?.top);
908
+ const miners = minersFromTop(pulse?.top);
909
+ if (miners.length) {
910
+ const who = miners.map((r) => `${r.comm} ${r.cpuPct}%`).join(", ");
911
+ notes.push({
912
+ kind: "crypto-miner",
913
+ title: "Похоже на майнер",
914
+ text: `${who}. Имя из известного списка криптомайнеров или почти 100% CPU при маленьком RSS — типичный признак чужого майнера, не «Перегруз» легитимным java.`,
915
+ do: "На сервере остановите процесс и проверьте, как он появился (crontab, docker, скомпрометированный SSH). Orb44 процессы сам не убивает.",
916
+ });
917
+ }
918
+ const hot = hotFromTop(pulse?.top, pulse?.memTotal).filter((r) => !looksLikeMiner(r));
813
919
  if (hot.length && !loadHigh) {
814
- const who = hot.map((r) => `${r.comm} ${r.cpuPct}%/${r.rssMb}M`).join(", ");
920
+ const floor = ramHotFloorMb(pulse?.memTotal);
921
+ const ramOnly = hot.every((r) => Number(r.rssMb) >= floor && Number(r.cpuPct) < 70);
922
+ const who = hot
923
+ .map((r) => {
924
+ const ram = Number(r.rssMb) >= floor;
925
+ const cpu = Number(r.cpuPct) >= 70;
926
+ if (ram && !cpu) return `${r.comm} ${r.rssMb} МБ RAM (CPU ${r.cpuPct}%)`;
927
+ if (cpu && !ram) return `${r.comm} CPU ${r.cpuPct}%`;
928
+ return `${r.comm} ${r.cpuPct}%/${r.rssMb}M`;
929
+ })
930
+ .join(", ");
815
931
  notes.push({
816
932
  kind: "hot-proc",
817
- title: "Сервис жрёт CPU или RAM",
818
- text: `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
819
- do: "Смотрите этот процесс (php-fpm, node, mysql). Orb44 его не рестартит.",
933
+ title: ramOnly ? "Процесс держит много RAM" : "Сервис жрёт CPU или RAM",
934
+ text: ramOnly
935
+ ? `${who}. В «Перегруз» попадает процесс из топа от ${floor} МБ RSS (30% RAM, минимум 1 ГБ). CPU тут ни при чём: load может быть спокойным.`
936
+ : `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
937
+ do: ramOnly
938
+ ? "Это не атака и не 100% процессора. Смотрите, что за процесс и сколько ему реально нужно. Orb44 его не рестартит и память не ограничивает."
939
+ : "Смотрите этот процесс (php-fpm, node, mysql). Orb44 его не рестартит.",
820
940
  });
821
941
  }
822
942
  if (Number(pr.conntrackPct) >= 80) {