From 7b7bb7c7c846d213518b7ef89004bb4572829893 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Tue, 24 Feb 2026 10:54:38 +0300 Subject: [PATCH] Work version --- .gitignore | 46 ++++ README.md | 113 +++++++++ certcheck.go | 196 ++++++++++++++++ config.go | 255 +++++++++++++++++++++ go.mod | 34 +++ go.sum | 58 +++++ logcheck.go | 481 +++++++++++++++++++++++++++++++++++++++ main.go | 176 ++++++++++++++ matchcheck.go | 511 +++++++++++++++++++++++++++++++++++++++++ portcheck.go | 529 ++++++++++++++++++++++++++++++++++++++++++ portsview.go | 620 ++++++++++++++++++++++++++++++++++++++++++++++++++ telegram.go | 78 +++++++ 12 files changed, 3097 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 certcheck.go create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logcheck.go create mode 100644 main.go create mode 100644 matchcheck.go create mode 100644 portcheck.go create mode 100644 portsview.go create mode 100644 telegram.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d67c02b --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Binaries +auspex +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Credentials +*.env + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config with sensitive data +config.yaml +config.yml +*.local.yaml +*.local.yml + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..7847805 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Auspex + +Набор утилит мониторинга инфраструктуры. + +## Утилиты + +| Ключ | Описание | +|------|----------| +| `-ss` | Просмотр занятых портов (интерактивный TUI на bubbletea) | +| `-c` | Проверка SSL сертификатов (сроки истечения, алерты в Telegram) | +| `-p` | Проверка доступности портов из nginx upstream конфигов | +| `-m` | Проверка совпадений портов Angie ↔ Docker (+ проверка запуска контейнеров) | +| `-l` | Проверка обновления файлов PT AF access logs | + +## Структура проекта + +``` +auspex/ +├── cmd/auspex/ +│ └── main.go # Точка входа, меню, роутинг CLI-флагов +├── internal/ +│ ├── config/ +│ │ └── config.go # Общая конфигурация (хардкод + env) +│ ├── telegram/ +│ │ └── telegram.go # Общий клиент Telegram +│ ├── portsview/ +│ │ └── portsview.go # -ss: TUI просмотр портов +│ ├── certcheck/ +│ │ └── certcheck.go # -c: проверка сертификатов +│ ├── portcheck/ +│ │ └── portcheck.go # -p: проверка доступности портов +│ └── matchcheck/ +│ └── matchcheck.go # -m: совпадения Angie ↔ Docker +├── go.mod +├── Makefile +└── README.md +``` + +## Сборка + +```bash +go mod tidy +go build -o auspex +``` + +## Использование + +```bash +# Интерактивное меню +./auspex + +# Быстрый вызов через флаги +./auspex -ss # TUI просмотр портов +./auspex -c # Проверка сертификатов +./auspex -p # Проверка доступности портов +./auspex -m # Проверка Angie ↔ Docker (все клиенты) +./auspex -m client_name # Проверка конкретного клиента +./auspex -l # Проверка свежести логов +./auspex -h # Справка +./auspex -v # Версия +``` + +## Конфигурация + +Все настройки находятся в `config.go`. + +### Текущий режим: хардкод +Значения прописаны прямо в коде. Для изменения — отредактируйте переменные и пересоберите. + +### Переключение на переменные окружения +В файле `config.go` рядом с каждым хардкод-значением есть закомментированные строки +для чтения из переменных окружения. Для переключения: + +1. Раскомментируйте нужные строки в `config.go` +2. Или вызовите `config.InitFromEnvOverrides()` в `main.go` (строка уже есть, закомментирована) + +### Переменные окружения (при включении) + +| Переменная | Описание | Значение по умолчанию | +|------------|----------|----------------------| +| `AUSPEX_TELEGRAM_BOT_TOKEN` | Токен Telegram бота | `YOUR_BOT_TOKEN` | +| `AUSPEX_TELEGRAM_CHAT_ID` | Chat ID для алертов | `YOUR_CHAT_ID` | +| `AUSPEX_PTAF_BASE_DIR` | Базовая директория PTAF | `/home/install/ptaf` | +| `AUSPEX_ANGIE_CONF_DIR` | Директория конфигов Angie | `/etc/angie/http.d` | +| `AUSPEX_SSL_DIR` | Директория SSL сертификатов | `/etc/ssl` | +| `AUSPEX_NGINX_CONFIG_GLOB` | Glob-путь к nginx конфигам | `/home/install/conf/ptaf-nginx/*/*/*/*.conf` | +| `AUSPEX_STATE_FILE_PATH` | Файл состояния port checker | `/tmp/port_checker_state.json` | +| `AUSPEX_CHECK_TIMEOUT_SEC` | Таймаут проверки порта (сек) | `10` | +| `AUSPEX_MAX_RETRIES` | Количество повторов | `3` | +| `AUSPEX_RETRY_DELAY_SEC` | Задержка между повторами (сек) | `2` | +| `AUSPEX_CERT_CRITICAL_DAYS` | Порог критического алерта (дни) | `3` | +| `AUSPEX_CERT_WARNING_DAYS` | Порог предупреждения (дни) | `15` | +| `AUSPEX_CERT_INFO_DAYS` | Порог информирования (дни) | `30` | +| `cfgDBUser` | БД юзер | `user` | +| `cfgDBPassword` | БД пароль | `qJB` | +| `cfgDBName` | Название БД | `waf_info` | +| `cfgDBPort` | Порт БД | `5432` | +| `cfgPrimaryDBHost` | Основная БД | `10.100.10.8` | +| `cfgSecondaryDBHost` | Резервная БД | `10.100.13.5` | + +## Зависимости + +- Go 1.22+ +- github.com/charmbracelet/bubbletea (TUI фреймворк для `-ss`) +- github.com/charmbracelet/bubbles (компоненты TUI) +- github.com/charmbracelet/lipgloss (стилизация TUI) +- github.com/lib/pq (запросы в БД) + +## Требования на хосте + +- `ss` или `netstat` — для просмотра портов +- `docker` — для проверки запущенных контейнеров (флаг `-m`) +- `ps` — для деталей процессов (флаг `-ss`) diff --git a/certcheck.go b/certcheck.go new file mode 100644 index 0000000..f3010f1 --- /dev/null +++ b/certcheck.go @@ -0,0 +1,196 @@ +package main + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Поддерживаемые расширения сертификатов +var ccValidExtensions = map[string]bool{ + ".crt": true, ".pem": true, ".cer": true, ".cert": true, + ".der": true, ".p7b": true, ".p7c": true, ".p7s": true, ".csr": true, +} + +type ccCertInfo struct { + Path string + NotAfter time.Time + DaysLeft int + CommonName string + Format string +} + +// runCertCheck запускает проверку сертификатов +func runCertCheck() error { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + log.Printf("Начинаю проверку сертификатов в %s на хосте: %s", cfgSSLDir, hostname) + + certPaths, err := ccFindCerts(cfgSSLDir) + if err != nil { + return fmt.Errorf("ошибка при поиске сертификатов: %v", err) + } + + log.Printf("Найдено файлов сертификатов: %d", len(certPaths)) + + pathsChan := make(chan string, len(certPaths)) + resultsChan := make(chan ccCertInfo, len(certPaths)) + var wg sync.WaitGroup + + for i := 0; i < cfgMaxWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for path := range pathsChan { + info, err := ccParseCert(path) + if err != nil { + log.Printf("Не удалось распарсить %s: %v", path, err) + continue + } + resultsChan <- info + } + }() + } + + for _, p := range certPaths { + pathsChan <- p + } + close(pathsChan) + + go func() { + wg.Wait() + close(resultsChan) + }() + + validCerts := 0 + for cert := range resultsChan { + validCerts++ + ccCheckAndAlert(cert, hostname) + } + + log.Printf("Успешно обработано сертификатов: %d", validCerts) + log.Println("Проверка завершена") + return nil +} + +func ccFindCerts(root string) ([]string, error) { + var paths []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + log.Printf("Ошибка доступа к %s: %v", path, err) + return nil + } + if info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ccValidExtensions[ext] { + paths = append(paths, path) + } + return nil + }) + return paths, err +} + +func ccParseCert(path string) (ccCertInfo, error) { + data, err := os.ReadFile(path) + if err != nil { + return ccCertInfo{}, err + } + + var cert *x509.Certificate + format := "Unknown" + + block, _ := pem.Decode(data) + if block != nil { + format = "PEM" + switch block.Type { + case "CERTIFICATE": + cert, err = x509.ParseCertificate(block.Bytes) + case "CERTIFICATE REQUEST": + return ccCertInfo{}, fmt.Errorf("это CSR файл, а не сертификат") + default: + return ccCertInfo{}, fmt.Errorf("неподдерживаемый тип PEM блока: %s", block.Type) + } + } else { + format = "DER" + cert, err = x509.ParseCertificate(data) + } + + if err != nil { + return ccCertInfo{}, err + } + + daysLeft := int(time.Until(cert.NotAfter).Hours() / 24) + return ccCertInfo{ + Path: path, NotAfter: cert.NotAfter, DaysLeft: daysLeft, + CommonName: cert.Subject.CommonName, Format: format, + }, nil +} + +func ccCheckAndAlert(cert ccCertInfo, hostname string) { + log.Printf("Сертификат: %s | Format: %s | CN: %s | Истекает: %s | Дней осталось: %d", + cert.Path, cert.Format, cert.CommonName, cert.NotAfter.Format("2006-01-02"), cert.DaysLeft) + + var message, emoji string + + switch { + case cert.DaysLeft < 0: + emoji = "💀💀💀" + message = fmt.Sprintf( + "%s СЕРТИФИКАТ УЖЕ ИСТЁК! %s\n\n"+ + "🖥 Хост: %s\n📄 Путь: %s\n🔐 CN: %s\n"+ + "📅 Истёк: %s\n⏱ Просрочен на: %d дней\n📦 Формат: %s\n\n"+ + "🔥 КРИТИЧЕСКАЯ СИТУАЦИЯ! СЕРВИС МОЖЕТ НЕ РАБОТАТЬ!", + emoji, emoji, hostname, cert.Path, cert.CommonName, + cert.NotAfter.Format("2006-01-02 15:04:05"), -cert.DaysLeft, cert.Format) + + case cert.DaysLeft <= cfgCertCriticalDays: + emoji = "🚨🚨🚨" + message = fmt.Sprintf( + "%s КРИТИЧНО! НЕМЕДЛЕННО ОБНОВИТЕ СЕРТИФИКАТ! %s\n\n"+ + "🖥 Хост: %s\n📄 Путь: %s\n🔐 CN: %s\n"+ + "📅 Истекает: %s\n⏱ Осталось дней: %d\n📦 Формат: %s\n\n"+ + "⚠️ ТРЕБУЕТСЯ НЕМЕДЛЕННОЕ ДЕЙСТВИЕ!", + emoji, emoji, hostname, cert.Path, cert.CommonName, + cert.NotAfter.Format("2006-01-02 15:04:05"), cert.DaysLeft, cert.Format) + + case cert.DaysLeft <= cfgCertWarningDays: + emoji = "⚠️⚠️" + message = fmt.Sprintf( + "%s ВНИМАНИЕ! Срочно требуется обновление сертификата! %s\n\n"+ + "🖥 Хост: %s\n📄 Путь: %s\n🔐 CN: %s\n"+ + "📅 Истекает: %s\n⏱ Осталось дней: %d\n📦 Формат: %s\n\n"+ + "Запланируйте обновление в ближайшее время!", + emoji, emoji, hostname, cert.Path, cert.CommonName, + cert.NotAfter.Format("2006-01-02 15:04:05"), cert.DaysLeft, cert.Format) + + case cert.DaysLeft <= cfgCertInfoDays: + emoji = "ℹ️" + message = fmt.Sprintf( + "%s Информация: сертификат скоро истечёт\n\n"+ + "🖥 Хост: %s\n📄 Путь: %s\n🔐 CN: %s\n"+ + "📅 Истекает: %s\n⏱ Осталось дней: %d\n📦 Формат: %s\n\n"+ + "Рекомендуется запланировать обновление.", + emoji, hostname, cert.Path, cert.CommonName, + cert.NotAfter.Format("2006-01-02 15:04:05"), cert.DaysLeft, cert.Format) + + default: + return // всё ок, алерт не нужен + } + + if err := telegramSendHTML(message); err != nil { + log.Printf("Ошибка отправки в Telegram: %v", err) + } else { + log.Println("✅ Алерт успешно отправлен в Telegram") + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..d24ece2 --- /dev/null +++ b/config.go @@ -0,0 +1,255 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +// ============================================================================= +// Глобальная конфигурация Auspex +// ============================================================================= +// +// Конфигурация загружается из /etc/auspex/auspex.env +// Fallback значения используются если env файл не найден +// ============================================================================= + +const ( + // Путь к файлу конфигурации + envFilePath = "/etc/auspex/auspex.env" +) + +// --- Telegram --- +var ( + cfgTelegramBotToken = "" + cfgTelegramChatID = "" +) + +// --- PostgreSQL Database --- +var ( + cfgDBUser = "" + cfgDBPassword = "" + cfgDBName = "waf_info" // Значение по умолчанию + cfgDBPort = 5432 // Значение по умолчанию +) + +// --- Пути к хостам БД --- +var ( + cfgPrimaryDBHost = "10.100.10.8" + cfgSecondaryDBHost = "10.100.13.5" +) + +// --- Пороги сертификатов (дни) --- +var ( + cfgCertCriticalDays = 3 + cfgCertWarningDays = 15 + cfgCertInfoDays = 30 +) + +// --- Пути --- +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" +) + +// --- Параметры проверки портов --- +var ( + cfgCheckTimeoutSec = 10 + cfgMaxRetries = 3 + cfgRetryDelaySec = 2 +) + +// cfgMaxWorkers — количество горутин для параллельной обработки +var cfgMaxWorkers = 10 + +// --- Log Check --- +var ( + cfgLogDir = "/var/log/ptaf_nginx" + cfgLogStaleMinutes = 61 // Порог в минутах для определения устаревшего лога +) + +// ============================================================================= +// Загрузка конфигурации из файла +// ============================================================================= + +// loadConfig загружает конфигурацию из /etc/auspex/auspex.env +func loadConfig() error { + file, err := os.Open(envFilePath) + if err != nil { + // Файл не найден - используем значения по умолчанию + return fmt.Errorf("файл конфигурации не найден: %s", envFilePath) + } + defer file.Close() + + envVars := make(map[string]string) + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + + // Пропускаем пустые строки и комментарии + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Парсим KEY=VALUE + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + fmt.Printf("⚠️ Некорректная строка %d в %s: %s\n", lineNum, envFilePath, line) + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Убираем кавычки если есть + value = strings.Trim(value, `"'`) + + envVars[key] = value + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("ошибка чтения файла: %v", err) + } + + // Применяем значения + applyConfig(envVars) + + return nil +} + +// applyConfig применяет загруженные значения к переменным конфигурации +func applyConfig(env map[string]string) { + // Telegram + if v, ok := env["TELEGRAM_BOT_TOKEN"]; ok && v != "" { + cfgTelegramBotToken = v + } + if v, ok := env["TELEGRAM_CHAT_ID"]; ok && v != "" { + cfgTelegramChatID = v + } + + // Database + if v, ok := env["DB_USER"]; ok && v != "" { + cfgDBUser = v + } + if v, ok := env["DB_PASSWORD"]; ok && v != "" { + cfgDBPassword = v + } + if v, ok := env["DB_NAME"]; ok && v != "" { + cfgDBName = v + } + if v, ok := env["DB_PORT"]; ok && v != "" { + if port, err := strconv.Atoi(v); err == nil { + cfgDBPort = port + } + } + if v, ok := env["PRIMARY_DB_HOST"]; ok && v != "" { + cfgPrimaryDBHost = v + } + if v, ok := env["SECONDARY_DB_HOST"]; ok && v != "" { + cfgSecondaryDBHost = v + } + + // Paths + if v, ok := env["PTAF_BASE_DIR"]; ok && v != "" { + cfgPTAFBaseDir = v + } + if v, ok := env["ANGIE_CONF_DIR"]; ok && v != "" { + cfgAngieConfDir = v + } + if v, ok := env["SSL_DIR"]; ok && v != "" { + cfgSSLDir = v + } + if v, ok := env["NGINX_CONFIG_GLOB"]; ok && v != "" { + cfgNginxConfigGlob = v + } + if v, ok := env["STATE_FILE_PATH"]; ok && v != "" { + cfgStateFilePath = v + } + if v, ok := env["LOG_DIR"]; ok && v != "" { + cfgLogDir = v + } + + // Thresholds + if v, ok := env["CERT_CRITICAL_DAYS"]; ok && v != "" { + if days, err := strconv.Atoi(v); err == nil { + cfgCertCriticalDays = days + } + } + if v, ok := env["CERT_WARNING_DAYS"]; ok && v != "" { + if days, err := strconv.Atoi(v); err == nil { + cfgCertWarningDays = days + } + } + if v, ok := env["CERT_INFO_DAYS"]; ok && v != "" { + if days, err := strconv.Atoi(v); err == nil { + cfgCertInfoDays = days + } + } + if v, ok := env["LOG_STALE_MINUTES"]; ok && v != "" { + if mins, err := strconv.Atoi(v); err == nil { + cfgLogStaleMinutes = mins + } + } + + // Timeouts and retries + if v, ok := env["CHECK_TIMEOUT_SEC"]; ok && v != "" { + if sec, err := strconv.Atoi(v); err == nil { + cfgCheckTimeoutSec = sec + } + } + if v, ok := env["MAX_RETRIES"]; ok && v != "" { + if retries, err := strconv.Atoi(v); err == nil { + cfgMaxRetries = retries + } + } + if v, ok := env["RETRY_DELAY_SEC"]; ok && v != "" { + if sec, err := strconv.Atoi(v); err == nil { + cfgRetryDelaySec = sec + } + } + if v, ok := env["MAX_WORKERS"]; ok && v != "" { + if workers, err := strconv.Atoi(v); err == nil { + cfgMaxWorkers = workers + } + } +} + +// ============================================================================= +// Вспомогательные функции +// ============================================================================= + +func isTelegramConfigured() bool { + return cfgTelegramBotToken != "" && + cfgTelegramBotToken != "YOUR_BOT_TOKEN" && + cfgTelegramChatID != "" && + cfgTelegramChatID != "YOUR_CHAT_ID" +} + +// printConfigStatus выводит статус загруженной конфигурации +func printConfigStatus() { + fmt.Println("📋 Статус конфигурации:") + + // Telegram + if isTelegramConfigured() { + fmt.Println(" ✅ Telegram: настроен") + } else { + fmt.Println(" ⚠️ Telegram: не настроен") + } + + // Database + if cfgDBUser != "" && cfgDBPassword != "" { + fmt.Printf(" ✅ База данных: %s@%s:%d/%s\n", cfgDBUser, cfgPrimaryDBHost, cfgDBPort, cfgDBName) + } else { + fmt.Println(" ⚠️ База данных: не настроена") + } + + fmt.Println() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6c488e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module auspex + +go 1.24.2 + +require ( + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/lib/pq v1.10.9 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a3e32b --- /dev/null +++ b/go.sum @@ -0,0 +1,58 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/logcheck.go b/logcheck.go new file mode 100644 index 0000000..ce6e36b --- /dev/null +++ b/logcheck.go @@ -0,0 +1,481 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/lib/pq" +) + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type lcInstanceInfo struct { + Hostname string + Instance string +} + +type lcClientInfo struct { + ClientTitle string + Instance string +} + +type lcAppInfo struct { + L7ResourceID string + ClientTitle string + CheckWriteLogs bool +} + +type lcLogFileStatus struct { + FilePath string + L7ResourceID string + ClientTitle string + LastModified time.Time + MinutesSince int + IsStale bool + CheckWriteLogs bool +} + +// ── Точка входа ────────────────────────────────────────────────────────────── + +func runLogCheck() error { + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + log.Printf("⚠️ Не удалось получить hostname: %v", err) + } + + log.Printf("🔍 LogCheck запущен на хосте: %s", hostname) + log.Printf("📊 Подключение к базе данных...") + + // Подключаемся к БД (сначала primary, при ошибке - secondary) + db, err := lcConnectDB() + if err != nil { + return fmt.Errorf("не удалось подключиться ни к одной БД: %v", err) + } + defer db.Close() + + log.Println("✅ Соединение с БД установлено") + + // Шаг 1: Получаем инстансы для текущего хоста + instances, err := lcGetInstancesByHostname(db, hostname) + if err != nil { + return fmt.Errorf("ошибка получения инстансов: %v", err) + } + + if len(instances) == 0 { + log.Printf("⚠️ Для хоста %s не найдено инстансов в БД", hostname) + return nil + } + + log.Printf("📋 Найдено инстансов: %d", len(instances)) + for _, inst := range instances { + log.Printf(" - %s", inst.Instance) + } + + // Шаг 2: Получаем клиентов для этих инстансов + clients, err := lcGetClientsByInstances(db, instances) + if err != nil { + return fmt.Errorf("ошибка получения клиентов: %v", err) + } + + if len(clients) == 0 { + log.Println("⚠️ Клиенты не найдены для данных инстансов") + return nil + } + + log.Printf("👥 Найдено клиентов: %d", len(clients)) + + // Шаг 3: Получаем приложения (l7resourceid) для этих клиентов + apps, err := lcGetAppsByClients(db, clients) + if err != nil { + return fmt.Errorf("ошибка получения приложений: %v", err) + } + + if len(apps) == 0 { + log.Println("⚠️ Приложения не найдены для данных клиентов") + return nil + } + + log.Printf("📦 Найдено приложений: %d", len(apps)) + + // Фильтруем только те, где check_write_logs = true + activeApps := make([]lcAppInfo, 0) + for _, app := range apps { + if app.CheckWriteLogs { + activeApps = append(activeApps, app) + } + } + + log.Printf("✓ Приложений с активной проверкой логов: %d", len(activeApps)) + + if len(activeApps) == 0 { + log.Println("ℹ️ Нет приложений с включенной проверкой логов (check_write_logs = true)") + return nil + } + + // Шаг 4: Проверяем файлы логов + log.Println("🔎 Проверка файлов логов...") + + staleFiles := make([]lcLogFileStatus, 0) + okFiles := 0 + notFoundFiles := 0 + + for _, app := range activeApps { + statuses, err := lcCheckLogFile(app) + if err != nil { + log.Printf("⚠️ [%s] %s: %v", app.ClientTitle, app.L7ResourceID, err) + notFoundFiles++ + continue + } + + // Обрабатываем все найденные файлы для данного приложения + for _, status := range statuses { + if status.IsStale { + staleFiles = append(staleFiles, status) + log.Printf("⚠️ [%s] %s (%s): лог устарел (%d мин)", + status.ClientTitle, status.L7ResourceID, filepath.Base(status.FilePath), status.MinutesSince) + } else { + okFiles++ + log.Printf("✓ [%s] %s (%s): актуален (%d мин)", + status.ClientTitle, status.L7ResourceID, filepath.Base(status.FilePath), status.MinutesSince) + } + } + } + + // Итоговая статистика + log.Println("\n📊 Статистика проверки:") + log.Printf(" ✅ Актуальных логов: %d", okFiles) + log.Printf(" ⚠️ Устаревших логов: %d", len(staleFiles)) + log.Printf(" ❌ Не найдено файлов: %d", notFoundFiles) + log.Printf(" 📝 Всего проверено приложений: %d", len(activeApps)) + + // Шаг 5: Отправляем алерты если есть проблемы + if len(staleFiles) > 0 { + log.Printf("\n🚨 Найдено устаревших логов: %d", len(staleFiles)) + log.Println("\n📋 Список устаревших логов:") + log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + // Группируем для вывода + type consoleGroup struct { + ClientTitle string + L7ResourceID string + Files []lcLogFileStatus + } + + groupMap := make(map[string]*consoleGroup) + for _, file := range staleFiles { + key := file.ClientTitle + "::" + file.L7ResourceID + if _, exists := groupMap[key]; !exists { + groupMap[key] = &consoleGroup{ + ClientTitle: file.ClientTitle, + L7ResourceID: file.L7ResourceID, + Files: make([]lcLogFileStatus, 0), + } + } + groupMap[key].Files = append(groupMap[key].Files, file) + } + + for _, group := range groupMap { + log.Printf("\n👤 [%s] %s:", group.ClientTitle, group.L7ResourceID) + for _, file := range group.Files { + var emoji string + if file.MinutesSince > 240 { + emoji = "💀" // Более 4 часов + } else if file.MinutesSince > 120 { + emoji = "🔥" // Более 2 часов + } else { + emoji = "⚠️" // 61-120 минут + } + log.Printf(" %s %s: %d минут назад", emoji, filepath.Base(file.FilePath), file.MinutesSince) + log.Printf(" 📄 %s", file.FilePath) + } + } + log.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + if isTelegramConfigured() { + lcSendAlerts(hostname, staleFiles) + } else { + log.Println("\nℹ️ Telegram уведомления отключены (не настроены данные)") + log.Println("💡 Для включения уведомлений настройте cfgTelegramBotToken и cfgTelegramChatID в config.go") + } + } else { + log.Println("\n✅ Все логи актуальны") + } + + log.Println("=== Проверка завершена ===") + return nil +} + +// ── Подключение к БД ───────────────────────────────────────────────────────── + +func lcConnectDB() (*sql.DB, error) { + // Пробуем primary + primaryDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfgPrimaryDBHost, cfgDBPort, cfgDBUser, cfgDBPassword, cfgDBName) + + db, err := sql.Open("postgres", primaryDSN) + if err == nil { + if err := db.Ping(); err == nil { + log.Printf("✓ Подключено к primary БД: %s", cfgPrimaryDBHost) + return db, nil + } + db.Close() + } + + log.Printf("⚠️ Primary БД недоступна (%s), пробуем secondary...", cfgPrimaryDBHost) + + // Пробуем secondary + secondaryDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfgSecondaryDBHost, cfgDBPort, cfgDBUser, cfgDBPassword, cfgDBName) + + db, err = sql.Open("postgres", secondaryDSN) + if err != nil { + return nil, fmt.Errorf("не удалось открыть соединение с secondary: %v", err) + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("secondary БД недоступна: %v", err) + } + + log.Printf("✓ Подключено к secondary БД: %s", cfgSecondaryDBHost) + return db, nil +} + +// ── Запросы к БД ───────────────────────────────────────────────────────────── + +func lcGetInstancesByHostname(db *sql.DB, hostname string) ([]lcInstanceInfo, error) { + query := `SELECT hostname, instance FROM instances_new WHERE hostname = $1` + + rows, err := db.Query(query, hostname) + if err != nil { + return nil, fmt.Errorf("ошибка выполнения запроса: %v", err) + } + defer rows.Close() + + var instances []lcInstanceInfo + for rows.Next() { + var inst lcInstanceInfo + if err := rows.Scan(&inst.Hostname, &inst.Instance); err != nil { + log.Printf("⚠️ Ошибка сканирования строки instances_new: %v", err) + continue + } + instances = append(instances, inst) + } + + return instances, rows.Err() +} + +func lcGetClientsByInstances(db *sql.DB, instances []lcInstanceInfo) ([]lcClientInfo, error) { + if len(instances) == 0 { + return nil, nil + } + + // Формируем список инстансов для IN (...) + instanceNames := make([]interface{}, len(instances)) + placeholders := make([]string, len(instances)) + for i, inst := range instances { + instanceNames[i] = inst.Instance + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + + // Используем IN вместо ANY для совместимости + query := fmt.Sprintf(`SELECT DISTINCT client_title, waf_instance + FROM client_info + WHERE waf_instance IN (%s)`, strings.Join(placeholders, ", ")) + + rows, err := db.Query(query, instanceNames...) + if err != nil { + return nil, fmt.Errorf("ошибка выполнения запроса: %v", err) + } + defer rows.Close() + + var clients []lcClientInfo + for rows.Next() { + var client lcClientInfo + if err := rows.Scan(&client.ClientTitle, &client.Instance); err != nil { + log.Printf("⚠️ Ошибка сканирования строки client_info: %v", err) + continue + } + clients = append(clients, client) + } + + return clients, rows.Err() +} + +func lcGetAppsByClients(db *sql.DB, clients []lcClientInfo) ([]lcAppInfo, error) { + if len(clients) == 0 { + return nil, nil + } + + clientTitles := make([]interface{}, len(clients)) + placeholders := make([]string, len(clients)) + for i, client := range clients { + clientTitles[i] = client.ClientTitle + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + + query := fmt.Sprintf(`SELECT l7resourceid, client_title, COALESCE(check_write_logs, false) + FROM apps_settings + WHERE client_title IN (%s)`, strings.Join(placeholders, ", ")) + + rows, err := db.Query(query, clientTitles...) + if err != nil { + return nil, fmt.Errorf("ошибка выполнения запроса: %v", err) + } + defer rows.Close() + + var apps []lcAppInfo + for rows.Next() { + var app lcAppInfo + if err := rows.Scan(&app.L7ResourceID, &app.ClientTitle, &app.CheckWriteLogs); err != nil { + log.Printf("⚠️ Ошибка сканирования строки apps_settings: %v", err) + continue + } + apps = append(apps, app) + } + + return apps, rows.Err() +} + +// ── Проверка файлов логов ──────────────────────────────────────────────────── + +func lcCheckLogFile(app lcAppInfo) ([]lcLogFileStatus, error) { + // Ищем файл вида: /var/log/ptaf_nginx/*/*/*_access.log + // Учитываем что может быть: 10307_nationallottery.ru_access.log или 10307_access.log + pattern := filepath.Join(cfgLogDir, "*", "*", app.L7ResourceID+"*_access.log") + + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("ошибка поиска файла: %v", err) + } + + if len(matches) == 0 { + return nil, fmt.Errorf("файл лога не найден") + } + + // Если найдено несколько файлов, проверяем все + var results []lcLogFileStatus + + if len(matches) > 1 { + log.Printf("ℹ️ Найдено несколько файлов для %s (%d шт):", app.L7ResourceID, len(matches)) + } + + for _, logFile := range matches { + // Получаем информацию о файле + fileInfo, err := os.Stat(logFile) + if err != nil { + log.Printf("⚠️ Ошибка получения информации о файле %s: %v", logFile, err) + continue + } + + lastModified := fileInfo.ModTime() + timeSince := time.Since(lastModified) + minutesSince := int(timeSince.Minutes()) + + status := lcLogFileStatus{ + FilePath: logFile, + L7ResourceID: app.L7ResourceID, + ClientTitle: app.ClientTitle, + LastModified: lastModified, + MinutesSince: minutesSince, + IsStale: minutesSince > cfgLogStaleMinutes, + CheckWriteLogs: app.CheckWriteLogs, + } + + results = append(results, status) + + if len(matches) > 1 { + log.Printf(" - %s (изменен: %s, %d мин назад)", + filepath.Base(logFile), + fileInfo.ModTime().Format("2006-01-02 15:04:05"), + minutesSince) + } + } + + return results, nil +} + +// ── Отправка алертов ───────────────────────────────────────────────────────── + +func lcSendAlerts(hostname string, staleFiles []lcLogFileStatus) { + if len(staleFiles) == 0 { + return + } + + // Группируем по клиентам и l7resourceid для удобства + type fileGroup struct { + ClientTitle string + L7ResourceID string + Files []lcLogFileStatus + } + + groupMap := make(map[string]*fileGroup) + for _, file := range staleFiles { + key := file.ClientTitle + "::" + file.L7ResourceID + if _, exists := groupMap[key]; !exists { + groupMap[key] = &fileGroup{ + ClientTitle: file.ClientTitle, + L7ResourceID: file.L7ResourceID, + Files: make([]lcLogFileStatus, 0), + } + } + groupMap[key].Files = append(groupMap[key].Files, file) + } + + // Формируем HTML сообщение + message := fmt.Sprintf( + "🚨 АЛЕРТ: Устаревшие логи! 🚨\n\n"+ + "🖥 Хост: %s\n"+ + "⏰ Время проверки: %s\n"+ + "📊 Найдено устаревших файлов: %d\n\n", + hostname, + time.Now().Format("2006-01-02 15:04:05"), + len(staleFiles)) + + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + + // Выводим по группам (клиент + l7resourceid) + for _, group := range groupMap { + message += fmt.Sprintf("👤 Клиент: %s\n", group.ClientTitle) + message += fmt.Sprintf("🆔 L7 Resource ID: %s\n\n", group.L7ResourceID) + + for _, file := range group.Files { + var emoji string + if file.MinutesSince > 240 { + emoji = "💀" // Более 4 часов + } else if file.MinutesSince > 120 { + emoji = "🔥" // Более 2 часов + } else { + emoji = "⚠️" // 61-120 минут + } + + message += fmt.Sprintf( + "%s %s\n"+ + " 📄 Путь: %s\n"+ + " ⏱ Последняя запись: %d минут назад\n"+ + " 📅 Время: %s\n\n", + emoji, + filepath.Base(file.FilePath), + file.FilePath, + file.MinutesSince, + file.LastModified.Format("2006-01-02 15:04:05")) + } + + message += "\n" + } + + message += "━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + message += "💡 Порог оповещения: более " + fmt.Sprintf("%d", cfgLogStaleMinutes) + " минут без записей" + + if err := telegramSendHTML(message); err != nil { + log.Printf("❌ Ошибка отправки в Telegram: %v", err) + } else { + log.Println("✅ Алерт успешно отправлен в Telegram") + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6c54dd3 --- /dev/null +++ b/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +const appVersion = "1.0.0" + +// Цвета терминала (общие для всех модулей) +const ( + cReset = "\033[0m" + cRed = "\033[0;31m" + cGreen = "\033[0;32m" + cYellow = "\033[1;33m" + cBlue = "\033[0;34m" + cCyan = "\033[0;36m" +) + +func main() { + // Загружаем конфигурацию из /etc/auspex/auspex.env + if err := loadConfig(); err != nil { + // Файл не найден - выводим предупреждение + fmt.Printf("⚠️ %v\n", err) + fmt.Println("⚠️ Используются значения по умолчанию") + fmt.Printf("💡 Создайте файл конфигурации: sudo mkdir -p /etc/auspex && sudo nano /etc/auspex/auspex.env\n\n") + } + + // Если переданы аргументы — быстрый вызов + if len(os.Args) > 1 { + runByFlag(os.Args[1], os.Args[2:]) + return + } + + // Интерактивное меню + showMenu() +} + +func runByFlag(flag string, extraArgs []string) { + switch flag { + case "-ss": + fmt.Printf("%s⚡ Auspex — Просмотр занятых портов (TUI)%s\n\n", cCyan, cReset) + if err := runPortsView(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + + case "-c": + fmt.Printf("%s🔐 Auspex — Проверка SSL сертификатов%s\n\n", cCyan, cReset) + if err := runCertCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + + case "-p": + fmt.Printf("%s🌐 Auspex — Проверка доступности портов%s\n\n", cCyan, cReset) + if err := runPortCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + + case "-m": + fmt.Printf("%s🔄 Auspex — Проверка совпадений Angie ↔ Docker%s\n\n", cCyan, cReset) + clientFilter := "" + if len(extraArgs) > 0 { + clientFilter = extraArgs[0] + } + if err := runMatchCheck(clientFilter); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + + case "-l": + fmt.Printf("%s📝 Auspex — Проверка активности логов%s\n\n", cCyan, cReset) + if err := runLogCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + os.Exit(1) + } + + case "-v", "--version": + fmt.Printf("auspex v%s\n", appVersion) + + case "-h", "--help": + printUsage() + + default: + fmt.Fprintf(os.Stderr, "%sНеизвестный флаг: %s%s\n\n", cRed, flag, cReset) + printUsage() + os.Exit(1) + } +} + +func showMenu() { + printBanner() + + fmt.Printf("%sВыберите утилиту:%s\n\n", cBlue, cReset) + fmt.Printf(" %s1%s) %s-ss%s Просмотр занятых портов (TUI интерфейс)\n", cGreen, cReset, cCyan, cReset) + fmt.Printf(" %s2%s) %s-c %s Проверка SSL сертификатов\n", cGreen, cReset, cCyan, cReset) + 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(" %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) + + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("%s>>> %s", cGreen, cReset) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + + switch input { + case "1", "-ss", "ss": + if err := runPortsView(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return + case "2", "-c", "c": + if err := runCertCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return + case "3", "-p", "p": + if err := runPortCheck(); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return + case "4", "-m", "m": + fmt.Printf(" Имя клиента (Enter = все): ") + clientInput, _ := reader.ReadString('\n') + clientInput = strings.TrimSpace(clientInput) + if err := runMatchCheck(clientInput); err != nil { + fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err) + } + return + case "5", "-l", "l": + if err := runLogCheck(); 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) + } + } +} + +func printBanner() { + fmt.Printf(`%s + █████╗ ██╗ ██╗███████╗██████╗ ███████╗██╗ ██╗ + ██╔══██╗██║ ██║██╔════╝██╔══██╗██╔════╝╚██╗██╔╝ + ███████║██║ ██║███████╗██████╔╝█████╗ ╚███╔╝ + ██╔══██║██║ ██║╚════██║██╔═══╝ ██╔══╝ ██╔██╗ + ██║ ██║╚██████╔╝███████║██║ ███████╗██╔╝ ██╗ + ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚══════╝╚═╝ ╚═╝ +%s`, cCyan, cReset) + fmt.Printf(" %sv%s%s — набор утилит мониторинга инфраструктуры\n\n", cBlue, appVersion, cReset) +} + +func printUsage() { + fmt.Printf("Использование: auspex [флаг] [аргументы]\n\n") + fmt.Printf("Флаги:\n") + fmt.Printf(" %-12s Просмотр занятых портов (интерактивный TUI)\n", "-ss") + fmt.Printf(" %-12s Проверка SSL сертификатов в %s\n", "-c", cfgSSLDir) + fmt.Printf(" %-12s Проверка доступности портов (nginx upstream)\n", "-p") + fmt.Printf(" %-12s Проверка совпадений портов Angie ↔ Docker [имя_клиента]\n", "-m") + fmt.Printf(" %-12s Проверка активности логов (PostgreSQL)\n", "-l") + fmt.Printf(" %-12s Показать версию\n", "-v") + fmt.Printf(" %-12s Показать эту справку\n\n", "-h") + fmt.Printf("Без аргументов запускается интерактивное меню.\n") +} diff --git a/matchcheck.go b/matchcheck.go new file mode 100644 index 0000000..d6375f3 --- /dev/null +++ b/matchcheck.go @@ -0,0 +1,511 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type mcStatistics struct { + TotalClients, ClientsOK, ClientsWarning, ClientsError int +} + +type mcAlert struct { + Hostname, Client, Container, ErrorType, Description string + IsCritical bool +} + +type mcContainerCheck struct { + Number, PortsOK, Errors, Warnings int + Ports []int + Running bool +} + +type mcClientCheck struct { + Name string + HasAngie bool + AngiePorts []int + Containers []mcContainerCheck + Errors, Warnings int +} + +var mcAlerts []mcAlert + +// ── Точка входа ────────────────────────────────────────────────────────────── + +func runMatchCheck(clientFilter string) error { + mcAlerts = nil + + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + mcPrintHeader() + + if _, err := os.Stat(cfgPTAFBaseDir); os.IsNotExist(err) { + mcPrintError(fmt.Sprintf("Директория %s не найдена", cfgPTAFBaseDir)) + return fmt.Errorf("директория %s не найдена", cfgPTAFBaseDir) + } + + var clients []string + if clientFilter != "" { + clientPath := filepath.Join(cfgPTAFBaseDir, clientFilter) + if _, err := os.Stat(clientPath); os.IsNotExist(err) { + mcPrintError(fmt.Sprintf("Клиент %s не найден в %s", clientFilter, cfgPTAFBaseDir)) + return fmt.Errorf("клиент %s не найден", clientFilter) + } + clients = []string{clientFilter} + } else { + entries, err := os.ReadDir(cfgPTAFBaseDir) + if err != nil { + return fmt.Errorf("ошибка чтения директории: %v", err) + } + for _, entry := range entries { + if entry.IsDir() { + clients = append(clients, entry.Name()) + } + } + sort.Strings(clients) + } + + if len(clients) == 0 { + mcPrintWarn(fmt.Sprintf("Клиенты не найдены в %s", cfgPTAFBaseDir)) + return nil + } + + fmt.Printf("%sНайдено клиентов: %d%s\n\n", cBlue, len(clients), cReset) + stats := &mcStatistics{TotalClients: len(clients)} + + for _, client := range clients { + result := mcCheckClient(client, hostname) + if result.Errors == 0 && result.Warnings == 0 { + stats.ClientsOK++ + } else if result.Errors == 0 { + stats.ClientsWarning++ + } else { + stats.ClientsError++ + } + } + + mcPrintSummary(stats) + + if len(mcAlerts) > 0 && isTelegramConfigured() { + mcSendAlerts(hostname, stats, mcAlerts) + } + + if stats.ClientsError > 0 { + return fmt.Errorf("обнаружено %d клиентов с ошибками", stats.ClientsError) + } + return nil +} + +// ── Проверка клиента ───────────────────────────────────────────────────────── + +func mcCheckClient(client, hostname string) mcClientCheck { + result := mcClientCheck{Name: client} + + mcPrintClientHeader(client) + + angieConfPath := filepath.Join(cfgAngieConfDir, fmt.Sprintf("angie-ptaf-%s.conf", client)) + if _, err := os.Stat(angieConfPath); os.IsNotExist(err) { + mcPrintWarn("Angie конфиг не найден") + fmt.Println(cYellow + " (возможно клиент отключен или находится на другом instance)" + cReset) + fmt.Println() + return result + } + + result.HasAngie = true + mcPrintOK("✓ Angie конфиг найден") + + angiePorts, err := mcExtractAngiePorts(angieConfPath) + if err != nil { + mcPrintWarn(fmt.Sprintf("Ошибка чтения Angie конфига: %v", err)) + fmt.Println() + return result + } + + if len(angiePorts) == 0 { + mcPrintWarn("Не найдено портов в Angie конфиге") + fmt.Println() + return result + } + + result.AngiePorts = angiePorts + fmt.Printf(" Порты в Angie: %d портов - %v\n", len(angiePorts), mcPortsStr(angiePorts)) + + clientPath := filepath.Join(cfgPTAFBaseDir, client) + containers := mcFindContainers(clientPath) + fmt.Printf(" Контейнеров на диске: %d\n\n", len(containers)) + + if len(containers) == 0 { + mcPrintWarn("Контейнеры не найдены") + fmt.Println() + return result + } + + for _, num := range containers { + cc := mcCheckContainer(client, num, angiePorts, hostname) + result.Containers = append(result.Containers, cc) + result.Errors += cc.Errors + result.Warnings += cc.Warnings + } + + if result.Errors == 0 && result.Warnings == 0 { + fmt.Printf("%s✓ Клиент %s: все проверки пройдены%s\n", cGreen, client, cReset) + } else if result.Errors == 0 { + fmt.Printf("%s⚠ Клиент %s: %d предупреждений%s\n", cYellow, client, result.Warnings, cReset) + } else { + fmt.Printf("%s✗ Клиент %s: %d ошибок, %d предупреждений%s\n", cRed, client, result.Errors, result.Warnings, cReset) + } + fmt.Println() + return result +} + +// ── Проверка контейнера ────────────────────────────────────────────────────── + +func mcCheckContainer(client string, num int, angiePorts []int, hostname string) mcContainerCheck { + result := mcContainerCheck{Number: num} + fullName := fmt.Sprintf("ptaf_%s_%03d", client, num) + label := fmt.Sprintf("контейнер %03d", num) + + fmt.Printf(" ┌─ Контейнер %03d\n", num) + + clientPath := filepath.Join(cfgPTAFBaseDir, client) + dirName := fmt.Sprintf("%s-ptaf-agent%03d", client, num) + containerDir := filepath.Join(clientPath, dirName) + composeFile := filepath.Join(containerDir, "docker-compose.yml") + portsFile := filepath.Join(containerDir, ".ports.json") + + // docker-compose.yml + if _, err := os.Stat(composeFile); os.IsNotExist(err) { + fmt.Printf(" %s│ ✗ docker-compose.yml не найден%s\n", cRed, cReset) + fmt.Println(" └─") + result.Errors++ + mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Missing File", "docker-compose.yml не найден", true}) + return result + } + + composePorts, err := mcExtractComposePorts(composeFile) + if err != nil { + fmt.Printf(" %s│ ✗ Ошибка чтения docker-compose.yml: %v%s\n", cRed, err, cReset) + fmt.Println(" └─") + result.Errors++ + mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Read Error", fmt.Sprintf("Ошибка чтения: %v", err), true}) + return result + } + + if len(composePorts) == 0 { + fmt.Printf(" %s│ ✗ Не найдено портов в docker-compose.yml%s\n", cRed, cReset) + fmt.Println(" └─") + result.Errors++ + mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "No Ports", "Не найдено портов", true}) + return result + } + + result.Ports = composePorts + fmt.Printf(" │ Порты: %v\n", mcPortsStr(composePorts)) + + // .ports.json + if _, err := os.Stat(portsFile); err == nil { + if !mcIsValidJSON(portsFile) { + fmt.Printf(" %s│ ⚠ .ports.json некорректный JSON%s\n", cYellow, cReset) + result.Warnings++ + } + } else { + fmt.Printf(" %s│ ⚠ .ports.json не найден%s\n", cYellow, cReset) + result.Warnings++ + } + + // Совпадение с Angie + containerOK := true + var missing []int + + for _, port := range composePorts { + if !mcContainsInt(angiePorts, port) { + fmt.Printf(" %s│ ✗ Порт %d ОТСУТСТВУЕТ в Angie!%s\n", cRed, port, cReset) + result.Errors++ + containerOK = false + missing = append(missing, port) + } + } + + if len(missing) > 0 { + mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Port Mismatch", + fmt.Sprintf("Порты отсутствуют в Angie: %v", missing), true}) + } + + // Проверка запуска + if mcIsContainerRunning(fullName) { + result.Running = true + fmt.Printf(" %s│ ✓ Контейнер запущен%s\n", cGreen, cReset) + + portsOK := 0 + var notListening []int + + for _, port := range composePorts { + if mcIsPortListening(port) { + portsOK++ + } else { + fmt.Printf(" %s│ ✗ Порт %d НЕ слушается%s\n", cRed, port, cReset) + result.Errors++ + containerOK = false + notListening = append(notListening, port) + } + } + + if len(notListening) > 0 { + mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Port Not Listening", + fmt.Sprintf("Порты не слушаются: %v", notListening), true}) + } + + result.PortsOK = portsOK + if portsOK == len(composePorts) { + fmt.Printf(" %s│ ✓ Все %d портов активны%s\n", cGreen, portsOK, cReset) + } + } else { + fmt.Printf(" %s│ ⚠ Контейнер НЕ запущен%s\n", cYellow, cReset) + result.Warnings++ + containerOK = false + mcAlerts = append(mcAlerts, mcAlert{hostname, client, label, "Container Stopped", "Контейнер не запущен", false}) + } + + if containerOK { + fmt.Printf(" %s└─ ✓ OK%s\n", cGreen, cReset) + } else { + fmt.Println(" └─") + } + fmt.Println() + return result +} + +// ── Парсеры ────────────────────────────────────────────────────────────────── + +func mcExtractAngiePorts(filePath string) ([]int, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + re := regexp.MustCompile(`server\s+127\.0\.0\.1:(\d+)`) + matches := re.FindAllStringSubmatch(string(data), -1) + pm := make(map[int]bool) + for _, m := range matches { + if len(m) > 1 { + var p int + fmt.Sscanf(m[1], "%d", &p) + pm[p] = true + } + } + ports := make([]int, 0, len(pm)) + for p := range pm { + ports = append(ports, p) + } + sort.Ints(ports) + return ports, nil +} + +func mcExtractComposePorts(filePath string) ([]int, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + re := regexp.MustCompile(`127\.0\.0\.1:(\d+):\d+/tcp`) + matches := re.FindAllStringSubmatch(string(data), -1) + pm := make(map[int]bool) + for _, m := range matches { + if len(m) > 1 { + var p int + fmt.Sscanf(m[1], "%d", &p) + pm[p] = true + } + } + ports := make([]int, 0, len(pm)) + for p := range pm { + ports = append(ports, p) + } + sort.Ints(ports) + return ports, nil +} + +func mcFindContainers(clientPath string) []int { + entries, err := os.ReadDir(clientPath) + if err != nil { + return nil + } + re := regexp.MustCompile(`-ptaf-agent(\d{3})$`) + var containers []int + for _, entry := range entries { + if !entry.IsDir() { + continue + } + m := re.FindStringSubmatch(entry.Name()) + if len(m) > 1 { + var num int + fmt.Sscanf(m[1], "%d", &num) + containers = append(containers, num) + } + } + sort.Ints(containers) + return containers +} + +func mcIsValidJSON(filePath string) bool { + data, err := os.ReadFile(filePath) + if err != nil { + return false + } + var js map[string]interface{} + return json.Unmarshal(data, &js) == nil +} + +func mcIsContainerRunning(name string) bool { + cmd := exec.Command("docker", "ps", "--format", "{{.Names}}") + output, err := cmd.Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(output), "\n") { + if strings.TrimSpace(line) == name { + return true + } + } + return false +} + +func mcIsPortListening(port int) bool { + cmd := exec.Command("ss", "-tlnp") + output, err := cmd.Output() + if err != nil { + cmd = exec.Command("netstat", "-tlnp") + output, err = cmd.Output() + if err != nil { + return false + } + } + return strings.Contains(string(output), fmt.Sprintf("127.0.0.1:%d", port)) +} + +func mcContainsInt(slice []int, val int) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false +} + +func mcPortsStr(ports []int) string { + strs := make([]string, len(ports)) + for i, p := range ports { + strs[i] = fmt.Sprintf("%d", p) + } + return strings.Join(strs, " ") +} + +// ── Вывод ──────────────────────────────────────────────────────────────────── + +func mcPrintHeader() { + fmt.Printf("%s╔════════════════════════════════════════╗\n", cBlue) + fmt.Println("║ Проверка портов PTAF (Angie ↔ Docker) ║") + fmt.Printf("╚════════════════════════════════════════╝%s\n\n", cReset) +} + +func mcPrintClientHeader(client string) { + fmt.Printf("%s==========================================\n", cCyan) + fmt.Printf("Клиент: %s\n", client) + fmt.Printf("==========================================%s\n\n", cReset) +} + +func mcPrintSummary(stats *mcStatistics) { + fmt.Printf("%s╔════════════════════════════════════════╗\n", cBlue) + fmt.Println("║ Итоговая статистика ║") + fmt.Printf("╚════════════════════════════════════════╝%s\n\n", cReset) + fmt.Printf("Всего клиентов проверено: %d\n", stats.TotalClients) + fmt.Printf("%s✓ Без проблем: %d%s\n", cGreen, stats.ClientsOK, cReset) + if stats.ClientsWarning > 0 { + fmt.Printf("%s⚠ С предупреждениями: %d%s\n", cYellow, stats.ClientsWarning, cReset) + } + if stats.ClientsError > 0 { + fmt.Printf("%s✗ С ошибками: %d%s\n", cRed, stats.ClientsError, cReset) + } + fmt.Println() +} + +func mcPrintOK(msg string) { fmt.Printf("%s%s%s\n", cGreen, msg, cReset) } +func mcPrintWarn(msg string) { fmt.Printf("%s⚠ %s%s\n", cYellow, msg, cReset) } +func mcPrintError(msg string) { fmt.Printf("%s✗ %s%s\n", cRed, msg, cReset) } + +// ── Telegram ───────────────────────────────────────────────────────────────── + +func mcSendAlerts(hostname string, stats *mcStatistics, alertList []mcAlert) { + var sb strings.Builder + sb.WriteString("🚨 PTAF Docker Ports Check Alert\n\n") + sb.WriteString(fmt.Sprintf("🖥 Хост: %s\n", hostname)) + sb.WriteString(fmt.Sprintf("📊 Проверено клиентов: %d\n", stats.TotalClients)) + sb.WriteString(fmt.Sprintf("✅ Без проблем: %d\n", stats.ClientsOK)) + if stats.ClientsWarning > 0 { + sb.WriteString(fmt.Sprintf("⚠️ С предупреждениями: %d\n", stats.ClientsWarning)) + } + if stats.ClientsError > 0 { + sb.WriteString(fmt.Sprintf("❌ С ошибками: %d\n", stats.ClientsError)) + } + + sb.WriteString("\n📋 Детали проблем:\n\n") + + clientAlerts := make(map[string][]mcAlert) + for _, a := range alertList { + clientAlerts[a.Client] = append(clientAlerts[a.Client], a) + } + + var sortedClients []string + for c := range clientAlerts { + sortedClients = append(sortedClients, c) + } + sort.Strings(sortedClients) + + for _, client := range sortedClients { + cas := clientAlerts[client] + crit, warn := 0, 0 + for _, a := range cas { + if a.IsCritical { + crit++ + } else { + warn++ + } + } + if crit > 0 { + sb.WriteString(fmt.Sprintf("❌ %s (%d ошибок", client, crit)) + if warn > 0 { + sb.WriteString(fmt.Sprintf(", %d предупреждений", warn)) + } + sb.WriteString(")\n") + } else { + sb.WriteString(fmt.Sprintf("⚠️ %s (%d предупреждений)\n", client, warn)) + } + for _, a := range cas { + icon := "⚠️" + if a.IsCritical { + icon = "❌" + } + sb.WriteString(fmt.Sprintf("%s %s: %s - %s\n", icon, a.Container, a.ErrorType, a.Description)) + } + sb.WriteString("\n") + } + + sb.WriteString("━━━━━━━━━━━━━━━━━━━━━\n") + sb.WriteString("🔍 Проверьте должны ли быть данные ресурсы на этом хосте.\n") + sb.WriteString(" Если нет, то удалите все старые контейнеры и конфиги для Angie.\n") + + if err := telegramSendHTML(sb.String()); err != nil { + fmt.Printf("%s[Telegram] ✗ Ошибка отправки: %v%s\n", cRed, err, cReset) + } else { + fmt.Printf("%s[Telegram] ✓ Алерт отправлен (%d проблем)%s\n", cGreen, len(alertList), cReset) + } +} diff --git a/portcheck.go b/portcheck.go new file mode 100644 index 0000000..b468900 --- /dev/null +++ b/portcheck.go @@ -0,0 +1,529 @@ +package main + +import ( + "bufio" + "database/sql" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + _ "github.com/lib/pq" +) + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type pcEndpoint struct { + Host string + Port string +} + +func (e pcEndpoint) String() string { return e.Host + ":" + e.Port } + +type pcEndpointStatus struct { + Endpoint string `json:"endpoint"` + Available bool `json:"available"` + Error string `json:"error,omitempty"` + ConfigFile string `json:"config_file"` +} + +type pcStateFile struct { + Timestamp string `json:"timestamp"` + Status map[string]pcEndpointStatus `json:"status"` +} + +// ── База данных ────────────────────────────────────────────────────────────── + +// pcConnectDB подключается к БД с failover +func pcConnectDB() (*sql.DB, error) { + // Пробуем primary + primaryDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfgPrimaryDBHost, cfgDBPort, cfgDBUser, cfgDBPassword, cfgDBName) + + db, err := sql.Open("postgres", primaryDSN) + if err == nil { + if err := db.Ping(); err == nil { + return db, nil + } + db.Close() + } + + // Пробуем secondary + secondaryDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + cfgSecondaryDBHost, cfgDBPort, cfgDBUser, cfgDBPassword, cfgDBName) + + db, err = sql.Open("postgres", secondaryDSN) + if err != nil { + return nil, fmt.Errorf("не удалось открыть соединение с secondary: %v", err) + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("secondary БД недоступна: %v", err) + } + + return db, nil +} + +// pcExtractL7ResourceID извлекает l7resourceid из имени файла +// Например: "ITAR-TASS_17822.conf" -> "17822" +func pcExtractL7ResourceID(filename string) string { + // Убираем путь, оставляем только имя файла + baseName := filepath.Base(filename) + + // Убираем расширение + nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + + // Ищем цифры после последнего подчеркивания + parts := strings.Split(nameWithoutExt, "_") + if len(parts) >= 2 { + // Берем последнюю часть (она должна содержать цифры) + return parts[len(parts)-1] + } + + return "" +} + +// pcGetCheckPortsStatus проверяет нужно ли проверять порты для данного l7resourceid +func pcGetCheckPortsStatus(db *sql.DB, l7resourceid string) (bool, error) { + if l7resourceid == "" { + return false, nil + } + + query := `SELECT COALESCE(check_ports, false) FROM apps_settings WHERE l7resourceid = $1` + + var checkPorts bool + err := db.QueryRow(query, l7resourceid).Scan(&checkPorts) + if err != nil { + if err == sql.ErrNoRows { + // Если записи нет в БД, по умолчанию не проверяем + return false, nil + } + return false, fmt.Errorf("ошибка запроса к БД: %v", err) + } + + return checkPorts, nil +} + +// pcLoadCheckPortsMap загружает все статусы check_ports для оптимизации +func pcLoadCheckPortsMap(db *sql.DB) (map[string]bool, error) { + query := `SELECT l7resourceid, COALESCE(check_ports, false) FROM apps_settings` + + rows, err := db.Query(query) + if err != nil { + return nil, fmt.Errorf("ошибка запроса к БД: %v", err) + } + defer rows.Close() + + checkMap := make(map[string]bool) + for rows.Next() { + var l7resourceid string + var checkPorts bool + if err := rows.Scan(&l7resourceid, &checkPorts); err != nil { + continue + } + checkMap[l7resourceid] = checkPorts + } + + return checkMap, rows.Err() +} + +// ── Точка входа ────────────────────────────────────────────────────────────── + +func runPortCheck() error { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + fmt.Printf("=== Port Checker запущен на %s ===\n\n", hostname) + + // Подключаемся к БД + fmt.Println("📊 Подключение к базе данных...") + db, err := pcConnectDB() + if err != nil { + fmt.Printf("⚠️ Не удалось подключиться к БД: %v\n", err) + fmt.Println("⚠️ Продолжаем без фильтрации по check_ports (проверяем все порты)\n") + db = nil + } else { + fmt.Println("✅ Соединение с БД установлено\n") + defer db.Close() + } + + // Загружаем карту check_ports для оптимизации + var checkPortsMap map[string]bool + if db != nil { + checkPortsMap, err = pcLoadCheckPortsMap(db) + if err != nil { + fmt.Printf("⚠️ Ошибка загрузки check_ports: %v\n", err) + fmt.Println("⚠️ Продолжаем без фильтрации\n") + checkPortsMap = nil + } else { + fmt.Printf("📋 Загружено статусов check_ports: %d\n\n", len(checkPortsMap)) + } + } + + files, err := filepath.Glob(cfgNginxConfigGlob) + if err != nil { + return fmt.Errorf("ошибка при поиске конфигурационных файлов: %v", err) + } + + if len(files) == 0 { + fmt.Printf("Конфигурационные файлы не найдены по пути: %s\n", cfgNginxConfigGlob) + return nil + } + + previousState := pcLoadState() + currentState := make(map[string]pcEndpointStatus) + + fileEndpoints := make(map[string][]pcEndpoint) + skippedConfigs := 0 + + for _, file := range files { + // Извлекаем l7resourceid из имени файла + l7resourceid := pcExtractL7ResourceID(file) + + // Проверяем нужно ли проверять этот конфиг + if checkPortsMap != nil && l7resourceid != "" { + checkPorts, exists := checkPortsMap[l7resourceid] + if exists && !checkPorts { + // check_ports = false, пропускаем + skippedConfigs++ + continue + } + // Если записи нет в БД или check_ports = true, проверяем + } + + eps := pcParseNginxConfig(file) + if len(eps) > 0 { + fileEndpoints[file] = eps + } + } + + if skippedConfigs > 0 { + fmt.Printf("ℹ️ Пропущено конфигов (check_ports = false): %d\n", skippedConfigs) + } + + if len(fileEndpoints) == 0 { + fmt.Println("Серверы не найдены в конфигурационных файлах") + return nil + } + + // Уникальные endpoints + globalEps := make(map[pcEndpoint]string) + for file, eps := range fileEndpoints { + baseName := filepath.Base(file) + cfgName := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + for _, ep := range eps { + if _, exists := globalEps[ep]; !exists { + globalEps[ep] = cfgName + } + } + } + + fmt.Printf("Найдено уникальных endpoints: %d\n\n", len(globalEps)) + + configGroups := make(map[string][]pcEndpoint) + for ep, cfgName := range globalEps { + configGroups[cfgName] = append(configGroups[cfgName], ep) + } + + sortedConfigs := make([]string, 0, len(configGroups)) + for cfg := range configGroups { + sortedConfigs = append(sortedConfigs, cfg) + } + sort.Strings(sortedConfigs) + + // Параллельная проверка + type checkResult struct { + status pcEndpointStatus + configName string + } + + results := make(chan checkResult, len(globalEps)) + checkCount := 0 + + for _, cfgName := range sortedConfigs { + eps := configGroups[cfgName] + sort.Slice(eps, func(i, j int) bool { + if eps[i].Host != eps[j].Host { + return eps[i].Host < eps[j].Host + } + return eps[i].Port < eps[j].Port + }) + + for _, ep := range eps { + checkCount++ + go func(e pcEndpoint, c string) { + results <- checkResult{status: pcCheckPort(e, c), configName: c} + }(ep, cfgName) + } + } + + statusByConfig := make(map[string][]pcEndpointStatus) + for i := 0; i < checkCount; i++ { + r := <-results + currentState[r.status.Endpoint] = r.status + statusByConfig[r.configName] = append(statusByConfig[r.configName], r.status) + } + close(results) + + // Вывод результатов + for _, cfgName := range sortedConfigs { + statuses := statusByConfig[cfgName] + sort.Slice(statuses, func(i, j int) bool { + return statuses[i].Endpoint < statuses[j].Endpoint + }) + fmt.Printf("=== %s ===\n", cfgName) + for _, s := range statuses { + pcPrintStatus(s) + } + fmt.Println() + } + + // Сравнение с предыдущим + if len(previousState.Status) > 0 { + changes := pcCompareStates(previousState.Status, currentState) + if len(changes) > 0 { + fmt.Println("=== Изменения с последней проверки ===") + for _, ch := range changes { + fmt.Println(ch) + } + fmt.Println() + + if isTelegramConfigured() { + pcSendNotification(changes) + } else { + fmt.Println("ℹ️ Telegram уведомления отключены (не настроены данные)") + } + } + } + + pcSaveState(currentState) + fmt.Println("=== Проверка завершена ===") + return nil +} + +// ── Парсинг nginx ──────────────────────────────────────────────────────────── + +func pcParseNginxConfig(filename string) []pcEndpoint { + file, err := os.Open(filename) + if err != nil { + return nil + } + defer file.Close() + + var endpoints []pcEndpoint + inUpstream := false + scanner := bufio.NewScanner(file) + re := regexp.MustCompile(`^\s*server\s+([0-9.]+):(\d+)`) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "upstream") { + inUpstream = true + continue + } + if inUpstream && line == "}" { + inUpstream = false + continue + } + if inUpstream { + m := re.FindStringSubmatch(line) + if len(m) == 3 { + endpoints = append(endpoints, pcEndpoint{Host: m[1], Port: m[2]}) + } + } + } + return endpoints +} + +// ── Проверка порта ─────────────────────────────────────────────────────────── + +func pcCheckPort(ep pcEndpoint, cfgName string) pcEndpointStatus { + address := ep.String() + timeout := time.Duration(cfgCheckTimeoutSec) * time.Second + delay := time.Duration(cfgRetryDelaySec) * time.Second + + var lastErr error + for attempt := 1; attempt <= cfgMaxRetries; attempt++ { + conn, err := net.DialTimeout("tcp", address, timeout) + if err == nil { + conn.Close() + return pcEndpointStatus{Endpoint: address, Available: true, ConfigFile: cfgName} + } + lastErr = err + if attempt < cfgMaxRetries { + time.Sleep(delay) + } + } + + return pcEndpointStatus{Endpoint: address, Available: false, Error: lastErr.Error(), ConfigFile: cfgName} +} + +func pcPrintStatus(s pcEndpointStatus) { + if s.Available { + fmt.Printf("%s - ДОСТУПЕН\n", s.Endpoint) + } else { + msg := s.Error + switch { + case strings.Contains(msg, "i/o timeout"): + msg = "timeout" + case strings.Contains(msg, "connection refused"): + msg = "connection refused" + case strings.Contains(msg, "no route to host"): + msg = "no route to host" + } + fmt.Printf("%s - НЕДОСТУПЕН (%s)\n", s.Endpoint, msg) + } +} + +// ── Состояние ──────────────────────────────────────────────────────────────── + +func pcLoadState() pcStateFile { + data, err := os.ReadFile(cfgStateFilePath) + if err != nil { + return pcStateFile{Status: make(map[string]pcEndpointStatus)} + } + var state pcStateFile + if json.Unmarshal(data, &state) != nil { + return pcStateFile{Status: make(map[string]pcEndpointStatus)} + } + return state +} + +func pcSaveState(status map[string]pcEndpointStatus) { + state := pcStateFile{ + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Status: status, + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + fmt.Printf("Ошибка при сохранении состояния: %v\n", err) + return + } + if err := os.WriteFile(cfgStateFilePath, data, 0644); err != nil { + fmt.Printf("Ошибка при записи файла состояния: %v\n", err) + } +} + +// ── Сравнение состояний ────────────────────────────────────────────────────── + +func pcCompareStates(prev, curr map[string]pcEndpointStatus) []string { + changesByConfig := make(map[string][]string) + + for ep, cs := range curr { + if ps, exists := prev[ep]; exists { + if ps.Available != cs.Available { + cfg := cs.ConfigFile + if cs.Available { + changesByConfig[cfg] = append(changesByConfig[cfg], + fmt.Sprintf("%s: НЕДОСТУПЕН → ДОСТУПЕН", ep)) + } else { + changesByConfig[cfg] = append(changesByConfig[cfg], + fmt.Sprintf("%s: ДОСТУПЕН → НЕДОСТУПЕН (%s)", ep, pcShortError(cs.Error))) + } + } + } else { + cfg := cs.ConfigFile + if cs.Available { + changesByConfig[cfg] = append(changesByConfig[cfg], + fmt.Sprintf("%s: НОВЫЙ (доступен)", ep)) + } else { + changesByConfig[cfg] = append(changesByConfig[cfg], + fmt.Sprintf("%s: НОВЫЙ (недоступен - %s)", ep, pcShortError(cs.Error))) + } + } + } + + for ep, ps := range prev { + if _, exists := curr[ep]; !exists { + changesByConfig[ps.ConfigFile] = append(changesByConfig[ps.ConfigFile], + fmt.Sprintf("%s: УДАЛЕН из конфигурации", ep)) + } + } + + var result []string + configs := make([]string, 0, len(changesByConfig)) + for c := range changesByConfig { + configs = append(configs, c) + } + sort.Strings(configs) + + for _, c := range configs { + chs := changesByConfig[c] + sort.Strings(chs) + result = append(result, fmt.Sprintf("[%s]", c)) + result = append(result, chs...) + result = append(result, "") + } + if len(result) > 0 && result[len(result)-1] == "" { + result = result[:len(result)-1] + } + return result +} + +func pcShortError(full string) string { + switch { + case strings.Contains(full, "i/o timeout"): + return "timeout" + case strings.Contains(full, "connection refused"): + return "connection refused" + case strings.Contains(full, "no route to host"): + return "no route" + case strings.Contains(full, "network is unreachable"): + return "network unreachable" + } + if len(full) > 50 { + return full[:50] + "..." + } + return full +} + +// ── Telegram ───────────────────────────────────────────────────────────────── + +func pcSendNotification(changes []string) { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "unknown" + } + + message := fmt.Sprintf("🔔 Изменения в статусе портов на %s\n\n", hostname) + + for _, line := range changes { + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + cfgName := strings.TrimPrefix(strings.TrimSuffix(line, "]"), "[") + message += fmt.Sprintf("\n--- %s ---\n", cfgName) + continue + } + if line == "" { + continue + } + switch { + case strings.Contains(line, "НЕДОСТУПЕН → ДОСТУПЕН"): + message += "✅ " + line + "\n" + case strings.Contains(line, "ДОСТУПЕН → НЕДОСТУПЕН"): + message += "❌ " + line + "\n" + case strings.Contains(line, "НОВЫЙ"): + message += "🆕 " + line + "\n" + case strings.Contains(line, "УДАЛЕН"): + message += "🗑️ " + line + "\n" + default: + message += line + "\n" + } + } + + message += fmt.Sprintf("\n⏰ Время проверки: %s", time.Now().Format("2006-01-02 15:04:05")) + + if err := telegramSendPlainText(message); err != nil { + fmt.Printf("❌ Telegram: Ошибка отправки: %v\n", err) + } else { + fmt.Println("✓ Уведомление отправлено в Telegram") + } +} diff --git a/portsview.go b/portsview.go new file mode 100644 index 0000000..d28eccf --- /dev/null +++ b/portsview.go @@ -0,0 +1,620 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ── Типы ───────────────────────────────────────────────────────────────────── + +type pvSortMode int + +const ( + pvSortByPort pvSortMode = iota + pvSortByProcess + pvSortByPID +) + +type pvPortEntry struct { + Port, Protocol, PID, Process, State, Address string +} + +type pvLoadedMsg struct { + entries []pvPortEntry + err error +} + +type pvDetailsMsg struct { + content string + err error +} + +type pvAutoTickMsg struct{} + +type pvModel struct { + table table.Model + textInput textinput.Model + entries []pvPortEntry + filteredEntries []pvPortEntry + err error + status string + width, height int + isFiltering bool + showDetails bool + detailsContent string + sortMode pvSortMode + loading bool + loadingDetails bool + autoRefresh bool + lastRefresh time.Time + cache *pvCache +} + +type pvCache struct { + mu sync.RWMutex + store map[string]pvCacheEntry +} + +type pvCacheEntry struct { + details string + timestamp time.Time +} + +func newPvCache() *pvCache { + return &pvCache{store: make(map[string]pvCacheEntry)} +} + +func (c *pvCache) get(pid string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.store[pid] + if !ok || time.Since(e.timestamp) > 30*time.Second { + return "", false + } + return e.details, true +} + +func (c *pvCache) set(pid, details string) { + c.mu.Lock() + defer c.mu.Unlock() + c.store[pid] = pvCacheEntry{details: details, timestamp: time.Now()} +} + +func (c *pvCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.store = make(map[string]pvCacheEntry) +} + +// ── Стили ──────────────────────────────────────────────────────────────────── + +var ( + pvCommonPorts = map[string]string{ + "21": "FTP", "22": "SSH", "23": "Telnet", "25": "SMTP", + "53": "DNS", "80": "HTTP", "110": "POP3", "143": "IMAP", + "443": "HTTPS", "3306": "MySQL", "5432": "PostgreSQL", + "6379": "Redis", "8080": "HTTP-Alt", "27017": "MongoDB", + } + + pvBaseStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#6c7086")). + Padding(1, 2) + + pvLogoStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#89b4fa")). + Padding(0, 1) + + pvHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6c7086")). + Padding(0, 1) + + pvStatusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#a6e3a1")). + Bold(true) + + pvInputStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#89b4fa")). + Padding(0, 1) + + pvDetailsStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#89b4fa")). + Padding(1, 2). + Width(80) + + pvDetailsTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#89b4fa")). + Padding(0, 0, 1, 0) + + pvLoadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f9e2af")). + Bold(true) +) + +// ── Точка входа ────────────────────────────────────────────────────────────── + +func runPortsView() error { + columns := []table.Column{ + {Title: "Port", Width: 8}, + {Title: "Proto", Width: 6}, + {Title: "State", Width: 12}, + {Title: "PID", Width: 8}, + {Title: "Address", Width: 20}, + {Title: "Process", Width: 20}, + } + + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#6c7086")). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("#cdd6f4")). + Background(lipgloss.Color("#313244")). + Bold(false) + t.SetStyles(s) + + ti := textinput.New() + ti.Placeholder = "Search ports, processes, pids..." + ti.CharLimit = 156 + ti.Width = 40 + + m := pvModel{ + table: t, + textInput: ti, + cache: newPvCache(), + autoRefresh: false, + } + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("ошибка запуска TUI: %w", err) + } + return nil +} + +// ── Tea Model ──────────────────────────────────────────────────────────────── + +func (m pvModel) Init() tea.Cmd { + return tea.Batch(pvLoadAsync, textinput.Blink, pvTickAuto) +} + +func pvTickAuto() tea.Msg { + time.Sleep(5 * time.Second) + return pvAutoTickMsg{} +} + +func pvLoadAsync() tea.Msg { + entries, err := pvGetPorts() + return pvLoadedMsg{entries: entries, err: err} +} + +func pvLoadDetails(pid, port, proto, addr, state, proc string, cache *pvCache) tea.Cmd { + return func() tea.Msg { + if cached, ok := cache.get(pid); ok { + content := cached + if pid != "-" { + content = fmt.Sprintf("Port: %s/%s\nPID: %s\nAddress: %s\nState: %s\nProcess: %s\n\n%s", + port, proto, pid, addr, state, proc, cached) + } + return pvDetailsMsg{content: content} + } + + details, err := pvGetProcessDetails(pid) + if err != nil { + return pvDetailsMsg{err: err} + } + cache.set(pid, details) + + content := details + if pid != "-" { + content = fmt.Sprintf("Port: %s/%s\nPID: %s\nAddress: %s\nState: %s\nProcess: %s\n\n%s", + port, proto, pid, addr, state, proc, details) + } + return pvDetailsMsg{content: content} + } +} + +func (m pvModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + if _, ok := msg.(pvAutoTickMsg); ok { + var cmds []tea.Cmd + if m.autoRefresh && !m.loading && time.Since(m.lastRefresh) > 5*time.Second { + cmds = append(cmds, pvLoadAsync) + } + cmds = append(cmds, pvTickAuto) + return m, tea.Batch(cmds...) + } + + if m.isFiltering { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "enter" || msg.String() == "esc" { + m.isFiltering = false + m.table.Focus() + return m, nil + } + } + m.textInput, cmd = m.textInput.Update(msg) + m.pvFilter() + return m, cmd + } + + if m.showDetails { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "esc" || msg.String() == "q" || msg.String() == "enter" { + m.showDetails = false + m.loadingDetails = false + return m, nil + } + case pvDetailsMsg: + m.loadingDetails = false + if msg.err != nil { + m.detailsContent = fmt.Sprintf("Error: %v", msg.err) + } else { + m.detailsContent = msg.content + } + return m, nil + } + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "/": + m.isFiltering = true + m.textInput.Focus() + m.textInput.SetValue("") + return m, textinput.Blink + case "r": + if !m.loading { + m.status = "Refreshing..." + m.loading = true + m.cache.clear() + return m, pvLoadAsync + } + case "a": + m.autoRefresh = !m.autoRefresh + if m.autoRefresh { + m.status = "Auto-refresh ON" + } else { + m.status = "Auto-refresh OFF" + } + case "s": + switch m.sortMode { + case pvSortByPort: + m.sortMode = pvSortByProcess + case pvSortByProcess: + m.sortMode = pvSortByPID + default: + m.sortMode = pvSortByPort + } + m.pvSort() + m.pvUpdateColumns() + m.pvUpdateTable() + case "enter": + if len(m.filteredEntries) > 0 && !m.loadingDetails { + idx := m.table.Cursor() + if idx >= 0 && idx < len(m.filteredEntries) { + t := m.filteredEntries[idx] + m.showDetails = true + m.loadingDetails = true + m.detailsContent = "Loading..." + return m, pvLoadDetails(t.PID, t.Port, t.Protocol, t.Address, t.State, t.Process, m.cache) + } + } + case "k": + if len(m.filteredEntries) > 0 { + idx := m.table.Cursor() + if idx >= 0 && idx < len(m.filteredEntries) { + t := m.filteredEntries[idx] + if t.PID == "-" { + if os.Geteuid() == 0 { + m.status = "Cannot kill system process" + } else { + m.status = "Run as sudo to kill this process" + } + return m, nil + } + err := pvKillProcess(t.PID) + if err != nil { + m.status = fmt.Sprintf("Error killing %s: %v", t.PID, err) + } else { + m.status = fmt.Sprintf("Killed %s (%s)", t.Process, t.PID) + m.cache.clear() + return m, pvLoadAsync + } + } + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + avail := m.height - 7 + pvBaseStyle = pvBaseStyle.Width(m.width - 2).Height(avail) + m.table.SetWidth(m.width - 4) + th := avail - 2 + if th < 2 { + th = 2 + } + m.table.SetHeight(th) + + case pvLoadedMsg: + m.loading = false + m.lastRefresh = time.Now() + if msg.err != nil { + m.err = msg.err + } else { + m.entries = msg.entries + m.pvSort() + m.pvFilter() + m.pvUpdateColumns() + m.err = nil + if m.status == "Refreshing..." { + m.status = "Refreshed" + } + } + + case error: + m.err = msg + m.loading = false + } + + m.table, cmd = m.table.Update(msg) + return m, cmd +} + +func (m *pvModel) pvSort() { + sort.Slice(m.entries, func(i, j int) bool { + switch m.sortMode { + case pvSortByProcess: + return strings.ToLower(m.entries[i].Process) < strings.ToLower(m.entries[j].Process) + case pvSortByPID: + if m.entries[i].PID == "-" { + return false + } + if m.entries[j].PID == "-" { + return true + } + p1, _ := strconv.Atoi(m.entries[i].PID) + p2, _ := strconv.Atoi(m.entries[j].PID) + return p1 < p2 + default: + p1, e1 := strconv.Atoi(m.entries[i].Port) + p2, e2 := strconv.Atoi(m.entries[j].Port) + if e1 == nil && e2 == nil { + if p1 == p2 { + return m.entries[i].Protocol < m.entries[j].Protocol + } + return p1 < p2 + } + return m.entries[i].Port < m.entries[j].Port + } + }) + m.pvFilter() +} + +func (m *pvModel) pvUpdateColumns() { + cols := []table.Column{ + {Title: "Port", Width: 8}, + {Title: "Proto", Width: 6}, + {Title: "State", Width: 12}, + {Title: "PID", Width: 8}, + {Title: "Address", Width: 20}, + {Title: "Process", Width: 20}, + } + arrow := " ▼" + switch m.sortMode { + case pvSortByPort: + cols[0].Title += arrow + case pvSortByPID: + cols[3].Title += arrow + case pvSortByProcess: + cols[5].Title += arrow + } + m.table.SetColumns(cols) +} + +func (m *pvModel) pvFilter() { + q := strings.ToLower(m.textInput.Value()) + m.filteredEntries = nil + for _, e := range m.entries { + if q == "" || + strings.Contains(strings.ToLower(e.Process), q) || + strings.Contains(e.Port, q) || + strings.Contains(e.PID, q) || + strings.Contains(strings.ToLower(e.State), q) || + strings.Contains(strings.ToLower(e.Address), q) { + m.filteredEntries = append(m.filteredEntries, e) + } + } + m.pvUpdateTable() +} + +func (m *pvModel) pvUpdateTable() { + rows := []table.Row{} + for _, e := range m.filteredEntries { + icon := "○" + if strings.Contains(e.State, "LISTEN") { + icon = "●" + } else if strings.Contains(e.State, "ESTAB") { + icon = "↔" + } + rows = append(rows, table.Row{e.Port, e.Protocol, icon + " " + e.State, e.PID, e.Address, e.Process}) + } + m.table.SetRows(rows) +} + +func (m pvModel) View() string { + if m.err != nil { + return fmt.Sprintf("Error: %v\nPress 'q' to quit", m.err) + } + + if m.showDetails { + content := pvDetailsTitleStyle.Render("Connection Details") + "\n" + if m.loadingDetails { + content += pvLoadingStyle.Render("Loading...") + "\n" + } else { + content += m.detailsContent + } + content += "\n\n" + pvHelpStyle.Render("Press Esc/Enter to close") + box := pvDetailsStyle.Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) + } + + logo := pvLogoStyle.Render("⚡ Auspex — Ports View") + if m.loading { + logo += " " + pvLoadingStyle.Render("●") + } + + tableView := pvBaseStyle.Render(m.table.View()) + + controls := "↑/↓: Navigate • /: Filter • Enter: Details • k: Kill • r: Refresh • s: Sort • a: Auto-refresh • q: Quit" + if m.isFiltering { + controls = "Type to search • Esc/Enter: Done" + inputView := pvInputStyle.Render(m.textInput.View()) + return fmt.Sprintf("%s\n%s\n%s\n%s", logo, tableView, inputView, pvHelpStyle.Render(controls)) + } + + status := m.status + if status != "" { + controls = pvStatusStyle.Render(status) + " • " + controls + } + if m.autoRefresh { + controls += " " + pvLoadingStyle.Render("[AUTO]") + } + + return fmt.Sprintf("%s\n%s\n%s", logo, tableView, pvHelpStyle.Render(controls)) +} + +// ── Системные функции ──────────────────────────────────────────────────────── + +func pvGetPorts() ([]pvPortEntry, error) { + cmd := exec.Command("ss", "-tulnp") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to run ss: %v", err) + } + + lines := strings.Split(string(output), "\n") + entries := make([]pvPortEntry, 0, len(lines)) + + for i, line := range lines { + if line == "" || i == 0 { + continue + } + fields := strings.Fields(line) + if len(fields) < 5 || fields[0] == "Netid" { + continue + } + entries = append(entries, pvParseSSLine(fields)) + } + + return entries, nil +} + +func pvParseSSLine(fields []string) pvPortEntry { + proto := fields[0] + state := fields[1] + localAddr := fields[4] + address := localAddr + port := "" + + if idx := strings.LastIndex(localAddr, ":"); idx != -1 { + port = localAddr[idx+1:] + address = localAddr[:idx] + } + + if address == "*" || address == "0.0.0.0" || address == "[::]" { + address = "All Interfaces" + } + + pid := "" + process := "" + + for _, f := range fields { + if strings.Contains(f, "users:((") { + content := strings.TrimPrefix(f, "users:((") + content = strings.TrimSuffix(content, "))") + content = strings.TrimSuffix(content, ")") + parts := strings.Split(content, ",") + for _, p := range parts { + if strings.HasPrefix(p, "\"") { + process = strings.Trim(p, "\"") + } + if strings.HasPrefix(p, "pid=") { + pid = strings.TrimPrefix(p, "pid=") + } + } + } + } + + if pid == "" { + pid = "-" + suffix := "(requires sudo)" + if os.Geteuid() == 0 { + suffix = "(system)" + } + if svc, ok := pvCommonPorts[port]; ok { + process = fmt.Sprintf("%s %s", svc, suffix) + } else { + process = suffix + } + } + + return pvPortEntry{Port: port, Protocol: proto, PID: pid, Process: process, State: state, Address: address} +} + +func pvKillProcess(pid string) error { + pidInt, err := strconv.Atoi(pid) + if err != nil { + return err + } + proc, err := os.FindProcess(pidInt) + if err != nil { + return err + } + return proc.Kill() +} + +func pvGetProcessDetails(pid string) (string, error) { + if pid == "-" { + if os.Geteuid() == 0 { + return "System process (no detailed information available).", nil + } + return "Process details require sudo privileges.", nil + } + cmd := exec.Command("ps", "-p", pid, "-o", "user,lstart,cmd", "--no-headers") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get details: %v", err) + } + return strings.TrimSpace(string(output)), nil +} diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..e6feabf --- /dev/null +++ b/telegram.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" +) + +// telegramSendHTML отправляет HTML-сообщение в Telegram +func telegramSendHTML(message string) error { + if !isTelegramConfigured() { + log.Printf("[Telegram] Не настроен. Сообщение:\n%s\n", message) + return fmt.Errorf("telegram не настроен") + } + + type tgMessage struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode"` + } + + msg := tgMessage{ + ChatID: cfgTelegramChatID, + Text: message, + ParseMode: "HTML", + } + + jsonData, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal error: %w", err) + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfgTelegramBotToken) + + resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("http error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("telegram api error: status %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// telegramSendPlainText отправляет текстовое сообщение без форматирования +func telegramSendPlainText(message string) error { + if !isTelegramConfigured() { + log.Printf("[Telegram] Не настроен. Сообщение:\n%s\n", message) + return fmt.Errorf("telegram не настроен") + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfgTelegramBotToken) + + data := url.Values{} + data.Set("chat_id", cfgTelegramChatID) + data.Set("text", message) + + resp, err := http.PostForm(apiURL, data) + if err != nil { + return fmt.Errorf("http error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("telegram api error: status %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +}