From 8707068ecd69ff184dccd6748c1e6b2e15271f0c Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Wed, 26 Aug 2026 12:58:09 +0300 Subject: [PATCH] V 1.5.0 --- ARCHITECTURE.md | 30 ++- README.md | 6 +- audit.go | 518 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 21 +- 4 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 audit.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9e2c031..d0f7800 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -49,6 +49,7 @@ auspex [флаг] [аргументы] ├── -fd → runFDCheck() ├── -dt → runDockerTUI() ├── -fw → runFirewallTUI() + ├── -au → runAudit(category) ├── -v → версия (+ вариант сборки: full/generic) └── -h → usage ``` @@ -389,6 +390,32 @@ Truncate — единственное действие, которое осво --- +### `audit.go` — комплексный аудит-скан (read-only) + +**Точка входа:** `runAudit(category string)` + +Не проверяет "что-то одно", а прогоняет 7 категорий и печатает единый отчёт, отсортированный по severity (critical → warning → info). Полностью read-only — ничего не чинит и не меняет, только собирает находки. Задуман как первый шаг при поиске бага: что на тренировочном стенде (не связанном с WAF), что на реальном сервере. + +**Категории:** `systemd`, `nginx` (детектит и nginx, и angie), `docker`, `cert`, `disk`, `network`, `firewall` — можно запустить одну через `auspex -au <категория>` (есть алиасы: `svc`→systemd, `angie`/`proxy`→nginx, `tls`/`ssl`→cert, `logs`→disk, `dns`→network, `iptables`/`ufw`→firewall). + +**Переиспользует существующие модули напрямую** (не дублирует парсинг): +- `ccFindCerts`/`ccParseCert` (certcheck.go) — сроки сертификатов +- `dcCheckDockerAvailable`/`dcGetContainers` (dockercheck.go) — статус контейнеров +- `fdScan`/`fdHumanSize` (fdcheck.go) — удалённые-но-открытые файлы как признак утечки места +- `fwDetectBackend`/`ListRules` (firewall.go) — список firewall-правил + +**Кросс-проверки между категориями — самая ценная часть:** +| Проверка | Как | Что выявляет | +|---|---|---| +| nginx `listen` ↔ реальные сокеты | парсит `nginx/angie -T`, сверяет с `ss -tlnp`/`-ulnp` (`auListeningPorts`) | конфиг применён, но процесс не слушает порт (не перечитал конфиг / упал) | +| nginx upstream ↔ доступность | парсит `server host:port;` из `-T`, TCP dial с таймаутом 2 сек | бэкенд не отвечает или неверный адрес в upstream | +| сертификат на диске ↔ то, что реально отдаёт TLS | `ccParseCert` против `tls.Dial` на `:443` | сервис не перечитал сертификат после обновления (самый частый "необъяснимый" баг с TLS) | +| firewall-правила ↔ слушающие порты | пересечение `ListRules()` и `auListeningPorts()` в обе стороны | правило разрешает порт, где никто не слушает (устарело) — и наоборот, порт слушает без явного allow (может блокироваться default policy) | + +Кросс-проверки по firewall и network — эвристические (не учитывают, например, ACCEPT default policy), поэтому такие находки идут с severity `warning`, а не `critical`, и в конце отчёта есть явная оговорка об этом. + +--- + ### `webcheck.go` — проверка доступности сайтов **Точка входа:** `runWebCheck(sidArg string)` @@ -573,6 +600,7 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать | dockerview | `dv` | | fdcheck | `fd` | | firewall | `fw` | +| audit | `au` | | email | `email` | | mattermost | `mm` | | webcheck | `wc` | @@ -588,4 +616,4 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать `buildinfo_waf.go` / `buildinfo_generic.go` по той же схеме задают константу `buildVariant` (`"full"` / `"generic"`), которую показывают баннер меню и `auspex -v`. -`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя). +`lib/pq` используется только в трёх WAF-тегированных файлах — в generic-сборке он просто не компилируется (в `go.mod` остаётся объявленным, это нормально для двух конфигураций одного модуля; не стоит гонять `go mod tidy` без тега `waf`, иначе он может решить, что зависимость лишняя). \ No newline at end of file diff --git a/README.md b/README.md index 9cb40da..d9e0938 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ | `-fd` | Поиск удалённых-но-открытых файловых дескрипторов (recover/reopen/truncate/restart/kill) | | `-dt` | Docker Live — интерактивный TUI (аналог lazydocker): логи, шелл, stop/start/restart/remove | | `-fw` | Firewall — TUI для ufw/nftables/iptables: просмотр правил + формы (allow/deny, forward, rate-limit) с dry-run и автооткатом | +| `-au` | Audit — комплексная read-only диагностика (systemd/nginx/docker/cert/disk/network/firewall) с кросс-проверками между категориями; `auspex -au <категория>` для одной секции | Модули **[WAF]** требуют БД `waf_info` и/или структуру каталогов PT AF и доступны только в сборке `full` (см. [Сборка](#сборка)). @@ -36,6 +37,7 @@ auspex/ ├── dockerview.go # -dt: Docker Live TUI (аналог lazydocker) ├── firewall.go # -fw: бэкенды ufw/nftables/iptables (detect/list/apply/undo) ├── firewall_tui.go # -fw: TUI-слой (формы, review, rollback-таймер) +├── audit.go # -au: комплексный аудит-скан (read-only) ├── portsview.go # -ss: TUI просмотр занятых портов ├── fdcheck.go # -fd: удалённые-но-открытые файловые дескрипторы ├── portcheck.go # -p: проверка доступности портов [WAF, build tag] @@ -93,6 +95,8 @@ sudo cp auspex /usr/local/bin/auspex ./auspex -fd # Поиск удалённых-но-открытых файловых дескрипторов ./auspex -dt # Docker Live TUI (аналог lazydocker) ./auspex -fw # Firewall TUI (ufw/nftables/iptables) +./auspex -au # Полный аудит-скан +./auspex -au nginx # Аудит только одной категории ./auspex -h # Справка ./auspex -v # Версия ``` @@ -288,4 +292,4 @@ CERT_INFO_DAYS=30 ## Подробнее -См. [ARCHITECTURE.md](ARCHITECTURE.md). +См. [ARCHITECTURE.md](ARCHITECTURE.md). \ No newline at end of file diff --git a/audit.go b/audit.go new file mode 100644 index 0000000..8269596 --- /dev/null +++ b/audit.go @@ -0,0 +1,518 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// ============================================================================= +// audit — комплексная диагностика сервера по категориям +// ============================================================================= +// +// В отличие от остальных модулей, это не "проверка одной вещи", а сквозной +// скан: systemd, nginx/Angie, Docker, сертификаты, диск/логи, сеть/DNS, +// firewall — плюс КРОСС-ПРОВЕРКИ между категориями (слушающие порты против +// firewall-правил, конфиг nginx против реальных сокетов, сертификат на диске +// против того, что реально отдаётся по TLS). Такие несостыковки — самый частый +// источник "необъяснимых" багов что на тренировочных стендах, что в проде. +// +// ПОЛНОСТЬЮ READ-ONLY: модуль ничего не меняет и не чинит — только собирает +// находки и печатает отчёт с рекомендациями, что проверить дальше руками. +// ============================================================================= + +type auFinding struct { + Category string + Severity string // "critical" | "warning" | "info" + Message string +} + +type auSocket struct { + Proto string + Port string +} + +var auCategories = []string{"systemd", "nginx", "docker", "cert", "disk", "network", "firewall"} + +var auAliases = map[string]string{ + "svc": "systemd", "service": "systemd", "services": "systemd", "systemctl": "systemd", + "angie": "nginx", "proxy": "nginx", "reverse-proxy": "nginx", + "container": "docker", "containers": "docker", + "certs": "cert", "tls": "cert", "ssl": "cert", "certificate": "cert", "certificates": "cert", + "logs": "disk", "log": "disk", "space": "disk", + "dns": "network", "net": "network", + "fw": "firewall", "iptables": "firewall", "nftables": "firewall", "ufw": "firewall", +} + +func runAudit(filter string) error { + runners := map[string]func() []auFinding{ + "systemd": auCheckSystemd, + "nginx": auCheckNginx, + "docker": auCheckDocker, + "cert": auCheckCerts, + "disk": auCheckDiskAndLogs, + "network": auCheckNetwork, + "firewall": auCheckFirewall, + } + + order := auCategories + if filter != "" { + f := strings.ToLower(strings.TrimSpace(filter)) + if canon, ok := auAliases[f]; ok { + f = canon + } + if _, ok := runners[f]; !ok { + return fmt.Errorf("неизвестная категория %q — доступны: %s", filter, strings.Join(auCategories, ", ")) + } + order = []string{f} + } + + fmt.Printf("%s🔍 Auspex Audit — комплексная диагностика%s\n\n", cCyan, cReset) + + var all []auFinding + for _, name := range order { + fmt.Printf("%s▶ %s...%s\n", cBlue, name, cReset) + all = append(all, runners[name]()...) + } + + auPrintReport(all) + return nil +} + +func auPrintReport(all []auFinding) { + var critical, warning, info []auFinding + for _, f := range all { + switch f.Severity { + case "critical": + critical = append(critical, f) + case "warning": + warning = append(warning, f) + default: + info = append(info, f) + } + } + + fmt.Println() + if len(critical) > 0 { + fmt.Printf("%s🎯 Наиболее вероятные причины проблемы (%d):%s\n", cRed, len(critical), cReset) + for _, f := range critical { + fmt.Printf(" 🔴 [%s] %s\n", f.Category, f.Message) + } + fmt.Println() + } else { + fmt.Printf("%sКритичных находок не обнаружено.%s\n\n", cGreen, cReset) + } + + if len(warning) > 0 { + fmt.Printf("%s⚠️ Требует внимания (%d):%s\n", cYellow, len(warning), cReset) + for _, f := range warning { + fmt.Printf(" 🟡 [%s] %s\n", f.Category, f.Message) + } + fmt.Println() + } + + if len(info) > 0 { + fmt.Printf("%sСправочно (%d):%s\n", cCyan, len(info), cReset) + for _, f := range info { + fmt.Printf(" ℹ️ [%s] %s\n", f.Category, f.Message) + } + fmt.Println() + } + + fmt.Printf("Итого: %d критично, %d предупреждений, %d информационных\n", len(critical), len(warning), len(info)) + fmt.Println() + fmt.Printf("%sНапоминание: это подсказки, а не диагноз. Часть находок (например\n"+ + "'порт слушает, но нет явного allow-правила') может быть ложной — не забывайте\n"+ + "про default policy firewall'а и специфику конкретного стенда.%s\n", cYellow, cReset) +} + +// ── auListeningPorts — общий хелпер для nginx- и firewall-кросс-проверок ───── + +func auListeningPorts() ([]auSocket, error) { + var socks []auSocket + for _, flag := range []string{"-tlnp", "-ulnp"} { + proto := "tcp" + if flag == "-ulnp" { + proto = "udp" + } + out, err := exec.Command("ss", flag).Output() + if err != nil { + continue + } + for i, line := range strings.Split(string(out), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + fields := strings.Fields(line) + port := "" + for _, f := range fields { + idx := strings.LastIndex(f, ":") + if idx == -1 || idx == len(f)-1 { + continue + } + if p, err := strconv.Atoi(f[idx+1:]); err == nil { + port = strconv.Itoa(p) + break + } + } + if port != "" { + socks = append(socks, auSocket{Proto: proto, Port: port}) + } + } + } + return socks, nil +} + +// ── systemd ────────────────────────────────────────────────────────────────── + +func auCheckSystemd() []auFinding { + if _, err := exec.LookPath("systemctl"); err != nil { + return []auFinding{{"systemd", "info", "systemctl не найден — пропущено (не systemd-система?)"}} + } + raw, err := exec.Command("systemctl", "list-units", "--type=service", "--all", "--no-legend", "--plain").Output() + if err != nil { + return []auFinding{{"systemd", "warning", fmt.Sprintf("не удалось получить список служб: %v", err)}} + } + + var out []auFinding + failed := 0 + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + unit, active, sub := fields[0], fields[2], fields[3] + if active == "failed" { + failed++ + out = append(out, auFinding{"systemd", "critical", + fmt.Sprintf("служба %s в состоянии failed (%s) — journalctl -u %s -n 50", unit, sub, unit)}) + } + } + if failed == 0 { + out = append(out, auFinding{"systemd", "info", "служб в состоянии failed не найдено"}) + } + return out +} + +// ── nginx / Angie ──────────────────────────────────────────────────────────── + +func auCheckNginx() []auFinding { + var out []auFinding + found := false + + for _, bin := range []string{"nginx", "angie"} { + if _, err := exec.LookPath(bin); err != nil { + continue + } + found = true + + testOut, testErr := exec.Command(bin, "-t").CombinedOutput() + if testErr != nil { + out = append(out, auFinding{"nginx", "critical", + fmt.Sprintf("'%s -t' провалилась: %s", bin, strings.TrimSpace(string(testOut)))}) + } else { + out = append(out, auFinding{"nginx", "info", fmt.Sprintf("'%s -t': синтаксис конфигурации в порядке", bin)}) + } + + activeOut, _ := exec.Command("systemctl", "is-active", bin).Output() + if strings.TrimSpace(string(activeOut)) != "active" { + out = append(out, auFinding{"nginx", "critical", + fmt.Sprintf("%s не активен (systemctl is-active: %s)", bin, strings.TrimSpace(string(activeOut)))}) + } + + if dump, err := exec.Command(bin, "-T").Output(); err == nil { + out = append(out, auCheckNginxListenPorts(bin, string(dump))...) + out = append(out, auCheckNginxUpstreams(string(dump))...) + } + } + + if !found { + out = append(out, auFinding{"nginx", "info", "nginx/angie не найдены на этом хосте — пропущено"}) + } + return out +} + +var auNginxListenRe = regexp.MustCompile(`listen\s+(?:\S+:)?(\d+)`) + +func auCheckNginxListenPorts(bin, dump string) []auFinding { + var out []auFinding + ports := map[string]bool{} + for _, m := range auNginxListenRe.FindAllStringSubmatch(dump, -1) { + ports[m[1]] = true + } + + listening, _ := auListeningPorts() + listenSet := map[string]bool{} + for _, s := range listening { + listenSet[s.Port] = true + } + + for port := range ports { + if !listenSet[port] { + out = append(out, auFinding{"nginx", "critical", fmt.Sprintf( + "%s настроен слушать порт %s, но по факту никто на нём не слушает — процесс не применил конфиг или упал после reload", + bin, port)}) + } + } + return out +} + +var auNginxUpstreamRe = regexp.MustCompile(`server\s+([\w.\-]+:\d+)\s*;`) + +func auCheckNginxUpstreams(dump string) []auFinding { + var out []auFinding + seen := map[string]bool{} + for _, m := range auNginxUpstreamRe.FindAllStringSubmatch(dump, -1) { + addr := m[1] + if seen[addr] { + continue + } + seen[addr] = true + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + out = append(out, auFinding{"nginx", "critical", + fmt.Sprintf("upstream %s недоступен (%v) — бэкенд не отвечает или указан неверный адрес", addr, err)}) + } else { + conn.Close() + } + } + return out +} + +// ── Docker ─────────────────────────────────────────────────────────────────── + +func auCheckDocker() []auFinding { + if err := dcCheckDockerAvailable(); err != nil { + return []auFinding{{"docker", "info", fmt.Sprintf("Docker недоступен — пропущено (%v)", err)}} + } + containers, err := dcGetContainers() + if err != nil { + return []auFinding{{"docker", "warning", fmt.Sprintf("не удалось получить список контейнеров: %v", err)}} + } + if len(containers) == 0 { + return []auFinding{{"docker", "info", "контейнеров не найдено"}} + } + + var out []auFinding + for _, c := range containers { + switch { + case c.Status == "running" && c.Health == "unhealthy": + out = append(out, auFinding{"docker", "critical", + fmt.Sprintf("контейнер %s запущен, но healthcheck сообщает unhealthy — docker logs %s", c.Name, c.Name)}) + case c.Status == "restarting": + out = append(out, auFinding{"docker", "critical", + fmt.Sprintf("контейнер %s в состоянии restarting (похоже на crash-loop) — docker logs %s", c.Name, c.Name)}) + case c.Status == "exited" || c.Status == "dead": + out = append(out, auFinding{"docker", "critical", + fmt.Sprintf("контейнер %s остановлен (%s) — docker logs %s", c.Name, c.Status, c.Name)}) + } + } + if len(out) == 0 { + out = append(out, auFinding{"docker", "info", fmt.Sprintf("все %d контейнер(ов) в порядке", len(containers))}) + } + return out +} + +// ── Сертификаты / TLS ──────────────────────────────────────────────────────── + +func auCheckCerts() []auFinding { + paths, err := ccFindCerts(cfgSSLDir) + if err != nil { + return []auFinding{{"cert", "warning", fmt.Sprintf("не удалось просканировать %s: %v", cfgSSLDir, err)}} + } + if len(paths) == 0 { + return []auFinding{{"cert", "info", fmt.Sprintf("сертификаты в %s не найдены", cfgSSLDir)}} + } + + var out []auFinding + var newest ccCertInfo + haveNewest := false + + for _, p := range paths { + info, err := ccParseCert(p) + if err != nil { + continue + } + switch { + case info.DaysLeft < 0: + out = append(out, auFinding{"cert", "critical", + fmt.Sprintf("%s (CN=%s) истёк %d дн. назад", info.Path, info.CommonName, -info.DaysLeft)}) + case info.DaysLeft <= cfgCertCriticalDays: + out = append(out, auFinding{"cert", "critical", + fmt.Sprintf("%s (CN=%s) истекает через %d дн.", info.Path, info.CommonName, info.DaysLeft)}) + case info.DaysLeft <= cfgCertWarningDays: + out = append(out, auFinding{"cert", "warning", + fmt.Sprintf("%s (CN=%s) истекает через %d дн.", info.Path, info.CommonName, info.DaysLeft)}) + } + if !haveNewest || info.NotAfter.After(newest.NotAfter) { + newest = info + haveNewest = true + } + } + + if haveNewest { + if served, err := auFetchServedCert("127.0.0.1:443", 2*time.Second); err == nil { + if served.NotAfter.Before(newest.NotAfter.Add(-24 * time.Hour)) { + out = append(out, auFinding{"cert", "critical", fmt.Sprintf( + "на диске есть сертификат новее (истекает %s), чем реально отдаётся по TLS на :443 (истекает %s) — похоже, сервис не перечитал сертификат после обновления/выпуска", + newest.NotAfter.Format("2006-01-02"), served.NotAfter.Format("2006-01-02"))}) + } + } + } + + if len(out) == 0 { + out = append(out, auFinding{"cert", "info", fmt.Sprintf("проверено %d сертификат(ов), проблем не найдено", len(paths))}) + } + return out +} + +func auFetchServedCert(addr string, timeout time.Duration) (*x509.Certificate, error) { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return nil, err + } + defer conn.Close() + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + return nil, fmt.Errorf("сертификат не получен в TLS-хендшейке") + } + return certs[0], nil +} + +// ── Диск и логи ────────────────────────────────────────────────────────────── + +func auCheckDiskAndLogs() []auFinding { + var out []auFinding + + if dfOut, err := exec.Command("df", "-P").Output(); err == nil { + for i, line := range strings.Split(string(dfOut), "\n") { + if i == 0 { + continue + } + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + fs, usePct, mount := fields[0], fields[4], fields[5] + if strings.Contains(fs, "tmpfs") || strings.Contains(fs, "overlay") || strings.Contains(fs, "squashfs") { + continue + } + n, err := strconv.Atoi(strings.TrimSuffix(usePct, "%")) + if err != nil { + continue + } + switch { + case n >= 95: + out = append(out, auFinding{"disk", "critical", fmt.Sprintf("%s заполнен на %d%% — критично мало места", mount, n)}) + case n >= 85: + out = append(out, auFinding{"disk", "warning", fmt.Sprintf("%s заполнен на %d%%", mount, n)}) + } + } + } + + entries := fdScan() + var leaked int64 + for _, e := range entries { + leaked += e.SizeBytes + } + switch { + case leaked > 500*1024*1024: + out = append(out, auFinding{"disk", "critical", + fmt.Sprintf("удалённые-но-открытые файлы держат %s места (см. -fd для деталей)", fdHumanSize(leaked))}) + case leaked > 50*1024*1024: + out = append(out, auFinding{"disk", "warning", + fmt.Sprintf("удалённые-но-открытые файлы держат %s (см. -fd)", fdHumanSize(leaked))}) + } + + if len(out) == 0 { + out = append(out, auFinding{"disk", "info", "диск и файловые дескрипторы в порядке"}) + } + return out +} + +// ── Сеть / DNS ─────────────────────────────────────────────────────────────── + +func auCheckNetwork() []auFinding { + var out []auFinding + + data, err := os.ReadFile("/etc/resolv.conf") + if err != nil || !strings.Contains(string(data), "nameserver") { + out = append(out, auFinding{"network", "warning", "в /etc/resolv.conf не найдено ни одного nameserver — DNS может не работать"}) + } else { + out = append(out, auFinding{"network", "info", "nameserver в /etc/resolv.conf присутствует"}) + } + + if routeOut, err := exec.Command("ip", "route", "show", "default").Output(); err == nil { + if strings.TrimSpace(string(routeOut)) == "" { + out = append(out, auFinding{"network", "warning", + "default route не найден — если сервер должен иметь исходящий доступ, это проблема (в изолированных стендах может быть ожидаемо)"}) + } else { + out = append(out, auFinding{"network", "info", "default route настроен"}) + } + } + + if _, err := net.LookupHost("localhost"); err != nil { + out = append(out, auFinding{"network", "warning", + fmt.Sprintf("не удалось разрешить даже 'localhost': %v — похоже, сломан резолвинг на уровне NSS/glibc", err)}) + } + + return out +} + +// ── Firewall ───────────────────────────────────────────────────────────────── + +func auCheckFirewall() []auFinding { + backend, err := fwDetectBackend() + if err != nil { + return []auFinding{{"firewall", "warning", fmt.Sprintf("не удалось определить firewall backend: %v", err)}} + } + rules, err := backend.ListRules() + if err != nil { + return []auFinding{{"firewall", "warning", fmt.Sprintf("не удалось получить правила (%s): %v", backend.Name(), err)}} + } + + out := []auFinding{{"firewall", "info", fmt.Sprintf("backend: %s, правил в списке: %d", backend.Name(), len(rules))}} + + listening, _ := auListeningPorts() + listenSet := map[string]bool{} + for _, s := range listening { + listenSet[s.Port] = true + } + + allowed := map[string]bool{} + for _, r := range rules { + if r.Port == "" { + continue + } + action := strings.ToUpper(r.Action) + if action == "ACCEPT" || action == "ALLOW" { + allowed[r.Port] = true + } + } + + for port := range allowed { + if !listenSet[port] { + out = append(out, auFinding{"firewall", "warning", fmt.Sprintf( + "правило разрешает порт %s, но на нём никто не слушает — либо сервис упал, либо правило устарело", port)}) + } + } + + for port := range listenSet { + if port == "22" { + continue // почти всегда разрешён отдельно/default policy — шумный false-positive + } + if !allowed[port] { + out = append(out, auFinding{"firewall", "warning", fmt.Sprintf( + "порт %s слушает, но явного allow-правила для него не найдено — проверьте доступность снаружи (может маскироваться default policy)", port)}) + } + } + + return out +} \ No newline at end of file diff --git a/main.go b/main.go index 9fd2f07..0f119b1 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,7 @@ import ( "strings" ) -const appVersion = "1.4.0" +const appVersion = "1.5.0" // Цвета терминала (общие для всех модулей) const ( @@ -123,6 +123,16 @@ func runByFlag(flag string, extraArgs []string) { os.Exit(1) } + case "-au": + category := "" + if len(extraArgs) > 0 { + category = extraArgs[0] + } + if err := runAudit(category); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + case "-v", "--version": fmt.Printf("auspex v%s (%s)\n", appVersion, buildVariant) @@ -197,6 +207,13 @@ func showMenu() { if err := runFirewallTUI(); err != nil { fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) } + case "12", "-au", "au": + fmt.Printf(" Категория (Enter = все: systemd/nginx/docker/cert/disk/network/firewall): ") + catInput, _ := reader.ReadString('\n') + catInput = strings.TrimSpace(catInput) + if err := runAudit(catInput); err != nil { + fmt.Fprintf(os.Stderr, "%sОшибка: %v%s\n", cRed, err, cReset) + } case "0", "q", "quit", "exit": fmt.Println("Выход.") return @@ -224,6 +241,7 @@ func printMenuOptions() { fmt.Printf(" %s9%s) %s-fd%s Удалённые-но-открытые файловые дескрипторы\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s10%s) %s-dt%s Docker Live — TUI (аналог lazydocker)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s11%s) %s-fw%s Firewall — TUI (ufw/nftables/iptables)\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s12%s) %s-au%s Audit — комплексная диагностика [категория]\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s0%s) Выход\n\n", cGreen, cReset) fmt.Printf("%sДля быстрого вызова: auspex <ключ>%s\n", cYellow, cReset) fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w %s\n\n", cYellow, cReset) @@ -260,6 +278,7 @@ func printUsage() { fmt.Printf(" %-12s Удалённые-но-открытые файловые дескрипторы (recover/reopen/restart/kill)\n", "-fd") fmt.Printf(" %-12s Docker Live — интерактивный TUI (аналог lazydocker)\n", "-dt") fmt.Printf(" %-12s Firewall — интерактивный TUI (ufw/nftables/iptables, dry-run 'D')\n", "-fw") + fmt.Printf(" %-12s Комплексный аудит-скан по категориям [категория] (read-only)\n", "-au") fmt.Printf(" %-12s Показать версию\n", "-v") fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf("Без аргументов запускается интерактивное меню.\n")