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 }