From ff1a3528c0ba428330f03a4384da501897f5ec3a Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Thu, 26 Feb 2026 12:04:00 +0300 Subject: [PATCH] Service check --- README.md | 10 +- config.go | 40 +++++- logcheck.go | 5 +- main.go | 20 ++- servicecheck.go | 342 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 servicecheck.go diff --git a/README.md b/README.md index 7847805..d71804c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Auspex -Набор утилит мониторинга инфраструктуры. +Набор утилит мониторинга инфраструктуры WAF. ## Утилиты @@ -11,6 +11,7 @@ | `-p` | Проверка доступности портов из nginx upstream конфигов | | `-m` | Проверка совпадений портов Angie ↔ Docker (+ проверка запуска контейнеров) | | `-l` | Проверка обновления файлов PT AF access logs | +| `-sv` | Проверка работоспособности служб | ## Структура проекта @@ -30,7 +31,9 @@ auspex/ │ ├── portcheck/ │ │ └── portcheck.go # -p: проверка доступности портов │ └── matchcheck/ -│ └── matchcheck.go # -m: совпадения Angie ↔ Docker +│ | └── matchcheck.go # -m: совпадения Angie ↔ Docker +│ └── servicecheck/ +│ └── servicecheck.go # -sv: проверка работоспособности служб ├── go.mod ├── Makefile └── README.md @@ -56,6 +59,7 @@ go build -o auspex ./auspex -m # Проверка Angie ↔ Docker (все клиенты) ./auspex -m client_name # Проверка конкретного клиента ./auspex -l # Проверка свежести логов +./auspex -sw # Проверка работоспособности служб ./auspex -h # Справка ./auspex -v # Версия ``` @@ -92,7 +96,7 @@ go build -o auspex | `AUSPEX_CERT_WARNING_DAYS` | Порог предупреждения (дни) | `15` | | `AUSPEX_CERT_INFO_DAYS` | Порог информирования (дни) | `30` | | `cfgDBUser` | БД юзер | `user` | -| `cfgDBPassword` | БД пароль | `qJB` | +| `cfgDBPassword` | БД пароль | `qJBrR` | | `cfgDBName` | Название БД | `waf_info` | | `cfgDBPort` | Порт БД | `5432` | | `cfgPrimaryDBHost` | Основная БД | `10.100.10.8` | diff --git a/config.go b/config.go index d24ece2..bf08fb4 100644 --- a/config.go +++ b/config.go @@ -50,11 +50,12 @@ var ( // --- Пути --- var ( - cfgPTAFBaseDir = "/home/install/ptaf" - cfgAngieConfDir = "/etc/angie/http.d" - cfgSSLDir = "/etc/ssl" - cfgNginxConfigGlob = "/home/install/conf/ptaf-nginx/*/*/*/*.conf" - cfgStateFilePath = "/tmp/port_checker_state.json" + cfgPTAFBaseDir = "/home/install/ptaf" + cfgAngieConfDir = "/etc/angie/http.d" + cfgSSLDir = "/etc/ssl" + cfgNginxConfigGlob = "/home/install/conf/ptaf-nginx/*/*/*/*.conf" + cfgStateFilePath = "/tmp/port_checker_state.json" + cfgServiceStateFilePath = "/tmp/auspex_service_state.json" ) // --- Параметры проверки портов --- @@ -73,6 +74,12 @@ var ( cfgLogStaleMinutes = 61 // Порог в минутах для определения устаревшего лога ) +// --- Service Check --- +// Список служб systemd для проверки на текущем хосте. +// Задаётся через SERVICES_TO_CHECK в auspex.env в виде списка через запятую. +// Пример: SERVICES_TO_CHECK=angie,docker +var cfgServicesToCheck = []string{} + // ============================================================================= // Загрузка конфигурации из файла // ============================================================================= @@ -173,6 +180,9 @@ func applyConfig(env map[string]string) { if v, ok := env["STATE_FILE_PATH"]; ok && v != "" { cfgStateFilePath = v } + if v, ok := env["SERVICE_STATE_FILE_PATH"]; ok && v != "" { + cfgServiceStateFilePath = v + } if v, ok := env["LOG_DIR"]; ok && v != "" { cfgLogDir = v } @@ -220,6 +230,19 @@ func applyConfig(env map[string]string) { cfgMaxWorkers = workers } } + + // Services to check + if v, ok := env["SERVICES_TO_CHECK"]; ok && v != "" { + parts := strings.Split(v, ",") + services := make([]string, 0, len(parts)) + for _, s := range parts { + s = strings.TrimSpace(s) + if s != "" { + services = append(services, s) + } + } + cfgServicesToCheck = services + } } // ============================================================================= @@ -251,5 +274,12 @@ func printConfigStatus() { fmt.Println(" ⚠️ База данных: не настроена") } + // Services + if len(cfgServicesToCheck) > 0 { + fmt.Printf(" ✅ Службы для проверки: %s\n", strings.Join(cfgServicesToCheck, ", ")) + } else { + fmt.Println(" ⚠️ Службы для проверки: не заданы (SERVICES_TO_CHECK)") + } + fmt.Println() } diff --git a/logcheck.go b/logcheck.go index ce6e36b..1dba378 100644 --- a/logcheck.go +++ b/logcheck.go @@ -458,13 +458,12 @@ func lcSendAlerts(hostname string, staleFiles []lcLogFileStatus) { message += fmt.Sprintf( "%s %s\n"+ " 📄 Путь: %s\n"+ - " ⏱ Последняя запись: %d минут назад\n"+ - " 📅 Время: %s\n\n", + " ⏱ Последняя запись: %d минут назад в %s\n\n", emoji, filepath.Base(file.FilePath), file.FilePath, file.MinutesSince, - file.LastModified.Format("2006-01-02 15:04:05")) + file.LastModified.Format("15:04:05 2006-01-02")) } message += "\n" diff --git a/main.go b/main.go index 6c54dd3..daa957c 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,7 @@ import ( "strings" ) -const appVersion = "1.0.0" +const appVersion = "1.1.0" // Цвета терминала (общие для всех модулей) const ( @@ -79,6 +79,13 @@ func runByFlag(flag string, extraArgs []string) { os.Exit(1) } + case "-sv": + fmt.Printf("%s🔧 Auspex — Проверка состояния служб%s\n\n", cCyan, cReset) + if err := runServiceCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + case "-v", "--version": fmt.Printf("auspex v%s\n", appVersion) @@ -101,9 +108,10 @@ func showMenu() { fmt.Printf(" %s3%s) %s-p %s Проверка доступности портов (nginx upstream)\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s4%s) %s-m %s Проверка совпадений портов Angie ↔ Docker\n", cGreen, cReset, cCyan, cReset) fmt.Printf(" %s5%s) %s-l %s Проверка активности логов (PostgreSQL)\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s6%s) %s-sv%s Проверка состояния служб (systemd)\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%s\n\n", cYellow, cReset) + fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv%s\n\n", cYellow, cReset) reader := bufio.NewReader(os.Stdin) @@ -141,11 +149,16 @@ func showMenu() { fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) } return + case "6", "-sv", "sv": + if err := runServiceCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return case "0", "q", "quit", "exit": fmt.Println("Выход.") return default: - fmt.Printf("%sНеизвестная команда. Введите 1-5 или 0 для выхода.%s\n", cRed, cReset) + fmt.Printf("%sНеизвестная команда. Введите 1-6 или 0 для выхода.%s\n", cRed, cReset) } } } @@ -170,6 +183,7 @@ func printUsage() { fmt.Printf(" %-12s Проверка доступности портов (nginx upstream)\n", "-p") fmt.Printf(" %-12s Проверка совпадений портов Angie ↔ Docker [имя_клиента]\n", "-m") fmt.Printf(" %-12s Проверка активности логов (PostgreSQL)\n", "-l") + fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv") fmt.Printf(" %-12s Показать версию\n", "-v") fmt.Printf(" %-12s Показать эту справку\n\n", "-h") fmt.Printf("Без аргументов запускается интерактивное меню.\n") diff --git a/servicecheck.go b/servicecheck.go new file mode 100644 index 0000000..cdd4db3 --- /dev/null +++ b/servicecheck.go @@ -0,0 +1,342 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type scServiceStatus struct { + Name string + Active bool + State string // active, inactive, failed, activating, deactivating, unknown + SubState string // running, dead, exited, etc. + Description string +} + +type scStateFile struct { + Timestamp string `json:"timestamp"` + Services map[string]string `json:"services"` // name → state +} + +type scStateChange struct { + Name string + PrevState string + CurrState string + CurrInfo scServiceStatus +} + +// ── Точка входа ─────────────────────────────────────────────────────────────── + +func runServiceCheck() error { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + log.Printf("⚠️ Не удалось получить hostname: %v", err) + } + + services := cfgServicesToCheck + if len(services) == 0 { + log.Println("ℹ️ Список служб для проверки пуст (SERVICES_TO_CHECK не задан)") + return nil + } + + log.Printf("🔍 ServiceCheck запущен на хосте: %s", hostname) + log.Printf("📋 Служб для проверки: %d (%s)", len(services), strings.Join(services, ", ")) + + // Параллельная проверка всех служб + statusChan := make(chan scServiceStatus, len(services)) + var wg sync.WaitGroup + + for _, svc := range services { + wg.Add(1) + go func(name string) { + defer wg.Done() + statusChan <- scCheckService(name) + }(svc) + } + + go func() { + wg.Wait() + close(statusChan) + }() + + // Собираем результаты в map для удобного сравнения + current := make(map[string]scServiceStatus) + for status := range statusChan { + current[status.Name] = status + if status.Active { + log.Printf("✅ %-20s | %s (%s)", status.Name, status.State, status.SubState) + } else { + log.Printf("❌ %-20s | %s (%s)", status.Name, status.State, status.SubState) + } + } + + // Загружаем предыдущее состояние + prevState := scLoadState() + + // Сравниваем — ищем изменения + changes := scCompareStates(prevState.Services, current) + + // Статистика + okCount := 0 + failCount := 0 + for _, s := range current { + if s.Active { + okCount++ + } else { + failCount++ + } + } + + log.Println("\n📊 Итоги проверки служб:") + log.Printf(" ✅ Работают нормально: %d", okCount) + log.Printf(" ❌ Проблемных служб: %d", failCount) + log.Printf(" 🔄 Изменений статуса: %d", len(changes)) + + // Обрабатываем изменения + if len(changes) > 0 { + log.Println("\n🔄 Изменения с последней проверки:") + log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + for _, ch := range changes { + arrow := scChangeArrow(ch.PrevState, ch.CurrState) + log.Printf(" %s %-20s | %s → %s", arrow, ch.Name, ch.PrevState, ch.CurrState) + } + log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + if isTelegramConfigured() { + scSendAlert(hostname, changes) + } else { + log.Println("\nℹ️ Telegram уведомления отключены (не настроены данные)") + } + } else { + if len(prevState.Services) == 0 { + log.Println("\nℹ️ Первый запуск — состояние сохранено, уведомления при изменениях") + } else { + log.Println("\n✅ Изменений нет — уведомления не отправляются") + } + } + + // Сохраняем текущее состояние + scSaveState(current) + + log.Println("=== Проверка служб завершена ===") + return nil +} + +// ── Проверка одной службы ──────────────────────────────────────────────────── + +func scCheckService(name string) scServiceStatus { + status := scServiceStatus{Name: name} + + // is-active возвращает non-zero если служба не active — это нормально + activeOut, _ := exec.Command("systemctl", "is-active", name).Output() + activeState := strings.TrimSpace(string(activeOut)) + if activeState == "" { + activeState = "unknown" + } + + status.State = activeState + status.Active = activeState == "active" + + // Получаем SubState и Description через show + showOut, err := exec.Command( + "systemctl", "show", name, + "--property=SubState,Description", + "--no-page", + ).Output() + + if err == nil { + for _, line := range strings.Split(string(showOut), "\n") { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case "SubState": + status.SubState = strings.TrimSpace(parts[1]) + case "Description": + status.Description = strings.TrimSpace(parts[1]) + } + } + } + + if status.SubState == "" { + status.SubState = "—" + } + + return status +} + +// ── Сравнение состояний ────────────────────────────────────────────────────── + +func scCompareStates(prev map[string]string, curr map[string]scServiceStatus) []scStateChange { + var changes []scStateChange + + for name, currStatus := range curr { + prevState, existed := prev[name] + + if !existed { + // Первый запуск или новая служба добавлена в конфиг — не алертим + continue + } + + if prevState != currStatus.State { + changes = append(changes, scStateChange{ + Name: name, + PrevState: prevState, + CurrState: currStatus.State, + CurrInfo: currStatus, + }) + } + } + + return changes +} + +// ── State-файл ──────────────────────────────────────────────────────────────── + +func scLoadState() scStateFile { + data, err := os.ReadFile(cfgServiceStateFilePath) + if err != nil { + // Файл не существует — первый запуск + return scStateFile{Services: make(map[string]string)} + } + + var state scStateFile + if err := json.Unmarshal(data, &state); err != nil { + log.Printf("⚠️ Не удалось прочитать state-файл служб: %v", err) + return scStateFile{Services: make(map[string]string)} + } + + return state +} + +func scSaveState(current map[string]scServiceStatus) { + services := make(map[string]string, len(current)) + for name, status := range current { + services[name] = status.State + } + + state := scStateFile{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Services: services, + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + log.Printf("⚠️ Ошибка сериализации state-файла: %v", err) + return + } + + if err := os.WriteFile(cfgServiceStateFilePath, data, 0644); err != nil { + log.Printf("⚠️ Ошибка записи state-файла %s: %v", cfgServiceStateFilePath, err) + } +} + +// ── Отправка алерта ────────────────────────────────────────────────────────── + +func scSendAlert(hostname string, changes []scStateChange) { + // Разбиваем на падения и восстановления + var fallen, recovered []scStateChange + for _, ch := range changes { + if ch.CurrInfo.Active { + recovered = append(recovered, ch) + } else { + fallen = append(fallen, ch) + } + } + + // Заголовок по наиболее критичному событию + var header string + switch { + case len(fallen) > 0 && len(recovered) > 0: + header = "⚠️ Изменения в состоянии служб" + case len(fallen) > 0: + header = "🚨 АЛЕРТ: Службы упали!" + default: + header = "✅ Службы восстановлены" + } + + message := fmt.Sprintf( + "%s\n\n"+ + "🖥 Хост: %s\n"+ + "⏰ Время: %s\n\n", + header, + hostname, + time.Now().Format("2006-01-02 15:04:05"), + ) + + // Упавшие службы + if len(fallen) > 0 { + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += fmt.Sprintf("❌ Упали (%d):\n\n", len(fallen)) + for _, ch := range fallen { + emoji := scStateEmoji(ch.CurrState) + message += fmt.Sprintf( + "%s %s\n"+ + " 📌 %s%s / %s\n"+ + " 🔧 systemctl status %s\n\n", + emoji, ch.Name, + ch.PrevState, ch.CurrState, ch.CurrInfo.SubState, + ch.Name, + ) + } + } + + // Восстановленные службы + if len(recovered) > 0 { + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += fmt.Sprintf("✅ Восстановились (%d):\n\n", len(recovered)) + for _, ch := range recovered { + message += fmt.Sprintf( + "✅ %s\n"+ + " 📌 %s%s\n\n", + ch.Name, + ch.PrevState, ch.CurrState, + ) + } + } + + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += "💡 journalctl -u <служба> -n 50 --no-pager" + + if err := telegramSendHTML(message); err != nil { + log.Printf("❌ Ошибка отправки в Telegram: %v", err) + } else { + log.Println("✅ Алерт успешно отправлен в Telegram") + } +} + +// ── Вспомогательные функции ────────────────────────────────────────────────── + +func scStateEmoji(state string) string { + switch state { + case "failed": + return "💀" + case "inactive": + return "🔴" + case "activating", "deactivating": + return "🟡" + default: + return "❓" + } +} + +func scChangeArrow(prev, curr string) string { + switch { + case prev == "active" && curr != "active": + return "❌" + case prev != "active" && curr == "active": + return "✅" + default: + return "🔄" + } +}