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' }