auspex/webcheck.go
2026-06-09 17:12:18 +03:00

777 lines
25 KiB
Go
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 _, ipRaw := range backendIPs {
// Убираем суффикс " (backup)" для реального подключения
ip := strings.TrimSuffix(ipRaw, " (backup)")
for _, proto := range []string{"https", "http"} {
r := wcDomainResult{Domain: domain, BackendIP: ipRaw, 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 и backup — down и другие режимы пропускаем
switch e.Mode {
case "primary", "backup":
// продолжаем
default:
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
}
ipStr := ip.String()
if e.Mode == "backup" {
ipStr = ip.String() + " (backup)"
}
if !seen[ipStr] {
seen[ipStr] = true
result = append(result, ipStr)
}
}
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'
}