Added double check

This commit is contained in:
Magnus Root 2026-05-12 17:44:48 +03:00
parent 8a37c62e63
commit 7c93457076
4 changed files with 893 additions and 7 deletions

View file

@ -31,9 +31,9 @@ Auspex — монолитное CLI-приложение на Go. Все мод
Отвечает за два режима запуска:
**CLI-флаги** (`runByFlag`): прямой вызов нужного модуля по флагу (`-c`, `-p`, `-l`, `-sv`, `-d`, `-m`, `-ss`).
**CLI-флаги** (`runByFlag`): прямой вызов нужного модуля по флагу (`-c`, `-p`, `-l`, `-sv`, `-d`, `-m`, `-ss`, `-w`).
**Интерактивное меню** (`showMenu`): ASCII-баннер, numbered menu, `bufio.Reader` для ввода. Поддерживает как цифры (`1`-`7`), так и текстовые алиасы (`-c`, `c`).
**Интерактивное меню** (`showMenu`): ASCII-баннер, numbered menu, `bufio.Reader` для ввода. Поддерживает как цифры (`1`-`8`), так и текстовые алиасы (`-c`, `c`).
```
auspex [флаг] [аргументы]
@ -45,6 +45,7 @@ auspex [флаг] [аргументы]
├── -l → runLogCheck()
├── -sv → runServiceCheck()
├── -d → runDockerCheck()
├── -w → runWebCheck(sid)
├── -v → версия
└── -h → usage
```
@ -302,6 +303,99 @@ Endpoint: `POST /api/v4/posts` с заголовком `Authorization: Bearer <t
---
---
### `webcheck.go` — проверка доступности сайтов
**Точка входа:** `runWebCheck(sidArg string)`
Проверяет HTTP-доступность доменов WAF-клиента через браузер **lynx**. Использование lynx позволяет обойти антибот-системы которые блокируют стандартные HTTP-клиенты (Go, Python, curl).
**Аргумент:** один или несколько SID через запятую:
```bash
auspex -w 14285
auspex -w 14285,11071,9823
```
**Алгоритм (один SID):**
1. Проверяет наличие `lynx` через `exec.LookPath` — если нет, понятная ошибка с командой установки
2. Подключается к PostgreSQL (primary → secondary failover) — один раз для всех SID
3. `wcGetDomains` — запрашивает `domain_name` и `aliases` из `sp_info WHERE sid = $1`, затем `client_title` из `apps_settings WHERE l7resourceid = $1`
4. Парсит `aliases` из JSONB-массива (`[]string`)
5. Очищает домены через `wcCleanDomain` — убирает markdown-ссылки вида `[текст](url)`, протоколы, слеши
6. Для каждого домена вызывает `wcCheckDomain``wcPrintResult`
7. Выводит итоговую статистику через `wcPrintSummary`
8. Сохраняет CSV-файл
**Алгоритм (несколько SID):**
При передаче нескольких SID утилита спрашивает способ сохранения CSV:
```
Несколько SID (3). Сохранить результаты:
1) В один файл (с разделителями по SID)
2) В отдельные файлы для каждого SID
```
- Вариант **1** — один файл `webcheck_{sid1}_{sid2}_{date}.csv` с секциями:
```
=== SID: 14285 | Название клиента ===
domain,protocol,http_code,available,checked_at
...
=== SID: 11071 | Другой клиент ===
...
```
- Вариант **2** — отдельный файл для каждого SID как обычно
БД и lynx инициализируются один раз независимо от количества SID.
**`wcGetDomains`** — два запроса к БД:
```sql
-- 1. Домены клиента
SELECT domain_name, aliases FROM sp_info WHERE sid = $1
-- 2. Название клиента (fallback: sid если не найдено)
SELECT client_title FROM apps_settings WHERE l7resourceid = $1 LIMIT 1
```
**`wcCheckDomain`:**
- Wildcard домены (`*.example.com`) — пропускаются немедленно с кодом `wildcard`
- Пробует `https://` → при DNS-ошибке возвращает `dns_error` без попытки http
- Пробует `http://` как fallback если https не дал HTTP-кода
- Если оба протокола не дали результата → `unavailable`
**`wcLynxGet(url)`** — ядро проверки:
```
lynx -head -dump -connect_timeout=10 -read_timeout=10 <url>
```
- `-head` — HTTP HEAD запрос (только заголовки, без загрузки тела)
- `-dump` — неинтерактивный режим, вывод в stdout
- Парсит HTTP-код регулярным выражением `HTTP/[\d.]+ (\d{3})`
- При редиректах берёт **последний** код — он актуальный
- DNS-ошибка определяется по строкам `Unable to locate remote host` и `Can't access startfile` в выводе lynx
**Коды результата:**
| HTTPCode | Значение |
|---|---|
| `200`, `301`, `302`... | HTTP-код ответа сервера |
| `dns_error` | Домен не резолвится |
| `wildcard` | Wildcard домен, пропущен |
| `error` | Оба протокола недоступны |
**`wcIsSuccessCode`** — считает успешными коды `2xx` и `3xx`.
**CSV-файл** сохраняется в `/var/log/auspex/` с UTF-8 BOM для корректного открытия в Excel:
- Один SID: `webcheck_{sid}_{date}.csv`
- Несколько SID (раздельно): `webcheck_{sid}_{date}.csv` для каждого
- Несколько SID (объединённо): `webcheck_{sid1}_{sid2}_..._{date}.csv`
**Просмотр CSV в терминале:**
```bash
column -t -s',' /var/log/auspex/webcheck_14285_2026-04-29.csv
```
### `portsview.go` — TUI просмотр портов
**Точка входа:** `runPortsView()`
@ -374,7 +468,9 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать
→ ping fail → secondary (cfgSecondaryDBHost)
```
Каждый модуль реализует свой `pcConnectDB()` / `lcConnectDB()` — функции идентичны по логике, префиксы разные для избежания коллизий имён в пакете `main`.
Каждый модуль реализует свой `pcConnectDB()` / `lcConnectDB()` / `wcConnectDB()` — функции идентичны по логике, префиксы разные для избежания коллизий имён в пакете `main`.
`webcheck` также использует PostgreSQL для получения доменов из таблицы `sp_info`.
---
@ -392,4 +488,5 @@ primary (cfgPrimaryDBHost) → ping OK? → использовать
| dockercheck | `dc` |
| email | `email` |
| mattermost | `mm` |
| config | `cfg` |
| webcheck | `wc` |
| config | `cfg` |

View file

@ -57,6 +57,7 @@ sudo cp auspex /usr/local/bin/auspex
./auspex -l # Проверка свежести логов
./auspex -sv # Проверка работоспособности служб
./auspex -d # Проверка работоспособности контейнеров
./auspex -w # Проверка веб доступности
./auspex -h # Справка
./auspex -v # Версия
```

