518 lines
No EOL
19 KiB
Go
518 lines
No EOL
19 KiB
Go
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
|
||
} |