Work version

This commit is contained in:
Magnus Root 2026-02-24 10:54:38 +03:00
commit 7b7bb7c7c8
12 changed files with 3097 additions and 0 deletions

46
.gitignore vendored Normal file
View file

@ -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

113
README.md Normal file
View file

@ -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`)

196
certcheck.go Normal file
View file

@ -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"+
"🖥 Хост: <b>%s</b>\n📄 Путь: <code>%s</code>\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"+
"🖥 Хост: <b>%s</b>\n📄 Путь: <code>%s</code>\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"+
"🖥 Хост: <b>%s</b>\n📄 Путь: <code>%s</code>\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"+
"🖥 Хост: <b>%s</b>\n📄 Путь: <code>%s</code>\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")
}
}

255
config.go Normal file
View file

@ -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()
}

34
go.mod Normal file
View file

@ -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
)

58
go.sum Normal file
View file

@ -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=

481
logcheck.go Normal file
View file

@ -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/*/*/<l7resourceid>*_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(
"🚨 <b>АЛЕРТ: Устаревшие логи!</b> 🚨\n\n"+
"🖥 <b>Хост:</b> <code>%s</code>\n"+
"⏰ <b>Время проверки:</b> %s\n"+
"📊 <b>Найдено устаревших файлов:</b> %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("👤 <b>Клиент:</b> %s\n", group.ClientTitle)
message += fmt.Sprintf("🆔 <b>L7 Resource ID:</b> <code>%s</code>\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 <b>%s</b>\n"+
" 📄 Путь: <code>%s</code>\n"+
" ⏱ Последняя запись: <b>%d минут назад</b>\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 += "💡 <i>Порог оповещения: более " + fmt.Sprintf("%d", cfgLogStaleMinutes) + " минут без записей</i>"
if err := telegramSendHTML(message); err != nil {
log.Printf("❌ Ошибка отправки в Telegram: %v", err)
} else {
log.Println("✅ Алерт успешно отправлен в Telegram")
}
}

176
main.go Normal file
View file

@ -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")
}

511
matchcheck.go Normal file
View file

@ -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("🚨 <b>PTAF Docker Ports Check Alert</b>\n\n")
sb.WriteString(fmt.Sprintf("🖥 <b>Хост:</b> <code>%s</code>\n", hostname))
sb.WriteString(fmt.Sprintf("📊 <b>Проверено клиентов:</b> %d\n", stats.TotalClients))
sb.WriteString(fmt.Sprintf("✅ <b>Без проблем:</b> %d\n", stats.ClientsOK))
if stats.ClientsWarning > 0 {
sb.WriteString(fmt.Sprintf("⚠️ <b>С предупреждениями:</b> %d\n", stats.ClientsWarning))
}
if stats.ClientsError > 0 {
sb.WriteString(fmt.Sprintf("❌ <b>С ошибками:</b> %d\n", stats.ClientsError))
}
sb.WriteString("\n<b>📋 Детали проблем:</b>\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("❌ <b>%s</b> (%d ошибок", client, crit))
if warn > 0 {
sb.WriteString(fmt.Sprintf(", %d предупреждений", warn))
}
sb.WriteString(")\n")
} else {
sb.WriteString(fmt.Sprintf("⚠️ <b>%s</b> (%d предупреждений)\n", client, warn))
}
for _, a := range cas {
icon := "⚠️"
if a.IsCritical {
icon = "❌"
}
sb.WriteString(fmt.Sprintf("%s <code>%s</code>: %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)
}
}

529
portcheck.go Normal file
View file

@ -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")
}
}

620
portsview.go Normal file
View file

@ -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
}

78
telegram.go Normal file
View file

@ -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
}