777 lines
25 KiB
Go
Executable file
777 lines
25 KiB
Go
Executable file
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'
|
||
}
|