From c8ad0a872a1e8fe775acdab3740da44bf0825535 Mon Sep 17 00:00:00 2001 From: Daniel Lavrushin Date: Thu, 11 Jun 2026 00:52:10 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20streamline=20DNS=20probing=20logic?= =?UTF-8?q?=20and=20enhance=20IP=20connectivity=20ch=E2=80=A6=20(#251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs.yml | 6 +- .github/workflows/release.yml | 37 +-- changelog.md | 11 +- changelog_ru.md | 9 + src/detector/classify.go | 137 +++-------- src/detector/dns.go | 81 +------ src/detector/dnsavail.go | 5 +- src/detector/domains.go | 5 +- src/detector/targets.go | 20 +- src/detector/targets.json | 66 ------ src/detector/tcp.go | 3 +- src/detector/telegram.go | 5 +- src/detector/transport.go | 52 ---- src/detector/types.go | 19 -- src/discovery/discovery.go | 177 +++----------- src/discovery/dns.go | 197 +++++++-------- src/discovery/runtime.go | 4 + src/discovery/transport.go | 48 ---- src/discovery/types.go | 1 + src/dns/query.go | 12 +- src/dns/query_test.go | 29 +++ src/http/handler/geoip.go | 9 +- src/http/handler/geosite.go | 9 +- src/http/handler/sets.go | 84 +++---- src/http/handler/socks5.go | 10 +- src/http/ui/src/i18n/en.json | 6 +- src/http/ui/src/i18n/ru.json | 6 +- src/http/ui/src/utils/connectionFilter.ts | 10 +- src/main.go | 3 + src/netprobe/classify.go | 144 +++++++++++ src/netprobe/classify_test.go | 72 ++++++ src/netprobe/markers.go | 58 +++++ src/netprobe/markers.json | 26 ++ src/netprobe/resolver.go | 277 ++++++++++++++++++++++ src/netprobe/resolver_test.go | 244 +++++++++++++++++++ src/netprobe/servers.go | 52 ++++ src/netprobe/servers.json | 28 +++ src/netprobe/transport.go | 63 +++++ src/netprobe/transport_test.go | 24 ++ src/tables/iptables.go | 15 +- src/tables/monitor.go | 2 +- src/tables/nftables.go | 9 +- src/tables/routing.go | 3 +- src/tables/routing_ipt.go | 7 + src/tables/routing_nft.go | 1 + src/tables/routing_proxy.go | 147 ++++++++++-- src/tproxy/port.go | 4 + src/watchdog/checker.go | 16 +- 48 files changed, 1478 insertions(+), 775 deletions(-) delete mode 100644 src/detector/transport.go delete mode 100644 src/discovery/transport.go create mode 100644 src/dns/query_test.go create mode 100644 src/netprobe/classify.go create mode 100644 src/netprobe/classify_test.go create mode 100644 src/netprobe/markers.go create mode 100644 src/netprobe/markers.json create mode 100644 src/netprobe/resolver.go create mode 100644 src/netprobe/resolver_test.go create mode 100644 src/netprobe/servers.go create mode 100644 src/netprobe/servers.json create mode 100644 src/netprobe/transport.go create mode 100644 src/netprobe/transport_test.go diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e11bb7a7..dddbc4da 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,17 +12,17 @@ jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup PNPM - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.18.2 run_install: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6dfda476..426aed20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,16 +28,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PNPM - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10.18.2 run_install: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 cache: "pnpm" @@ -45,9 +45,10 @@ jobs: ./src/http/ui/pnpm-lock.yaml - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: src/go.mod + cache-dependency-path: src/go.sum - name: Generate UI defaults working-directory: src @@ -60,7 +61,7 @@ jobs: VITE_APP_VERSION="$VERSION" pnpm build - name: Upload UI artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ui-build path: src/http/ui/dist @@ -72,15 +73,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: src/go.mod + cache-dependency-path: src/go.sum - name: Download UI artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ui-build path: src/http/ui/dist @@ -119,15 +121,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: src/go.mod + cache-dependency-path: src/go.sum - name: Download UI artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ui-build path: src/http/ui/dist @@ -139,7 +142,7 @@ jobs: make linux-${ARCH} VERSION="$VERSION" - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ env.BINARY_NAME }}-linux-${{ matrix.arch }} path: out/assets/* @@ -153,10 +156,10 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: pattern: ${{ env.BINARY_NAME }}-* path: release-assets @@ -187,7 +190,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: v${{ env.VERSION }} name: v${{ env.VERSION }} @@ -210,7 +213,7 @@ jobs: packages: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -265,7 +268,7 @@ jobs: if: always() steps: - name: Delete artifacts - uses: geekyeggo/delete-artifact@v5 + uses: geekyeggo/delete-artifact@v6 with: name: ${{ env.BINARY_NAME }}-* failOnError: false diff --git a/changelog.md b/changelog.md index 57214b67..6882b04a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # B4 - Bye Bye Big Bro +## [1.68.0] - 2026-06-10 + +- FIXED: **Discovery found nothing on connections that tamper with DNS** - on networks where the provider tampers with DNS (so ordinary name lookups came back wrong or empty), Discovery could not look up its test sites and reported that nothing worked, even when a working setup existed. It looks those sites up over encrypted DNS instead, and saves the result set to use encrypted DNS (DNS-over-HTTPS) for its lookups by default, which holds up where plain DNS is tampered with. +- FIXED: **Cancelling Discovery could crash b4** - pressing Cancel while a search was testing several sites at once could crash the whole service and force a restart. +- FIXED: **Stopping b4 could leave its firewall rules on the router** - the cleanup at shutdown could be undone at the last moment, so the rules stayed in place after exit. +- FIXED: **Routing through another interface broke after that interface changed its address** - when the chosen outgoing interface (for example a VPN or a modem) got a new address, the set's traffic kept using the old one until b4 was restarted. +- FIXED: **Incomplete cleanup on stop** - stopping b4 (or `--clear-iptables`) left traces behind: adjusted system network settings, stray routing entries when two sets shared one outgoing interface, and a firewall rule that piled up with proxy or bridge routing. +- FIXED: **Saving settings could leave the running configuration out of step** - changing the SOCKS5 proxy or your sets (adding domains or IPs, creating, editing, reordering, deleting, or bulk-toggling) could save a result that did not match what was actually running. + ## [1.67.2] - 2026-06-10 - FIXED: **Web UI update reported success but kept the old version on some setups** - where b4 isn't managed by a normal service (for example running directly in a container), the update replaced the program on disk but never restarted the running copy. It now stops the old copy and relaunches the new one, so the update actually takes effect. @@ -702,7 +711,7 @@ ## [1.10.1] - 2025-11-03 -- IMPROVED: Intermittent connection failures where blocked sites would randomly fail to load in certain browsers (`Safari`, `Firefox`, `Chrome`). Connections _should_ now be more stable and reliable across all browsers by optimizing packet fragmentation strategy. +- IMPROVED: Intermittent connection failures where blocked sites would randomly fail to load in certain browsers (`Safari`, `Firefox`, `Chrome`). Connections *should* now be more stable and reliable across all browsers by optimizing packet fragmentation strategy. ## [1.10.0] - 2025-11-02 diff --git a/changelog_ru.md b/changelog_ru.md index e59076a7..582bf403 100644 --- a/changelog_ru.md +++ b/changelog_ru.md @@ -1,5 +1,14 @@ # B4 - Bye Bye Big Bro +## [1.68.0] - 2026-06-10 + +- ИСПРАВЛЕНО: **Дискавери ничего не находило на подключениях с подменой DNS** - там, где провайдер подменяет DNS, Дискавери не могло определить адреса тестовых сайтов и сообщало, что ничего не работает, даже когда рабочая конфигурация существовала. Поиск адресов идёт через зашифрованный DNS, а сохранённый сет по умолчанию использует DNS-over-HTTPS. +- ИСПРАВЛЕНО: **Отмена Дискавери могла привести к падению b4** - нажатие «Отмена» во время поиска, проверявшего несколько сайтов одновременно, могло обрушить всю службу, и её приходилось перезапускать. +- ИСПРАВЛЕНО: **Остановка b4 могла оставлять его сетевые правила на роутере** - очистка при завершении работы могла быть отменена в последний момент, и правила оставались после выхода. +- ИСПРАВЛЕНО: **Маршрутизация через другой интерфейс ломалась после смены его адреса** - когда выбранный исходящий интерфейс (например, VPN или модем) получал новый адрес, трафик сета продолжал использовать старый до перезапуска b4. +- ИСПРАВЛЕНО: **Неполная очистка при остановке** - остановка b4 (или `--clear-iptables`) оставляла следы: изменённые системные сетевые настройки, лишние маршрутные записи, когда два сета использовали один исходящий интерфейс, и накапливающееся сетевое правило при маршрутизации через прокси или мост. +- ИСПРАВЛЕНО: **Сохранение настроек могло привести к расхождению между сохранённой и работающей конфигурацией** - изменение SOCKS5-прокси или ваших сетов (добавление доменов или IP, создание, редактирование, изменение порядка, удаление или массовое включение/выключение) могло сохранить результат, не соответствующий тому, что фактически работало. + ## [1.67.2] - 2026-06-10 - ИСПРАВЛЕНО: **Обновление через веб-интерфейс сообщало об успехе, но на некоторых установках оставалась старая версия** - там, где b4 не управляется обычной службой (например, при запуске прямо в контейнере), обновление заменяло программу на диске, но не перезапускало работающую копию. Теперь b4 останавливает старую копию и запускает новую, поэтому обновление действительно вступает в силу. diff --git a/src/detector/classify.go b/src/detector/classify.go index 20831d8e..1548edeb 100644 --- a/src/detector/classify.go +++ b/src/detector/classify.go @@ -1,127 +1,44 @@ package detector -import ( - "strings" -) +import "github.com/daniellavrushin/b4/netprobe" -type tlsStage int +type DomainStatus = netprobe.DomainStatus const ( - stageConnect tlsStage = iota - stageHandshake - stageRead + DomainOk = netprobe.DomainOk + DomainTLSDPI = netprobe.DomainTLSDPI + DomainTLSMITM = netprobe.DomainTLSMITM + DomainTLSSpoof = netprobe.DomainTLSSpoof + DomainTLSAlert = netprobe.DomainTLSAlert + DomainTLSReset = netprobe.DomainTLSReset + DomainTLSDrop = netprobe.DomainTLSDrop + DomainSYNDrop = netprobe.DomainSYNDrop + DomainTCP16 = netprobe.DomainTCP16 + DomainISPPage = netprobe.DomainISPPage + DomainBlocked = netprobe.DomainBlocked + DomainDNSFake = netprobe.DomainDNSFake + DomainTimeout = netprobe.DomainTimeout + DomainError = netprobe.DomainError ) +type tlsStage = netprobe.TLSStage + const ( - tcp16MinBytes = 12 * 1024 - tcp16MaxBytes = 69 * 1024 + stageConnect = netprobe.StageConnect + stageHandshake = netprobe.StageHandshake + stageRead = netprobe.StageRead ) +const tcp16MaxBytes = netprobe.TCP16MaxBytes + func ClassifyTLSError(err error) (DomainStatus, string) { - return ClassifyTLSErrorStaged(err, stageHandshake, 0) + return netprobe.ClassifyTLSError(err) } func ClassifyTLSErrorStaged(err error, stage tlsStage, bytesRead int) (DomainStatus, string) { - if err == nil { - return DomainOk, "" - } - - msg := strings.ToLower(err.Error()) - - isTimeout := strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "timed out") - isEOF := strings.Contains(msg, "eof") - isReset := strings.Contains(msg, "connection reset") || strings.Contains(msg, "reset by peer") - - if stage == stageRead && isTimeout && bytesRead >= tcp16MinBytes && bytesRead <= tcp16MaxBytes { - return DomainTCP16, "Read stalled after TSPU fat-flow window (12-69KB)" - } - - if isTimeout { - switch stage { - case stageConnect: - return DomainSYNDrop, "TCP SYN dropped (no handshake)" - case stageHandshake: - return DomainTLSDrop, "TLS handshake timed out (drop)" - default: - return DomainTimeout, "Connection timed out" - } - } - - if strings.Contains(msg, "wrong version number") { - return DomainTLSSpoof, "Non-TLS response received (DPI replacement)" - } - for _, p := range []string{"record overflow", "oversized", "record layer failure", "decode error", "decoding error", "illegal parameter", "bad record mac", "decryption failed"} { - if strings.Contains(msg, p) { - return DomainTLSSpoof, "Garbage TLS response (DPI injection)" - } - } - - if strings.Contains(msg, "alert") || strings.Contains(msg, "unrecognized name") || strings.Contains(msg, "handshake failure") { - switch { - case strings.Contains(msg, "unrecognized name"): - return DomainTLSAlert, "SNI blocked (unrecognized name)" - case strings.Contains(msg, "protocol version"): - return DomainTLSAlert, "TLS protocol version alert" - default: - return DomainTLSAlert, "TLS alert (DPI disruption)" - } - } - - if isReset { - if stage == stageHandshake || stage == stageConnect { - return DomainTLSReset, "TCP RST during handshake (active reset)" - } - return DomainTLSReset, "TCP RST during transfer" - } - - if isEOF { - if stage == stageHandshake || bytesRead == 0 { - return DomainTLSReset, "Connection terminated (EOF injection)" - } - return DomainTLSReset, "Connection dropped during transfer (EOF)" - } - - for _, p := range []string{"self-signed", "self signed", "unknown authority", "certificate has expired", "certificate is not valid", "hostname mismatch", "name mismatch", "x509", "certificate"} { - if strings.Contains(msg, p) { - return DomainTLSMITM, "Certificate substitution (possible MITM)" - } - } - - if strings.Contains(msg, "no shared cipher") || strings.Contains(msg, "cipher") { - return DomainTLSMITM, "Cipher mismatch (possible MITM)" - } - - if strings.Contains(msg, "refused") { - return DomainBlocked, "Connection refused" - } - if strings.Contains(msg, "no such host") || strings.Contains(msg, "no address") { - return DomainError, "DNS resolution failed" - } - if strings.Contains(msg, "internal error") { - return DomainError, "TLS internal error" - } - - return DomainError, err.Error() + return netprobe.ClassifyTLSErrorStaged(err, stage, bytesRead) } -func ClassifyHTTPResponse(statusCode int, location string, body string) (DomainStatus, string) { - if statusCode == 451 { - return DomainISPPage, "HTTP 451 Unavailable For Legal Reasons" - } - - locLower := strings.ToLower(location) - for _, marker := range BlockMarkers { - if strings.Contains(locLower, marker) { - return DomainISPPage, "Redirect to ISP block page: " + location - } - } - - bodyLower := strings.ToLower(body) - for _, marker := range BodyBlockMarkers { - if strings.Contains(bodyLower, marker) { - return DomainISPPage, "ISP block page detected in response body" - } - } - - return DomainOk, "" +func ClassifyHTTPResponse(statusCode int, location, body string) (DomainStatus, string) { + return netprobe.ClassifyHTTPResponse(statusCode, location, body) } diff --git a/src/detector/dns.go b/src/detector/dns.go index 169991f0..abe3cd21 100644 --- a/src/detector/dns.go +++ b/src/detector/dns.go @@ -2,25 +2,14 @@ package detector import ( "context" - "encoding/json" "fmt" - "io" - "net" - "net/http" "sync" "time" - "github.com/daniellavrushin/b4/dns" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" ) -type dohResponse struct { - Answer []struct { - Type int `json:"type"` - Data string `json:"data"` - } `json:"Answer"` -} - func (s *DetectorSuite) runDNSCheck(ctx context.Context) *DNSResult { log.DiscoveryLogf("[Detector] Starting DNS integrity check for %d domains", len(DNSCheckDomains)) @@ -210,38 +199,15 @@ func resolveDoH(ctx context.Context, mark uint, serverURL, domain string) (strin ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - client := markedHTTPClient(mark, 5*time.Second) - - reqURL := fmt.Sprintf("%s?name=%s&type=A", serverURL, domain) - req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + r := &netprobe.Resolver{Mark: int(mark), Timeout: 5 * time.Second} + ips, err := r.ResolveDoHOnce(ctx, netprobe.DoHServer{URL: serverURL, Format: netprobe.DoHJSON}, domain, "A") if err != nil { return "", err } - req.Header.Set("Accept", "application/dns-json") - - resp, err := client.Do(req) - if err != nil { - return "", err + if len(ips) == 0 { + return "", fmt.Errorf("no A record found") } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var doh dohResponse - if err := json.Unmarshal(body, &doh); err != nil { - return "", err - } - - for _, ans := range doh.Answer { - if ans.Type == 1 { - return ans.Data, nil - } - } - - return "", fmt.Errorf("no A record found") + return ips[0], nil } type udpResult struct { @@ -254,39 +220,16 @@ func resolveUDP(ctx context.Context, mark uint, server, domain string) (udpResul ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - conn, err := markedDialer(mark, 5*time.Second).DialContext(ctx, "udp", net.JoinHostPort(server, "53")) + r := &netprobe.Resolver{Mark: int(mark), Timeout: 5 * time.Second} + ans, err := r.ResolveUDPOnce(ctx, server, domain, "A") if err != nil { return udpResult{}, err } - defer conn.Close() - - if deadline, ok := ctx.Deadline(); ok { - conn.SetDeadline(deadline) - } - - if _, err := conn.Write(dns.BuildAQuery(domain, 0x4242)); err != nil { - return udpResult{}, err - } - - buf := make([]byte, 1500) - n, err := conn.Read(buf) - if err != nil { - return udpResult{}, err - } - resp := buf[:n] - if len(resp) < 12 { - return udpResult{}, fmt.Errorf("short DNS response") - } - - if rcode := resp[3] & 0x0F; rcode == 3 { + if ans.NXDomain { return udpResult{NXDomain: true}, nil } - - for _, ip := range dns.ParseResponseIPs(resp) { - if v4 := ip.To4(); v4 != nil { - return udpResult{IP: v4.String()}, nil - } + if len(ans.IPs) == 0 { + return udpResult{Empty: true}, nil } - - return udpResult{Empty: true}, nil + return udpResult{IP: ans.IPs[0]}, nil } diff --git a/src/detector/dnsavail.go b/src/detector/dnsavail.go index f7bb568b..86ba4d63 100644 --- a/src/detector/dnsavail.go +++ b/src/detector/dnsavail.go @@ -9,6 +9,7 @@ import ( "time" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" ) const dnsAvailTimeout = 5 * time.Second @@ -45,7 +46,7 @@ func (s *DetectorSuite) runDNSAvailCheck(ctx context.Context) *DNSAvailResult { var client *http.Client if server.Kind == "doh" { pr.Kind = DNSAvailDoH - client = markedHTTPClient(s.mark, dnsAvailTimeout) + client = netprobe.HTTPClient(int(s.mark), dnsAvailTimeout) defer client.CloseIdleConnections() } else { pr.Kind = DNSAvailUDP @@ -116,7 +117,7 @@ func (s *DetectorSuite) dnsAvailProbe(ctx context.Context, client *http.Client, resolver := &net.Resolver{ PreferGo: true, Dial: func(c context.Context, network, address string) (net.Conn, error) { - return markedDialer(s.mark, dnsAvailTimeout).DialContext(c, "udp", net.JoinHostPort(server.Address, "53")) + return netprobe.Dialer(int(s.mark), dnsAvailTimeout, 0).DialContext(c, "udp", net.JoinHostPort(server.Address, "53")) }, } if _, err := resolver.LookupIPAddr(probeCtx, domain); err != nil { diff --git a/src/detector/domains.go b/src/detector/domains.go index a387cf00..da60d464 100644 --- a/src/detector/domains.go +++ b/src/detector/domains.go @@ -13,6 +13,7 @@ import ( "time" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" ) func (s *DetectorSuite) runDomainsCheck(ctx context.Context, stubIPs map[string]bool) *DomainsResult { @@ -144,7 +145,7 @@ func (s *DetectorSuite) probeTLS(ctx context.Context, domain, ip string, version MaxVersion: version, } - conn, err := markedDialer(s.mark, 10*time.Second).DialContext(ctx, "tcp", ip+":443") + conn, err := netprobe.Dialer(int(s.mark), 10*time.Second, 0).DialContext(ctx, "tcp", ip+":443") if err != nil { status, detail := ClassifyTLSErrorStaged(err, stageConnect, 0) return &TLSProbeResult{ @@ -221,7 +222,7 @@ func (s *DetectorSuite) probeHTTP(ctx context.Context, domain, ip string) *HTTPP transport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return markedDialer(s.mark, 10*time.Second).DialContext(ctx, "tcp", ip+":80") + return netprobe.Dialer(int(s.mark), 10*time.Second, 0).DialContext(ctx, "tcp", ip+":80") }, } diff --git a/src/detector/targets.go b/src/detector/targets.go index 23a0854a..3587a093 100644 --- a/src/detector/targets.go +++ b/src/detector/targets.go @@ -5,6 +5,7 @@ import ( "encoding/json" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" ) //go:embed targets.json @@ -14,9 +15,7 @@ var ( DNSCheckDomains []string CheckDomains []string UDPDNSServers []string - DoHServers []doHServer - BlockMarkers []string - BodyBlockMarkers []string + DoHServers []netprobe.DoHServer CDNRedirectPatterns []string TCPTargets []TCPTarget WhitelistSNI []string @@ -25,11 +24,6 @@ var ( TelegramConfig telegramTargets ) -type doHServer struct { - Name string `json:"name"` - URL string `json:"url"` -} - type dnsAvailServer struct { Name string `json:"name"` Address string `json:"address"` @@ -44,10 +38,6 @@ type telegramTargets struct { type targetsData struct { DNSCheckDomains []string `json:"dns_check_domains"` CheckDomains []string `json:"check_domains"` - UDPDNSServers []string `json:"udp_dns_servers"` - DoHServers []doHServer `json:"doh_servers"` - BlockMarkers []string `json:"block_markers"` - BodyBlockMarkers []string `json:"body_block_markers"` CDNRedirectPatterns []string `json:"cdn_redirect_patterns"` TCPTargets []TCPTarget `json:"tcp_targets"` WhitelistSNI []string `json:"whitelist_sni"` @@ -64,10 +54,8 @@ func init() { } DNSCheckDomains = data.DNSCheckDomains CheckDomains = data.CheckDomains - UDPDNSServers = data.UDPDNSServers - DoHServers = data.DoHServers - BlockMarkers = data.BlockMarkers - BodyBlockMarkers = data.BodyBlockMarkers + UDPDNSServers = netprobe.DefaultUDPServers + DoHServers = netprobe.DefaultDoHServers CDNRedirectPatterns = data.CDNRedirectPatterns TCPTargets = data.TCPTargets WhitelistSNI = data.WhitelistSNI diff --git a/src/detector/targets.json b/src/detector/targets.json index 4bf0acdc..43bf0798 100644 --- a/src/detector/targets.json +++ b/src/detector/targets.json @@ -55,72 +55,6 @@ "vk.ru", "www.google.com" ], - "udp_dns_servers": [ - "8.8.8.8", - "1.1.1.1", - "9.9.9.9", - "94.140.14.14", - "77.88.8.8", - "223.5.5.5", - "208.67.222.222", - "76.76.2.0", - "194.242.2.2" - ], - "doh_servers": [ - { - "name": "Google (IP)", - "url": "https://8.8.8.8/resolve" - }, - { - "name": "Google", - "url": "https://dns.google/resolve" - }, - { - "name": "Cloudflare (IP)", - "url": "https://1.1.1.1/dns-query" - }, - { - "name": "Cloudflare", - "url": "https://cloudflare-dns.com/dns-query" - }, - { - "name": "Cloudflare (alt)", - "url": "https://one.one.one.one/dns-query" - }, - { - "name": "AdGuard", - "url": "https://dns.adguard-dns.com/resolve" - }, - { - "name": "Alibaba", - "url": "https://dns.alidns.com/resolve" - } - ], - "block_markers": [ - "lawfilter", - "warning.rt.ru", - "blocked", - "access-denied", - "eais", - "zapret-info", - "rkn.gov.ru", - "mvd.ru" - ], - "body_block_markers": [ - "blocked", - "заблокирован", - "запрещён", - "запрещен", - "ограничен", - "единый реестр", - "роскомнадзор", - "rkn.gov.ru", - "nap.gov.ru", - "eais.rkn.gov.ru", - "warning.rt.ru", - "blocklist", - "решению суда" - ], "cdn_redirect_patterns": [ "cloudflare", "akamai", diff --git a/src/detector/tcp.go b/src/detector/tcp.go index de11b2b5..5bc697f5 100644 --- a/src/detector/tcp.go +++ b/src/detector/tcp.go @@ -12,6 +12,7 @@ import ( "time" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" ) const ( @@ -111,7 +112,7 @@ func newFatProbeState(mark uint, ip string, port int, sni string, rttHint float6 DisableKeepAlives: false, TLSClientConfig: tlsConf, DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { - return markedDialer(mark, fatConnectTimeout).DialContext(ctx, "tcp", addr) + return netprobe.Dialer(int(mark), fatConnectTimeout, 0).DialContext(ctx, "tcp", addr) }, } diff --git a/src/detector/telegram.go b/src/detector/telegram.go index 294060a7..731cc442 100644 --- a/src/detector/telegram.go +++ b/src/detector/telegram.go @@ -11,6 +11,7 @@ import ( "github.com/daniellavrushin/b4/log" "github.com/daniellavrushin/b4/mtproto" + "github.com/daniellavrushin/b4/netprobe" ) const ( @@ -73,7 +74,7 @@ func (s *DetectorSuite) telegramDCPings(ctx context.Context) []TelegramDCPing { defer cancel() start := time.Now() - conn, err := markedDialer(s.mark, tgDCPingTimeout).DialContext(pingCtx, "tcp", e.addr) + conn, err := netprobe.Dialer(int(s.mark), tgDCPingTimeout, 0).DialContext(pingCtx, "tcp", e.addr) if err == nil { p.Ok = true p.RTTMs = round1(float64(time.Since(start).Microseconds()) / 1000.0) @@ -269,7 +270,7 @@ func telegramVerdict(r *TelegramResult) TelegramVerdict { } func telegramClient(mark uint) *http.Client { - d := markedDialer(mark, fatConnectTimeout) + d := netprobe.Dialer(int(mark), fatConnectTimeout, 0) return &http.Client{ Transport: &http.Transport{ DialContext: d.DialContext, diff --git a/src/detector/transport.go b/src/detector/transport.go deleted file mode 100644 index 21b4f4fa..00000000 --- a/src/detector/transport.go +++ /dev/null @@ -1,52 +0,0 @@ -package detector - -import ( - "fmt" - "net" - "net/http" - "syscall" - "time" - - "golang.org/x/sys/unix" -) - -func markControl(mark uint) func(string, string, syscall.RawConn) error { - if mark == 0 { - return nil - } - return func(_, _ string, c syscall.RawConn) error { - var ctrlErr error - if err := c.Control(func(fd uintptr) { - ctrlErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, int(uint32(mark))) - }); err != nil { - return err - } - if ctrlErr != nil { - return fmt.Errorf("failed to set SO_MARK=%d: %w", mark, ctrlErr) - } - return nil - } -} - -func markedDialer(mark uint, timeout time.Duration) *net.Dialer { - return &net.Dialer{ - Timeout: timeout, - Control: markControl(mark), - } -} - -func markedHTTPClient(mark uint, timeout time.Duration) *http.Client { - d := markedDialer(mark, timeout) - tr := &http.Transport{ - DialContext: d.DialContext, - ForceAttemptHTTP2: true, - TLSHandshakeTimeout: timeout, - ResponseHeaderTimeout: timeout, - MaxIdleConns: 100, - IdleConnTimeout: 30 * time.Second, - } - return &http.Client{ - Transport: tr, - Timeout: timeout, - } -} diff --git a/src/detector/types.go b/src/detector/types.go index 39d7f0e3..0e7c0cf6 100644 --- a/src/detector/types.go +++ b/src/detector/types.go @@ -137,25 +137,6 @@ type TelegramResult struct { // Domain accessibility check types -type DomainStatus string - -const ( - DomainOk DomainStatus = "OK" - DomainTLSDPI DomainStatus = "TLS_DPI" - DomainTLSMITM DomainStatus = "TLS_MITM" - DomainTLSSpoof DomainStatus = "TLS_SPOOF" - DomainTLSAlert DomainStatus = "TLS_ALERT" - DomainTLSReset DomainStatus = "TLS_RST" - DomainTLSDrop DomainStatus = "TLS_DROP" - DomainSYNDrop DomainStatus = "SYN_DROP" - DomainTCP16 DomainStatus = "TCP16" - DomainISPPage DomainStatus = "ISP_PAGE" - DomainBlocked DomainStatus = "BLOCKED" - DomainDNSFake DomainStatus = "DNS_FAKE" - DomainTimeout DomainStatus = "TIMEOUT" - DomainError DomainStatus = "ERROR" -) - type TLSProbeResult struct { Status DomainStatus `json:"status"` Detail string `json:"detail,omitempty"` diff --git a/src/discovery/discovery.go b/src/discovery/discovery.go index ca015eb7..e59b8145 100644 --- a/src/discovery/discovery.go +++ b/src/discovery/discovery.go @@ -16,6 +16,7 @@ import ( "github.com/daniellavrushin/b4/config" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" "github.com/daniellavrushin/b4/nfq" ) @@ -30,100 +31,6 @@ const ( validationRetryDelay = 100 * time.Millisecond ) -// ISP block page detection markers (shared with detector module). -var BlockPageRedirectMarkers = []string{ - "lawfilter", "warning.rt.ru", "blocked", "access-denied", - "eais", "zapret-info", "rkn.gov.ru", "mvd.ru", -} - -var BlockPageBodyMarkers = []string{ - "заблокирован", "запрещён", "запрещен", "ограничен", - "единый реестр", "роскомнадзор", "rkn.gov.ru", "nap.gov.ru", - "eais.rkn.gov.ru", "warning.rt.ru", "решению суда", -} - -// detectBlockPage checks response body for ISP block page markers. -// Returns error description if block page detected, empty string otherwise. -func DetectBlockPage(body []byte) string { - if len(body) == 0 { - return "" - } - bodyLower := strings.ToLower(string(body)) - for _, marker := range BlockPageBodyMarkers { - if strings.Contains(bodyLower, marker) { - return "ISP block page detected in response" - } - } - return "" -} - -// humanizeError converts raw Go error strings into user-friendly descriptions. -// Uses the same DPI/MITM classification patterns as the detector module. -func HumanizeError(raw string) string { - lower := strings.ToLower(raw) - - // DPI interference patterns (from detector/classify.go) - dpiPatterns := []struct { - pattern, desc string - }{ - {"unexpected eof", "connection closed by DPI (unexpected EOF)"}, - {"eof occurred in violation", "connection closed by DPI (EOF violation)"}, - {"connection reset", "connection reset by DPI/firewall"}, - {"bad record mac", "TLS record corrupted by DPI"}, - {"decryption failed", "TLS decryption failed (DPI tampering)"}, - {"illegal parameter", "TLS blocked (DPI injection)"}, - {"decode error", "TLS blocked (DPI injection)"}, - {"record overflow", "TLS record overflow (DPI injection)"}, - {"unrecognized name", "blocked by SNI filtering"}, - {"handshake failure", "TLS handshake blocked by DPI"}, - {"close notify", "connection closed by DPI (alert injection)"}, - {"wrong version number", "non-TLS response received (DPI replacement)"}, - } - for _, p := range dpiPatterns { - if strings.Contains(lower, p.pattern) { - return p.desc - } - } - - // MITM patterns - mitmPatterns := []struct { - pattern, desc string - }{ - {"self-signed", "fake certificate detected (possible MITM)"}, - {"self signed", "fake certificate detected (possible MITM)"}, - {"unknown authority", "unknown CA (possible MITM)"}, - {"certificate has expired", "expired certificate (possible MITM)"}, - {"x509", "certificate error (possible MITM)"}, - } - for _, p := range mitmPatterns { - if strings.Contains(lower, p.pattern) { - return p.desc - } - } - - // Network-level errors - switch { - case strings.Contains(lower, "context deadline exceeded") || strings.Contains(lower, "i/o timeout"): - return "connection timed out (no response)" - case strings.Contains(lower, "connection refused"): - return "connection refused (port closed)" - case strings.Contains(lower, "no route to host"): - return "no route to host (IP unreachable)" - case strings.Contains(lower, "network is unreachable"): - return "network unreachable" - case strings.Contains(lower, "eof"): - return "connection closed unexpectedly" - } - - // Already human-readable - if strings.Contains(lower, "stalled") || strings.Contains(lower, "truncated") || - strings.Contains(lower, "ips failed") || strings.Contains(lower, "isp block") { - return raw - } - - return raw -} - func NewDiscoverySuite(inputs []string, pool *nfq.Pool, skipDNS bool, skipCache bool, payloadFiles []string, validationTries int, tlsVersion string, ipVersion string, flowMark uint) *DiscoverySuite { domainInputs := parseDiscoveryInputs(inputs) if len(domainInputs) == 0 { @@ -1049,10 +956,11 @@ func (ds *DiscoverySuite) testPresetAllDomains(preset ConfigPreset) map[string]C var mu sync.Mutex var wg sync.WaitGroup +spawn: for _, di := range ds.Domains { select { case <-ds.cancel: - return results + break spawn default: } @@ -1166,17 +1074,25 @@ func (ds *DiscoverySuite) collectTargetIPs(domain string, maxIPs int) []string { } func (ds *DiscoverySuite) fetchForDomain(di DomainInput, timeout time.Duration) CheckResult { - geoip, geosite := GetCDNCategories(di.Domain) - if len(geoip) > 0 || len(geosite) > 0 { - return ds.fetchUsingIPForDomain(di, timeout, "") - } - // Use IPs already collected during DNS discovery — no fresh DNS lookups. // Fresh lookups are slow (poisoned DNS can timeout) and redundant since // DNS discovery already gathered all valid IPs from DoH + system resolver. // Limit to 2 IPs to avoid slow sequential fallback. allIPs := ds.collectTargetIPs(di.Domain, 2) + geoip, geosite := GetCDNCategories(di.Domain) + if len(geoip) > 0 || len(geosite) > 0 { + // CDN domains are matched by geoip/geosite in a real config, but the + // validation fetch still needs a resolvable IP. Pin the IP discovered + // during the DNS phase; only fall back to system DNS when none exist + // (otherwise a poisoned system resolver fails every preset). + ip := "" + if len(allIPs) > 0 { + ip = allIPs[0] + } + return ds.fetchUsingIPForDomain(di, timeout, ip) + } + for _, ip := range allIPs { result := ds.fetchUsingIPForDomain(di, timeout, ip) if result.Status == CheckStatusComplete { @@ -1263,7 +1179,7 @@ func (ds *DiscoverySuite) fetchUsingIPForDomain(di DomainInput, timeout time.Dur IdleConnTimeout: timeout, } - baseDialer := markedDialer(ds.flowMark, timeout/2, timeout) + baseDialer := netprobe.Dialer(int(ds.flowMark), timeout/2, timeout) forcedNet := ds.dialNetwork() transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { @@ -1300,7 +1216,8 @@ func (ds *DiscoverySuite) fetchUsingIPForDomain(di DomainInput, timeout time.Dur resp, err := client.Do(req) if err != nil { result.Status = CheckStatusFailed - result.Error = HumanizeError(err.Error()) + _, detail := netprobe.ClassifyTLSError(err) + result.Error = detail result.Duration = time.Since(start) return result } @@ -1317,14 +1234,11 @@ func (ds *DiscoverySuite) fetchUsingIPForDomain(di DomainInput, timeout time.Dur return result } if loc := resp.Header.Get("Location"); loc != "" { - locLower := strings.ToLower(loc) - for _, marker := range BlockPageRedirectMarkers { - if strings.Contains(locLower, marker) { - result.Status = CheckStatusFailed - result.Error = "ISP block page (redirect to " + loc + ")" - result.Duration = time.Since(start) - return result - } + if netprobe.IsBlockPageRedirect(loc) { + result.Status = CheckStatusFailed + result.Error = "ISP block page (redirect to " + loc + ")" + result.Duration = time.Since(start) + return result } } @@ -1392,7 +1306,7 @@ evaluate: } // Check for ISP block page in response body before marking as success. - if blockErr := DetectBlockPage(headBuf); blockErr != "" { + if blockErr := netprobe.DetectBlockPageBody(headBuf); blockErr != "" { result.Status = CheckStatusFailed result.Error = blockErr return result @@ -1547,6 +1461,17 @@ func (ds *DiscoverySuite) determineBest(baselineSpeed float64) { } } +func (ds *DiscoverySuite) cdnDNSConfig() config.DNSConfig { + if ds.discoveredDNS.Enabled { + return ds.discoveredDNS + } + doh := "https://1.1.1.1/dns-query" + if len(netprobe.WireDoHServers) > 0 { + doh = netprobe.WireDoHServers[0] + } + return config.DNSConfig{Enabled: true, DoHURL: doh} +} + func (ds *DiscoverySuite) buildTestConfig(preset ConfigPreset) *config.Config { testSet := config.NewSetConfig() testSet.Name = preset.Name @@ -1592,19 +1517,7 @@ func (ds *DiscoverySuite) buildTestConfig(preset ConfigPreset) *config.Config { } if !ds.skipDNS { - if len(ds.cfg.System.Checker.ReferenceDNS) > 0 { - testSet.DNS = config.DNSConfig{ - Enabled: true, - TargetDNS: ds.cfg.System.Checker.ReferenceDNS[0], - FragmentQuery: true, - } - } else { - testSet.DNS = config.DNSConfig{ - Enabled: true, - TargetDNS: "9.9.9.9", - FragmentQuery: true, - } - } + testSet.DNS = ds.cdnDNSConfig() } tempCfg := &config.Config{System: ds.cfg.System} domains, ips, err := tempCfg.GetTargetsForSet(&testSet) @@ -1740,19 +1653,7 @@ func (ds *DiscoverySuite) buildTestConfigMulti(preset ConfigPreset) *config.Conf } if hasCDN && !ds.skipDNS { - if len(ds.cfg.System.Checker.ReferenceDNS) > 0 { - testSet.DNS = config.DNSConfig{ - Enabled: true, - TargetDNS: ds.cfg.System.Checker.ReferenceDNS[0], - FragmentQuery: true, - } - } else { - testSet.DNS = config.DNSConfig{ - Enabled: true, - TargetDNS: "9.9.9.9", - FragmentQuery: true, - } - } + testSet.DNS = ds.cdnDNSConfig() } if len(testSet.Targets.GeoIpCategories) > 0 || len(testSet.Targets.GeoSiteCategories) > 0 { @@ -2179,7 +2080,7 @@ func (ds *DiscoverySuite) measureNetworkBaseline() float64 { Timeout: timeout, Transport: &http.Transport{ TLSClientConfig: ds.tlsConfig(), - DialContext: markedDialer(ds.flowMark, timeout/2, timeout).DialContext, + DialContext: netprobe.Dialer(int(ds.flowMark), timeout/2, timeout).DialContext, }, } diff --git a/src/discovery/dns.go b/src/discovery/dns.go index 2a565156..b73bdaad 100644 --- a/src/discovery/dns.go +++ b/src/discovery/dns.go @@ -6,28 +6,20 @@ import ( _ "embed" "encoding/json" "fmt" - "io" "net" - "net/http" "strings" "sync" "time" "github.com/daniellavrushin/b4/config" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" "github.com/daniellavrushin/b4/nfq" ) //go:embed dns.json var cdnJSON []byte -type dohResponse struct { - Answer []struct { - Data string `json:"data"` - Type int `json:"type"` - } `json:"Answer"` -} - type CDNEntry struct { Match []string `json:"match"` GeoIP []string `json:"geoip"` @@ -97,26 +89,34 @@ func (ds *DiscoverySuite) runDNSDiscoveryForDomain(domain string) *DNSDiscoveryR return prober.Probe(ctx) } -// applyBestDNSConfig applies the best DNS bypass config found across all domains. func (ds *DiscoverySuite) applyBestDNSConfig() { - var bestServer string + var bestDoH, bestServer string needsFragment := false for _, dnsResult := range ds.dnsResults { if dnsResult == nil || !dnsResult.IsPoisoned { continue } - if dnsResult.BestServer != "" { + if bestDoH == "" && dnsResult.BestDoHURL != "" { + bestDoH = dnsResult.BestDoHURL + } + if bestServer == "" && dnsResult.BestServer != "" { bestServer = dnsResult.BestServer needsFragment = dnsResult.NeedsFragment - break } if dnsResult.NeedsFragment { needsFragment = true } } - if bestServer != "" || needsFragment { + switch { + case bestDoH != "": + ds.discoveredDNS = config.DNSConfig{ + Enabled: true, + DoHURL: bestDoH, + } + log.DiscoveryLogf(" Applied DNS bypass: DoH=%s", bestDoH) + case bestServer != "" || needsFragment: ds.discoveredDNS = config.DNSConfig{ Enabled: true, TargetDNS: bestServer, @@ -130,7 +130,7 @@ func (r *DNSDiscoveryResult) hasWorkingConfig() bool { if r == nil { return true } - return !r.IsPoisoned || r.BestServer != "" || r.NeedsFragment + return !r.IsPoisoned || r.BestDoHURL != "" || r.BestServer != "" || r.NeedsFragment } func NewDNSProber(domain string, timeout time.Duration, pool *nfq.Pool, cfg *config.Config, flowMark uint, ipVersion string) *DNSProber { @@ -144,8 +144,6 @@ func NewDNSProber(domain string, timeout time.Duration, pool *nfq.Pool, cfg *con } } -// ipNetwork returns the resolver network ("ip4"/"ip6") for this probe run. -// An explicit per-run ipVersion wins; "auto" preserves the queue-config default. func (p *DNSProber) ipNetwork() string { switch p.ipVersion { case "ipv4": @@ -159,7 +157,6 @@ func (p *DNSProber) ipNetwork() string { return "ip4" } -// dnsRecordType returns the DoH record type ("A"/"AAAA") matching ipNetwork. func (p *DNSProber) dnsRecordType() string { if p.ipNetwork() == "ip6" { return "AAAA" @@ -172,7 +169,6 @@ func (p *DNSProber) Probe(ctx context.Context) *DNSDiscoveryResult { ProbeResults: []DNSProbeResult{}, } - // Run system resolver and DoH in parallel. var expectedIPs, systemIPs []string var wg sync.WaitGroup wg.Add(2) @@ -210,8 +206,8 @@ func (p *DNSProber) Probe(ctx context.Context) *DNSDiscoveryResult { } log.DiscoveryLogf(" DNS: system IPs %v, reference IPs (DoH): %v", systemIPs, expectedIPs) - if p.findValidIP(ctx, expectedIPs) == "" { - log.DiscoveryLogf(" DNS: neither system nor reference IPs serve %s (transport issue or site down)", p.domain) + if !p.anyIPConnectable(ctx, expectedIPs) { + log.DiscoveryLogf(" DNS: reference IPs for %s are unreachable at TCP level (transport issue or site down)", p.domain) result.TransportBlocked = true result.ExpectedIPs = uniqueIPs(expectedIPs, systemIPs) return result @@ -237,23 +233,17 @@ func (p *DNSProber) Probe(ctx context.Context) *DNSDiscoveryResult { } result.ProbeResults = append(result.ProbeResults, sysResult) - // Step 6: Try DNS bypass strategies. p.findDNSBypass(ctx, result, expectedIPs[0]) return result } -// findDNSBypass tries fragmentation and alternative DNS servers to bypass poisoning. func (p *DNSProber) findDNSBypass(ctx context.Context, result *DNSDiscoveryResult, expectedIP string) { - // Try fragmented query on system resolver first. - fragResult := p.testDNSWithFragment(ctx, "", expectedIP) - result.ProbeResults = append(result.ProbeResults, fragResult) - if fragResult.Works { - result.NeedsFragment = true - log.DiscoveryLogf(" DNS: fragmented query bypass works for %s", p.domain) + if url := p.findDoHBypass(ctx, result, expectedIP); url != "" { + result.BestDoHURL = url + log.DiscoveryLogf(" DNS: DoH bypass works for %s via %s", p.domain, url) return } - // Try alternative DNS servers (plain and fragmented). for _, server := range p.cfg.System.Checker.ReferenceDNS { plainResult := p.testDNS(ctx, server, false, expectedIP) result.ProbeResults = append(result.ProbeResults, plainResult) @@ -276,8 +266,27 @@ func (p *DNSProber) findDNSBypass(ctx context.Context, result *DNSDiscoveryResul log.DiscoveryLogf(" DNS: no working DNS config found for %s", p.domain) } -// sameSubnet checks if any system IP shares a /24 (IPv4) or /48 (IPv6) subnet -// with any reference IP. Same-subnet IPs are CDN edge variance, not DNS poisoning. +func (p *DNSProber) findDoHBypass(ctx context.Context, result *DNSDiscoveryResult, expectedIP string) string { + r := &netprobe.Resolver{Mark: int(p.flowMark), Timeout: p.timeout} + recordType := p.dnsRecordType() + + for _, url := range netprobe.WireDoHServers { + probe := DNSProbeResult{Server: url, ExpectedIP: expectedIP} + + ips, err := r.ResolveDoHOnce(ctx, netprobe.DoHServer{URL: url, Format: netprobe.DoHWire}, p.domain, recordType) + if err == nil && len(ips) > 0 { + probe.ResolvedIP = ips[0] + probe.Works = true + result.ProbeResults = append(result.ProbeResults, probe) + return url + } + + result.ProbeResults = append(result.ProbeResults, probe) + } + + return "" +} + func sameSubnet(systemIPs, referenceIPs []string) bool { refSubnets := make(map[string]bool) for _, ipStr := range referenceIPs { @@ -310,7 +319,6 @@ func sameSubnet(systemIPs, referenceIPs []string) bool { return false } -// uniqueIPs merges two IP lists, deduplicating entries. func uniqueIPs(primary, secondary []string) []string { seen := make(map[string]bool, len(primary)) result := make([]string, 0, len(primary)+len(secondary)) @@ -332,7 +340,7 @@ func uniqueIPs(primary, secondary []string) []string { func (p *DNSProber) getSystemResolverIPs(ctx context.Context) []string { network := p.ipNetwork() - resolver := markedResolver(p.flowMark, p.timeout/2, "") + resolver := netprobe.MarkedResolver(int(p.flowMark), p.timeout/2, "") ips, err := resolver.LookupIP(ctx, network, p.domain) if err != nil { log.DiscoveryLogf(" DNS: system resolver error: %v", err) @@ -358,80 +366,14 @@ func (p *DNSProber) getSystemResolverIPs(ctx context.Context) []string { } func (p *DNSProber) getExpectedIPs(ctx context.Context) []string { - recordType := p.dnsRecordType() - - dohServers := []string{ - "https://dns.google/resolve?name=%s&type=" + recordType, - "https://dns.quad9.net:5053/dns-query?name=%s&type=" + recordType, - "https://cloudflare-dns.com/dns-query?name=%s&type=" + recordType, - } - - client := &http.Client{ + r := &netprobe.Resolver{ + Mark: int(p.flowMark), Timeout: p.timeout, - Transport: &http.Transport{ - DialContext: markedDialer(p.flowMark, p.timeout/2, p.timeout).DialContext, - }, + UDP: append(append([]string{}, netprobe.DefaultUDPServers...), p.cfg.System.Checker.ReferenceDNS...), } - seenIPs := make(map[string]bool) - var allIPs []string - - for _, endpoint := range dohServers { - url := fmt.Sprintf(endpoint, p.domain) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - continue - } - req.Header.Set("Accept", "application/dns-json") - - resp, err := client.Do(req) - if err != nil { - log.Tracef("DoH %s failed: %v", endpoint, err) - continue - } - - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - var doh dohResponse - if err := json.Unmarshal(body, &doh); err != nil { - continue - } - - wantType := 1 - if recordType == "AAAA" { - wantType = 28 - } - - unvalidatedIPs := []string{} - for _, ans := range doh.Answer { - if ans.Type == wantType { - ip := ans.Data - if seenIPs[ip] { - continue - } - seenIPs[ip] = true - unvalidatedIPs = append(unvalidatedIPs, ip) - - if p.testIPServesDomain(ctx, ip) { - log.Tracef("DoH: verified %s for %s", ip, p.domain) - allIPs = append(allIPs, ip) - } - } - } - - if len(allIPs) == 0 && len(unvalidatedIPs) > 0 { - log.Tracef("DoH: TLS validation failed, trusting unvalidated IPs: %v", unvalidatedIPs) - allIPs = unvalidatedIPs - } - - if len(allIPs) > 0 { - break - } - } - - if len(allIPs) == 0 { + out, err := r.ResolveResilient(ctx, p.domain, p.dnsRecordType()) + if err != nil || len(out.IPs) == 0 { ip := p.getExpectedIPFallback(ctx) if ip != "" { return []string{ip} @@ -439,14 +381,26 @@ func (p *DNSProber) getExpectedIPs(ctx context.Context) []string { return nil } - return allIPs + var validated []string + for _, ip := range out.IPs { + if p.testIPServesDomain(ctx, ip) { + log.Tracef("DoH: verified %s for %s", ip, p.domain) + validated = append(validated, ip) + } + } + if len(validated) > 0 { + return validated + } + + log.Tracef("DoH: TLS validation failed for %s, trusting resolved IPs: %v", p.domain, out.IPs) + return out.IPs } func (p *DNSProber) getExpectedIPFallback(ctx context.Context) string { network := p.ipNetwork() for _, server := range p.cfg.System.Checker.ReferenceDNS { - resolver := markedResolver(p.flowMark, p.timeout/3, server) + resolver := netprobe.MarkedResolver(int(p.flowMark), p.timeout/3, server) ips, err := resolver.LookupIP(ctx, network, p.domain) if err == nil && len(ips) > 0 { @@ -467,9 +421,9 @@ func (p *DNSProber) testDNS(ctx context.Context, server string, fragmented bool, ExpectedIP: expectedIP, } - resolver := markedResolver(p.flowMark, p.timeout, "") + resolver := netprobe.MarkedResolver(int(p.flowMark), p.timeout, "") if server != "" { - resolver = markedResolver(p.flowMark, p.timeout, server) + resolver = netprobe.MarkedResolver(int(p.flowMark), p.timeout, server) } network := p.ipNetwork() @@ -495,8 +449,6 @@ func (p *DNSProber) testDNS(ctx context.Context, server string, fragmented bool, return result } -// findValidIP returns the first IP from the list that serves the domain (TLS handshake), -// or empty string if none work. func (p *DNSProber) findValidIP(ctx context.Context, ips []string) string { valCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -508,8 +460,22 @@ func (p *DNSProber) findValidIP(ctx context.Context, ips []string) string { return "" } +func (p *DNSProber) anyIPConnectable(ctx context.Context, ips []string) bool { + connCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + dialer := netprobe.Dialer(int(p.flowMark), p.timeout/2, p.timeout) + for _, ip := range ips { + conn, err := dialer.DialContext(connCtx, "tcp", net.JoinHostPort(ip, "443")) + if err == nil { + conn.Close() + return true + } + } + return false +} + func (p *DNSProber) testIPServesDomain(ctx context.Context, ip string) bool { - dialer := markedDialer(p.flowMark, p.timeout/2, p.timeout) + dialer := netprobe.Dialer(int(p.flowMark), p.timeout/2, p.timeout) conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, "443")) if err != nil { return false @@ -536,22 +502,19 @@ func (p *DNSProber) testDNSWithFragment(ctx context.Context, server string, expe ExpectedIP: expectedIP, } - // Apply DNS config to pool temporarily testCfg := p.buildDNSTestConfig(server, true) if err := p.pool.UpdateConfig(testCfg); err != nil { return result } - defer p.pool.UpdateConfig(p.cfg) // Restore + defer p.pool.UpdateConfig(p.cfg) time.Sleep(time.Duration(p.cfg.System.Checker.ConfigPropagateMs) * time.Millisecond) - // Use a timeout so we don't hang if fragmented DNS gets no response lookupCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - // Now DNS queries should be fragmented via NFQ start := time.Now() - resolver := markedResolver(p.flowMark, p.timeout/2, "") + resolver := netprobe.MarkedResolver(int(p.flowMark), p.timeout/2, "") ips, err := resolver.LookupIPAddr(lookupCtx, p.domain) result.Latency = time.Since(start) diff --git a/src/discovery/runtime.go b/src/discovery/runtime.go index 727bb1b2..5db39974 100644 --- a/src/discovery/runtime.go +++ b/src/discovery/runtime.go @@ -87,6 +87,10 @@ func (m *Runtime) Start(cfg *config.Config) (*StartResult, error) { discoveryCfg.Queue.IsDiscovery = true discoveryCfg.System.Tables.SkipSetup = true + for _, set := range discoveryCfg.Sets { + set.DNS = config.DNSConfig{} + } + pool := nfq.NewPool(discoveryCfg) if err := pool.Start(); err != nil { tables.ClearDiscoverySteeringRules(cfg, flowMark, injectedMark) diff --git a/src/discovery/transport.go b/src/discovery/transport.go deleted file mode 100644 index c3786ed4..00000000 --- a/src/discovery/transport.go +++ /dev/null @@ -1,48 +0,0 @@ -package discovery - -import ( - "context" - "fmt" - "net" - "syscall" - "time" - - "golang.org/x/sys/unix" -) - -func markControl(mark uint) func(string, string, syscall.RawConn) error { - return func(_, _ string, c syscall.RawConn) error { - var ctrlErr error - if err := c.Control(func(fd uintptr) { - ctrlErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, int(mark)) - }); err != nil { - return err - } - if ctrlErr != nil { - return fmt.Errorf("failed to set SO_MARK=%d: %w", mark, ctrlErr) - } - return nil - } -} - -func markedDialer(mark uint, timeout, keepAlive time.Duration) *net.Dialer { - return &net.Dialer{ - Timeout: timeout, - KeepAlive: keepAlive, - Control: markControl(mark), - } -} - -func markedResolver(mark uint, timeout time.Duration, server string) *net.Resolver { - return &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { - d := markedDialer(mark, timeout, timeout) - target := addr - if server != "" { - target = net.JoinHostPort(server, "53") - } - return d.DialContext(ctx, network, target) - }, - } -} diff --git a/src/discovery/types.go b/src/discovery/types.go index 7f238f65..98d54e7c 100644 --- a/src/discovery/types.go +++ b/src/discovery/types.go @@ -151,6 +151,7 @@ type DNSDiscoveryResult struct { TransportBlocked bool `json:"transport_blocked,omitempty"` ExpectedIPs []string `json:"expected_ips,omitempty"` BestServer string `json:"best_server,omitempty"` + BestDoHURL string `json:"best_doh_url,omitempty"` NeedsFragment bool `json:"needs_fragment"` ProbeResults []DNSProbeResult `json:"probe_results,omitempty"` } diff --git a/src/dns/query.go b/src/dns/query.go index fa8dfbc3..15f782f9 100644 --- a/src/dns/query.go +++ b/src/dns/query.go @@ -6,6 +6,10 @@ import ( ) func BuildAQuery(domain string, txid uint16) []byte { + return BuildQuery(domain, txid, 1) +} + +func BuildQuery(domain string, txid uint16, qtype uint16) []byte { domain = strings.TrimSuffix(strings.TrimSpace(domain), ".") buf := make([]byte, 12, 12+len(domain)+2+5) @@ -30,10 +34,10 @@ func BuildAQuery(domain string, txid uint16) []byte { } buf = append(buf, 0) - qtype := make([]byte, 4) - binary.BigEndian.PutUint16(qtype[0:2], 1) - binary.BigEndian.PutUint16(qtype[2:4], 1) - buf = append(buf, qtype...) + qsuffix := make([]byte, 4) + binary.BigEndian.PutUint16(qsuffix[0:2], qtype) + binary.BigEndian.PutUint16(qsuffix[2:4], 1) + buf = append(buf, qsuffix...) return buf } diff --git a/src/dns/query_test.go b/src/dns/query_test.go new file mode 100644 index 00000000..412d75f6 --- /dev/null +++ b/src/dns/query_test.go @@ -0,0 +1,29 @@ +package dns + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestBuildAQueryDelegatesToBuildQuery(t *testing.T) { + if !bytes.Equal(BuildAQuery("example.com", 0x1234), BuildQuery("example.com", 0x1234, 1)) { + t.Fatal("BuildAQuery must equal BuildQuery with qtype=1 (A)") + } +} + +func TestBuildQueryQType(t *testing.T) { + cases := []struct { + qtype uint16 + }{{1}, {28}} + for _, c := range cases { + q := BuildQuery("example.com", 0, c.qtype) + got := binary.BigEndian.Uint16(q[len(q)-4 : len(q)-2]) + if got != c.qtype { + t.Errorf("qtype byte = %d, want %d", got, c.qtype) + } + if qclass := binary.BigEndian.Uint16(q[len(q)-2:]); qclass != 1 { + t.Errorf("qclass = %d, want 1 (IN)", qclass) + } + } +} diff --git a/src/http/handler/geoip.go b/src/http/handler/geoip.go index 279060dd..be86b74f 100644 --- a/src/http/handler/geoip.go +++ b/src/http/handler/geoip.go @@ -63,7 +63,8 @@ func (a *API) AddGeoIpTag(w http.ResponseWriter, r *http.Request) { req.SetId = config.CreateSetSentinel } - set := a.getCfg().GetSetById(req.SetId) + newCfg := a.getCfg().Clone() + set := newCfg.GetSetById(req.SetId) if set == nil && req.SetId == config.CreateSetSentinel { newSet := config.DefaultSetConfig @@ -73,10 +74,10 @@ func (a *API) AddGeoIpTag(w http.ResponseWriter, r *http.Request) { if req.SetName != "" { set.Name = req.SetName } else { - set.Name = "Set " + fmt.Sprintf("%d", len(a.getCfg().Sets)+1) + set.Name = "Set " + fmt.Sprintf("%d", len(newCfg.Sets)+1) } - a.getCfg().Sets = append([]*config.SetConfig{set}, a.getCfg().Sets...) + newCfg.Sets = append([]*config.SetConfig{set}, newCfg.Sets...) } if set == nil { @@ -97,7 +98,7 @@ func (a *API) AddGeoIpTag(w http.ResponseWriter, r *http.Request) { } log.Infof("Added CIDR '%s' to set '%s' domains list", req.Cidr, set.Id) - err = a.saveAndPushConfig(a.getCfg()) + err = a.saveAndPushConfig(newCfg) if err != nil { log.Errorf("Failed to apply domain changes after adding domain: %v", err) diff --git a/src/http/handler/geosite.go b/src/http/handler/geosite.go index 50211186..75cf1d21 100644 --- a/src/http/handler/geosite.go +++ b/src/http/handler/geosite.go @@ -53,7 +53,8 @@ func (a *API) addGeositeDomain(w http.ResponseWriter, r *http.Request) { req.SetId = config.CreateSetSentinel } - set := a.getCfg().GetSetById(req.SetId) + newCfg := a.getCfg().Clone() + set := newCfg.GetSetById(req.SetId) if set == nil && req.SetId == config.CreateSetSentinel { newSet := config.DefaultSetConfig @@ -63,10 +64,10 @@ func (a *API) addGeositeDomain(w http.ResponseWriter, r *http.Request) { if req.SetName != "" { set.Name = req.SetName } else { - set.Name = "Set " + fmt.Sprintf("%d", len(a.getCfg().Sets)+1) + set.Name = "Set " + fmt.Sprintf("%d", len(newCfg.Sets)+1) } - a.getCfg().Sets = append([]*config.SetConfig{set}, a.getCfg().Sets...) + newCfg.Sets = append([]*config.SetConfig{set}, newCfg.Sets...) } if set == nil { @@ -88,7 +89,7 @@ func (a *API) addGeositeDomain(w http.ResponseWriter, r *http.Request) { log.Infof("Added domain '%s' to set '%s' domains list", req.Domain, set.Id) - err = a.saveAndPushConfig(a.getCfg()) + err = a.saveAndPushConfig(newCfg) if err != nil { log.Errorf("Failed to apply domain changes after adding domain: %v", err) diff --git a/src/http/handler/sets.go b/src/http/handler/sets.go index 67e8eaba..65684f49 100644 --- a/src/http/handler/sets.go +++ b/src/http/handler/sets.go @@ -122,15 +122,11 @@ func (api *API) handleSetDomains(w http.ResponseWriter, r *http.Request) { return } - oldConfig := api.getCfg().Clone() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() setId := r.PathValue("id") - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - var req struct { Domain string `json:"domain"` } @@ -140,17 +136,17 @@ func (api *API) handleSetDomains(w http.ResponseWriter, r *http.Request) { } // Find set and add domain - for _, set := range api.getCfg().Sets { + for _, set := range newCfg.Sets { if set.Id == setId { set.Targets.SNIDomains = append(set.Targets.SNIDomains, req.Domain) set.Targets.DomainsToMatch = append(set.Targets.DomainsToMatch, req.Domain) - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } @@ -243,24 +239,25 @@ func (api *API) createSet(w http.ResponseWriter, r *http.Request) { return } - oldConfig := api.getCfg().Clone() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() set.Id = uuid.New().String() log.Tracef("createSet: routing before defaults: enabled=%v, egress=%s, ttl=%d", set.Routing.Enabled, set.Routing.EgressInterface, set.Routing.IPTTLSeconds) api.initializeSetDefaults(&set) log.Tracef("createSet: routing after defaults: enabled=%v, egress=%s, ttl=%d", set.Routing.Enabled, set.Routing.EgressInterface, set.Routing.IPTTLSeconds) - api.getCfg().Sets = append([]*config.SetConfig{&set}, api.getCfg().Sets...) + newCfg.Sets = append([]*config.SetConfig{&set}, newCfg.Sets...) api.loadTargetsForSetCached(&set) - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { log.Errorf("Failed to save config after creating set: %v", err) writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } @@ -289,13 +286,14 @@ func (api *API) updateSet(w http.ResponseWriter, r *http.Request, id string) { log.Tracef("updateSet: routing received: enabled=%v, egress=%s, ttl=%d", updated.Routing.Enabled, updated.Routing.EgressInterface, updated.Routing.IPTTLSeconds) - oldConfig := api.getCfg().Clone() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() found := false - for i, set := range api.getCfg().Sets { + for i, set := range newCfg.Sets { if set.Id == id { updated.Id = id // preserve ID - api.getCfg().Sets[i] = &updated + newCfg.Sets[i] = &updated found = true break } @@ -308,13 +306,13 @@ func (api *API) updateSet(w http.ResponseWriter, r *http.Request, id string) { api.loadTargetsForSetCached(&updated) - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { log.Errorf("Failed to save config after updating set: %v", err) writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } @@ -332,11 +330,12 @@ func (api *API) updateSet(w http.ResponseWriter, r *http.Request, id string) { // @Security BearerAuth // @Router /sets/{id} [delete] func (api *API) deleteSet(w http.ResponseWriter, id string) { - oldConfig := api.getCfg() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() found := false - filtered := make([]*config.SetConfig, 0, len(api.getCfg().Sets)) - for _, set := range api.getCfg().Sets { + filtered := make([]*config.SetConfig, 0, len(newCfg.Sets)) + for _, set := range newCfg.Sets { if set.Id == id { found = true continue @@ -349,15 +348,15 @@ func (api *API) deleteSet(w http.ResponseWriter, id string) { return } - api.getCfg().Sets = filtered + newCfg.Sets = filtered - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { log.Errorf("Failed to save config after deleting set: %v", err) writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } @@ -380,7 +379,8 @@ func (api *API) handleReorderSets(w http.ResponseWriter, r *http.Request) { return } - oldConfig := api.getCfg().Clone() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() var req struct { SetIds []string `json:"set_ids"` @@ -392,7 +392,7 @@ func (api *API) handleReorderSets(w http.ResponseWriter, r *http.Request) { // Build new order setMap := make(map[string]*config.SetConfig) - for _, set := range api.getCfg().Sets { + for _, set := range newCfg.Sets { setMap[set.Id] = set } @@ -403,19 +403,19 @@ func (api *API) handleReorderSets(w http.ResponseWriter, r *http.Request) { } } - if len(reordered) != len(api.getCfg().Sets) { + if len(reordered) != len(newCfg.Sets) { writeAPIError(w, ErrBadRequest("Invalid set IDs")) return } - api.getCfg().Sets = reordered + newCfg.Sets = reordered - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } @@ -501,35 +501,36 @@ func (api *API) handleBatchDeleteSets(w http.ResponseWriter, r *http.Request) { return } - oldConfig := api.getCfg().Clone() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() toDelete := make(map[string]bool, len(req.Ids)) for _, id := range req.Ids { toDelete[id] = true } - filtered := make([]*config.SetConfig, 0, len(api.getCfg().Sets)) - for _, set := range api.getCfg().Sets { + filtered := make([]*config.SetConfig, 0, len(newCfg.Sets)) + for _, set := range newCfg.Sets { if !toDelete[set.Id] { filtered = append(filtered, set) } } - deleted := len(api.getCfg().Sets) - len(filtered) + deleted := len(newCfg.Sets) - len(filtered) if deleted == 0 { writeAPIError(w, ErrNotFound("No matching sets found")) return } - api.getCfg().Sets = filtered + newCfg.Sets = filtered - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { log.Errorf("Failed to save config after batch deleting sets: %v", err) writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } @@ -566,7 +567,8 @@ func (api *API) handleBatchSetEnabled(w http.ResponseWriter, r *http.Request) { return } - oldConfig := api.getCfg().Clone() + oldCfg := api.getCfg() + newCfg := oldCfg.Clone() target := make(map[string]bool, len(req.Ids)) for _, id := range req.Ids { @@ -575,7 +577,7 @@ func (api *API) handleBatchSetEnabled(w http.ResponseWriter, r *http.Request) { matched := 0 updated := 0 - for _, set := range api.getCfg().Sets { + for _, set := range newCfg.Sets { if target[set.Id] { matched++ if set.Enabled != req.Enabled { @@ -596,13 +598,13 @@ func (api *API) handleBatchSetEnabled(w http.ResponseWriter, r *http.Request) { return } - if err := api.saveAndPushConfig(api.getCfg()); err != nil { + if err := api.saveAndPushConfig(newCfg); err != nil { log.Errorf("Failed to save config after batch toggling sets: %v", err) writeAPIError(w, err) return } - if api.PerformSoftRestart(api.getCfg(), oldConfig) { + if api.PerformSoftRestart(newCfg, oldCfg) { log.Infof("Soft restart completed successfully") } diff --git a/src/http/handler/socks5.go b/src/http/handler/socks5.go index 253ffa71..d91fe685 100644 --- a/src/http/handler/socks5.go +++ b/src/http/handler/socks5.go @@ -66,12 +66,12 @@ func (api *API) updateSocks5Config(w http.ResponseWriter, r *http.Request) { return } - cfg := api.getCfg() - cfg.System.Socks5 = req + cur := api.getCfg() + newCfg := cur.Clone() + newCfg.System.Socks5 = req - if err := cfg.SaveToFile(cfg.ConfigPath); err != nil { - log.Errorf("Failed to save SOCKS5 config: %v", err) - writeJsonError(w, http.StatusInternalServerError, "Failed to save configuration") + if err := api.saveAndPushConfig(newCfg); err != nil { + writeAPIError(w, err) return } diff --git a/src/http/ui/src/i18n/en.json b/src/http/ui/src/i18n/en.json index 46162520..80b0f80b 100644 --- a/src/http/ui/src/i18n/en.json +++ b/src/http/ui/src/i18n/en.json @@ -428,9 +428,9 @@ "referenceDomain": "Reference Domain", "referenceDomainHelp": "A known accessible domain used to measure your raw network speed without DPI bypass. Discovery compares preset results against this baseline to evaluate performance. Choose a fast, reliable domain that is not blocked in your region.", "dnsConfig": "DNS Configuration", - "addDns": "Add DNS Server", - "addDnsHelp": "DNS servers used during discovery to detect DNS poisoning and find a working bypass. Each server is tested with plain and fragmented queries to determine if it can resolve blocked domains correctly.", - "activeDns": "Active DNS servers for poisoning detection:" + "addDns": "Add fallback DNS server", + "addDnsHelp": "Fallback UDP DNS servers used during discovery when DoH is unavailable. Discovery resolves over DoH first; if that fails, each server here is tested with plain and fragmented queries to find a working bypass.", + "activeDns": "Fallback UDP DNS servers:" }, "Watchdog": { "title": "Watchdog", diff --git a/src/http/ui/src/i18n/ru.json b/src/http/ui/src/i18n/ru.json index 0a5a74e6..b32d0c80 100644 --- a/src/http/ui/src/i18n/ru.json +++ b/src/http/ui/src/i18n/ru.json @@ -424,9 +424,9 @@ "referenceDomain": "Эталонный домен", "referenceDomainHelp": "Доступный домен для измерения реальной скорости сети без обхода DPI. Поиск сравнивает результаты пресетов с этим эталоном для оценки производительности. Выберите быстрый и надёжный домен, который не заблокирован в вашем регионе.", "dnsConfig": "Настройки DNS", - "addDns": "Добавить DNS сервер", - "addDnsHelp": "DNS серверы, используемые при поиске для обнаружения DNS-подмены и нахождения рабочего обхода. Каждый сервер тестируется обычным и фрагментированным запросом, чтобы определить, может ли он корректно разрешать заблокированные домены.", - "activeDns": "Активные DNS серверы для проверки подмены:" + "addDns": "Добавить запасной DNS сервер", + "addDnsHelp": "Запасные UDP DNS серверы, используемые при поиске, когда DoH недоступен. Поиск сначала разрешает через DoH; если не удаётся, каждый сервер здесь тестируется обычным и фрагментированным запросом для нахождения рабочего обхода.", + "activeDns": "Запасные UDP DNS серверы:" }, "Watchdog": { "title": "Мониторинг", diff --git a/src/http/ui/src/utils/connectionFilter.ts b/src/http/ui/src/utils/connectionFilter.ts index 16db61a3..7fb52093 100644 --- a/src/http/ui/src/utils/connectionFilter.ts +++ b/src/http/ui/src/utils/connectionFilter.ts @@ -14,7 +14,7 @@ function addTerm(acc: ParsedConnectionFilter, rawTerm: string): void { if (colonIndex > 0) { const field = term.substring(0, colonIndex).trim(); const value = term.substring(colonIndex + 1).trim(); - if (!field || !value) return; + if (!field) return; const target = isExclude ? acc.fieldExcludes : acc.fieldFilters; target[field] ??= []; target[field].push(value); @@ -52,6 +52,10 @@ export function parseConnectionFilter( return hasAnyTerm(acc) ? acc : null; } +function fieldMatches(fieldValue: string, value: string): boolean { + return value ? fieldValue.includes(value) : fieldValue.trim() === ""; +} + export function matchesConnectionFilter( parsed: ParsedConnectionFilter, getFieldValue: (field: string) => string, @@ -59,12 +63,12 @@ export function matchesConnectionFilter( ): boolean { for (const [field, values] of Object.entries(parsed.fieldFilters)) { const fieldValue = getFieldValue(field).toLowerCase(); - if (!values.some((value) => fieldValue.includes(value))) return false; + if (!values.some((value) => fieldMatches(fieldValue, value))) return false; } for (const [field, values] of Object.entries(parsed.fieldExcludes)) { const fieldValue = getFieldValue(field).toLowerCase(); - if (values.some((value) => fieldValue.includes(value))) return false; + if (values.some((value) => fieldMatches(fieldValue, value))) return false; } for (const term of parsed.globalFilters) { diff --git a/src/main.go b/src/main.go index af1a24b0..796ff2f7 100644 --- a/src/main.go +++ b/src/main.go @@ -356,6 +356,9 @@ func runB4(cmd *cobra.Command, args []string) error { if geoScheduler != nil { geoScheduler.Stop() } + if tablesMonitor != nil { + tablesMonitor.Stop() + } tproxyMgr.Stop() // Perform graceful shutdown with timeout diff --git a/src/netprobe/classify.go b/src/netprobe/classify.go new file mode 100644 index 00000000..468d1ecf --- /dev/null +++ b/src/netprobe/classify.go @@ -0,0 +1,144 @@ +package netprobe + +import "strings" + +type DomainStatus string + +const ( + DomainOk DomainStatus = "OK" + DomainTLSDPI DomainStatus = "TLS_DPI" + DomainTLSMITM DomainStatus = "TLS_MITM" + DomainTLSSpoof DomainStatus = "TLS_SPOOF" + DomainTLSAlert DomainStatus = "TLS_ALERT" + DomainTLSReset DomainStatus = "TLS_RST" + DomainTLSDrop DomainStatus = "TLS_DROP" + DomainSYNDrop DomainStatus = "SYN_DROP" + DomainTCP16 DomainStatus = "TCP16" + DomainISPPage DomainStatus = "ISP_PAGE" + DomainBlocked DomainStatus = "BLOCKED" + DomainDNSFake DomainStatus = "DNS_FAKE" + DomainTimeout DomainStatus = "TIMEOUT" + DomainError DomainStatus = "ERROR" +) + +type TLSStage int + +const ( + StageConnect TLSStage = iota + StageHandshake + StageRead +) + +const ( + TCP16MinBytes = 12 * 1024 + TCP16MaxBytes = 69 * 1024 +) + +func ClassifyTLSError(err error) (DomainStatus, string) { + return ClassifyTLSErrorStaged(err, StageHandshake, 0) +} + +func ClassifyTLSErrorStaged(err error, stage TLSStage, bytesRead int) (DomainStatus, string) { + if err == nil { + return DomainOk, "" + } + + msg := strings.ToLower(err.Error()) + + isTimeout := strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "timed out") + isEOF := strings.Contains(msg, "eof") + isReset := strings.Contains(msg, "connection reset") || strings.Contains(msg, "reset by peer") + + if stage == StageRead && isTimeout && bytesRead >= TCP16MinBytes && bytesRead <= TCP16MaxBytes { + return DomainTCP16, "Read stalled after TSPU fat-flow window (12-69KB)" + } + + if isTimeout { + switch stage { + case StageConnect: + return DomainSYNDrop, "TCP SYN dropped (no handshake)" + case StageHandshake: + return DomainTLSDrop, "TLS handshake timed out (drop)" + default: + return DomainTimeout, "Connection timed out" + } + } + + if strings.Contains(msg, "wrong version number") { + return DomainTLSSpoof, "Non-TLS response received (DPI replacement)" + } + for _, p := range []string{"record overflow", "oversized", "record layer failure", "decode error", "decoding error", "illegal parameter", "bad record mac", "decryption failed"} { + if strings.Contains(msg, p) { + return DomainTLSSpoof, "Garbage TLS response (DPI injection)" + } + } + + if strings.Contains(msg, "alert") || strings.Contains(msg, "unrecognized name") || strings.Contains(msg, "handshake failure") || strings.Contains(msg, "protocol version") { + switch { + case strings.Contains(msg, "unrecognized name"): + return DomainTLSAlert, "SNI blocked (unrecognized name)" + case strings.Contains(msg, "protocol version"): + return DomainTLSAlert, "TLS protocol version alert" + default: + return DomainTLSAlert, "TLS alert (DPI disruption)" + } + } + + if isReset { + if stage == StageHandshake || stage == StageConnect { + return DomainTLSReset, "TCP RST during handshake (active reset)" + } + return DomainTLSReset, "TCP RST during transfer" + } + + if isEOF { + if stage == StageHandshake || bytesRead == 0 { + return DomainTLSReset, "Connection terminated (EOF injection)" + } + return DomainTLSReset, "Connection dropped during transfer (EOF)" + } + + for _, p := range []string{"self-signed", "self signed", "unknown authority", "certificate has expired", "certificate is not valid", "hostname mismatch", "name mismatch", "x509", "certificate"} { + if strings.Contains(msg, p) { + return DomainTLSMITM, "Certificate substitution (possible MITM)" + } + } + + if strings.Contains(msg, "no shared cipher") || strings.Contains(msg, "cipher") { + return DomainTLSMITM, "Cipher mismatch (possible MITM)" + } + + if strings.Contains(msg, "refused") { + return DomainBlocked, "Connection refused" + } + if strings.Contains(msg, "no route to host") { + return DomainError, "No route to host (IP unreachable)" + } + if strings.Contains(msg, "network is unreachable") { + return DomainError, "Network unreachable" + } + if strings.Contains(msg, "no such host") || strings.Contains(msg, "no address") { + return DomainError, "DNS resolution failed" + } + if strings.Contains(msg, "internal error") { + return DomainError, "TLS internal error" + } + + return DomainError, err.Error() +} + +func ClassifyHTTPResponse(statusCode int, location, body string) (DomainStatus, string) { + if statusCode == 451 { + return DomainISPPage, "HTTP 451 Unavailable For Legal Reasons" + } + + if IsBlockPageRedirect(location) { + return DomainISPPage, "Redirect to ISP block page: " + location + } + + if DetectBlockPageBody([]byte(body)) != "" { + return DomainISPPage, "ISP block page detected in response body" + } + + return DomainOk, "" +} diff --git a/src/netprobe/classify_test.go b/src/netprobe/classify_test.go new file mode 100644 index 00000000..c6a79090 --- /dev/null +++ b/src/netprobe/classify_test.go @@ -0,0 +1,72 @@ +package netprobe + +import ( + "errors" + "testing" +) + +func TestClassifyTLSError(t *testing.T) { + cases := []struct { + raw string + stage TLSStage + bytesRead int + wantStatus DomainStatus + }{ + {"read: connection reset by peer", StageHandshake, 0, DomainTLSReset}, + {"tls: unrecognized name", StageHandshake, 0, DomainTLSAlert}, + {"remote error: tls: protocol version", StageHandshake, 0, DomainTLSAlert}, + {"tls: wrong version number", StageHandshake, 0, DomainTLSSpoof}, + {"x509: certificate signed by unknown authority", StageHandshake, 0, DomainTLSMITM}, + {"dial tcp: i/o timeout", StageConnect, 0, DomainSYNDrop}, + {"i/o timeout", StageRead, 20 * 1024, DomainTCP16}, + {"connection refused", StageHandshake, 0, DomainBlocked}, + {"dial tcp 1.2.3.4:443: connect: no route to host", StageConnect, 0, DomainError}, + {"dial tcp 1.2.3.4:443: connect: network is unreachable", StageConnect, 0, DomainError}, + {"no such host", StageHandshake, 0, DomainError}, + } + for _, c := range cases { + got, _ := ClassifyTLSErrorStaged(errors.New(c.raw), c.stage, c.bytesRead) + if got != c.wantStatus { + t.Errorf("ClassifyTLSErrorStaged(%q, stage=%d, n=%d) = %q, want %q", c.raw, c.stage, c.bytesRead, got, c.wantStatus) + } + } + if s, _ := ClassifyTLSError(nil); s != DomainOk { + t.Errorf("ClassifyTLSError(nil) = %q, want OK", s) + } +} + +func TestClassifyHTTPResponse(t *testing.T) { + if s, _ := ClassifyHTTPResponse(451, "", ""); s != DomainISPPage { + t.Errorf("HTTP 451 should be ISP_PAGE, got %q", s) + } + if s, _ := ClassifyHTTPResponse(302, "https://warning.rt.ru/blocked", ""); s != DomainISPPage { + t.Errorf("block redirect should be ISP_PAGE, got %q", s) + } + if s, _ := ClassifyHTTPResponse(200, "", "Доступ заблокирован по решению суда"); s != DomainISPPage { + t.Errorf("block body should be ISP_PAGE, got %q", s) + } + if s, _ := ClassifyHTTPResponse(200, "", "normal page"); s != DomainOk { + t.Errorf("benign page should be OK, got %q", s) + } +} + +func TestDetectBlockPageBody(t *testing.T) { + if DetectBlockPageBody([]byte("Доступ заблокирован по решению суда")) == "" { + t.Error("expected block page detection on Russian RKN body") + } + if DetectBlockPageBody([]byte("normal youtube page, nothing blocked here")) != "" { + t.Error("benign body with the word 'blocked' must not trip body detection") + } + if DetectBlockPageBody(nil) != "" { + t.Error("empty body must return no detection") + } +} + +func TestIsBlockPageRedirect(t *testing.T) { + if !IsBlockPageRedirect("https://warning.rt.ru/blocked") { + t.Error("expected redirect block detection") + } + if IsBlockPageRedirect("https://accounts.google.com/login") { + t.Error("benign redirect must not trip detection") + } +} diff --git a/src/netprobe/markers.go b/src/netprobe/markers.go new file mode 100644 index 00000000..54fe4908 --- /dev/null +++ b/src/netprobe/markers.go @@ -0,0 +1,58 @@ +package netprobe + +import ( + _ "embed" + "encoding/json" + "strings" + + "github.com/daniellavrushin/b4/log" +) + +//go:embed markers.json +var markersJSON []byte + +var ( + BlockPageRedirectMarkers []string + BlockPageBodyMarkers []string +) + +type markersData struct { + RedirectMarkers []string `json:"redirect_markers"` + BodyMarkers []string `json:"body_markers"` +} + +func init() { + var data markersData + if err := json.Unmarshal(markersJSON, &data); err != nil { + log.Errorf("Failed to parse embedded netprobe markers.json: %v", err) + return + } + BlockPageRedirectMarkers = data.RedirectMarkers + BlockPageBodyMarkers = data.BodyMarkers +} + +func IsBlockPageRedirect(location string) bool { + if location == "" { + return false + } + lower := strings.ToLower(location) + for _, marker := range BlockPageRedirectMarkers { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func DetectBlockPageBody(body []byte) string { + if len(body) == 0 { + return "" + } + lower := strings.ToLower(string(body)) + for _, marker := range BlockPageBodyMarkers { + if strings.Contains(lower, marker) { + return "ISP block page detected in response" + } + } + return "" +} diff --git a/src/netprobe/markers.json b/src/netprobe/markers.json new file mode 100644 index 00000000..2abd6465 --- /dev/null +++ b/src/netprobe/markers.json @@ -0,0 +1,26 @@ +{ + "redirect_markers": [ + "lawfilter", + "warning.rt.ru", + "blocked", + "access-denied", + "eais", + "zapret-info", + "rkn.gov.ru", + "mvd.ru", + "blocklist" + ], + "body_markers": [ + "заблокирован", + "запрещён", + "запрещен", + "ограничен", + "единый реестр", + "роскомнадзор", + "rkn.gov.ru", + "nap.gov.ru", + "eais.rkn.gov.ru", + "warning.rt.ru", + "решению суда" + ] +} diff --git a/src/netprobe/resolver.go b/src/netprobe/resolver.go new file mode 100644 index 00000000..8ad9c570 --- /dev/null +++ b/src/netprobe/resolver.go @@ -0,0 +1,277 @@ +package netprobe + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" + + "github.com/daniellavrushin/b4/dns" +) + +type Resolver struct { + Mark int + Timeout time.Duration + DoH []DoHServer + UDP []string +} + +type UDPAnswer struct { + IPs []string + NXDomain bool + Empty bool +} + +type ResolveOutcome struct { + IPs []string + DoHURL string + UDPSrv string +} + +type dohJSONResponse struct { + Answer []struct { + Type int `json:"type"` + Data string `json:"data"` + } `json:"Answer"` +} + +func (r *Resolver) dohServers() []DoHServer { + if len(r.DoH) > 0 { + return r.DoH + } + return DefaultDoHServers +} + +func (r *Resolver) udpServers() []string { + if len(r.UDP) > 0 { + return r.UDP + } + return DefaultUDPServers +} + +func (r *Resolver) timeout() time.Duration { + if r.Timeout > 0 { + return r.Timeout + } + return 5 * time.Second +} + +func wantsV6(recordType string) bool { + return recordType == "AAAA" || recordType == "ip6" +} + +func matchesFamily(ip net.IP, recordType string) bool { + if wantsV6(recordType) { + return ip.To4() == nil && ip.To16() != nil + } + return ip.To4() != nil +} + +func qtypeForRecord(recordType string) uint16 { + if wantsV6(recordType) { + return 28 + } + return 1 +} + +func (r *Resolver) ResolveDoHOnce(ctx context.Context, srv DoHServer, domain, recordType string) ([]string, error) { + client := HTTPClient(r.Mark, r.timeout()) + defer client.CloseIdleConnections() + + if srv.Format == DoHWire { + query := dns.BuildQuery(domain, 0, qtypeForRecord(recordType)) + body, err := dns.ResolveDoH(ctx, client, srv.URL, query) + if err != nil { + return nil, err + } + return filterIPStrings(dns.ParseResponseIPs(body), recordType), nil + } + + if recordType == "" { + recordType = "A" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + if err != nil { + return nil, err + } + q := req.URL.Query() + q.Set("name", domain) + q.Set("type", recordType) + req.URL.RawQuery = q.Encode() + req.Header.Set("Accept", "application/dns-json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("doh %s: unexpected status %d", srv.URL, resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 65536)) + if err != nil { + return nil, err + } + + var doh dohJSONResponse + if err := json.Unmarshal(body, &doh); err != nil { + return nil, err + } + + wantType := 1 + if wantsV6(recordType) { + wantType = 28 + } + + seen := make(map[string]bool) + var ips []string + for _, ans := range doh.Answer { + if ans.Type != wantType { + continue + } + if ans.Data == "" || seen[ans.Data] { + continue + } + seen[ans.Data] = true + ips = append(ips, ans.Data) + } + return ips, nil +} + +func (r *Resolver) ResolveUDPOnce(ctx context.Context, server, domain, recordType string) (UDPAnswer, error) { + udpCtx, cancel := context.WithTimeout(ctx, r.timeout()) + defer cancel() + + addr := server + if _, _, err := net.SplitHostPort(server); err != nil { + addr = net.JoinHostPort(server, "53") + } + conn, err := Dialer(r.Mark, r.timeout(), 0).DialContext(udpCtx, "udp", addr) + if err != nil { + return UDPAnswer{}, err + } + defer conn.Close() + + if deadline, ok := udpCtx.Deadline(); ok { + conn.SetDeadline(deadline) + } + + if _, err := conn.Write(dns.BuildQuery(domain, 0x4242, qtypeForRecord(recordType))); err != nil { + return UDPAnswer{}, err + } + + buf := make([]byte, 1500) + n, err := conn.Read(buf) + if err != nil { + return UDPAnswer{}, err + } + resp := buf[:n] + if len(resp) < 12 { + return UDPAnswer{}, fmt.Errorf("short DNS response") + } + + if rcode := resp[3] & 0x0F; rcode == 3 { + return UDPAnswer{NXDomain: true}, nil + } + + ips := filterIPStrings(dns.ParseResponseIPs(resp), recordType) + if len(ips) == 0 { + return UDPAnswer{Empty: true}, nil + } + return UDPAnswer{IPs: ips}, nil +} + +func (r *Resolver) ResolveResilient(ctx context.Context, domain, recordType string) (ResolveOutcome, error) { + dohAttempts := make([]func(context.Context) (ResolveOutcome, bool), 0, len(r.dohServers())) + for _, srv := range r.dohServers() { + srv := srv + dohAttempts = append(dohAttempts, func(c context.Context) (ResolveOutcome, bool) { + ips, err := r.ResolveDoHOnce(c, srv, domain, recordType) + if err == nil && len(ips) > 0 { + return ResolveOutcome{IPs: ips, DoHURL: srv.URL}, true + } + return ResolveOutcome{}, false + }) + } + if out, ok := r.race(ctx, dohAttempts); ok { + return out, nil + } + + udpAttempts := make([]func(context.Context) (ResolveOutcome, bool), 0, len(r.udpServers())) + for _, server := range r.udpServers() { + server := server + udpAttempts = append(udpAttempts, func(c context.Context) (ResolveOutcome, bool) { + ans, err := r.ResolveUDPOnce(c, server, domain, recordType) + if err == nil && len(ans.IPs) > 0 { + return ResolveOutcome{IPs: ans.IPs, UDPSrv: server}, true + } + return ResolveOutcome{}, false + }) + } + if out, ok := r.race(ctx, udpAttempts); ok { + return out, nil + } + + return ResolveOutcome{}, fmt.Errorf("no DoH or UDP server resolved %s", domain) +} + +func (r *Resolver) phaseTimeout() time.Duration { + if t := r.timeout(); t < 5*time.Second { + return t + } + return 5 * time.Second +} + +func (r *Resolver) race(ctx context.Context, attempts []func(context.Context) (ResolveOutcome, bool)) (ResolveOutcome, bool) { + if len(attempts) == 0 { + return ResolveOutcome{}, false + } + rctx, cancel := context.WithTimeout(ctx, r.phaseTimeout()) + defer cancel() + + ch := make(chan ResolveOutcome, len(attempts)) + for _, a := range attempts { + a := a + go func() { + if out, ok := a(rctx); ok { + ch <- out + return + } + ch <- ResolveOutcome{} + }() + } + + for range attempts { + select { + case out := <-ch: + if len(out.IPs) > 0 { + return out, true + } + case <-rctx.Done(): + return ResolveOutcome{}, false + } + } + return ResolveOutcome{}, false +} + +func filterIPStrings(ips []net.IP, recordType string) []string { + seen := make(map[string]bool) + var out []string + for _, ip := range ips { + if !matchesFamily(ip, recordType) { + continue + } + s := ip.String() + if seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} diff --git a/src/netprobe/resolver_test.go b/src/netprobe/resolver_test.go new file mode 100644 index 00000000..f0f27385 --- /dev/null +++ b/src/netprobe/resolver_test.go @@ -0,0 +1,244 @@ +package netprobe + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestResolveDoHOnceJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("name") != "example.com" { + t.Errorf("unexpected name param: %q", r.URL.Query().Get("name")) + } + w.Header().Set("Content-Type", "application/dns-json") + w.Write([]byte(`{"Answer":[{"type":1,"data":"93.184.216.34"},{"type":28,"data":"2606:2800:220:1:248:1893:25c8:1946"}]}`)) + })) + defer srv.Close() + + r := &Resolver{Timeout: 2 * time.Second} + ips, err := r.ResolveDoHOnce(context.Background(), DoHServer{URL: srv.URL, Format: DoHJSON}, "example.com", "A") + if err != nil { + t.Fatalf("ResolveDoHOnce error: %v", err) + } + if len(ips) != 1 || ips[0] != "93.184.216.34" { + t.Fatalf("want [93.184.216.34], got %v", ips) + } + + v6, err := r.ResolveDoHOnce(context.Background(), DoHServer{URL: srv.URL, Format: DoHJSON}, "example.com", "AAAA") + if err != nil { + t.Fatalf("ResolveDoHOnce AAAA error: %v", err) + } + if len(v6) != 1 || !strings.HasPrefix(v6[0], "2606:2800") { + t.Fatalf("want one AAAA, got %v", v6) + } +} + +func TestResolveResilientFallsThroughToWorkingServer(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer bad.Close() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"Answer":[{"type":1,"data":"1.2.3.4"}]}`)) + })) + defer good.Close() + + r := &Resolver{ + Timeout: 2 * time.Second, + DoH: []DoHServer{ + {URL: bad.URL, Format: DoHJSON}, + {URL: good.URL, Format: DoHJSON}, + }, + UDP: []string{}, + } + out, err := r.ResolveResilient(context.Background(), "example.com", "A") + if err != nil { + t.Fatalf("ResolveResilient error: %v", err) + } + if len(out.IPs) != 1 || out.IPs[0] != "1.2.3.4" { + t.Fatalf("want [1.2.3.4], got %v", out.IPs) + } + if out.DoHURL != good.URL { + t.Fatalf("want winner %s, got %s", good.URL, out.DoHURL) + } +} + +func TestResolveResilientFallsThroughToUDP(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer bad.Close() + + udpAddr, stop := startUDPResponder(t, dnsAResponse()) + defer stop() + + r := &Resolver{ + Timeout: 2 * time.Second, + DoH: []DoHServer{{URL: bad.URL, Format: DoHJSON}}, + UDP: []string{udpAddr}, + } + out, err := r.ResolveResilient(context.Background(), "example.com", "A") + if err != nil { + t.Fatalf("ResolveResilient error: %v", err) + } + if len(out.IPs) != 1 || out.IPs[0] != "5.6.7.8" { + t.Fatalf("want [5.6.7.8] from UDP, got %v", out.IPs) + } + if out.UDPSrv != udpAddr { + t.Fatalf("want udp winner %s, got %s", udpAddr, out.UDPSrv) + } +} + +func TestResolveResilientUDPNotStarvedByBlockedDoH(t *testing.T) { + hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer hang.Close() + + udpAddr, stop := startUDPResponder(t, dnsAResponse()) + defer stop() + + r := &Resolver{ + Timeout: 1500 * time.Millisecond, + DoH: []DoHServer{ + {URL: hang.URL, Format: DoHJSON}, + {URL: hang.URL, Format: DoHJSON}, + {URL: hang.URL, Format: DoHJSON}, + }, + UDP: []string{udpAddr}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + out, err := r.ResolveResilient(ctx, "example.com", "A") + if err != nil { + t.Fatalf("ResolveResilient error (blocked DoH starved UDP): %v", err) + } + if len(out.IPs) != 1 || out.IPs[0] != "5.6.7.8" || out.UDPSrv != udpAddr { + t.Fatalf("want UDP answer [5.6.7.8] from %s, got %+v", udpAddr, out) + } +} + +func TestResolveUDPOnceNXDomain(t *testing.T) { + resp := make([]byte, 12) + resp[3] = 0x03 + udpAddr, stop := startUDPResponder(t, resp) + defer stop() + + r := &Resolver{Timeout: 2 * time.Second} + ans, err := r.ResolveUDPOnce(context.Background(), udpAddr, "example.com", "A") + if err != nil { + t.Fatalf("ResolveUDPOnce error: %v", err) + } + if !ans.NXDomain { + t.Fatalf("want NXDomain, got %+v", ans) + } +} + +func TestResolveUDPOnceEmpty(t *testing.T) { + udpAddr, stop := startUDPResponder(t, make([]byte, 12)) + defer stop() + + r := &Resolver{Timeout: 2 * time.Second} + ans, err := r.ResolveUDPOnce(context.Background(), udpAddr, "example.com", "A") + if err != nil { + t.Fatalf("ResolveUDPOnce error: %v", err) + } + if !ans.Empty || ans.NXDomain { + t.Fatalf("want Empty, got %+v", ans) + } +} + +func TestDefaultDoHServersIPHostFirst(t *testing.T) { + if len(DefaultDoHServers) == 0 { + t.Fatal("DefaultDoHServers empty") + } + host := dohHost(t, DefaultDoHServers[0].URL) + if net.ParseIP(host) == nil { + t.Fatalf("first DoH server must be IP-host (DNS-poison safe), got %q", host) + } +} + +func TestMatchesFamily(t *testing.T) { + if !matchesFamily(net.ParseIP("1.2.3.4"), "A") { + t.Error("1.2.3.4 should match A") + } + if matchesFamily(net.ParseIP("1.2.3.4"), "AAAA") { + t.Error("1.2.3.4 should not match AAAA") + } + if !matchesFamily(net.ParseIP("2606:2800::1"), "AAAA") { + t.Error("v6 should match AAAA") + } +} + +func dohHost(t *testing.T, rawURL string) string { + t.Helper() + s := strings.TrimPrefix(rawURL, "https://") + s = strings.TrimPrefix(s, "http://") + if i := strings.IndexAny(s, "/:"); i >= 0 { + s = s[:i] + } + return s +} + +func startUDPResponder(t *testing.T, reply []byte) (string, func()) { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket: %v", err) + } + go func() { + buf := make([]byte, 1500) + for { + n, addr, err := pc.ReadFrom(buf) + if err != nil { + return + } + out := make([]byte, len(reply)) + copy(out, reply) + if len(out) >= 2 && n >= 2 { + out[0] = buf[0] + out[1] = buf[1] + } + pc.WriteTo(out, addr) + } + }() + host, port, _ := net.SplitHostPort(pc.LocalAddr().String()) + _ = host + return net.JoinHostPort("127.0.0.1", port), func() { pc.Close() } +} + +func dnsAResponse() []byte { + msg := []byte{ + 0x00, 0x00, + 0x81, 0x80, + 0x00, 0x01, + 0x00, 0x01, + 0x00, 0x00, + 0x00, 0x00, + } + question := []byte{ + 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', + 0x03, 'c', 'o', 'm', + 0x00, + 0x00, 0x01, + 0x00, 0x01, + } + answer := []byte{ + 0xc0, 0x0c, + 0x00, 0x01, + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x3c, + 0x00, 0x04, + 5, 6, 7, 8, + } + msg = append(msg, question...) + msg = append(msg, answer...) + return msg +} diff --git a/src/netprobe/servers.go b/src/netprobe/servers.go new file mode 100644 index 00000000..513b5a97 --- /dev/null +++ b/src/netprobe/servers.go @@ -0,0 +1,52 @@ +package netprobe + +import ( + _ "embed" + "encoding/json" + + "github.com/daniellavrushin/b4/log" +) + +//go:embed servers.json +var serversJSON []byte + +type DoHFormat string + +const ( + DoHJSON DoHFormat = "json" + DoHWire DoHFormat = "wire" +) + +type DoHServer struct { + Name string `json:"name"` + URL string `json:"url"` + Format DoHFormat `json:"format"` +} + +var ( + DefaultDoHServers []DoHServer + DefaultUDPServers []string + WireDoHServers []string +) + +type serversData struct { + DoHServers []DoHServer `json:"doh_servers"` + UDPServers []string `json:"udp_dns_servers"` + WireDoHServers []string `json:"wire_doh_servers"` +} + +func init() { + var data serversData + if err := json.Unmarshal(serversJSON, &data); err != nil { + log.Errorf("Failed to parse embedded netprobe servers.json: %v", err) + return + } + for i := range data.DoHServers { + if data.DoHServers[i].Format == "" { + data.DoHServers[i].Format = DoHJSON + } + } + DefaultDoHServers = data.DoHServers + DefaultUDPServers = data.UDPServers + WireDoHServers = data.WireDoHServers +} diff --git a/src/netprobe/servers.json b/src/netprobe/servers.json new file mode 100644 index 00000000..2fea1fbf --- /dev/null +++ b/src/netprobe/servers.json @@ -0,0 +1,28 @@ +{ + "doh_servers": [ + { "name": "Google (IP)", "url": "https://8.8.8.8/resolve", "format": "json" }, + { "name": "Cloudflare (IP)", "url": "https://1.1.1.1/dns-query", "format": "json" }, + { "name": "Google", "url": "https://dns.google/resolve", "format": "json" }, + { "name": "Cloudflare", "url": "https://cloudflare-dns.com/dns-query", "format": "json" }, + { "name": "Cloudflare (alt)", "url": "https://one.one.one.one/dns-query", "format": "json" }, + { "name": "AdGuard", "url": "https://dns.adguard-dns.com/resolve", "format": "json" }, + { "name": "Alibaba", "url": "https://dns.alidns.com/resolve", "format": "json" } + ], + "udp_dns_servers": [ + "8.8.8.8", + "1.1.1.1", + "9.9.9.9", + "94.140.14.14", + "77.88.8.8", + "223.5.5.5", + "208.67.222.222", + "76.76.2.0", + "194.242.2.2" + ], + "wire_doh_servers": [ + "https://1.1.1.1/dns-query", + "https://8.8.8.8/dns-query", + "https://9.9.9.9/dns-query", + "https://dns.google/dns-query" + ] +} diff --git a/src/netprobe/transport.go b/src/netprobe/transport.go new file mode 100644 index 00000000..d2a809a9 --- /dev/null +++ b/src/netprobe/transport.go @@ -0,0 +1,63 @@ +package netprobe + +import ( + "context" + "fmt" + "net" + "net/http" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +func MarkControl(mark int) func(string, string, syscall.RawConn) error { + if mark == 0 { + return nil + } + return func(_, _ string, c syscall.RawConn) error { + var serr error + if cerr := c.Control(func(fd uintptr) { + serr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, mark) + }); cerr != nil { + return cerr + } + if serr != nil { + return fmt.Errorf("failed to set SO_MARK=%d: %w", mark, serr) + } + return nil + } +} + +func Dialer(mark int, timeout, keepAlive time.Duration) *net.Dialer { + return &net.Dialer{ + Timeout: timeout, + KeepAlive: keepAlive, + Control: MarkControl(mark), + } +} + +func HTTPClient(mark int, timeout time.Duration) *http.Client { + tr := &http.Transport{ + DialContext: Dialer(mark, timeout, timeout).DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + MaxIdleConns: 100, + IdleConnTimeout: 30 * time.Second, + } + return &http.Client{Transport: tr, Timeout: timeout} +} + +func MarkedResolver(mark int, timeout time.Duration, server string) *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, addr string) (net.Conn, error) { + target := addr + if server != "" { + target = net.JoinHostPort(server, "53") + } + return Dialer(mark, timeout, timeout).DialContext(ctx, network, target) + }, + } +} diff --git a/src/netprobe/transport_test.go b/src/netprobe/transport_test.go new file mode 100644 index 00000000..e555d12d --- /dev/null +++ b/src/netprobe/transport_test.go @@ -0,0 +1,24 @@ +package netprobe + +import ( + "testing" + "time" +) + +func TestMarkControlZero(t *testing.T) { + if MarkControl(0) != nil { + t.Fatal("MarkControl(0) must be nil so no SO_MARK is set") + } + if MarkControl(42) == nil { + t.Fatal("MarkControl(non-zero) must install a control func") + } +} + +func TestDialerControl(t *testing.T) { + if Dialer(0, time.Second, 0).Control != nil { + t.Fatal("Dialer(0,...) must have nil Control") + } + if Dialer(7, time.Second, time.Second).Control == nil { + t.Fatal("Dialer(non-zero,...) must have Control") + } +} diff --git a/src/tables/iptables.go b/src/tables/iptables.go index a7e950ae..fc9730ad 100644 --- a/src/tables/iptables.go +++ b/src/tables/iptables.go @@ -170,6 +170,13 @@ type SysctlSetting struct { var sysctlSnapPath = "/tmp/b4_sysctl_snapshot.json" +func b4SysctlSettings() []SysctlSetting { + return []SysctlSetting{ + {Name: "net.netfilter.nf_conntrack_checksum", Desired: "0", Revert: "1"}, + {Name: "net.netfilter.nf_conntrack_tcp_be_liberal", Desired: "1", Revert: "0"}, + } +} + func loadSysctlSnapshot() map[string]string { b, err := os.ReadFile(sysctlSnapPath) if err != nil { @@ -691,12 +698,7 @@ func (manager *IPTablesManager) buildManifest() (Manifest, error) { } } - sysctls := []SysctlSetting{ - {Name: "net.netfilter.nf_conntrack_checksum", Desired: "0", Revert: "1"}, - {Name: "net.netfilter.nf_conntrack_tcp_be_liberal", Desired: "1", Revert: "0"}, - } - - return Manifest{IPSets: ipsets, Chains: chains, Rules: rules, Sysctls: sysctls}, nil + return Manifest{IPSets: ipsets, Chains: chains, Rules: rules, Sysctls: b4SysctlSettings()}, nil } func (ipt *IPTablesManager) Apply() error { @@ -728,6 +730,7 @@ func (ipt *IPTablesManager) Clear() error { m.RemoveChains() m.DestroyIPSets() destroyOrphanMSSIPSets() + m.RevertSysctls() return nil } diff --git a/src/tables/monitor.go b/src/tables/monitor.go index 268f5f38..5ef6be1a 100644 --- a/src/tables/monitor.go +++ b/src/tables/monitor.go @@ -123,7 +123,7 @@ func (m *Monitor) monitorLoop() { if m.routingIfacesChanged(cfg) { log.Warnf("Routing interface change detected, resyncing routing rules...") - RoutingSyncConfig(cfg) + RoutingForceResync(cfg) m.snapshotRoutingIfaces(cfg) log.Tracef("Routing rules resynced after interface change") } else if !RoutingRulesPresent(cfg) { diff --git a/src/tables/nftables.go b/src/tables/nftables.go index d627bd47..aba349dd 100644 --- a/src/tables/nftables.go +++ b/src/tables/nftables.go @@ -326,8 +326,9 @@ func (n *NFTablesManager) Apply() error { return err } - setSysctlOrProc("net.netfilter.nf_conntrack_checksum", "0") - setSysctlOrProc("net.netfilter.nf_conntrack_tcp_be_liberal", "1") + for _, s := range b4SysctlSettings() { + s.Apply() + } if err := n.ApplyMasquerade(); err != nil { return err @@ -364,6 +365,10 @@ func (n *NFTablesManager) Clear() error { } } + for _, s := range b4SysctlSettings() { + s.RevertBack() + } + return nil } diff --git a/src/tables/routing.go b/src/tables/routing.go index 3ac54c6a..ad0af099 100644 --- a/src/tables/routing.go +++ b/src/tables/routing.go @@ -368,8 +368,9 @@ func RoutingClearAll() { } } } else { - for _, st := range routeRuleCache { + for id, st := range routeRuleCache { routeCleanupAny(be, st) + delete(routeRuleCache, id) } be.clearAll() } diff --git a/src/tables/routing_ipt.go b/src/tables/routing_ipt.go index ee5796ac..9de5f352 100644 --- a/src/tables/routing_ipt.go +++ b/src/tables/routing_ipt.go @@ -235,6 +235,13 @@ func (b *routeIptBackend) clearAll() { } } + for _, cmd := range b.iptBoth() { + if !hasBinary(cmd) { + continue + } + sweepProxyInputAcceptsIpt(cmd) + } + // Clean up stale b4r_* ipsets if hasBinary("ipset") { out, _ := run("ipset", "list", "-n") diff --git a/src/tables/routing_nft.go b/src/tables/routing_nft.go index 99d63091..f5895718 100644 --- a/src/tables/routing_nft.go +++ b/src/tables/routing_nft.go @@ -208,6 +208,7 @@ func (b *routeNftBackend) destroyIPSet(name string) { } func (b *routeNftBackend) clearAll() { + sweepProxyInputAcceptsNft() runLogged("routing: flush route table", "nft", "flush", "table", "inet", routeNftTable) runLogged("routing: delete route table", "nft", "delete", "table", "inet", routeNftTable) } diff --git a/src/tables/routing_proxy.go b/src/tables/routing_proxy.go index f6adbe45..642c6848 100644 --- a/src/tables/routing_proxy.go +++ b/src/tables/routing_proxy.go @@ -3,6 +3,8 @@ package tables import ( "fmt" "os" + "regexp" + "strconv" "strings" "sync" @@ -11,6 +13,29 @@ import ( "github.com/daniellavrushin/b4/tproxy" ) +var nftMarkRuleRe = regexp.MustCompile(`meta mark & 0x([0-9a-fA-F]+) == 0x([0-9a-fA-F]+) (accept|return)`) + +func nftParseMarkRule(line string) (uint32, string, bool) { + m := nftMarkRuleRe.FindStringSubmatch(line) + if m == nil { + return 0, "", false + } + a, errA := strconv.ParseUint(m[1], 16, 32) + b, errB := strconv.ParseUint(m[2], 16, 32) + if errA != nil || errB != nil || a != b { + return 0, "", false + } + return uint32(a), m[3], true +} + +func nftHandleFromLine(line string) string { + idx := strings.LastIndex(line, "# handle ") + if idx < 0 { + return "" + } + return strings.TrimSpace(line[idx+len("# handle "):]) +} + const proxyRulePriority = 5 const proxyLocalDeliveryTable = 252 @@ -300,9 +325,17 @@ func insertProxyOutputJump(be routeBackend, chain string) { } func ensureProxyOutputBaseRulesNft(cfg *config.Config, st routeState, queueMark uint32) { - bypassSig := fmt.Sprintf("meta mark & 0x%x == 0x%x return", queueMark, queueMark) out, err := run("nft", "list", "chain", "inet", routeNftTable, routeNftOutput) - if err == nil && !strings.Contains(out, bypassSig) { + hasBypass := false + if err == nil { + for _, line := range strings.Split(out, "\n") { + if m, verb, ok := nftParseMarkRule(line); ok && verb == "return" && m == queueMark { + hasBypass = true + break + } + } + } + if err == nil && !hasBypass { runLogged("routing: insert output bypass (proxy)", "nft", "insert", "rule", "inet", routeNftTable, routeNftOutput, "meta", "mark", "&", fmt.Sprintf("0x%x", queueMark), "==", fmt.Sprintf("0x%x", queueMark), "return") @@ -415,6 +448,7 @@ func addProxyDivertRuleNft(chain string, v6 bool, setName string, mark uint32) { func addProxyInputAccept(be routeBackend, mark uint32) { markHex := fmt.Sprintf("0x%x/0x%x", mark, mark) if be.name() == backendNFTables { + removeProxyInputAcceptNft(mark) table, chain, ok := proxyInputChain() if !ok { log.Tracef("routing: no nft input filter chain found (tried inet fw4, inet filter); skipping input accept rule") @@ -439,33 +473,94 @@ func addProxyInputAccept(be routeBackend, mark uint32) { } } +func removeProxyInputAcceptNft(mark uint32) { + for _, c := range [][2]string{{"filter", "input"}, {"fw4", "input"}} { + table, chain := c[0], c[1] + out, err := run("nft", "-a", "list", "chain", "inet", table, chain) + if err != nil { + continue + } + for _, line := range strings.Split(out, "\n") { + m, verb, ok := nftParseMarkRule(line) + if !ok || verb != "accept" || m != mark { + continue + } + handle := nftHandleFromLine(line) + if handle == "" { + continue + } + runLogged("routing: delete input accept (proxy)", + "nft", "delete", "rule", "inet", table, chain, "handle", handle) + } + } +} + +func sweepProxyInputAcceptsNft() { + for _, c := range [][2]string{{"filter", "input"}, {"fw4", "input"}} { + table, chain := c[0], c[1] + out, err := run("nft", "-a", "list", "chain", "inet", table, chain) + if err != nil { + continue + } + for _, line := range strings.Split(out, "\n") { + m, verb, ok := nftParseMarkRule(line) + if !ok || verb != "accept" || !tproxy.InMarkRange(m) { + continue + } + handle := nftHandleFromLine(line) + if handle == "" { + continue + } + runLogged("routing: sweep input accept (proxy)", + "nft", "delete", "rule", "inet", table, chain, "handle", handle) + } + } +} + +func sweepProxyInputAcceptsIpt(cmd string) { + out, err := run(cmd, "-w", "-S", "INPUT") + if err != nil { + return + } + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-A INPUT ") || !strings.HasSuffix(line, "-j ACCEPT") || !strings.Contains(line, "-m mark --mark ") { + continue + } + m, ok := iptMarkFromRule(line) + if !ok || !tproxy.InMarkRange(m) { + continue + } + args := strings.Fields(line) + args[0] = "-D" + runLogged("routing: sweep input accept (proxy)", append([]string{cmd, "-w"}, args...)...) + } +} + +func iptMarkFromRule(line string) (uint32, bool) { + fields := strings.Fields(line) + for i, f := range fields { + if f != "--mark" || i+1 >= len(fields) { + continue + } + parts := strings.Split(fields[i+1], "/") + if len(parts) != 2 { + return 0, false + } + a, errA := strconv.ParseUint(strings.TrimPrefix(parts[0], "0x"), 16, 32) + b, errB := strconv.ParseUint(strings.TrimPrefix(parts[1], "0x"), 16, 32) + if errA != nil || errB != nil || a != b { + return 0, false + } + return uint32(a), true + } + return 0, false +} + func removeProxyInputAccept(be routeBackend, mark uint32) { markHex := fmt.Sprintf("0x%x/0x%x", mark, mark) if be.name() == backendNFTables { - sig := fmt.Sprintf("meta mark & 0x%x == 0x%x accept", mark, mark) - for _, c := range [][2]string{{"filter", "input"}, {"fw4", "input"}} { - table, chain := c[0], c[1] - out, err := run("nft", "-a", "list", "chain", "inet", table, chain) - if err != nil { - continue - } - for _, line := range strings.Split(out, "\n") { - handleIdx := strings.LastIndex(line, "# handle ") - if handleIdx == -1 { - continue - } - rule := strings.TrimSpace(line[:handleIdx]) - if !strings.Contains(rule, sig) { - continue - } - handle := strings.TrimSpace(line[handleIdx+len("# handle "):]) - if handle == "" { - continue - } - runLogged("routing: delete input accept (proxy)", - "nft", "delete", "rule", "inet", table, chain, "handle", handle) - } - } + removeProxyInputAcceptNft(mark) return } for _, fam := range []string{backendIPTables, backendIP6Tables, backendIPTablesLegacy, backendIP6TablesLegacy} { diff --git a/src/tproxy/port.go b/src/tproxy/port.go index 029ac6bf..5d485964 100644 --- a/src/tproxy/port.go +++ b/src/tproxy/port.go @@ -25,3 +25,7 @@ func PortFor(mark uint32) int { return DefaultPortBase + int(mark%PortRange) } +func InMarkRange(mark uint32) bool { + return mark >= MarkBase && mark < MarkBase+MarkRange +} + diff --git a/src/watchdog/checker.go b/src/watchdog/checker.go index 1b57fdee..8134ba31 100644 --- a/src/watchdog/checker.go +++ b/src/watchdog/checker.go @@ -11,8 +11,8 @@ import ( "syscall" "time" - "github.com/daniellavrushin/b4/discovery" "github.com/daniellavrushin/b4/log" + "github.com/daniellavrushin/b4/netprobe" "golang.org/x/sys/unix" ) @@ -45,7 +45,7 @@ func checkDomain(input string, mark uint, timeout time.Duration) CheckResult { }, ResponseHeaderTimeout: timeout, IdleConnTimeout: timeout, - DialContext: dialer.DialContext, + DialContext: dialer.DialContext, } client := &http.Client{ @@ -53,11 +53,8 @@ func checkDomain(input string, mark uint, timeout time.Duration) CheckResult { Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) > 0 { - locLower := strings.ToLower(req.URL.String()) - for _, marker := range discovery.BlockPageRedirectMarkers { - if strings.Contains(locLower, marker) { - return fmt.Errorf("ISP block page (redirect to %s)", req.URL.String()) - } + if netprobe.IsBlockPageRedirect(req.URL.String()) { + return fmt.Errorf("ISP block page (redirect to %s)", req.URL.String()) } } if len(via) >= 3 { @@ -76,7 +73,8 @@ func checkDomain(input string, mark uint, timeout time.Duration) CheckResult { start := time.Now() resp, err := client.Do(req) if err != nil { - return CheckResult{Error: discovery.HumanizeError(err.Error())} + _, detail := netprobe.ClassifyTLSError(err) + return CheckResult{Error: detail} } defer resp.Body.Close() @@ -121,7 +119,7 @@ evaluate: speed = float64(bytesRead) / duration.Seconds() } - if blockErr := discovery.DetectBlockPage(headBuf); blockErr != "" { + if blockErr := netprobe.DetectBlockPageBody(headBuf); blockErr != "" { return CheckResult{Error: blockErr} }