@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 +4 -4
- package/package.json +1 -1
- package/server/pulse.mjs +128 -8
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.
|
|
17
|
-
npx @orb44/cli@0.1.
|
|
18
|
-
npx @orb44/cli@0.1.
|
|
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.
|
|
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.
|
|
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([
|
|
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
|
|
453
|
-
|
|
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
|
|
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
|
|
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:
|
|
819
|
-
|
|
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) {
|