Work version
This commit is contained in:
commit
7b7bb7c7c8
12 changed files with 3097 additions and 0 deletions
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
113
README.md
Normal 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
196
certcheck.go
Normal 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
255
config.go
Normal 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
34
go.mod
Normal 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
58
go.sum
Normal 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
481
logcheck.go
Normal 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
176
main.go
Normal 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
511
matchcheck.go
Normal 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
529
portcheck.go
Normal 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
620
portsview.go
Normal 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
78
telegram.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue