347 lines
11 KiB
Go
347 lines
11 KiB
Go
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 = "⚠️ <b>Изменения в состоянии служб</b>"
|
||
case len(fallen) > 0:
|
||
header = "🚨 <b>АЛЕРТ: Службы упали!</b>"
|
||
default:
|
||
header = "✅ <b>Службы восстановлены</b>"
|
||
}
|
||
|
||
message := fmt.Sprintf(
|
||
"%s\n\n"+
|
||
"🖥 <b>Хост:</b> <code>%s</code>\n"+
|
||
"⏰ <b>Время:</b> %s\n\n",
|
||
header,
|
||
hostname,
|
||
time.Now().Format("2006-01-02 15:04:05"),
|
||
)
|
||
|
||
// Упавшие службы
|
||
if len(fallen) > 0 {
|
||
message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
||
message += fmt.Sprintf("❌ <b>Упали (%d):</b>\n\n", len(fallen))
|
||
for _, ch := range fallen {
|
||
emoji := scStateEmoji(ch.CurrState)
|
||
message += fmt.Sprintf(
|
||
"%s <b>%s</b>\n"+
|
||
" 📌 <code>%s</code> → <code>%s</code> / <code>%s</code>\n"+
|
||
" 🔧 <code>systemctl status %s</code>\n\n",
|
||
emoji, ch.Name,
|
||
ch.PrevState, ch.CurrState, ch.CurrInfo.SubState,
|
||
ch.Name,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Восстановленные службы
|
||
if len(recovered) > 0 {
|
||
message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
||
message += fmt.Sprintf("✅ <b>Восстановились (%d):</b>\n\n", len(recovered))
|
||
for _, ch := range recovered {
|
||
message += fmt.Sprintf(
|
||
"✅ <b>%s</b>\n"+
|
||
" 📌 <code>%s</code> → <code>%s</code>\n\n",
|
||
ch.Name,
|
||
ch.PrevState, ch.CurrState,
|
||
)
|
||
}
|
||
}
|
||
|
||
message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
||
message += "💡 <i>journalctl -u <служба> -n 50 --no-pager</i>"
|
||
|
||
if err := telegramSendHTML(message); err != nil {
|
||
log.Printf("❌ Ошибка отправки в Telegram: %v", err)
|
||
} else {
|
||
log.Println("✅ Алерт успешно отправлен в Telegram")
|
||
}
|
||
if err := emailSendHTML("Auspex: Проблемы со службами", message); err != nil {
|
||
log.Printf("❌ Ошибка отправки Email: %v", err)
|
||
} else {
|
||
log.Println("✅ Алерт успешно отправлен на Email")
|
||
}
|
||
}
|
||
|
||
// ── Вспомогательные функции ──────────────────────────────────────────────────
|
||
|
||
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 "🔄"
|
||
}
|
||
}
|