diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64ae664..f202d35 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 +``` +- `-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` | \ No newline at end of file +| webcheck | `wc` | +| config | `cfg` | diff --git a/README.md b/README.md index e93dc41..7a81c26 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ sudo cp auspex /usr/local/bin/auspex ./auspex -l # Проверка свежести логов ./auspex -sv # Проверка работоспособности служб ./auspex -d # Проверка работоспособности контейнеров +./auspex -w # Проверка веб доступности ./auspex -h # Справка ./auspex -v # Версия ``` diff --git a/main.go b/main.go index 9755347..fdf435d 100644 --- a/main.go +++ b/main.go @@ -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 %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") diff --git a/webcheck.go b/webcheck.go new file mode 100644 index 0000000..ee1c110 --- /dev/null +++ b/webcheck.go @@ -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 [,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' +}