commit 7b7bb7c7c846d213518b7ef89004bb4572829893 Author: Magnus Root Date: Tue Feb 24 10:54:38 2026 +0300 Work version 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 +}