@orb44/cli 0.1.7 → 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.7 status
17
- npx @orb44/cli@0.1.7 pulse
18
- npx @orb44/cli@0.1.7 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.7 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/bin/orb44.mjs CHANGED
@@ -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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orb44/cli",
3
- "version": "0.1.7",
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 || {})) {
@@ -266,6 +324,7 @@ export function sanitizePulse(raw = {}) {
266
324
  const limited = Boolean(raw.limited);
267
325
  const oom = Boolean(raw.oom) || hardening.oomKills > 0;
268
326
  const pressure = sanitizePressure(raw.pressure || raw);
327
+ const cliVersion = sanitizeCliVersion(raw.cliVersion);
269
328
  const base = {
270
329
  ts: Number(raw.ts) || Date.now(),
271
330
  hostname: sanitizeComm(raw.hostname) || os.hostname().slice(0, 40),
@@ -281,11 +340,22 @@ export function sanitizePulse(raw = {}) {
281
340
  limited,
282
341
  hardening,
283
342
  pressure,
343
+ cliVersion,
284
344
  };
285
345
  return { ...base, gradeInside: gradeInside(base) };
286
346
  }
287
347
 
288
- const ADMIN_PORTS = new Set([2019, 2375, 2376, 3306, 5432, 6379, 27017, 9200, 11211, 15672, 8500, 2379, 6443, 9090, 5601, 7474, 7687, 1080, 6432]);
348
+ function sanitizeCliVersion(raw) {
349
+ const v = String(raw || "").trim();
350
+ if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}(-[a-z0-9.]+)?$/i.test(v)) return null;
351
+ if (v === "0.0.0") return null;
352
+ return v.slice(0, 32);
353
+ }
354
+
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
+ ]);
289
359
 
290
360
  const SERVICE_NAME = {
291
361
  22: "SSH",
@@ -301,6 +371,7 @@ const SERVICE_NAME = {
301
371
  3306: "MySQL",
302
372
  5432: "Postgres",
303
373
  5601: "Kibana",
374
+ 6333: "Qdrant",
304
375
  6379: "Redis",
305
376
  6432: "PgBouncer",
306
377
  6443: "kube-api",
@@ -310,8 +381,12 @@ const SERVICE_NAME = {
310
381
  8787: "туннель",
311
382
  9090: "Prometheus",
312
383
  9200: "Elasticsearch",
384
+ 9222: "Chrome CDP",
385
+ 9229: "Node inspector",
313
386
  11211: "memcached",
387
+ 11434: "Ollama",
314
388
  15672: "RabbitMQ",
389
+ 19530: "Milvus",
315
390
  27017: "Mongo",
316
391
  };
317
392
 
@@ -440,8 +515,37 @@ export function stuckFromTop(top = []) {
440
515
  }).slice(0, 6);
441
516
  }
442
517
 
443
- export function hotFromTop(top = []) {
444
- 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);
445
549
  }
446
550
 
447
551
  export function cpuCount(pulse) {
@@ -619,6 +723,7 @@ export function gradeInside(pulse = {}) {
619
723
  if (h.sshPassword && h.sshRoot && h.sshWorld) return "D";
620
724
  if (!fw && exposedAdmin.length) return "D";
621
725
  if (!fw && !ban) return "D";
726
+ if (minersFromTop(pulse.top).length) return "D";
622
727
  if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
623
728
  if (!fw || !ban) return "C";
624
729
  if (diskHigh || oom || h.rebootNeeded || !h.timesync || loadHigh || connHot || stuck) return "C";
@@ -800,14 +905,38 @@ export function compareInside(pulse, rec = {}, prev = null) {
800
905
  do: "Не убивайте с кабинета — его нет. На сервере: ps и диск, не WAF. Orb44 процессы сам не трогает.",
801
906
  });
802
907
  }
803
- 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));
804
919
  if (hot.length && !loadHigh) {
805
- 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(", ");
806
931
  notes.push({
807
932
  kind: "hot-proc",
808
- title: "Сервис жрёт CPU или RAM",
809
- text: `Топ без общей перегрузки load: ${who}. Воркер уже упёрся, витрина может ещё отвечать.`,
810
- 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 его не рестартит.",
811
940
  });
812
941
  }
813
942
  if (Number(pr.conntrackPct) >= 80) {
@@ -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");