refactor: streamline DNS probing logic and enhance IP connectivity ch… (#251)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled

This commit is contained in:
Daniel Lavrushin 2026-06-11 00:52:10 +02:00 committed by GitHub
parent 12696523f6
commit c8ad0a872a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1478 additions and 775 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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 останавливает старую копию и запускает новую, поэтому обновление действительно вступает в силу.

View file

@ -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)
}

View file

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

View file

@ -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 {

View file

@ -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")
},
}

View file

@ -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

View file

@ -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",

View file

@ -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)
},
}

View file

@ -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,

View file

@ -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,
}
}

View file

@ -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"`

View file

@ -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,
},
}

View file

@ -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)

View file

@ -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)

View file

@ -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)
},
}
}

View file

@ -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"`
}

View file

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

29
src/dns/query_test.go Normal file
View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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")
}

View file

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

View file

@ -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",

View file

@ -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": "Мониторинг",

View file

@ -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) {

View file

@ -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

144
src/netprobe/classify.go Normal file
View file

@ -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, ""
}

View file

@ -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, "", "<html>normal page</html>"); 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("<html>normal youtube page, nothing blocked here</html>")) != "" {
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")
}
}

58
src/netprobe/markers.go Normal file
View file

@ -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 ""
}

26
src/netprobe/markers.json Normal file
View file

@ -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",
"решению суда"
]
}

277
src/netprobe/resolver.go Normal file
View file

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

View file

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

52
src/netprobe/servers.go Normal file
View file

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

28
src/netprobe/servers.json Normal file
View file

@ -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"
]
}

63
src/netprobe/transport.go Normal file
View file

@ -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)
},
}
}

View file

@ -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")
}
}

View file

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

View file

@ -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) {

View file

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

View file

@ -368,8 +368,9 @@ func RoutingClearAll() {
}
}
} else {
for _, st := range routeRuleCache {
for id, st := range routeRuleCache {
routeCleanupAny(be, st)
delete(routeRuleCache, id)
}
be.clearAll()
}

View file

@ -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")

View file

@ -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)
}

View file

@ -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} {

View file

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

View file

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