auspex/audit.go
2026-08-26 12:58:09 +03:00

518 lines
No EOL
19 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}