27
main.go
View file

@ -7,7 +7,7 @@ import (
"strings"
)
const appVersion = "1.3.5"
const appVersion = "1.4.0"
// Цвета терминала (общие для всех модулей)
const (
@ -93,6 +93,17 @@ func runByFlag(flag string, extraArgs []string) {
os.Exit(1)
}
case "-w":
fmt.Printf("%s🌍 Auspex — Проверка доступности сайтов (lynx)%s\n\n", cCyan, cReset)
sid := ""
if len(extraArgs) > 0 {
sid = extraArgs[0]
}
if err := runWebCheck(sid); err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
os.Exit(1)
}
case "-v", "--version":
fmt.Printf("auspex v%s\n", appVersion)
@ -117,9 +128,10 @@ func showMenu() {
fmt.Printf(" %s5%s) %s-l %s Проверка активности логов (PostgreSQL)\n", cGreen, cReset, cCyan, cReset)
fmt.Printf(" %s6%s) %s-sv%s Проверка состояния служб (systemd)\n", cGreen, cReset, cCyan, cReset)
fmt.Printf(" %s7%s) %s-dc%s Проверка контейнеров Docker\n", cGreen, cReset, cCyan, cReset)
fmt.Printf(" %s8%s) %s-w %s Проверка доступности сайтов (lynx) [sid]\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 | auspex -sv | auspex -dc%s\n\n", cYellow, cReset)
fmt.Printf("%sПример: auspex -ss | auspex -c | auspex -p | auspex -m [клиент] | auspex -l | auspex -sv | auspex -dc | auspex -w <sid>%s\n\n", cYellow, cReset)
reader := bufio.NewReader(os.Stdin)
@ -167,11 +179,19 @@ func showMenu() {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
}
return
case "8", "-w", "w":
fmt.Printf(" SID: ")
sidInput, _ := reader.ReadString('\n')
sidInput = strings.TrimSpace(sidInput)
if err := runWebCheck(sidInput); err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
}
return
case "0", "q", "quit", "exit":
fmt.Println("Выход.")
return
default:
fmt.Printf("%sНеизвестная команда. Введите 1-7 или 0 для выхода.%s\n", cRed, cReset)
fmt.Printf("%sНеизвестная команда. Введите 1-8 или 0 для выхода.%s\n", cRed, cReset)
}
}
}
@ -198,6 +218,7 @@ func printUsage() {
fmt.Printf(" %-12s Проверка активности логов (PostgreSQL)\n", "-l")
fmt.Printf(" %-12s Проверка состояния служб systemd\n", "-sv")
fmt.Printf(" %-12s Проверка контейнеров Docker\n", "-dc")
fmt.Printf(" %-12s Проверка доступности сайтов через lynx [sid]\n", "-w")
fmt.Printf(" %-12s Показать версию\n", "-v")
fmt.Printf(" %-12s Показать эту справку\n\n", "-h")
fmt.Printf("Без аргументов запускается интерактивное меню.\n")

767
webcheck.go Normal file
View file

@ -0,0 +1,767 @@
package main
import (
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"log"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
_ "github.com/lib/pq"
)
// ── Типы ─────────────────────────────────────────────────────────────────────
type wcDomainResult struct {
Domain string
Protocol string // https / http / unavailable / skipped
HTTPCode string // "200", "404", "503", "error", "dns_error", "wildcard"
Available bool
BackendIP string // IP через который проверялось (только в direct-режиме)
Source string // "curl" или "lynx"
CheckedAt time.Time
}
// ── Точка входа ───────────────────────────────────────────────────────────────
func runWebCheck(sidArg string) error {
if sidArg == "" {
return fmt.Errorf("необходимо указать SID: auspex -w <sid>[,sid2,sid3]")
}
// Поддержка нескольких SID через запятую
sids := strings.Split(sidArg, ",")
for i, s := range sids {
sids[i] = strings.TrimSpace(s)
}
// Убираем пустые
var cleanSids []string
for _, s := range sids {
if s != "" {
cleanSids = append(cleanSids, s)
}
}
sids = cleanSids
// Проверяем наличие lynx один раз
if err := wcCheckLynx(); err != nil {
return err
}
// Подключаемся к БД один раз для всех SID
db, err := wcConnectDB()
if err != nil {
return fmt.Errorf("ошибка подключения к БД: %v", err)
}
defer db.Close()
// Загружаем исключаемые сети один раз для всех SID
excludedNets, err := wcLoadExcludedNetworks(db)
if err != nil {
log.Printf("⚠️ Не удалось загрузить исключаемые сети: %v", err)
excludedNets = nil
}
hostname, _ := os.Hostname()
// Если SID один — стандартный режим
if len(sids) == 1 {
return wcRunSingle(db, hostname, sids[0], excludedNets, true)
}
// Несколько SID — спрашиваем про CSV
fmt.Printf("\nНесколько SID (%d). Сохранить результаты:\n", len(sids))
fmt.Println(" 1) В один файл (с разделителями по SID)")
fmt.Println(" 2) В отдельные файлы для каждого SID")
fmt.Printf(">>> ")
var choice string
fmt.Scanln(&choice)
combined := choice == "1"
// Собираем результаты всех SID
var allResults []wcSIDResult
for _, sid := range sids {
log.Println()
log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
log.Printf("🌐 WebCheck | хост: %s | SID: %s", hostname, sid)
domains, clientTitle, err := wcGetDomains(db, sid)
if err != nil {
log.Printf("❌ SID %s: %v", sid, err)
continue
}
if len(domains) == 0 {
log.Printf(" Доменов для SID %s не найдено", sid)
continue
}
backendIPs, err := wcGetBackendIPs(db, sid, excludedNets)
if err != nil {
log.Printf("⚠️ Не удалось получить backend IP для SID %s: %v", sid, err)
}
log.Printf("📋 Клиент: %s | Доменов: %d | Backend IPs: %v", clientTitle, len(domains), backendIPs)
log.Println()
results := wcCheckDomainsParallel(domains, backendIPs)
for _, r := range results {
wcPrintResult(r)
}
wcPrintSummary(results)
if combined {
allResults = append(allResults, wcSIDResult{sid, clientTitle, results})
} else {
// Отдельный файл для каждого SID
if csvPath, err := wcSaveCSV(sid, results); err != nil {
log.Printf("⚠️ Ошибка сохранения CSV: %v", err)
} else {
log.Printf("💾 Результаты сохранены: %s", csvPath)
}
}
}
// Сохраняем объединённый CSV
if combined && len(allResults) > 0 {
date := time.Now().Format("2006-01-02")
sidsJoined := strings.Join(sids, "_")
csvPath, err := wcSaveCombinedCSV(sidsJoined, date, allResults)
if err != nil {
log.Printf("⚠️ Ошибка сохранения CSV: %v", err)
} else {
log.Printf("\n💾 Объединённый файл: %s", csvPath)
}
}
log.Println("\n=== Проверка доступности сайтов завершена ===")
return nil
}
// wcRunSingle выполняет проверку для одного SID
func wcRunSingle(db *sql.DB, hostname, sid string, excludedNets []*net.IPNet, saveCsv bool) error {
log.Printf("🌐 WebCheck | хост: %s | SID: %s", hostname, sid)
domains, clientTitle, err := wcGetDomains(db, sid)
if err != nil {
return fmt.Errorf("ошибка получения доменов: %v", err)
}
if len(domains) == 0 {
log.Printf(" Доменов для SID %s не найдено", sid)
return nil
}
backendIPs, err := wcGetBackendIPs(db, sid, excludedNets)
if err != nil {
log.Printf("⚠️ Не удалось получить backend IP: %v", err)
}
if len(backendIPs) > 0 {
log.Printf("📋 Клиент: %s | Доменов: %d | Backend IPs: %v", clientTitle, len(domains), backendIPs)
} else {
log.Printf("📋 Клиент: %s | Доменов: %d | Режим: через интернет (lynx)", clientTitle, len(domains))
}
log.Println()
results := wcCheckDomainsParallel(domains, backendIPs)
for _, r := range results {
wcPrintResult(r)
}
wcPrintSummary(results)
if saveCsv {
if csvPath, err := wcSaveCSV(sid, results); err != nil {
log.Printf("⚠️ Ошибка сохранения CSV: %v", err)
} else {
log.Printf("💾 Результаты сохранены: %s", csvPath)
}
}
log.Println("=== Проверка доступности сайтов завершена ===")
return nil
}
// wcSIDResult хранит результаты проверки одного SID
type wcSIDResult struct {
SID string
Title string
Results []wcDomainResult
}
// wcSaveCombinedCSV сохраняет результаты нескольких SID в один файл с разделителями
func wcSaveCombinedCSV(sidsJoined, date string, allResults []wcSIDResult) (string, error) {
dir := "/var/log/auspex"
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("не удалось создать директорию %s: %v", dir, err)
}
filename := fmt.Sprintf("webcheck_%s_%s.csv", sidsJoined, date)
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
return "", err
}
defer f.Close()
// BOM для корректного открытия в Excel
f.WriteString("")
w := csv.NewWriter(f)
defer w.Flush()
for _, sr := range allResults {
// Заголовок секции
w.Write([]string{fmt.Sprintf("=== SID: %s | %s ===", sr.SID, sr.Title)})
w.Write([]string{"domain", "source", "protocol", "http_code", "available", "backend_ip", "checked_at"})
for _, r := range sr.Results {
available := "false"
if r.Available {
available = "true"
}
w.Write([]string{r.Domain, r.Source, r.Protocol, r.HTTPCode, available, r.BackendIP, r.CheckedAt.Format("2006-01-02 15:04:05")})
}
// Пустая строка между секциями
w.Write([]string{})
}
return path, nil
}
// ── Проверка домена ──────────────────────────────────────────────────────────
// wcCheckDomainFull проверяет домен двумя способами:
// 1. Через каждый backend IP (curl --resolve) — если backend IPs найдены
// 2. Через интернет (lynx) — всегда
// Возвращает срез результатов: один от lynx + по одному от каждого backend IP
func wcCheckDomainFull(domain string, backendIPs []string) []wcDomainResult {
// Wildcard домены проверить невозможно — пропускаем
if strings.HasPrefix(domain, "*.") {
return []wcDomainResult{{
Domain: domain,
Protocol: "skipped",
HTTPCode: "wildcard",
Available: false,
CheckedAt: time.Now(),
}}
}
var results []wcDomainResult
// Проверка через backend IPs (curl) — каждый протокол независимо
for _, ip := range backendIPs {
for _, proto := range []string{"https", "http"} {
r := wcDomainResult{Domain: domain, BackendIP: ip, Source: "curl", CheckedAt: time.Now()}
code, err := wcCurlGet(domain, ip, proto)
if err != nil {
r.Protocol = proto
r.HTTPCode = "error"
r.Available = false
} else {
r.Protocol = proto
r.HTTPCode = code
r.Available = wcIsSuccessCode(code)
}
results = append(results, r)
}
}
// Резолвим IP домена один раз для всех lynx-проверок
resolvedIP := ""
if addrs, err := net.LookupHost(domain); err == nil && len(addrs) > 0 {
resolvedIP = addrs[0]
}
// Проверка через интернет (lynx) — каждый протокол независимо
for _, proto := range []string{"https", "http"} {
r := wcDomainResult{Domain: domain, Source: "lynx", BackendIP: resolvedIP, CheckedAt: time.Now()}
url := fmt.Sprintf("%s://%s", proto, domain)
code, err := wcLynxGet(url)
if err != nil {
if strings.Contains(err.Error(), "dns") {
r.Protocol = proto
r.HTTPCode = "dns_error"
r.Available = false
results = append(results, r)
break // DNS не резолвится — http тоже не будет работать
}
r.Protocol = proto
r.HTTPCode = "error"
r.Available = false
} else {
r.Protocol = proto
r.HTTPCode = code
r.Available = wcIsSuccessCode(code)
}
results = append(results, r)
}
return results
}
// wcCheckDomainsParallel проверяет все домены параллельно.
// Порядок результатов сохраняется — соответствует порядку доменов.
func wcCheckDomainsParallel(domains []string, backendIPs []string) []wcDomainResult {
type indexedResults struct {
index int
results []wcDomainResult
}
ch := make(chan indexedResults, len(domains))
var wg sync.WaitGroup
for i, domain := range domains {
wg.Add(1)
go func(idx int, d string) {
defer wg.Done()
ch <- indexedResults{idx, wcCheckDomainFull(d, backendIPs)}
}(i, domain)
}
go func() {
wg.Wait()
close(ch)
}()
// Собираем результаты с сохранением порядка
ordered := make([][]wcDomainResult, len(domains))
for r := range ch {
ordered[r.index] = r.results
}
var results []wcDomainResult
for _, r := range ordered {
results = append(results, r...)
}
return results
}
// wcCurlGet проверяет домен напрямую через backend IP используя curl --resolve.
// Это позволяет проверить сервер до заведения трафика через WAF.
func wcCurlGet(domain, ip, proto string) (string, error) {
// --resolve domain:port:ip — форсирует резолв домена на указанный IP
resolveHTTPS := fmt.Sprintf("%s:443:%s", domain, ip)
resolveHTTP := fmt.Sprintf("%s:80:%s", domain, ip)
url := fmt.Sprintf("%s://%s", proto, domain)
args := []string{
"-s", // silent
"-o", "/dev/null", // не выводить тело
"-w", "%{http_code}", // только код ответа
"-k", // игнорировать SSL ошибки (сертификат может быть не для IP)
"--connect-timeout", "10",
"--max-time", "15",
"--resolve", resolveHTTPS,
"--resolve", resolveHTTP,
url,
}
cmd := exec.Command("curl", args...)
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("curl error: %v", err)
}
code := strings.TrimSpace(string(out))
if code == "000" || code == "" {
return "", fmt.Errorf("нет ответа от сервера")
}
return code, nil
}
// ── Получение backend IP ──────────────────────────────────────────────────────
// wcLoadExcludedNetworks загружает antibot_networks и waf_networks из таблицы ips.
// Эти сети исключаются из списка backend IP.
func wcLoadExcludedNetworks(db *sql.DB) ([]*net.IPNet, error) {
rows, err := db.Query(`SELECT antibot_networks, waf_networks FROM ips`)
if err != nil {
return nil, err
}
defer rows.Close()
var nets []*net.IPNet
for rows.Next() {
var antibot, waf sql.NullString
if err := rows.Scan(&antibot, &waf); err != nil {
continue
}
for _, raw := range []sql.NullString{antibot, waf} {
if !raw.Valid || raw.String == "" {
continue
}
// Формат: {10.0.0.0/8,192.168.0.0/16} — убираем фигурные скобки
clean := strings.Trim(raw.String, "{}")
for _, cidr := range strings.Split(clean, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
log.Printf("⚠️ Не удалось распарсить CIDR %q: %v", cidr, err)
continue
}
nets = append(nets, ipnet)
}
}
}
return nets, rows.Err()
}
// wcGetBackendIPs получает backend IP для SID из sp_info (или manual_info как fallback),
// фильтрует через исключаемые сети и возвращает подходящие IP.
// origins хранится как JSONB массив объектов: [{"ip": "1.2.3.4", "mode": "primary", "weight": 100}]
// Берём только записи с mode = "primary".
func wcGetBackendIPs(db *sql.DB, sid string, excludedNets []*net.IPNet) ([]string, error) {
var originsRaw sql.NullString
// Пробуем sp_info
err := db.QueryRow(`SELECT origins::text FROM sp_info WHERE sid = $1`, sid).Scan(&originsRaw)
if err != nil || !originsRaw.Valid || originsRaw.String == "" || originsRaw.String == "null" || originsRaw.String == "[]" {
// Fallback на manual_info
err = db.QueryRow(`SELECT origins::text FROM manual_info WHERE sid = $1`, sid).Scan(&originsRaw)
if err != nil || !originsRaw.Valid || originsRaw.String == "" {
return nil, nil // нет origins — работаем через lynx
}
}
// Парсим JSONB: [{"ip": "1.2.3.4", "mode": "primary", "weight": 100}, ...]
type originEntry struct {
IP string `json:"ip"`
Mode string `json:"mode"`
Weight int `json:"weight"`
}
var entries []originEntry
if err := json.Unmarshal([]byte(originsRaw.String), &entries); err != nil {
return nil, fmt.Errorf("не удалось распарсить origins JSON: %v", err)
}
var result []string
seen := make(map[string]bool)
for _, e := range entries {
// Берём только primary — down и другие режимы пропускаем
if e.Mode != "primary" {
log.Printf(" ⏭️ %s пропущен (mode: %s)", e.IP, e.Mode)
continue
}
ip := net.ParseIP(strings.TrimSpace(e.IP))
if ip == nil {
log.Printf(" ⚠️ Не удалось распарсить IP: %q", e.IP)
continue
}
if wcIsExcluded(ip, excludedNets) {
log.Printf(" ⏭️ %s исключён (antibot/waf сеть)", ip)
continue
}
if !seen[ip.String()] {
seen[ip.String()] = true
result = append(result, ip.String())
}
}
if len(result) > 0 {
log.Printf("🔍 Backend IPs после фильтрации: %v", result)
} else {
log.Printf(" Backend IPs не найдены после фильтрации — используем lynx")
}
return result, nil
}
// wcIsExcluded проверяет входит ли IP в список исключаемых сетей
func wcIsExcluded(ip net.IP, nets []*net.IPNet) bool {
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
// wcLynxGet выполняет запрос через lynx и возвращает HTTP-код ответа.
// Использует -head для запроса только заголовков и -dump для неинтерактивного режима.
func wcLynxGet(url string) (string, error) {
cmd := exec.Command(
"lynx",
"-head",
"-dump",
"-connect_timeout=10",
"-read_timeout=10",
url,
)
out, _ := cmd.CombinedOutput()
output := string(out)
// DNS не резолвится
if strings.Contains(output, "Unable to locate remote host") ||
strings.Contains(output, "Can't access startfile") {
return "", fmt.Errorf("dns: хост не найден")
}
// Ищем строку вида "HTTP/1.1 200 OK" или "HTTP/2 404"
re := regexp.MustCompile(`HTTP/[\d.]+ (\d{3})`)
matches := re.FindAllStringSubmatch(output, -1)
if len(matches) == 0 {
return "", fmt.Errorf("не удалось получить HTTP-код из ответа lynx")
}
// Берём последний код — актуален если были редиректы
lastMatch := matches[len(matches)-1]
return lastMatch[1], nil
}
// ── Получение доменов из БД ───────────────────────────────────────────────────
func wcGetDomains(db *sql.DB, sid string) ([]string, string, error) {
query := `
SELECT domain_name, aliases
FROM sp_info
WHERE sid = $1
`
var domainName sql.NullString
var aliasesJSON sql.NullString
err := db.QueryRow(query, sid).Scan(&domainName, &aliasesJSON)
if err == sql.ErrNoRows {
return nil, "", fmt.Errorf("SID %s не найден в таблице sp_info", sid)
}
if err != nil {
return nil, "", err
}
// Получаем client_title из apps_settings по l7resourceid = sid
clientTitle := sid // fallback если не найдено
var ct sql.NullString
err = db.QueryRow(`SELECT client_title FROM apps_settings WHERE l7resourceid = $1 LIMIT 1`, sid).Scan(&ct)
if err == nil && ct.Valid && ct.String != "" {
clientTitle = ct.String
}
var domains []string
// Основной домен
if domainName.Valid && domainName.String != "" {
domains = append(domains, wcCleanDomain(domainName.String))
}
// Алиасы из JSONB — массив строк вида ["domain1", "domain2"]
if aliasesJSON.Valid && aliasesJSON.String != "" && aliasesJSON.String != "null" {
var aliases []string
if err := json.Unmarshal([]byte(aliasesJSON.String), &aliases); err != nil {
log.Printf("⚠️ Не удалось распарсить aliases JSON: %v", err)
} else {
for _, a := range aliases {
clean := wcCleanDomain(a)
if clean != "" {
domains = append(domains, clean)
}
}
}
}
return domains, clientTitle, nil
}
// wcCleanDomain убирает протокол и markdown-ссылки из доменного имени.
// Например: "[lk.gorodpay.ru](http://lk.gorodpay.ru)" → "lk.gorodpay.ru"
func wcCleanDomain(raw string) string {
s := strings.TrimSpace(raw)
// Markdown-ссылка вида [текст](url) — берём только текстовую часть.
// Используем нежадный матч чтобы корректно обработать скобки в URL.
mdRe := regexp.MustCompile(`^\[([^\]]+)\]\(.+\)$`)
if m := mdRe.FindStringSubmatch(s); len(m) > 1 {
s = m[1]
}
// Убираем протокол если есть
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
// Убираем завершающий слеш
s = strings.TrimRight(s, "/")
return s
}
// ── Подключение к БД ─────────────────────────────────────────────────────────
func wcConnectDB() (*sql.DB, error) {
hosts := []string{cfgPrimaryDBHost, cfgSecondaryDBHost}
for _, host := range hosts {
dsn := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, cfgDBPort, cfgDBUser, cfgDBPassword, cfgDBName,
)
db, err := sql.Open("postgres", dsn)
if err != nil {
continue
}
if err := db.Ping(); err != nil {
db.Close()
log.Printf("⚠️ БД %s недоступна: %v", host, err)
continue
}
log.Printf("✅ Подключение к БД: %s", host)
return db, nil
}
return nil, fmt.Errorf("все хосты БД недоступны")
}
// ── Проверка lynx ─────────────────────────────────────────────────────────────
func wcCheckLynx() error {
if _, err := exec.LookPath("lynx"); err != nil {
return fmt.Errorf("lynx не найден — установите: apt install lynx")
}
return nil
}
func wcCheckCurl() error {
if _, err := exec.LookPath("curl"); err != nil {
return fmt.Errorf("curl не найден — установите: apt install curl")
}
return nil
}
// ── CSV ───────────────────────────────────────────────────────────────────────
func wcSaveCSV(sid string, results []wcDomainResult) (string, error) {
dir := "/var/log/auspex"
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("не удалось создать директорию %s: %v", dir, err)
}
date := time.Now().Format("2006-01-02")
filename := fmt.Sprintf("webcheck_%s_%s.csv", sid, date)
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
return "", err
}
defer f.Close()
// BOM для корректного открытия в Excel
f.WriteString("\xEF\xBB\xBF")
w := csv.NewWriter(f)
defer w.Flush()
// Заголовок
w.Write([]string{"domain", "source", "protocol", "http_code", "available", "backend_ip", "checked_at"})
for _, r := range results {
available := "false"
if r.Available {
available = "true"
}
w.Write([]string{
r.Domain,
r.Source,
r.Protocol,
r.HTTPCode,
available,
r.BackendIP,
r.CheckedAt.Format("2006-01-02 15:04:05"),
})
}
return path, nil
}
// ── Вывод ─────────────────────────────────────────────────────────────────────
func wcPrintResult(r wcDomainResult) {
var emoji, status string
switch r.HTTPCode {
case "wildcard":
emoji = "⏭️ "
status = "ПРОПУЩЕН (wildcard)"
case "dns_error":
emoji = "💀"
status = "DNS НЕ РЕЗОЛВИТСЯ"
case "error":
emoji = "💀"
status = "НЕДОСТУПЕН"
default:
switch {
case r.Available && r.HTTPCode == "200":
emoji = "✅"
status = "OK [200]"
case r.Available:
emoji = "⚠️ "
status = fmt.Sprintf("OK [%s]", r.HTTPCode)
default:
emoji = "🔴"
status = fmt.Sprintf("ОШИБКА [%s]", r.HTTPCode)
}
}
if r.BackendIP != "" {
log.Printf("%s %-45s | %-5s | %-18s | %-8s | %s", emoji, r.Domain, r.Source, r.BackendIP, r.Protocol, status)
} else {
log.Printf("%s %-45s | %-5s | %-8s | %s", emoji, r.Domain, r.Source, r.Protocol, status)
}
}
func wcPrintSummary(results []wcDomainResult) {
ok, warn, fail, dns, skipped := 0, 0, 0, 0, 0
for _, r := range results {
switch r.HTTPCode {
case "wildcard":
skipped++
case "dns_error":
dns++
case "error":
fail++
default:
if r.Available && r.HTTPCode == "200" {
ok++
} else if r.Available {
warn++
} else {
fail++
}
}
}
log.Println()
log.Println("📊 Итоги:")
log.Printf(" ✅ Доступны (200): %d", ok)
log.Printf(" ⚠️ Другой код ответа: %d", warn)
log.Printf(" 🔴 Ошибка HTTP: %d", fail)
log.Printf(" 💀 DNS не резолвится: %d", dns)
log.Printf(" ⏭️ Пропущено (wildcard): %d", skipped)
}
// wcIsSuccessCode возвращает true для кодов 2xx и 3xx
func wcIsSuccessCode(code string) bool {
if len(code) == 0 {
return false
}
return code[0] == '2' || code[0] == '3'
}