@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 +4 -4
- package/bin/orb44.mjs +5 -2
- package/package.json +1 -1
- package/server/pulse.mjs +348 -12
- 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.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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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, (
|
|
492
|
-
const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.
|
|
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
|
|
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
|
}
|
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");
|