@orb44/cli 0.1.0
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 +20 -0
- package/bin/orb44.mjs +561 -0
- package/package.json +40 -0
- package/server/cli-menu.mjs +133 -0
- package/server/pulse.mjs +704 -0
- package/server/sat-i18n.mjs +293 -0
package/server/pulse.mjs
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { t } from "./sat-i18n.mjs";
|
|
5
|
+
|
|
6
|
+
const COMM_RE = /[^a-zA-Z0-9._+-]/g;
|
|
7
|
+
|
|
8
|
+
export function sanitizeComm(raw) {
|
|
9
|
+
let s = String(raw || "").trim();
|
|
10
|
+
const base = s.split("/").filter(Boolean).pop();
|
|
11
|
+
if (base) s = base;
|
|
12
|
+
s = s.replace(COMM_RE, "").slice(0, 40);
|
|
13
|
+
return s || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function decodeProcIp4(hex) {
|
|
17
|
+
const h = String(hex || "").padStart(8, "0").slice(0, 8);
|
|
18
|
+
if (!/^[0-9a-fA-F]{8}$/.test(h)) return null;
|
|
19
|
+
if (h === "00000000") return "0.0.0.0";
|
|
20
|
+
const b = h.match(/../g).map((x) => parseInt(x, 16));
|
|
21
|
+
return `${b[3]}.${b[2]}.${b[1]}.${b[0]}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseProcNetTcp(table) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
for (const line of String(table || "").split("\n").slice(1)) {
|
|
27
|
+
const p = line.trim().split(/\s+/);
|
|
28
|
+
if (p.length < 4 || p[3] !== "0A") continue;
|
|
29
|
+
const [ipHex, portHex] = String(p[1] || "").split(":");
|
|
30
|
+
const port = parseInt(portHex, 16);
|
|
31
|
+
const addr = decodeProcIp4(ipHex);
|
|
32
|
+
if (!addr || !Number.isFinite(port) || port <= 0) continue;
|
|
33
|
+
rows.push({ addr, port, comm: null });
|
|
34
|
+
}
|
|
35
|
+
return rows;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function memInfo() {
|
|
39
|
+
const memTotal = os.totalmem();
|
|
40
|
+
const memUsed = memTotal - os.freemem();
|
|
41
|
+
return { memTotal, memUsed };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function diskUsedPct(root = "/") {
|
|
45
|
+
try {
|
|
46
|
+
if (typeof fs.statfsSync !== "function") return null;
|
|
47
|
+
const s = fs.statfsSync(root);
|
|
48
|
+
const total = Number(s.blocks) * Number(s.bsize);
|
|
49
|
+
const free = Number(s.bavail) * Number(s.bsize);
|
|
50
|
+
if (!total) return null;
|
|
51
|
+
return Math.max(0, Math.min(100, Math.round((1 - free / total) * 100)));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function originAddrs(ifaces = os.networkInterfaces()) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const rows of Object.values(ifaces || {})) {
|
|
60
|
+
for (const r of rows || []) {
|
|
61
|
+
const family = String(r.family);
|
|
62
|
+
if (r.internal) continue;
|
|
63
|
+
if (family !== "IPv4" && family !== "4") continue;
|
|
64
|
+
if (String(r.address || "").startsWith("169.254.")) continue;
|
|
65
|
+
out.push(r.address);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...new Set(out)].slice(0, 8);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function run(cmd, args) {
|
|
72
|
+
try {
|
|
73
|
+
return execFileSync(cmd, args, { encoding: "utf8", timeout: 3500, stdio: ["ignore", "pipe", "ignore"] });
|
|
74
|
+
} catch {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SKIP_TOP = new Set(["ps", "ss", "lsof"]);
|
|
80
|
+
|
|
81
|
+
export function parsePsTop(text) {
|
|
82
|
+
const rows = [];
|
|
83
|
+
for (const line of String(text || "").split("\n")) {
|
|
84
|
+
const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)/);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const comm = sanitizeComm(m[4]);
|
|
87
|
+
if (!comm || SKIP_TOP.has(comm)) continue;
|
|
88
|
+
rows.push({
|
|
89
|
+
comm,
|
|
90
|
+
cpuPct: Math.round(Number(m[2]) * 10) / 10,
|
|
91
|
+
rssMb: Math.round(Number(m[3]) / 1024),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
rows.sort((a, b) => b.cpuPct - a.cpuPct || b.rssMb - a.rssMb);
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
if (seen.has(r.comm)) continue;
|
|
99
|
+
seen.add(r.comm);
|
|
100
|
+
out.push(r);
|
|
101
|
+
if (out.length >= 8) break;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function splitHostPort(token) {
|
|
107
|
+
const raw = String(token || "");
|
|
108
|
+
const last = raw.lastIndexOf(":");
|
|
109
|
+
if (last < 0) return null;
|
|
110
|
+
const host = raw.slice(0, last).replace(/^\[|\]$/g, "");
|
|
111
|
+
const port = Number(raw.slice(last + 1));
|
|
112
|
+
if (!Number.isFinite(port) || port <= 0) return null;
|
|
113
|
+
const addr = host === "*" ? "0.0.0.0" : host;
|
|
114
|
+
return { addr, port };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function parseLsofListen(text) {
|
|
118
|
+
const rows = [];
|
|
119
|
+
for (const line of String(text || "").split("\n").slice(1)) {
|
|
120
|
+
const name = /\s(\S+:\d+)\s+\(LISTEN\)/.exec(line)?.[1];
|
|
121
|
+
if (!name) continue;
|
|
122
|
+
const hp = splitHostPort(name);
|
|
123
|
+
if (!hp) continue;
|
|
124
|
+
const comm = sanitizeComm(line.trim().split(/\s+/)[0]);
|
|
125
|
+
rows.push({ ...hp, comm });
|
|
126
|
+
}
|
|
127
|
+
const key = (r) => `${r.addr}|${r.port}`;
|
|
128
|
+
const uniq = new Map();
|
|
129
|
+
for (const r of rows) uniq.set(key(r), r);
|
|
130
|
+
return [...uniq.values()].slice(0, 40);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseSsListen(text) {
|
|
134
|
+
const rows = [];
|
|
135
|
+
for (const line of String(text || "").split("\n")) {
|
|
136
|
+
if (!/\bLISTEN\b/i.test(line)) continue;
|
|
137
|
+
const parts = line.trim().split(/\s+/);
|
|
138
|
+
const idx = parts.findIndex((p) => /^LISTEN$/i.test(p));
|
|
139
|
+
const local = idx >= 0 ? parts[idx + 3] : "";
|
|
140
|
+
const hp = splitHostPort(local);
|
|
141
|
+
if (!hp) continue;
|
|
142
|
+
const comm = sanitizeComm(/\(\("([^"]+)"/.exec(line)?.[1]);
|
|
143
|
+
rows.push({ ...hp, comm });
|
|
144
|
+
}
|
|
145
|
+
const key = (r) => `${r.addr}|${r.port}`;
|
|
146
|
+
const uniq = new Map();
|
|
147
|
+
for (const r of rows) uniq.set(key(r), r);
|
|
148
|
+
return [...uniq.values()].slice(0, 40);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function listenLinux() {
|
|
152
|
+
try {
|
|
153
|
+
const tcp = fs.readFileSync("/proc/net/tcp", "utf8");
|
|
154
|
+
return parseProcNetTcp(tcp);
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function listenTable() {
|
|
161
|
+
const ss = parseSsListen(run("ss", ["-lptn"]));
|
|
162
|
+
if (ss.length) return { listen: ss, named: ss.some((r) => r.comm) };
|
|
163
|
+
const lsof = parseLsofListen(run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]));
|
|
164
|
+
if (lsof.length) return { listen: lsof, named: lsof.some((r) => r.comm) };
|
|
165
|
+
const linux = listenLinux();
|
|
166
|
+
if (linux.length) return { listen: linux, named: false };
|
|
167
|
+
return { listen: [], named: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function topTable() {
|
|
171
|
+
const text = run("ps", ["-axo", "pid=,pcpu=,rss=,comm="]);
|
|
172
|
+
return parsePsTop(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function okListenAddr(a) {
|
|
176
|
+
const s = String(a || "");
|
|
177
|
+
if (s === "0.0.0.0" || s === "::" || s === "::1") return true;
|
|
178
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return true;
|
|
179
|
+
return /^[0-9a-fA-F:]+$/.test(s) && s.includes(":");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function sanitizePulse(raw = {}) {
|
|
183
|
+
const top = Array.isArray(raw.top)
|
|
184
|
+
? raw.top
|
|
185
|
+
.slice(0, 8)
|
|
186
|
+
.map((r) => ({
|
|
187
|
+
comm: sanitizeComm(r.comm),
|
|
188
|
+
cpuPct: Math.max(0, Math.min(100, Number(r.cpuPct) || 0)),
|
|
189
|
+
rssMb: Math.max(0, Math.round(Number(r.rssMb) || 0)),
|
|
190
|
+
}))
|
|
191
|
+
.filter((r) => r.comm)
|
|
192
|
+
: [];
|
|
193
|
+
const listen = Array.isArray(raw.listen)
|
|
194
|
+
? raw.listen
|
|
195
|
+
.slice(0, 40)
|
|
196
|
+
.map((r) => ({
|
|
197
|
+
addr: String(r.addr || "").slice(0, 45),
|
|
198
|
+
port: Number(r.port) || 0,
|
|
199
|
+
comm: sanitizeComm(r.comm),
|
|
200
|
+
}))
|
|
201
|
+
.filter((r) => r.port > 0 && r.port < 65536 && okListenAddr(r.addr))
|
|
202
|
+
: [];
|
|
203
|
+
const originA = Array.isArray(raw.originA)
|
|
204
|
+
? raw.originA.map((x) => String(x || "")).filter((x) => /^\d{1,3}(\.\d{1,3}){3}$/.test(x)).slice(0, 8)
|
|
205
|
+
: [];
|
|
206
|
+
const fw = String(raw.hardening?.firewall || "");
|
|
207
|
+
const ban = String(raw.hardening?.fail2ban || "");
|
|
208
|
+
const sync = String(raw.hardening?.timesync || "");
|
|
209
|
+
const tri = (v) => (v === true || v === false ? v : null);
|
|
210
|
+
const hardening = {
|
|
211
|
+
firewall: ["ufw", "nftables", "firewalld", "iptables"].includes(fw) ? fw : null,
|
|
212
|
+
fail2ban: ["fail2ban", "sshguard"].includes(ban) ? ban : null,
|
|
213
|
+
fail2banBanned: Math.max(0, Math.min(99999, Number(raw.hardening?.fail2banBanned) || 0)),
|
|
214
|
+
updates: Boolean(raw.hardening?.updates),
|
|
215
|
+
sshPassword: tri(raw.hardening?.sshPassword),
|
|
216
|
+
sshRoot: tri(raw.hardening?.sshRoot),
|
|
217
|
+
sshWorld: Boolean(raw.hardening?.sshWorld),
|
|
218
|
+
rebootNeeded: Boolean(raw.hardening?.rebootNeeded),
|
|
219
|
+
timesync: ["chrony", "systemd-timesyncd", "ntp"].includes(sync) ? sync : null,
|
|
220
|
+
apparmor: tri(raw.hardening?.apparmor),
|
|
221
|
+
selinux: tri(raw.hardening?.selinux),
|
|
222
|
+
dockerApi: Boolean(raw.hardening?.dockerApi),
|
|
223
|
+
oomKills: Math.max(0, Math.min(999, Number(raw.hardening?.oomKills) || 0)),
|
|
224
|
+
};
|
|
225
|
+
const limited = Boolean(raw.limited);
|
|
226
|
+
const oom = Boolean(raw.oom) || hardening.oomKills > 0;
|
|
227
|
+
const base = {
|
|
228
|
+
ts: Number(raw.ts) || Date.now(),
|
|
229
|
+
hostname: sanitizeComm(raw.hostname) || os.hostname().slice(0, 40),
|
|
230
|
+
load1: Math.round((Number(raw.load1) || 0) * 100) / 100,
|
|
231
|
+
memUsed: Math.max(0, Math.round(Number(raw.memUsed) || 0)),
|
|
232
|
+
memTotal: Math.max(0, Math.round(Number(raw.memTotal) || 0)),
|
|
233
|
+
diskUsedPct: raw.diskUsedPct == null ? null : Math.max(0, Math.min(100, Math.round(Number(raw.diskUsedPct)))),
|
|
234
|
+
top,
|
|
235
|
+
listen,
|
|
236
|
+
originA,
|
|
237
|
+
failedUnit: raw.failedUnit ? String(raw.failedUnit).slice(0, 80) : null,
|
|
238
|
+
oom,
|
|
239
|
+
limited,
|
|
240
|
+
hardening,
|
|
241
|
+
};
|
|
242
|
+
return { ...base, gradeInside: gradeInside(base) };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ADMIN_PORTS = new Set([2019, 2375, 2376, 3306, 5432, 6379, 27017, 9200, 11211, 15672, 8500, 2379, 6443, 9090, 5601, 7474, 7687, 1080, 6432]);
|
|
246
|
+
|
|
247
|
+
const SERVICE_NAME = {
|
|
248
|
+
22: "SSH",
|
|
249
|
+
53: "DNS",
|
|
250
|
+
80: "HTTP",
|
|
251
|
+
443: "HTTPS",
|
|
252
|
+
1080: "SOCKS",
|
|
253
|
+
2019: "Caddy admin",
|
|
254
|
+
2375: "Docker",
|
|
255
|
+
2376: "Docker TLS",
|
|
256
|
+
2379: "etcd",
|
|
257
|
+
3000: "Node",
|
|
258
|
+
3306: "MySQL",
|
|
259
|
+
5432: "Postgres",
|
|
260
|
+
5601: "Kibana",
|
|
261
|
+
6379: "Redis",
|
|
262
|
+
6432: "PgBouncer",
|
|
263
|
+
6443: "kube-api",
|
|
264
|
+
7474: "Neo4j",
|
|
265
|
+
7687: "Bolt",
|
|
266
|
+
8500: "Consul",
|
|
267
|
+
8787: "туннель",
|
|
268
|
+
9090: "Prometheus",
|
|
269
|
+
9200: "Elasticsearch",
|
|
270
|
+
11211: "memcached",
|
|
271
|
+
15672: "RabbitMQ",
|
|
272
|
+
27017: "Mongo",
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export function serviceName(port) {
|
|
276
|
+
return SERVICE_NAME[Number(port)] || null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function exposed(addr) {
|
|
280
|
+
return addr === "0.0.0.0" || addr === "::" || addr === "*";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function runOk(cmd, args) {
|
|
284
|
+
try {
|
|
285
|
+
execFileSync(cmd, args, { encoding: "utf8", timeout: 2500, stdio: ["ignore", "pipe", "ignore"] });
|
|
286
|
+
return true;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function runOut(cmd, args) {
|
|
293
|
+
try {
|
|
294
|
+
return execFileSync(cmd, args, { encoding: "utf8", timeout: 2500, stdio: ["ignore", "pipe", "ignore"] });
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return e?.stdout ? String(e.stdout) : "";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function parseUfwStatus(text) {
|
|
301
|
+
const t = String(text || "").toLowerCase();
|
|
302
|
+
if (t.includes("status: active")) return true;
|
|
303
|
+
if (t.includes("status: inactive")) return false;
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function parseSshdT(text) {
|
|
308
|
+
const get = (key) => {
|
|
309
|
+
const re = new RegExp(`^${key}\\s+(\\S+)`, "im");
|
|
310
|
+
return re.exec(String(text || ""))?.[1]?.toLowerCase() || "";
|
|
311
|
+
};
|
|
312
|
+
const pw = get("passwordauthentication");
|
|
313
|
+
const root = get("permitrootlogin");
|
|
314
|
+
return {
|
|
315
|
+
sshPassword: pw === "yes" ? true : pw === "no" ? false : null,
|
|
316
|
+
sshRoot: root === "yes" ? true : root ? false : null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function parseVmstatOom(text) {
|
|
321
|
+
const m = /(?:^|\n)oom_kill\s+(\d+)/.exec(String(text || ""));
|
|
322
|
+
return m ? Number(m[1]) : 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function insideServiceWorldPorts(pulse) {
|
|
326
|
+
return [...new Set((pulse?.listen || []).filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port)).map((r) => r.port))];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function collectHardening(listen = []) {
|
|
330
|
+
const ufw = parseUfwStatus(runOut("ufw", ["status"]));
|
|
331
|
+
let firewall = null;
|
|
332
|
+
if (ufw === true) firewall = "ufw";
|
|
333
|
+
else if (runOk("systemctl", ["is-active", "nftables"])) firewall = "nftables";
|
|
334
|
+
else if (runOk("systemctl", ["is-active", "firewalld"])) firewall = "firewalld";
|
|
335
|
+
else {
|
|
336
|
+
const pol = runOut("iptables", ["-S", "INPUT"]);
|
|
337
|
+
if (/^-P INPUT (DROP|REJECT)/m.test(pol)) firewall = "iptables";
|
|
338
|
+
}
|
|
339
|
+
let fail2ban = null;
|
|
340
|
+
let fail2banBanned = 0;
|
|
341
|
+
if (runOk("systemctl", ["is-active", "fail2ban"])) fail2ban = "fail2ban";
|
|
342
|
+
else if (runOk("systemctl", ["is-active", "sshguard"])) fail2ban = "sshguard";
|
|
343
|
+
if (fail2ban === "fail2ban") {
|
|
344
|
+
const jail = runOut("fail2ban-client", ["status", "sshd"]) || runOut("fail2ban-client", ["status"]);
|
|
345
|
+
fail2banBanned = parseFail2banJail(jail).banned;
|
|
346
|
+
}
|
|
347
|
+
const updates = runOk("systemctl", ["is-active", "unattended-upgrades"]) || fs.existsSync("/etc/apt/apt.conf.d/20auto-upgrades");
|
|
348
|
+
const ssh = parseSshdT(runOut("sshd", ["-T"]) || runOut("/usr/sbin/sshd", ["-T"]));
|
|
349
|
+
const sshWorld = listen.some((r) => exposed(r.addr) && r.port === 22);
|
|
350
|
+
const dockerApi = listen.some((r) => exposed(r.addr) && (r.port === 2375 || r.port === 2376));
|
|
351
|
+
let timesync = null;
|
|
352
|
+
if (runOk("systemctl", ["is-active", "chrony"]) || runOk("systemctl", ["is-active", "chronyd"])) timesync = "chrony";
|
|
353
|
+
else if (runOk("systemctl", ["is-active", "systemd-timesyncd"])) timesync = "systemd-timesyncd";
|
|
354
|
+
else if (runOk("systemctl", ["is-active", "ntp"]) || runOk("systemctl", ["is-active", "ntpd"])) timesync = "ntp";
|
|
355
|
+
let apparmor = null;
|
|
356
|
+
if (fs.existsSync("/sys/kernel/security/apparmor")) apparmor = true;
|
|
357
|
+
else if (process.platform === "linux") apparmor = false;
|
|
358
|
+
let selinux = null;
|
|
359
|
+
try {
|
|
360
|
+
selinux = fs.readFileSync("/sys/fs/selinux/enforce", "utf8").trim() === "1";
|
|
361
|
+
} catch {
|
|
362
|
+
if (process.platform === "linux") selinux = false;
|
|
363
|
+
}
|
|
364
|
+
const rebootNeeded = fs.existsSync("/var/run/reboot-required");
|
|
365
|
+
let oomKills = 0;
|
|
366
|
+
try {
|
|
367
|
+
oomKills = parseVmstatOom(fs.readFileSync("/proc/vmstat", "utf8"));
|
|
368
|
+
} catch {
|
|
369
|
+
oomKills = 0;
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
firewall,
|
|
373
|
+
fail2ban,
|
|
374
|
+
fail2banBanned,
|
|
375
|
+
updates: Boolean(updates),
|
|
376
|
+
...ssh,
|
|
377
|
+
sshWorld,
|
|
378
|
+
rebootNeeded,
|
|
379
|
+
timesync,
|
|
380
|
+
apparmor,
|
|
381
|
+
selinux,
|
|
382
|
+
dockerApi,
|
|
383
|
+
oomKills,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function parseFail2banJail(text) {
|
|
388
|
+
const banned = /Currently banned:\s*(\d+)/i.exec(String(text || ""));
|
|
389
|
+
const failed = /Currently failed:\s*(\d+)/i.exec(String(text || ""));
|
|
390
|
+
return {
|
|
391
|
+
banned: banned ? Number(banned[1]) : 0,
|
|
392
|
+
failed: failed ? Number(failed[1]) : 0,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function gradeInside(pulse = {}) {
|
|
397
|
+
const listen = pulse.listen || [];
|
|
398
|
+
const h = pulse.hardening || {};
|
|
399
|
+
const exposedAdmin = listen.filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port));
|
|
400
|
+
const fw = Boolean(h.firewall);
|
|
401
|
+
const ban = Boolean(h.fail2ban);
|
|
402
|
+
const updates = Boolean(h.updates);
|
|
403
|
+
const diskHigh = Number(pulse.diskUsedPct) >= 85;
|
|
404
|
+
const oom = Boolean(pulse.oom) || Number(h.oomKills) > 0;
|
|
405
|
+
if (h.dockerApi) return "D";
|
|
406
|
+
if (h.sshPassword && h.sshRoot && h.sshWorld) return "D";
|
|
407
|
+
if (!fw && exposedAdmin.length) return "D";
|
|
408
|
+
if (!fw && !ban) return "D";
|
|
409
|
+
if (exposedAdmin.length || h.sshPassword || h.sshRoot) return "C";
|
|
410
|
+
if (!fw || !ban) return "C";
|
|
411
|
+
if (diskHigh || oom || h.rebootNeeded || !h.timesync) return "C";
|
|
412
|
+
if (fw && ban && updates && h.timesync && !h.sshPassword && !h.sshRoot && !exposedAdmin.length && !h.dockerApi) return "A";
|
|
413
|
+
if (fw && ban && !exposedAdmin.length && !h.dockerApi) return "B";
|
|
414
|
+
return "C";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function labelPort(r) {
|
|
418
|
+
const svc = serviceName(r.port);
|
|
419
|
+
return svc ? `${svc} :${r.port}` : `:${r.port}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function compareInside(pulse, rec = {}) {
|
|
423
|
+
const notes = [];
|
|
424
|
+
const listen = pulse?.listen || [];
|
|
425
|
+
const streetIp = rec.ticket?.ip || rec.watch?.current?.ip || null;
|
|
426
|
+
const incident = rec.incident?.code || rec.watch?.current?.incident || "";
|
|
427
|
+
const exposedAdmin = listen.filter((r) => exposed(r.addr) && ADMIN_PORTS.has(r.port));
|
|
428
|
+
const streetOpen = rec.watch?.current?.openPorts;
|
|
429
|
+
const hasStreet = Array.isArray(streetOpen);
|
|
430
|
+
if (exposedAdmin.length) {
|
|
431
|
+
const leaked = hasStreet ? exposedAdmin.filter((r) => streetOpen.includes(r.port)) : [];
|
|
432
|
+
const held = hasStreet ? exposedAdmin.filter((r) => !streetOpen.includes(r.port)) : exposedAdmin;
|
|
433
|
+
if (leaked.length) {
|
|
434
|
+
const names = [...new Set(leaked.map((r) => labelPort(r)))];
|
|
435
|
+
notes.push({
|
|
436
|
+
kind: "listen-leaked",
|
|
437
|
+
title: "Служебные порты отвечают и с улицы",
|
|
438
|
+
text: `Машина слушает ${names.join(", ")} на 0.0.0.0, и снимок с интернета на тех же портах получил ответ сервиса, не пустой SYN-ACK. Это уже не «файрвол, скорее всего, держит» — сокет открыт снаружи.`,
|
|
439
|
+
do: "Сначала закройте порт на файрволе панели и в docker-compose поставьте 127.0.0.1 перед номером. Orb44 файрвол сам не включает и compose не правит.",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (held.length) {
|
|
443
|
+
const names = [...new Set(held.map((r) => labelPort(r)))];
|
|
444
|
+
notes.push({
|
|
445
|
+
kind: "listen-open",
|
|
446
|
+
title: hasStreet ? "Базы слушают сеть сервера, с улицы молчат" : "Базы слушают всю сеть сервера",
|
|
447
|
+
text: hasStreet
|
|
448
|
+
? `Сейчас ${names.join(", ")} принимают подключения с любого сетевого адреса этой машины. Снимок с интернета на этих портах не получил баннер сервиса — файрвол держит. Если порт откроют в панели VPS, до базы доберётся любой, не только ваш сайт.`
|
|
449
|
+
: `Сейчас ${names.join(", ")} принимают подключения не только с программ на этой машине, а с любого её сетевого адреса. Из интернета их может быть не видно: файрвол на VPS эти порты, скорее всего, закрывает. Если порт откроют — до Redis или Postgres доберётся любой, не только ваш сайт.`,
|
|
450
|
+
do: "На кортадо в docker-compose у этих сервисов замените порты. Пример: было 6379:6379, нужно 127.0.0.1:6379:6379. Так же для 5432, 7474 и остальных оранжевых меток. Тогда к базе подключатся только программы на этом сервере. Orb44 файл сам не изменит.",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (pulse?.originA?.length && streetIp && !pulse.originA.includes(streetIp)) {
|
|
455
|
+
notes.push({
|
|
456
|
+
kind: "origin-mismatch",
|
|
457
|
+
title: "Адрес машины не совпал со снимком",
|
|
458
|
+
text: `На машине ${pulse.originA.join(", ")}, снаружи A=${streetIp}. Другой ящик или серый щит.`,
|
|
459
|
+
do: "Сверьте DNS A с тем VPS, куда поставили сателлит. Если сайт за Cloudflare — это ожидаемо, не инцидент.",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const loadHigh = Number(pulse?.load1) >= Math.max(2, (os.cpus() || []).length);
|
|
463
|
+
const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.9;
|
|
464
|
+
if (loadHigh || memHigh) {
|
|
465
|
+
const top = pulse.top?.[0];
|
|
466
|
+
const who = top ? `${top.comm} ${top.cpuPct}%` : "процесс в топе не виден";
|
|
467
|
+
if (/auth_hot|auth_open/.test(incident)) {
|
|
468
|
+
notes.push({
|
|
469
|
+
kind: "load-street",
|
|
470
|
+
title: "Нагрузка и открытый вход",
|
|
471
|
+
text: `Машина под нагрузкой, снаружи открыт вход — похоже бьют во вход. Топ: ${who}.`,
|
|
472
|
+
do: "Сначала вход на витрине, не процессы. Сателлит здесь только подтверждает, что CPU живой.",
|
|
473
|
+
});
|
|
474
|
+
} else {
|
|
475
|
+
notes.push({
|
|
476
|
+
kind: "load-local",
|
|
477
|
+
title: "Машина тяжёлая, витрина тихая",
|
|
478
|
+
text: `Нагрузка локальная, снаружи тихо — не атака витрины. Топ: ${who}.`,
|
|
479
|
+
do: "Смотрите процесс в топе (воркер, docker). WAF тут ни при чём.",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const h = pulse.hardening || {};
|
|
484
|
+
const fw = h.firewall;
|
|
485
|
+
const ban = h.fail2ban;
|
|
486
|
+
if (pulse.hardening) {
|
|
487
|
+
if (!fw && !ban) {
|
|
488
|
+
notes.push({
|
|
489
|
+
kind: "hardening",
|
|
490
|
+
title: "Минимум защиты не виден",
|
|
491
|
+
text: "Нет включённого файрвола и нет fail2ban. Лишние порты и подбор SSH никто не режет.",
|
|
492
|
+
do: "На кортадо включите ufw и fail2ban. Orb44 пакеты сам не ставит.",
|
|
493
|
+
});
|
|
494
|
+
} else if (!fw) {
|
|
495
|
+
notes.push({
|
|
496
|
+
kind: "hardening",
|
|
497
|
+
title: "Файрвол не виден",
|
|
498
|
+
text: "Не нашлось включённого ufw, nftables или firewalld. Тогда оранжевые порты баз — это не «закрыто файрволом», а открыто в сеть сервера.",
|
|
499
|
+
do: "Включите ufw и закройте лишние порты. Orb44 файрвол сам не включает.",
|
|
500
|
+
});
|
|
501
|
+
} else if (!ban) {
|
|
502
|
+
notes.push({
|
|
503
|
+
kind: "hardening",
|
|
504
|
+
title: "Нет защиты подбора SSH",
|
|
505
|
+
text: "fail2ban или sshguard не запущены. Повторные попытки входа по SSH ничем не режутся.",
|
|
506
|
+
do: "Поставьте и включите fail2ban на кортадо. Orb44 пакеты сам не ставит.",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
const sshBits = [];
|
|
510
|
+
if (h.sshPassword) sshBits.push("вход по паролю включён");
|
|
511
|
+
if (h.sshRoot) sshBits.push("root может зайти по SSH");
|
|
512
|
+
if (h.sshWorld && (h.sshPassword || h.sshRoot)) sshBits.push("порт 22 слушает всю сеть");
|
|
513
|
+
if (sshBits.length) {
|
|
514
|
+
notes.push({
|
|
515
|
+
kind: "hardening",
|
|
516
|
+
title: "SSH слабее, чем нужно",
|
|
517
|
+
text: `${sshBits.join(". ")}. Пароли не подбираем — это живые настройки sshd, не /etc/shadow.`,
|
|
518
|
+
do: "В sshd: PasswordAuthentication no и PermitRootLogin no. Порт 22 лучше не светить всей сети. Orb44 sshd сам не правит.",
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (h.dockerApi) {
|
|
522
|
+
notes.push({
|
|
523
|
+
kind: "hardening",
|
|
524
|
+
title: "Docker API слушает сеть сервера",
|
|
525
|
+
text: "Порт 2375 или 2376 принимает подключения не только с этой машины. С улицы его может закрывать файрвол, но сокет уже не loopback.",
|
|
526
|
+
do: "Уберите публикацию Docker API наружу. Достаточно unix-сокета. Orb44 Docker сам не трогает.",
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
if (h.rebootNeeded) {
|
|
530
|
+
notes.push({
|
|
531
|
+
kind: "hardening",
|
|
532
|
+
title: "Ядро ждет перезагрузки",
|
|
533
|
+
text: "На диске есть /var/run/reboot-required — пакеты ядра встали, машина ещё на старом.",
|
|
534
|
+
do: "Запланируйте reboot в окно. Orb44 сервер сам не перезагружает.",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
if (!h.timesync) {
|
|
538
|
+
notes.push({
|
|
539
|
+
kind: "hardening",
|
|
540
|
+
title: "Часы машины не синхронизируются",
|
|
541
|
+
text: "Не видно chrony, systemd-timesyncd или ntp. Кривые часы ломают TLS и разбор логов.",
|
|
542
|
+
do: "Включите systemd-timesyncd или chrony. Orb44 пакеты сам не ставит.",
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
if (h.apparmor === false && h.selinux === false) {
|
|
546
|
+
notes.push({
|
|
547
|
+
kind: "hardening",
|
|
548
|
+
title: "Нет AppArmor и SELinux",
|
|
549
|
+
text: "На Linux не видно ни AppArmor, ни enforcing SELinux. Это не дыра сама по себе, но процесс ничем не ограничен.",
|
|
550
|
+
do: "На Ubuntu обычно достаточно apparmor. Orb44 LSM сам не включает.",
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
if (Number(pulse.diskUsedPct) >= 85) {
|
|
554
|
+
notes.push({
|
|
555
|
+
kind: "hardening",
|
|
556
|
+
title: "Диск почти полный",
|
|
557
|
+
text: `Корневой раздел занят на ${pulse.diskUsedPct}%. Логи и апдейты начнут падать раньше, чем витрина.`,
|
|
558
|
+
do: "Почистите логи и неиспользуемые образы Docker. Orb44 файлы сам не удаляет.",
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (pulse.oom || Number(h.oomKills) > 0) {
|
|
562
|
+
notes.push({
|
|
563
|
+
kind: "hardening",
|
|
564
|
+
title: "Ядро убивало процессы по памяти",
|
|
565
|
+
text: Number(h.oomKills) > 0 ? `В vmstat oom_kill=${h.oomKills}. Кто-то уже упирался в RAM.` : "Пульс пометил OOM.",
|
|
566
|
+
do: "Смотрите топ по RAM и лимиты контейнеров. Orb44 процессы сам не убивает и не поднимает.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const banned = Number(h.fail2banBanned) || 0;
|
|
570
|
+
if (banned > 0 && /auth_hot|auth_open/.test(incident)) {
|
|
571
|
+
notes.push({
|
|
572
|
+
kind: "stuffing",
|
|
573
|
+
title: "fail2ban копит баны, вход с улицы горячий",
|
|
574
|
+
text: `Сейчас в бане ${banned}. Пароли не подбираем — это чужой перебор, сателлит только считает.`,
|
|
575
|
+
do: "Смотрите fail2ban и форму входа на витрине. Orb44 пароли не перебирает.",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return notes;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export const INSIDE_ALERT_DEBOUNCE_MS = 6 * 3600 * 1000;
|
|
583
|
+
|
|
584
|
+
function streetIncidentCode(rec = {}) {
|
|
585
|
+
return rec.incident?.code || rec.watch?.current?.incident || rec.watch?.last?.incident || "";
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function insideWatchReasons(pulse, prev, rec = {}) {
|
|
589
|
+
const notes = [];
|
|
590
|
+
const incident = String(streetIncidentCode(rec) || "");
|
|
591
|
+
const streetHot = /auth_hot|auth_open|l7_exhaustion/.test(incident);
|
|
592
|
+
const loadHigh = Number(pulse?.load1) >= 2;
|
|
593
|
+
const memHigh = pulse?.memTotal && pulse.memUsed / pulse.memTotal >= 0.85;
|
|
594
|
+
const top = pulse?.top?.[0];
|
|
595
|
+
const who = top ? `${top.comm} ${top.cpuPct}%` : "процесс в топе не виден";
|
|
596
|
+
if (loadHigh || memHigh) {
|
|
597
|
+
if (streetHot) {
|
|
598
|
+
notes.push({
|
|
599
|
+
kind: "stuffing",
|
|
600
|
+
title: "Нагрузка и открытый вход",
|
|
601
|
+
text: `Машина под нагрузкой, снаружи горячий вход — похоже бьют во вход, не локальный воркер. Топ: ${who}. Пароли не подбираем.`,
|
|
602
|
+
});
|
|
603
|
+
} else {
|
|
604
|
+
notes.push({
|
|
605
|
+
kind: "load-local",
|
|
606
|
+
title: "Машина тяжёлая, витрина тихая",
|
|
607
|
+
text: `Нагрузка локальная, с улицы не видно атаки. Топ: ${who}.`,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const banned = Number(pulse?.hardening?.fail2banBanned) || 0;
|
|
612
|
+
const prevBanned = Number(prev?.hardening?.fail2banBanned) || 0;
|
|
613
|
+
if (prev && banned > prevBanned) {
|
|
614
|
+
notes.push({
|
|
615
|
+
kind: "stuffing-ssh",
|
|
616
|
+
title: "fail2ban копит баны",
|
|
617
|
+
text: `Было ${prevBanned}, стало ${banned}. Это чужой перебор SSH. Пароли не подбираем.`,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (prev) {
|
|
621
|
+
const nowPorts = insideServiceWorldPorts(pulse);
|
|
622
|
+
const prevPorts = new Set(insideServiceWorldPorts(prev));
|
|
623
|
+
const added = nowPorts.filter((p) => !prevPorts.has(p));
|
|
624
|
+
if (added.length) {
|
|
625
|
+
notes.push({
|
|
626
|
+
kind: "listen-new",
|
|
627
|
+
title: "Новый служебный порт на 0.0.0.0",
|
|
628
|
+
text: `Появились ${added.join(", ")}. С прошлого пульса их не было.`,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const streetIp = rec.ticket?.ip || rec.watch?.current?.ip || rec.watch?.last?.ip || null;
|
|
633
|
+
if (pulse?.originA?.length && streetIp && !pulse.originA.includes(streetIp)) {
|
|
634
|
+
notes.push({
|
|
635
|
+
kind: "origin-mismatch",
|
|
636
|
+
title: "Адрес машины не совпал со снимком",
|
|
637
|
+
text: `На машине ${pulse.originA.join(", ")}, снаружи A=${streetIp}.`,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return notes;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function insideAlertDecision(row, reasons, now = Date.now(), debounceMs = INSIDE_ALERT_DEBOUNCE_MS) {
|
|
644
|
+
if (!reasons?.length) return { emit: false, key: null };
|
|
645
|
+
const key = [...new Set(reasons.map((r) => r.kind))].sort().join(",");
|
|
646
|
+
if (row?.lastInsideAlertKey === key && row?.lastInsideAlertAt && now - row.lastInsideAlertAt < debounceMs) {
|
|
647
|
+
return { emit: false, key };
|
|
648
|
+
}
|
|
649
|
+
return { emit: true, key };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function collectPulse() {
|
|
653
|
+
const { memUsed, memTotal } = memInfo();
|
|
654
|
+
const { listen, named } = listenTable();
|
|
655
|
+
const top = topTable();
|
|
656
|
+
const hardening = collectHardening(listen);
|
|
657
|
+
const limited = listen.length > 0 && !named;
|
|
658
|
+
return sanitizePulse({
|
|
659
|
+
ts: Date.now(),
|
|
660
|
+
hostname: os.hostname(),
|
|
661
|
+
load1: os.loadavg()[0],
|
|
662
|
+
memUsed,
|
|
663
|
+
memTotal,
|
|
664
|
+
diskUsedPct: diskUsedPct("/"),
|
|
665
|
+
top,
|
|
666
|
+
listen,
|
|
667
|
+
originA: originAddrs(),
|
|
668
|
+
failedUnit: null,
|
|
669
|
+
oom: Number(hardening.oomKills) > 0,
|
|
670
|
+
limited,
|
|
671
|
+
hardening,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function formatPulsePreview(pulse, lang = "en") {
|
|
676
|
+
const gb = (n) => (Number(n) / 1024 / 1024 / 1024).toFixed(1);
|
|
677
|
+
const none = t(lang, "preview_none");
|
|
678
|
+
const lines = [
|
|
679
|
+
t(lang, "preview_host", { h: pulse.hostname || "—" }),
|
|
680
|
+
t(lang, "preview_load", { load: pulse.load1, used: gb(pulse.memUsed), total: gb(pulse.memTotal), disk: pulse.diskUsedPct ?? "—" }),
|
|
681
|
+
t(lang, "preview_listen", {
|
|
682
|
+
list: (pulse.listen || []).slice(0, 12).map((r) => `${r.addr}:${r.port}${r.comm ? ` ${r.comm}` : ""}`).join(", ") || none,
|
|
683
|
+
}),
|
|
684
|
+
t(lang, "preview_top", {
|
|
685
|
+
list: (pulse.top || []).slice(0, 5).map((r) => `${r.comm} ${r.cpuPct}% ${r.rssMb}M`).join(", ") || none,
|
|
686
|
+
}),
|
|
687
|
+
];
|
|
688
|
+
const h = pulse.hardening;
|
|
689
|
+
if (h) {
|
|
690
|
+
const ssh = h.sshPassword ? t(lang, "preview_ssh_pass") : h.sshPassword === false ? t(lang, "preview_ssh_key") : "?";
|
|
691
|
+
lines.push(
|
|
692
|
+
t(lang, "preview_guard", {
|
|
693
|
+
fw: h.firewall || t(lang, "preview_fw_no"),
|
|
694
|
+
ban: h.fail2ban || t(lang, "preview_ban_no"),
|
|
695
|
+
upd: h.updates ? t(lang, "preview_yes") : t(lang, "preview_no"),
|
|
696
|
+
sync: h.timesync || t(lang, "preview_no"),
|
|
697
|
+
ssh,
|
|
698
|
+
grade: pulse.gradeInside || "—",
|
|
699
|
+
})
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (pulse.limited) lines.push(t(lang, "preview_limited"));
|
|
703
|
+
return lines.join("\n");
|
|
704
|
+
}
|