@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 +4 -4
- package/bin/orb44.mjs +2 -0
- package/package.json +1 -1
- package/server/pulse.mjs +137 -8
- package/server/sat-update.mjs +27 -5
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/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.
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
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
|
|
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
|
|
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:
|
|
810
|
-
|
|
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) {
|
package/server/sat-update.mjs
CHANGED
|
@@ -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:
|
|
107
|
+
return { version: String(version), tarball: canonicalTarballUrl(tarball) };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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");
|