mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
refactor: streamline DNS probing logic and enhance IP connectivity ch… (#251)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled
This commit is contained in:
parent
12696523f6
commit
c8ad0a872a
48 changed files with 1478 additions and 775 deletions
6
.github/workflows/docs.yml
vendored
6
.github/workflows/docs.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
changelog.md
11
changelog.md
|
|
@ -1,5 +1,14 @@
|
|||
# B4 - Bye Bye Big Bro
|
||||
|
||||
## [1.68.0] - 2026-06-10
|
||||
|
||||
- FIXED: **Discovery found nothing on connections that tamper with DNS** - on networks where the provider tampers with DNS (so ordinary name lookups came back wrong or empty), Discovery could not look up its test sites and reported that nothing worked, even when a working setup existed. It looks those sites up over encrypted DNS instead, and saves the result set to use encrypted DNS (DNS-over-HTTPS) for its lookups by default, which holds up where plain DNS is tampered with.
|
||||
- FIXED: **Cancelling Discovery could crash b4** - pressing Cancel while a search was testing several sites at once could crash the whole service and force a restart.
|
||||
- FIXED: **Stopping b4 could leave its firewall rules on the router** - the cleanup at shutdown could be undone at the last moment, so the rules stayed in place after exit.
|
||||
- FIXED: **Routing through another interface broke after that interface changed its address** - when the chosen outgoing interface (for example a VPN or a modem) got a new address, the set's traffic kept using the old one until b4 was restarted.
|
||||
- FIXED: **Incomplete cleanup on stop** - stopping b4 (or `--clear-iptables`) left traces behind: adjusted system network settings, stray routing entries when two sets shared one outgoing interface, and a firewall rule that piled up with proxy or bridge routing.
|
||||
- FIXED: **Saving settings could leave the running configuration out of step** - changing the SOCKS5 proxy or your sets (adding domains or IPs, creating, editing, reordering, deleting, or bulk-toggling) could save a result that did not match what was actually running.
|
||||
|
||||
## [1.67.2] - 2026-06-10
|
||||
|
||||
- FIXED: **Web UI update reported success but kept the old version on some setups** - where b4 isn't managed by a normal service (for example running directly in a container), the update replaced the program on disk but never restarted the running copy. It now stops the old copy and relaunches the new one, so the update actually takes effect.
|
||||
|
|
@ -702,7 +711,7 @@
|
|||
|
||||
## [1.10.1] - 2025-11-03
|
||||
|
||||
- IMPROVED: Intermittent connection failures where blocked sites would randomly fail to load in certain browsers (`Safari`, `Firefox`, `Chrome`). Connections _should_ now be more stable and reliable across all browsers by optimizing packet fragmentation strategy.
|
||||
- IMPROVED: Intermittent connection failures where blocked sites would randomly fail to load in certain browsers (`Safari`, `Firefox`, `Chrome`). Connections *should* now be more stable and reliable across all browsers by optimizing packet fragmentation strategy.
|
||||
|
||||
## [1.10.0] - 2025-11-02
|
||||
|
||||
|
|
|
|||
|
|
@ -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 останавливает старую копию и запускает новую, поэтому обновление действительно вступает в силу.
|
||||
|
|
|
|||
|
|
@ -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, ""
|
||||
return netprobe.ClassifyTLSErrorStaged(err, stage, bytesRead)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,39 +199,16 @@ 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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return "", fmt.Errorf("no A record found")
|
||||
}
|
||||
return ips[0], nil
|
||||
}
|
||||
|
||||
type udpResult struct {
|
||||
IP string
|
||||
|
|
@ -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{IP: ans.IPs[0]}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,16 +1234,13 @@ 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) {
|
||||
if netprobe.IsBlockPageRedirect(loc) {
|
||||
result.Status = CheckStatusFailed
|
||||
result.Error = "ISP block page (redirect to " + loc + ")"
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf := make([]byte, 16*1024)
|
||||
tailBuf := make([]byte, 0, 64) // rolling tail for </body></html> detection
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
29
src/dns/query_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Мониторинг",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
144
src/netprobe/classify.go
Normal 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, ""
|
||||
}
|
||||
72
src/netprobe/classify_test.go
Normal file
72
src/netprobe/classify_test.go
Normal 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
58
src/netprobe/markers.go
Normal 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
26
src/netprobe/markers.json
Normal 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
277
src/netprobe/resolver.go
Normal 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
|
||||
}
|
||||
244
src/netprobe/resolver_test.go
Normal file
244
src/netprobe/resolver_test.go
Normal 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
52
src/netprobe/servers.go
Normal 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
28
src/netprobe/servers.json
Normal 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
63
src/netprobe/transport.go
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
24
src/netprobe/transport_test.go
Normal file
24
src/netprobe/transport_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -368,8 +368,9 @@ func RoutingClearAll() {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
for _, st := range routeRuleCache {
|
||||
for id, st := range routeRuleCache {
|
||||
routeCleanupAny(be, st)
|
||||
delete(routeRuleCache, id)
|
||||
}
|
||||
be.clearAll()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,10 +473,7 @@ func addProxyInputAccept(be routeBackend, mark uint32) {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
|
@ -450,15 +481,11 @@ func removeProxyInputAccept(be routeBackend, mark uint32) {
|
|||
continue
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
handleIdx := strings.LastIndex(line, "# handle ")
|
||||
if handleIdx == -1 {
|
||||
m, verb, ok := nftParseMarkRule(line)
|
||||
if !ok || verb != "accept" || m != mark {
|
||||
continue
|
||||
}
|
||||
rule := strings.TrimSpace(line[:handleIdx])
|
||||
if !strings.Contains(rule, sig) {
|
||||
continue
|
||||
}
|
||||
handle := strings.TrimSpace(line[handleIdx+len("# handle "):])
|
||||
handle := nftHandleFromLine(line)
|
||||
if handle == "" {
|
||||
continue
|
||||
}
|
||||
|
|
@ -466,6 +493,74 @@ func removeProxyInputAccept(be routeBackend, mark uint32) {
|
|||
"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 {
|
||||
removeProxyInputAcceptNft(mark)
|
||||
return
|
||||
}
|
||||
for _, fam := range []string{backendIPTables, backendIP6Tables, backendIPTablesLegacy, backendIP6TablesLegacy} {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
@ -53,13 +53,10 @@ 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) {
|
||||
if netprobe.IsBlockPageRedirect(req.URL.String()) {
|
||||
return fmt.Errorf("ISP block page (redirect to %s)", req.URL.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
|
|
@ -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}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue