Doh set feature (#245)

This commit is contained in:
Daniel Lavrushin 2026-06-08 20:55:40 +02:00 committed by GitHub
parent d267d12083
commit df470073ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1326 additions and 487 deletions

View file

@ -1,9 +1,14 @@
# B4 - Bye Bye Big Bro
## [1.66.1] - 2026-06-08
## [1.67.0] - 2026-06-08
- ADDED: **Encrypted DNS (DoH) for a set** - a set's DNS tab can now send its name lookups over an encrypted DNS-over-HTTPS connection instead of to a plain DNS server's IP. Some services only work when looked up through a specific resolver (for example `xbox-dns.ru` for sites that say "not available in your country"), and some providers tamper with ordinary DNS - encrypted DNS gets around both. Switch the set between "Plain DNS" and "DNS-over-HTTPS", then pick a server from the built-in list or paste your own address. Only that set's lookups are affected, so the rest of your DNS stays as it is.
- ADDED: **Target a set to IPv4 or IPv6 only** - a set can now be limited to only IPv4 or only IPv6 traffic, alongside the existing domain, IP, and TLS-version targeting. Some networks handle IPv4 and IPv6 differently, so the settings that work for a site over one can differ over the other. You can now make one set for a site over IPv4 and a separate set for the same site over IPv6, each with its own options. Discovery can also search for a working setup specifically over IPv4 or IPv6 and saves the result limited to that version. The default is "Any" (both), and the choice only appears when both IPv4 and IPv6 are turned on in Settings.
- FIXED: **Sending a set's traffic to a proxy didn't work while bypass options were on** - when a set was set to route its traffic through an upstream proxy (or the Telegram bridge), b4 still tried to apply its DPI-bypass tricks to that same traffic, which fought with the routing and the connection failed to open. b4 now hands routed traffic straight to the proxy, so routing works even with the bypass options left enabled - and routed connections still show up on the Traffic page.
- FIXED: **RST protection did nothing on sets that also used SYN-based bypass** - if a set combined "RST protection" with certain TCP bypass techniques, the protection was silently inactive because those connections weren't being tracked, so the site still got reset. RST protection now works in that combination. It also blocks the matching fake reset aimed at the destination server, not only the one aimed at your device.
- FIXED: **Dummy network interfaces were missing from the interface lists** - a dummy interface (for example `dummy0`) did not appear in Settings, so it could not be picked for monitoring or NAT masquerade. Any dummy interface that is up now shows up in both lists.
- FIXED: **Telegram desktop kept reconnecting every 30-60 seconds over the Telegram bridge** - a background keep-alive added in 1.66.0 to hold idle connections open actually sent a signal Telegram's servers rejected, so the connection dropped and rebuilt itself about twice a minute (you would see parts of the Telegram window flicker). That keep-alive has been removed, so connections stay healthy on their own again.
- FIXED: **Monitoring a site could add it to a blocking set and break it** - if you monitored a site (for example `youtube.com`) and also ran a set that blocks or routes traffic - such as an ad-blocking set built from a category like `category-ads-all` - the watchdog could mistake that set for the one that owns your site, because the block list happened to contain a sub-domain of it (for example `ads.youtube.com`). When the site dipped and the watchdog tried to repair it, it added the site to that block/routing set, so the site got blocked instead of restored. The watchdog now only repairs sets that list the site by name, and never touches a set that has Routing turned on, so monitoring can no longer poison a blocking or routing set. Note: if an earlier version already added the monitored site to such a set, remove it there by hand once.
## [1.66.0] - 2026-06-07

View file

@ -3,4 +3,71 @@ sidebar_position: 8
title: Domain Watchdog
---
English translation is in progress. See the [Russian version](/ru/docs/watchdog).
# Domain Watchdog
The watchdog periodically checks that the domains you list are reachable, and when it detects blocking it automatically runs [discovery](./discovery) to find a working configuration.
You can find its settings in **Settings → Discovery → Watchdog section**.
![](/img/watchdog/20260404234138.png)
## How it works
1. On a set interval, the watchdog makes an HTTP request to each domain in the list
2. If a domain does not respond, or a block page is detected, a failure is recorded
3. After several consecutive failures (the "Max retries" parameter), the watchdog automatically runs discovery for that domain
4. If discovery finds a working strategy, it is applied to a suitable existing set - and if there is none, a new set is created
## Parameters
| Parameter | Description | Default |
| --- | --- | --- |
| Check interval | How often to check domains while everything works | `300` sec (5 min) |
| Failure interval | How often to check a domain that is already in the "Degraded" state | `60` sec |
| Cooldown | Pause after a repair attempt before normal checks resume | `900` sec (15 min) |
| Timeout | Maximum time to wait for a response from a domain | `15` sec |
| Max retries | How many consecutive failures are needed to trigger auto-repair | `3` |
![](/img/watchdog/20260404234557.png)
## Domain statuses
| Status | Meaning |
| --- | --- |
| **Healthy** | The domain responds normally |
| **Degraded** | Failures have been recorded, but the repair threshold has not been reached yet |
| **Escalating** | Discovery is running to find a working configuration |
| **Queued** | The domain is waiting for its next check |
## Which set is used for the repair
Once discovery finds a working strategy, the watchdog has to decide where to write it. An existing set is reused **only** when all three of the following are true:
- the set is enabled;
- the set has Routing **turned off** (the Routing tab);
- the domain is **listed by name** in that set's domain (SNI) list.
When such a set is found, the watchdog overwrites its bypass parameters (TCP, fragmentation, faking) with the ones discovery just found. In other words, a repair changes that set's bypass settings - that is the whole point: to drop in a strategy that currently gets through.
If no suitable set exists, a new set named `watchdog-<domain>` is created with the discovered strategy.
:::warning Routing sets are never touched
The watchdog never adds a monitored domain to a set that has Routing enabled (block, proxy, interface, or the Telegram bridge). A GeoSite-category match (for example the sub-domain `ads.youtube.com` inside `category-ads-all`) does not count either - only a domain listed directly in a set's domain list does. This prevents a monitored site from being mistakenly added to a blocking set and breaking.
:::
## Adding domains
There are two ways to add domains:
- From the "Domain Watchdog" panel on the dashboard - an input field with a "+" button
- From **Settings → Discovery → Watchdog section**
You can enter either a domain (for example `youtube.com`) or a full URL (for example `https://youtube.com/watch?v=test`). If a URL is given, the watchdog checks that exact address. If only a domain is given, it checks `https://domain/`.
:::tip Which domains to add
Add domains you actually use and that may get blocked. The watchdog checks HTTP reachability specifically, so the domain must respond over HTTP/HTTPS.
:::
:::warning Watchdog and discovery
If discovery is already running manually, the watchdog will not start a parallel run - it waits for the current one to finish.
:::

View file

@ -15,8 +15,8 @@ title: Мониторинг доменов
1. Мониторинг с заданным интервалом делает HTTP-запрос к каждому домену из списка
2. Если домен не отвечает или обнаружена страница блокировки - фиксируется ошибка
3. После нескольких последовательных ошибок (настраивается) мониторинг автоматически запускает дискавери для этого домена
4. Найденная конфигурация применяется к существующему сету или создаётся новый
3. После нескольких последовательных ошибок (параметр «Макс. попыток») мониторинг автоматически запускает дискавери для этого домена
4. Если дискавери нашёл рабочую стратегию - она применяется к подходящему существующему сету, а если такого нет - создаётся новый сет
## Параметры
@ -39,6 +39,22 @@ title: Мониторинг доменов
| **Восстановление** | Запущен дискавери для поиска рабочей конфигурации |
| **В очереди** | Домен ожидает следующей проверки |
## Какой сет используется для восстановления
Когда дискавери нашёл рабочую стратегию, мониторинг должен решить, куда её записать. Существующий сет используется **только** если выполнены все три условия:
- сет включён;
- у сета **выключена** маршрутизация (вкладка «Маршрутизация»);
- домен **явно указан** в списке доменов (SNI) этого сета.
Если такой сет найден, мониторинг перезаписывает его параметры обхода (TCP, фрагментация, фейки) на только что найденные. То есть восстановление меняет настройки обхода у этого сета - это и есть его задача: подставить стратегию, которая сейчас проходит.
Если подходящего сета нет, создаётся новый сет с именем `watchdog-<домен>` с найденной стратегией.
:::warning Сеты с маршрутизацией не трогаются
Мониторинг никогда не добавляет отслеживаемый домен в сет с включённой маршрутизацией (блокировка, прокси, интерфейс, мост Telegram). Совпадение по категории GeoSite (например, поддомен `ads.youtube.com` в `category-ads-all`) тоже не считается - учитывается только домен, прямо указанный в списке доменов сета. Это исключает ситуацию, когда отслеживаемый сайт по ошибке попадает в блокирующий сет и перестаёт открываться.
:::
## Добавление доменов
Домены можно добавлять двумя способами:

View file

@ -86,6 +86,7 @@ var DefaultSetConfig = SetConfig{
Enabled: false,
FragmentQuery: false,
TargetDNS: "",
DoHURL: "",
},
Routing: RoutingConfig{

View file

@ -433,14 +433,26 @@ func (set *SetConfig) MatchesTLSVersion(tlsVersion uint16) bool {
if set.Targets.TLSVersion == "" {
return true
}
if tlsVersion == 0 {
return true
}
filterVer := TLSVersionCode(set.Targets.TLSVersion)
if filterVer == 0 {
return false // invalid filter value — don't silently match everything
}
return tlsVersion == filterVer
return tlsVersion == 0 || tlsVersion == filterVer
}
// MatchesIPVersion checks if the packet's IP version matches this set's filter.
// Returns true if no filter is configured or if version is 0 (unknown).
func (set *SetConfig) MatchesIPVersion(version uint8) bool {
switch set.Targets.IPVersion {
case "":
return true
case "4":
return version == 0 || version == 4
case "6":
return version == 0 || version == 6
default:
return false // invalid filter value — don't silently match everything
}
}
func (set *SetConfig) HasIPOrDomainTargets() bool {

View file

@ -65,6 +65,15 @@ var migrationRegistry = map[int]MigrationFunc{
41: migrateV41to42, // Add MTProto CF Worker domain config
42: migrateV42to43, // Add per-set routing block action
43: migrateV43to44, // Add MTProto DC-list fallback source config
44: migrateV44to45, // Add per-set DNS-over-HTTPS redirect target
}
func migrateV44to45(c *Config, _ map[string]interface{}) error {
log.Tracef("Migration v44->v45: Adding per-set DNS-over-HTTPS redirect target")
for _, set := range c.Sets {
set.DNS.DoHURL = DefaultSetConfig.DNS.DoHURL
}
return nil
}
func migrateV43to44(c *Config, _ map[string]interface{}) error {

View file

@ -221,7 +221,8 @@ type TargetsConfig struct {
GeoSiteCategories []string `json:"geosite_categories"`
GeoIpCategories []string `json:"geoip_categories"`
SourceDevices []string `json:"source_devices"`
TLSVersion string `json:"tls"` // "1.2", "1.3", or "" (match any)
TLSVersion string `json:"tls"` // "1.2", "1.3", or "" (match any)
IPVersion string `json:"ip_version"` // "4", "6", or "" (match any)
DomainsToMatch []string `json:"-"`
IpsToMatch []string `json:"-"`
}
@ -401,6 +402,7 @@ func ResolveStrategyPool(pool []string, fallback string) string {
type DNSConfig struct {
Enabled bool `json:"enabled"`
TargetDNS string `json:"target_dns"`
DoHURL string `json:"doh_url"`
FragmentQuery bool `json:"fragment_query"`
}

View file

@ -149,6 +149,11 @@ func (c *Config) Validate() error {
}
}
if set.DNS.Enabled && set.DNS.DoHURL != "" && !strings.HasPrefix(strings.ToLower(set.DNS.DoHURL), "https://") {
v.addf(fmt.Sprintf("sets[%d].dns.doh_url", setIdx), "doh_url_must_be_https", map[string]any{"set": set.Name}, "set %q: DNS-over-HTTPS URL must start with https://", set.Name)
return v.result()
}
if len(set.Fragmentation.SeqOverlapPattern) > 0 {
set.Fragmentation.SeqOverlapBytes = make([]byte, len(set.Fragmentation.SeqOverlapPattern))
for i, s := range set.Fragmentation.SeqOverlapPattern {

View file

@ -1,29 +1,17 @@
package detector
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/daniellavrushin/b4/dns"
)
const dohContentType = "application/dns-message"
var errDoHMethodNotAllowed = errors.New("doh method not allowed")
func resolveDoHWire(ctx context.Context, client *http.Client, serverURL, domain string) (string, error) {
query := dns.BuildAQuery(domain, 0)
body, err := dohWirePOST(ctx, client, serverURL, query)
if errors.Is(err, errDoHMethodNotAllowed) {
body, err = dohWireGET(ctx, client, serverURL, query)
}
body, err := dns.ResolveDoH(ctx, client, serverURL, query)
if err != nil {
return "", err
}
@ -36,44 +24,3 @@ func resolveDoHWire(ctx context.Context, client *http.Client, serverURL, domain
}
return "", fmt.Errorf("no A record")
}
func dohWirePOST(ctx context.Context, client *http.Client, serverURL string, query []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, bytes.NewReader(query))
if err != nil {
return nil, err
}
req.Header.Set("Accept", dohContentType)
req.Header.Set("Content-Type", dohContentType)
return dohWireDo(client, req)
}
func dohWireGET(ctx context.Context, client *http.Client, serverURL string, query []byte) ([]byte, error) {
enc := base64.RawURLEncoding.EncodeToString(query)
sep := "?"
if strings.ContainsRune(serverURL, '?') {
sep = "&"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL+sep+"dns="+enc, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", dohContentType)
return dohWireDo(client, req)
}
func dohWireDo(client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
io.Copy(io.Discard, resp.Body)
return nil, errDoHMethodNotAllowed
}
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("doh status %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 65536))
}

View file

@ -124,7 +124,7 @@ func HumanizeError(raw string) string {
return raw
}
func NewDiscoverySuite(inputs []string, pool *nfq.Pool, skipDNS bool, skipCache bool, payloadFiles []string, validationTries int, tlsVersion string, flowMark uint) *DiscoverySuite {
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 {
suite := NewCheckSuite(domainInputs)
@ -141,6 +141,10 @@ func NewDiscoverySuite(inputs []string, pool *nfq.Pool, skipDNS bool, skipCache
tlsVersion = "auto"
}
if ipVersion == "" {
ipVersion = "auto"
}
domainResults := make(map[string]*DomainDiscoveryResult)
for _, di := range domainInputs {
domainResults[di.Domain] = &DomainDiscoveryResult{
@ -161,6 +165,7 @@ func NewDiscoverySuite(inputs []string, pool *nfq.Pool, skipDNS bool, skipCache
skipCache: skipCache,
validationTries: validationTries,
tlsVersion: tlsVersion,
ipVersion: ipVersion,
flowMark: flowMark,
}
@ -211,9 +216,16 @@ func (ds *DiscoverySuite) RunDiscovery() {
for i, di := range ds.Domains {
domainNames[i] = di.Domain
}
if ds.tlsVersion != "" && ds.tlsVersion != "auto" {
tlsSet := ds.tlsVersion != "" && ds.tlsVersion != "auto"
ipSet := ds.ipVersion != "" && ds.ipVersion != "auto"
switch {
case tlsSet && ipSet:
log.DiscoveryLogf("Starting discovery for %d domains: %v (TLS: %s, IP: %s)", len(ds.Domains), domainNames, ds.tlsVersion, ds.ipVersion)
case tlsSet:
log.DiscoveryLogf("Starting discovery for %d domains: %v (TLS: %s)", len(ds.Domains), domainNames, ds.tlsVersion)
} else {
case ipSet:
log.DiscoveryLogf("Starting discovery for %d domains: %v (IP: %s)", len(ds.Domains), domainNames, ds.ipVersion)
default:
log.DiscoveryLogf("Starting discovery for %d domains: %v", len(ds.Domains), domainNames)
}
log.DiscoveryLogf("═══════════════════════════════════════")
@ -1163,6 +1175,31 @@ func (ds *DiscoverySuite) tlsFilterVersion() string {
}
}
// ipFilterVersion converts the discovery IP version setting to a config IP filter value.
func (ds *DiscoverySuite) ipFilterVersion() string {
switch ds.ipVersion {
case "ipv4":
return "4"
case "ipv6":
return "6"
default:
return ""
}
}
// dialNetwork forces the probe address family ("tcp4"/"tcp6") for an explicit
// IP version run, or "" to leave family selection to the resolver/OS.
func (ds *DiscoverySuite) dialNetwork() string {
switch ds.ipVersion {
case "ipv4":
return "tcp4"
case "ipv6":
return "tcp6"
default:
return ""
}
}
func (ds *DiscoverySuite) tlsConfig() *tls.Config {
cfg := &tls.Config{InsecureSkipVerify: true}
switch ds.tlsVersion {
@ -1193,9 +1230,13 @@ func (ds *DiscoverySuite) fetchUsingIPForDomain(di DomainInput, timeout time.Dur
}
baseDialer := markedDialer(ds.flowMark, timeout/2, timeout)
forcedNet := ds.dialNetwork()
if ip != "" {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
if forcedNet != "" {
network = forcedNet
}
if ip != "" {
_, port, _ := net.SplitHostPort(addr)
if port == "" {
port = "443"
@ -1204,8 +1245,7 @@ func (ds *DiscoverySuite) fetchUsingIPForDomain(di DomainInput, timeout time.Dur
log.Tracef("DNS bypass: connecting to %s instead of %s", directAddr, addr)
return baseDialer.DialContext(ctx, network, directAddr)
}
} else {
transport.DialContext = baseDialer.DialContext
return baseDialer.DialContext(ctx, network, addr)
}
client := &http.Client{
@ -1506,6 +1546,7 @@ func (ds *DiscoverySuite) buildTestConfig(preset ConfigPreset) *config.Config {
testSet.Targets.SNIDomains = []string{ds.Domain}
testSet.Targets.DomainsToMatch = []string{ds.Domain}
testSet.Targets.TLSVersion = ds.tlsFilterVersion()
testSet.Targets.IPVersion = ds.ipFilterVersion()
geoip, geosite := GetCDNCategories(ds.Domain)
if len(geoip) > 0 || len(geosite) > 0 {
@ -1647,6 +1688,7 @@ func (ds *DiscoverySuite) buildTestConfigMulti(preset ConfigPreset) *config.Conf
testSet.Targets.SNIDomains = allDomains
testSet.Targets.DomainsToMatch = allDomains
testSet.Targets.TLSVersion = ds.tlsFilterVersion()
testSet.Targets.IPVersion = ds.ipFilterVersion()
if len(allIPs) > 0 {
cidrIPs := make([]string, 0, len(allIPs))

View file

@ -35,11 +35,12 @@ type CDNEntry struct {
}
type DNSProber struct {
domain string
timeout time.Duration
pool *nfq.Pool
cfg *config.Config
flowMark uint
domain string
timeout time.Duration
pool *nfq.Pool
cfg *config.Config
flowMark uint
ipVersion string
}
var (
@ -87,6 +88,7 @@ func (ds *DiscoverySuite) runDNSDiscoveryForDomain(domain string) *DNSDiscoveryR
ds.pool,
ds.cfg,
ds.flowMark,
ds.ipVersion,
)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@ -131,16 +133,40 @@ func (r *DNSDiscoveryResult) hasWorkingConfig() bool {
return !r.IsPoisoned || r.BestServer != "" || r.NeedsFragment
}
func NewDNSProber(domain string, timeout time.Duration, pool *nfq.Pool, cfg *config.Config, flowMark uint) *DNSProber {
func NewDNSProber(domain string, timeout time.Duration, pool *nfq.Pool, cfg *config.Config, flowMark uint, ipVersion string) *DNSProber {
return &DNSProber{
domain: domain,
timeout: timeout,
pool: pool,
cfg: cfg,
flowMark: flowMark,
domain: domain,
timeout: timeout,
pool: pool,
cfg: cfg,
flowMark: flowMark,
ipVersion: ipVersion,
}
}
// 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":
return "ip4"
case "ipv6":
return "ip6"
}
if p.cfg.Queue.IPv6Enabled && !p.cfg.Queue.IPv4Enabled {
return "ip6"
}
return "ip4"
}
// dnsRecordType returns the DoH record type ("A"/"AAAA") matching ipNetwork.
func (p *DNSProber) dnsRecordType() string {
if p.ipNetwork() == "ip6" {
return "AAAA"
}
return "A"
}
func (p *DNSProber) Probe(ctx context.Context) *DNSDiscoveryResult {
result := &DNSDiscoveryResult{
ProbeResults: []DNSProbeResult{},
@ -304,10 +330,7 @@ func uniqueIPs(primary, secondary []string) []string {
}
func (p *DNSProber) getSystemResolverIPs(ctx context.Context) []string {
network := "ip4"
if p.cfg.Queue.IPv6Enabled && !p.cfg.Queue.IPv4Enabled {
network = "ip6"
}
network := p.ipNetwork()
resolver := markedResolver(p.flowMark, p.timeout/2, "")
ips, err := resolver.LookupIP(ctx, network, p.domain)
@ -335,10 +358,7 @@ func (p *DNSProber) getSystemResolverIPs(ctx context.Context) []string {
}
func (p *DNSProber) getExpectedIPs(ctx context.Context) []string {
recordType := "A"
if p.cfg.Queue.IPv6Enabled && !p.cfg.Queue.IPv4Enabled {
recordType = "AAAA"
}
recordType := p.dnsRecordType()
dohServers := []string{
"https://dns.google/resolve?name=%s&type=" + recordType,
@ -423,10 +443,7 @@ func (p *DNSProber) getExpectedIPs(ctx context.Context) []string {
}
func (p *DNSProber) getExpectedIPFallback(ctx context.Context) string {
network := "ip4"
if p.cfg.Queue.IPv6Enabled && !p.cfg.Queue.IPv4Enabled {
network = "ip6"
}
network := p.ipNetwork()
for _, server := range p.cfg.System.Checker.ReferenceDNS {
resolver := markedResolver(p.flowMark, p.timeout/3, server)
@ -455,10 +472,7 @@ func (p *DNSProber) testDNS(ctx context.Context, server string, fragmented bool,
resolver = markedResolver(p.flowMark, p.timeout, server)
}
network := "ip4"
if p.cfg.Queue.IPv6Enabled && !p.cfg.Queue.IPv4Enabled {
network = "ip6"
}
network := p.ipNetwork()
start := time.Now()
ips, err := resolver.LookupIP(ctx, network, p.domain)

View file

@ -35,6 +35,7 @@ type StartSuiteOptions struct {
PayloadFiles []string
ValidationTries int
TLSVersion string
IPVersion string
}
type Runtime struct {
@ -128,6 +129,7 @@ func (m *Runtime) StartSuite(cfg *config.Config, urls []string, opts StartSuiteO
opts.PayloadFiles,
opts.ValidationTries,
opts.TLSVersion,
opts.IPVersion,
runtimeState.FlowMark,
)
m.SetActiveSuiteID(suite.Id)

View file

@ -181,6 +181,7 @@ type DiscoverySuite struct {
skipCache bool
validationTries int
tlsVersion string // "auto", "tls12", "tls13"
ipVersion string // "auto", "ipv4", "ipv6"
flowMark uint
discoveryCache *DiscoveryCache

94
src/dns/doh.go Normal file
View file

@ -0,0 +1,94 @@
package dns
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
const DoHContentType = "application/dns-message"
var ErrDoHMethodNotAllowed = errors.New("doh method not allowed")
func MarkedDoHClient(mark int, timeout time.Duration) *http.Client {
tr := &http.Transport{
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
MaxIdleConns: 100,
IdleConnTimeout: 30 * time.Second,
}
d := &net.Dialer{Timeout: timeout}
if mark != 0 {
d.Control = 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
}
return serr
}
}
tr.DialContext = d.DialContext
return &http.Client{Transport: tr, Timeout: timeout}
}
func ResolveDoH(ctx context.Context, client *http.Client, serverURL string, query []byte) ([]byte, error) {
body, err := dohPOST(ctx, client, serverURL, query)
if errors.Is(err, ErrDoHMethodNotAllowed) {
body, err = dohGET(ctx, client, serverURL, query)
}
return body, err
}
func dohPOST(ctx context.Context, client *http.Client, serverURL string, query []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serverURL, bytes.NewReader(query))
if err != nil {
return nil, err
}
req.Header.Set("Accept", DoHContentType)
req.Header.Set("Content-Type", DoHContentType)
return dohDo(client, req)
}
func dohGET(ctx context.Context, client *http.Client, serverURL string, query []byte) ([]byte, error) {
enc := base64.RawURLEncoding.EncodeToString(query)
sep := "?"
if strings.ContainsRune(serverURL, '?') {
sep = "&"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL+sep+"dns="+enc, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", DoHContentType)
return dohDo(client, req)
}
func dohDo(client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
io.Copy(io.Discard, resp.Body)
return nil, ErrDoHMethodNotAllowed
}
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("doh status %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 65536))
}

View file

@ -150,6 +150,7 @@ func (api *API) handleStartDiscovery(w http.ResponseWriter, r *http.Request) {
PayloadFiles: req.PayloadFiles,
ValidationTries: validationTries,
TLSVersion: req.TLSVersion,
IPVersion: req.IPVersion,
})
if err != nil {
if errors.Is(err, discovery.ErrDiscoveryAlreadyRunning) {

View file

@ -8,6 +8,7 @@ type DiscoveryRequest struct {
PayloadFiles []string `json:"payload_files,omitempty"`
ValidationTries int `json:"validation_tries,omitempty"`
TLSVersion string `json:"tls_version,omitempty"` // "auto", "tls12", "tls13"
IPVersion string `json:"ip_version,omitempty"` // "auto", "ipv4", "ipv6"
}
type DiscoveryResponse struct {

View file

@ -10,6 +10,7 @@ export const discoveryApi = {
payload_files?: string[],
validation_tries?: number,
tls_version?: string,
ip_version?: string,
) =>
apiPost<DiscoveryResponse>("/api/discovery/start", {
check_urls,
@ -18,6 +19,7 @@ export const discoveryApi = {
payload_files: payload_files ?? [],
validation_tries: validation_tries ?? 1,
tls_version: tls_version ?? "auto",
ip_version: ip_version ?? "auto",
}),
status: (id: string) => apiGet<DiscoverySuite>(`/api/discovery/status/${id}`),
cancel: (id: string) => apiDelete(`/api/discovery/cancel/${id}`),

View file

@ -2,332 +2,239 @@
{
"ip": "8.8.4.4",
"name": "Google Secondary",
"desc": "Fast, global (~14ms)",
"dnssec": true,
"tags": [],
"desc": "Fast, global",
"warn": true
},
{
"ip": "185.228.168.9",
"name": "CleanBrowsing",
"desc": "Security filter, Seattle (~10ms)",
"dnssec": true,
"tags": ["fast", "security"]
"desc": "Security filter, Seattle"
},
{
"ip": "95.85.95.85",
"name": "Gcore",
"desc": "Fast CDN, Luxembourg (~11ms)",
"dnssec": true,
"tags": ["fast"]
"desc": "Fast CDN, Luxembourg"
},
{
"ip": "2001:4860:4860::8844",
"name": "Google IPv6",
"desc": "Fast, global (~11ms)",
"dnssec": true,
"tags": ["ipv6"],
"warn": true,
"ipv6": true
"desc": "Fast, global",
"ipv6": true,
"warn": true
},
{
"ip": "195.46.39.105",
"name": "SafeDNS",
"desc": "Frankfurt, content filter (~11ms)",
"dnssec": true,
"tags": ["fast", "filtering"]
"desc": "Frankfurt, content filter"
},
{
"ip": "208.67.222.222",
"name": "OpenDNS",
"desc": "Cisco, customizable (~12ms)",
"dnssec": true,
"tags": ["fast", "filtering"]
"desc": "Cisco, customizable"
},
{
"ip": "2606:4700:4700::1001",
"name": "Cloudflare IPv6",
"desc": "Fast, privacy-first (~12ms)",
"dnssec": true,
"tags": ["fast", "privacy", "ipv6"],
"desc": "Fast, privacy-first",
"ipv6": true
},
{
"ip": "1.1.1.1",
"name": "Cloudflare",
"desc": "Fastest, privacy-first (~12ms)",
"dnssec": true,
"tags": ["fast", "privacy"]
"desc": "Fastest, privacy-first"
},
{
"ip": "195.46.39.39",
"name": "SafeDNS Primary",
"desc": "Frankfurt (~13ms)",
"dnssec": true,
"tags": ["fast", "filtering"]
"desc": "Frankfurt"
},
{
"ip": "8.8.8.8",
"name": "Google",
"desc": "Fast, global (~14ms)",
"dnssec": true,
"tags": [],
"desc": "Fast, global",
"warn": true
},
{
"ip": "223.6.6.6",
"name": "Alibaba",
"desc": "China, may be slow/blocked (~15ms)",
"dnssec": false,
"tags": ["china", "poisoned"],
"china": true
"desc": "China, may be slow/blocked"
},
{
"ip": "45.90.29.77",
"name": "NextDNS",
"desc": "NY, customizable (~16ms)",
"dnssec": true,
"tags": ["fast", "privacy"]
"desc": "NY, customizable"
},
{
"ip": "208.67.220.220",
"name": "OpenDNS Secondary",
"desc": "Newark (~16ms)",
"dnssec": true,
"tags": ["fast", "filtering"]
"desc": "Newark"
},
{
"ip": "2620:119:35::35",
"name": "OpenDNS IPv6",
"desc": "Cisco (~16ms)",
"dnssec": true,
"tags": ["ipv6", "filtering"],
"desc": "Cisco",
"ipv6": true
},
{
"ip": "1.0.0.3",
"name": "Cloudflare Family",
"desc": "Malware + adult filter (~17ms)",
"dnssec": true,
"tags": ["fast", "family"]
"desc": "Malware + adult filter"
},
{
"ip": "8.26.56.26",
"name": "Comodo Secure",
"desc": "Malware blocking (~17ms)",
"dnssec": true,
"tags": ["fast", "security"]
"desc": "Malware blocking"
},
{
"ip": "208.67.222.220",
"name": "OpenDNS Alt",
"desc": "Newark (~17ms)",
"dnssec": true,
"tags": ["filtering"]
"desc": "Newark"
},
{
"ip": "2620:0:ccc::2",
"name": "OpenDNS IPv6 Alt",
"desc": "NY (~19ms)",
"dnssec": true,
"tags": ["ipv6", "filtering"],
"desc": "NY",
"ipv6": true
},
{
"ip": "8.20.247.10",
"name": "Comodo Secondary",
"desc": "NJ (~20ms)",
"dnssec": true,
"tags": ["security"]
"desc": "NJ"
},
{
"ip": "223.5.5.5",
"name": "Alibaba Primary",
"desc": "China, may be slow/blocked (~20ms)",
"dnssec": false,
"tags": ["china", "poisoned"],
"china": true
"desc": "China, may be slow/blocked"
},
{
"ip": "1.1.1.2",
"name": "Cloudflare Malware",
"desc": "Blocks malware (~20ms)",
"dnssec": true,
"tags": ["security"]
"desc": "Blocks malware"
},
{
"ip": "1.0.0.2",
"name": "Cloudflare Malware Alt",
"desc": "Blocks malware (~20ms)",
"dnssec": true,
"tags": ["security"]
"desc": "Blocks malware"
},
{
"ip": "149.112.112.13",
"name": "Quad9",
"desc": "SF, malware blocking (~24ms)",
"dnssec": true,
"tags": ["privacy", "security"]
"desc": "SF, malware blocking"
},
{
"ip": "64.6.64.6",
"name": "Neustar",
"desc": "NY, reliable (~24ms)",
"dnssec": true,
"tags": ["privacy"]
"desc": "NY, reliable"
},
{
"ip": "2620:fe::10",
"name": "Quad9 IPv6",
"desc": "SF (~25ms)",
"dnssec": true,
"tags": ["ipv6", "privacy", "security"],
"desc": "SF",
"ipv6": true
},
{
"ip": "2620:0:ccd::2",
"name": "OpenDNS IPv6 Newark",
"desc": "Newark (~25ms)",
"dnssec": true,
"tags": ["ipv6", "filtering"],
"desc": "Newark",
"ipv6": true
},
{
"ip": "2400:3200:baba::1",
"name": "Alibaba IPv6",
"desc": "China, may be slow/blocked",
"dnssec": false,
"tags": ["ipv6", "china", "poisoned"],
"ipv6": true,
"china": true
"ipv6": true
},
{
"ip": "149.112.112.9",
"name": "Quad9 Alt",
"desc": "SF (~43ms)",
"dnssec": true,
"tags": ["privacy", "security"]
"desc": "SF"
},
{
"ip": "94.140.14.140",
"name": "AdGuard Family",
"desc": "Blocks ads + adult (~43ms)",
"dnssec": true,
"tags": ["adblock", "family"]
"desc": "Blocks ads + adult"
},
{
"ip": "9.9.9.10",
"name": "Quad9 No Filter",
"desc": "No malware blocking (~45ms)",
"dnssec": true,
"tags": ["privacy"]
"desc": "No malware blocking"
},
{
"ip": "9.9.9.9",
"name": "Quad9 Primary",
"desc": "Malware blocking (~51ms)",
"dnssec": true,
"tags": ["privacy", "security"]
"desc": "Malware blocking"
},
{
"ip": "149.112.112.112",
"name": "Quad9 Secondary",
"desc": "SF (~52ms)",
"dnssec": true,
"tags": ["privacy", "security"]
"desc": "SF"
},
{
"ip": "8.26.56.10",
"name": "Comodo Alt",
"desc": "Clifton (~62ms)",
"dnssec": true,
"tags": ["security"]
"desc": "Clifton"
},
{
"ip": "149.112.112.11",
"name": "Quad9 ECS",
"desc": "SF, with ECS (~64ms)",
"dnssec": true,
"tags": ["privacy"]
"desc": "SF, with ECS"
},
{
"ip": "185.228.169.9",
"name": "CleanBrowsing EU",
"desc": "Amsterdam (~80ms)",
"dnssec": true,
"tags": ["security"]
"desc": "Amsterdam"
},
{
"ip": "205.171.3.66",
"name": "Level3",
"desc": "CenturyLink, Chicago (~108ms)",
"dnssec": true,
"tags": []
"desc": "CenturyLink, Chicago"
},
{
"ip": "2620:fe::fe",
"name": "Quad9 IPv6 Primary",
"desc": "SF (~111ms)",
"dnssec": true,
"tags": ["ipv6", "privacy", "security"],
"desc": "SF",
"ipv6": true
},
{
"ip": "74.82.42.42",
"name": "Hurricane Electric",
"desc": "Reliable backbone (~116ms)",
"dnssec": true,
"tags": ["privacy"]
"desc": "Reliable backbone"
},
{
"ip": "77.88.8.8",
"name": "Yandex",
"desc": "⚠️ Russian, likely poisoned",
"dnssec": true,
"tags": ["poisoned"],
"warn": true
},
{
"ip": "204.194.232.200",
"name": "OpenDNS Miami",
"desc": "Miami (~144ms)",
"dnssec": true,
"tags": ["filtering"]
"desc": "Miami"
},
{
"ip": "216.146.35.35",
"name": "Oracle Dyn",
"desc": "Ashburn (~149ms)",
"dnssec": true,
"tags": []
"desc": "Ashburn"
},
{
"ip": "2.56.220.2",
"name": "Gcore IPv4 Alt",
"desc": "Luxembourg (~194ms)",
"dnssec": true,
"tags": []
"desc": "Luxembourg"
},
{
"ip": "94.140.15.15",
"name": "AdGuard Secondary",
"desc": "Cyprus (~270ms)",
"dnssec": true,
"tags": ["adblock"]
"desc": "Cyprus"
},
{
"ip": "8.20.247.20",
"name": "Comodo Slow",
"desc": "Clifton (~316ms)",
"dnssec": true,
"tags": ["security"]
"desc": "Clifton"
},
{
"ip": "2a10:50c0::1:ff",
"name": "AdGuard IPv6",
"desc": "Cyprus (~480ms)",
"dnssec": true,
"tags": ["ipv6", "adblock"],
"desc": "Cyprus",
"ipv6": true
}
]

View file

@ -0,0 +1,52 @@
[
{
"url": "https://xbox-dns.ru/dns-query",
"name": "xbox-dns.ru",
"desc": "Public DoH resolver (RU)"
},
{
"url": "https://dns.comss.one/dns-query",
"name": "Comss.one",
"desc": "AdGuard-based public resolver (RU)"
},
{
"url": "https://cloudflare-dns.com/dns-query",
"name": "Cloudflare",
"desc": "Fast, privacy-first"
},
{
"url": "https://dns.google/dns-query",
"name": "Google",
"desc": "Fast, global"
},
{
"url": "https://dns.quad9.net/dns-query",
"name": "Quad9",
"desc": "Security filter, malware blocking"
},
{
"url": "https://dns.adguard-dns.com/dns-query",
"name": "AdGuard",
"desc": "Blocks ads and trackers"
},
{
"url": "https://family.adguard-dns.com/dns-query",
"name": "AdGuard Family",
"desc": "Blocks ads, trackers and adult content"
},
{
"url": "https://dns.mullvad.net/dns-query",
"name": "Mullvad",
"desc": "Privacy-first, no logging"
},
{
"url": "https://protective.joindns4.eu/dns-query",
"name": "DNS4EU Protective",
"desc": "EU public resolver, blocks malware and phishing"
},
{
"url": "https://common.dot.dns.yandex.net/dns-query",
"name": "Yandex",
"desc": "Russian resolver, fast in RU/CIS"
}
]

View file

@ -48,6 +48,7 @@ import { RunningDomainCard } from "./RunningDomainCard";
import { StrategyGroupCard } from "./StrategyGroupCard";
import { FailedDomainCard } from "./FailedDomainCard";
import { HistoryGroupCard } from "./HistoryGroupCard";
import { configApi } from "@b4.settings";
export const DiscoveryRunner = () => {
const { t } = useTranslation();
@ -118,6 +119,10 @@ export const DiscoveryRunner = () => {
(localStorage.getItem(
"b4_discovery_tls_version",
) as DiscoveryOptions["tlsVersion"]) || "auto",
ipVersion:
(localStorage.getItem(
"b4_discovery_ip_version",
) as DiscoveryOptions["ipVersion"]) || "auto",
}));
useEffect(() => {
@ -143,6 +148,20 @@ export const DiscoveryRunner = () => {
localStorage.setItem("b4_discovery_tls_version", options.tlsVersion);
}, [options.tlsVersion]);
useEffect(() => {
localStorage.setItem("b4_discovery_ip_version", options.ipVersion);
}, [options.ipVersion]);
const [ipVersionEnabled, setIpVersionEnabled] = useState(true);
useEffect(() => {
void configApi
.get()
.then((c) => setIpVersionEnabled(!!c.queue?.ipv4 && !!c.queue?.ipv6))
.catch(() => {});
}, []);
const effectiveIpVersion = ipVersionEnabled ? options.ipVersion : "auto";
const [checkUrls, setCheckUrls] = useState<string[]>([]);
const [urlInput, setUrlInput] = useState("");
@ -169,6 +188,8 @@ export const DiscoveryRunner = () => {
let presetName = result.preset_name;
if (options.tlsVersion === "tls12") presetName += "-tls12";
else if (options.tlsVersion === "tls13") presetName += "-tls13";
if (effectiveIpVersion === "ipv4") presetName += "-ipv4";
else if (effectiveIpVersion === "ipv6") presetName += "-ipv6";
setAddDialog({
open: true,
@ -183,6 +204,8 @@ export const DiscoveryRunner = () => {
let presetName = group.winnerPreset || familyNames[group.family];
if (options.tlsVersion === "tls12") presetName += "-tls12";
else if (options.tlsVersion === "tls13") presetName += "-tls13";
if (effectiveIpVersion === "ipv4") presetName += "-ipv4";
else if (effectiveIpVersion === "ipv6") presetName += "-ipv6";
setAddDialog({
open: true,
@ -217,7 +240,12 @@ export const DiscoveryRunner = () => {
const addUrls = useCallback((raw: string) => {
const parts = raw
.split(/[\n,]+/)
.map((l) => l.trim().replace(/^["'`]+|["'`]+$/g, "").trim())
.map((l) =>
l
.trim()
.replace(/^["'`]+|["'`]+$/g, "")
.trim(),
)
.filter((l) => l.length > 0);
if (parts.length === 0) return;
setCheckUrls((prev) => {
@ -394,6 +422,7 @@ export const DiscoveryRunner = () => {
options.payloadFiles,
options.validationTries,
options.tlsVersion,
effectiveIpVersion,
);
}}
disabled={checkUrls.length === 0}
@ -445,6 +474,7 @@ export const DiscoveryRunner = () => {
)}
<DiscoveryOptionsPanel
options={options}
ipVersionEnabled={ipVersionEnabled}
onChange={setOptions}
onClearCache={() => {
void (async () => {

View file

@ -23,6 +23,7 @@ import { Capture } from "@b4.capture";
import { useTranslation } from "react-i18next";
export type TLSVersion = "auto" | "tls12" | "tls13";
export type IPVersion = "auto" | "ipv4" | "ipv6";
export interface DiscoveryOptions {
skipDNS: boolean;
@ -30,6 +31,7 @@ export interface DiscoveryOptions {
payloadFiles: string[];
validationTries: number;
tlsVersion: TLSVersion;
ipVersion: IPVersion;
}
interface DiscoveryOptionsPanelProps {
@ -38,6 +40,7 @@ interface DiscoveryOptionsPanelProps {
onClearCache?: () => void;
captures: Capture[];
disabled?: boolean;
ipVersionEnabled?: boolean;
}
export const DiscoveryOptionsPanel = ({
@ -46,6 +49,7 @@ export const DiscoveryOptionsPanel = ({
onClearCache,
captures,
disabled = false,
ipVersionEnabled = true,
}: DiscoveryOptionsPanelProps) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(() => {
@ -62,7 +66,8 @@ export const DiscoveryOptionsPanel = ({
options.skipCache ||
options.payloadFiles.length > 0 ||
options.validationTries > 1 ||
options.tlsVersion !== "auto";
options.tlsVersion !== "auto" ||
(ipVersionEnabled && options.ipVersion !== "auto");
return (
<Box
@ -92,7 +97,7 @@ export const DiscoveryOptionsPanel = ({
</Typography>
{!expanded && hasOptions && (
<B4Badge
label={getOptionsSummary(options, t)}
label={getOptionsSummary(options, t, ipVersionEnabled)}
sx={{
height: 20,
fontSize: "0.7rem",
@ -204,6 +209,51 @@ export const DiscoveryOptionsPanel = ({
</ToggleButtonGroup>
</Box>
{/* IP Version */}
{ipVersionEnabled && (
<Box>
<Typography variant="body1" sx={{ mb: 1 }}>
{t("discovery.options.ipVersion")}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 1, display: "block" }}
>
{t("discovery.options.ipVersionHint")}
</Typography>
<ToggleButtonGroup
value={options.ipVersion}
exclusive
onChange={(_, value) => {
if (value !== null) {
onChange({ ...options, ipVersion: value as IPVersion });
}
}}
disabled={disabled}
size="small"
sx={{
"& .MuiToggleButton-root": {
color: colors.text.secondary,
borderColor: colors.border.default,
textTransform: "none",
px: 2,
"&.Mui-selected": {
bgcolor: colors.accent.secondary,
color: colors.secondary,
borderColor: colors.secondary,
"&:hover": { bgcolor: colors.accent.secondary },
},
},
}}
>
<ToggleButton value="auto">Auto</ToggleButton>
<ToggleButton value="ipv4">IPv4</ToggleButton>
<ToggleButton value="ipv6">IPv6</ToggleButton>
</ToggleButtonGroup>
</Box>
)}
{/* Custom Payloads */}
{tlsCaptures.length > 0 && (
<Box>
@ -272,7 +322,10 @@ export const DiscoveryOptionsPanel = ({
{tlsCaptures.length === 0 && (
<Typography variant="caption" color="text.secondary">
{t("discovery.options.noPayloads")}{" "}
<a href="/settings/payloads" style={{ color: colors.secondary }}>
<a
href="/settings/payloads"
style={{ color: colors.secondary }}
>
{t("discovery.options.capturePayloads")}
</a>{" "}
{t("discovery.options.noPayloadsSuffix")}
@ -285,19 +338,31 @@ export const DiscoveryOptionsPanel = ({
);
};
function getOptionsSummary(options: DiscoveryOptions, t: (key: string, opts?: Record<string, unknown>) => string): string {
function getOptionsSummary(
options: DiscoveryOptions,
t: (key: string, opts?: Record<string, unknown>) => string,
ipVersionEnabled: boolean,
): string {
const parts: string[] = [];
if (options.skipDNS) parts.push(t("discovery.options.summarySkipDns"));
if (options.skipCache) parts.push(t("discovery.options.summarySkipCache"));
if (options.tlsVersion === "tls12") parts.push("TLS 1.2");
if (options.tlsVersion === "tls13") parts.push("TLS 1.3");
if (ipVersionEnabled && options.ipVersion === "ipv4") parts.push("IPv4");
if (ipVersionEnabled && options.ipVersion === "ipv6") parts.push("IPv6");
if (options.validationTries > 1)
parts.push(t("discovery.options.summaryTries", { count: options.validationTries }));
parts.push(
t("discovery.options.summaryTries", { count: options.validationTries }),
);
if (options.payloadFiles.length > 0) {
parts.push(
options.payloadFiles.length > 1
? t("discovery.options.summaryPayloads_plural", { count: options.payloadFiles.length })
: t("discovery.options.summaryPayloads", { count: options.payloadFiles.length }),
? t("discovery.options.summaryPayloads_plural", {
count: options.payloadFiles.length,
})
: t("discovery.options.summaryPayloads", {
count: options.payloadFiles.length,
}),
);
}
return parts.join(", ");

View file

@ -272,6 +272,8 @@ export const SetEditorPage = ({
config={editedSet}
stats={stats}
otherSetsTargets={otherSetsTargets}
ipv4={config.queue.ipv4}
ipv6={config.queue.ipv6}
onChange={handleChange}
/>
</B4TabPanel>

View file

@ -63,6 +63,8 @@ interface TargetSettingsProps {
geo: GeoConfig;
stats?: SetStats;
otherSetsTargets?: OtherSetsTargets;
ipv4?: boolean;
ipv6?: boolean;
onChange: (field: string, value: string | string[]) => void;
}
@ -96,8 +98,11 @@ export const TargetSettings = ({
geo,
stats,
otherSetsTargets,
ipv4,
ipv6,
}: TargetSettingsProps) => {
const { t } = useTranslation();
const showIpVersionFilter = (!!ipv4 && !!ipv6) || !!config.targets.ip_version;
const [tabValue, setTabValue] = useState(0);
const {
devices,
@ -432,20 +437,39 @@ export const TargetSettings = ({
<B4TabPanel value={tabValue} index={0} idPrefix="target-tab">
<B4Hint>{t("sets.targets.domainAlert")}</B4Hint>
<Box sx={{ my: 3, maxWidth: 260 }}>
<B4Select
label={t("sets.targets.tlsVersionFilter")}
value={config.targets.tls ?? ""}
options={[
{ value: "", label: t("sets.targets.tlsAny") },
{ value: "1.2", label: "TLS 1.2" },
{ value: "1.3", label: "TLS 1.3" },
]}
helperText={t("sets.targets.tlsHelperText")}
onChange={(e) =>
onChange("targets.tls", e.target.value as string)
}
/>
<Box sx={{ my: 3, display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ maxWidth: 260, flex: 1, minWidth: 200 }}>
<B4Select
label={t("sets.targets.tlsVersionFilter")}
value={config.targets.tls ?? ""}
options={[
{ value: "", label: t("sets.targets.tlsAny") },
{ value: "1.2", label: "TLS 1.2" },
{ value: "1.3", label: "TLS 1.3" },
]}
helperText={t("sets.targets.tlsHelperText")}
onChange={(e) =>
onChange("targets.tls", e.target.value as string)
}
/>
</Box>
{showIpVersionFilter && (
<Box sx={{ maxWidth: 260, flex: 1, minWidth: 200 }}>
<B4Select
label={t("sets.targets.ipVersionFilter")}
value={config.targets.ip_version ?? ""}
options={[
{ value: "", label: t("sets.targets.ipVersionAny") },
{ value: "4", label: "IPv4" },
{ value: "6", label: "IPv6" },
]}
helperText={t("sets.targets.ipVersionHelperText")}
onChange={(e) =>
onChange("targets.ip_version", e.target.value as string)
}
/>
</Box>
)}
</Box>
<Grid container spacing={2}>

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import {
Box,
Grid,
@ -6,28 +7,30 @@ import {
ListItemIcon,
ListItemText,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
import { B4Alert, B4Badge, B4Hint, B4Switch, B4TextField } from "@b4.elements";
import {
DnsIcon,
SecurityIcon,
CheckIcon,
BlockIcon,
SpeedIcon,
} from "@b4.icons";
import { DnsIcon, CheckIcon, BlockIcon } from "@b4.icons";
import { B4SetConfig } from "@models/config";
import { colors } from "@design";
import { useTranslation } from "react-i18next";
import dns from "@assets/dns.json";
import doh from "@assets/doh.json";
interface DnsEntry {
name: string;
ip: string;
ipv6: boolean;
ipv6?: boolean;
desc: string;
warn?: boolean;
}
interface DohEntry {
name: string;
url: string;
desc: string;
dnssec?: boolean;
tags: string[];
warn?: boolean;
}
@ -35,6 +38,10 @@ const POPULAR_DNS = (dns as DnsEntry[]).sort((a, b) =>
a.name.localeCompare(b.name),
);
const POPULAR_DOH = doh as DohEntry[];
type ResolverMode = "udp" | "doh";
interface DnsRedirectProps {
config: B4SetConfig;
ipv6: boolean;
@ -49,13 +56,28 @@ export const DnsRedirect = ({ config, ipv6, onChange }: DnsRedirectProps) => {
const dnsConfig = config.dns || {
enabled: false,
target_dns: "",
doh_url: "",
fragment_query: false,
};
const selectedServer = POPULAR_DNS.find((d) => d.ip === dnsConfig.target_dns);
const handleServerSelect = (ip: string) => {
onChange("dns.target_dns", ip);
const [mode, setMode] = useState<ResolverMode>(
dnsConfig.doh_url ? "doh" : "udp",
);
const handleModeChange = (_: unknown, next: ResolverMode | null) => {
if (!next || next === mode) return;
setMode(next);
if (next === "udp") {
onChange("dns.doh_url", "");
} else {
onChange("dns.target_dns", "");
}
};
const selectedServer = POPULAR_DNS.find((d) => d.ip === dnsConfig.target_dns);
const selectedDoH = POPULAR_DOH.find((d) => d.url === dnsConfig.doh_url);
const activeResolver = mode === "doh" ? dnsConfig.doh_url : dnsConfig.target_dns;
return (
<Grid container spacing={3}>
<B4Hint>{t("sets.dns.alert")}</B4Hint>
@ -71,171 +93,289 @@ export const DnsRedirect = ({ config, ipv6, onChange }: DnsRedirectProps) => {
{dnsConfig.enabled && (
<>
<Grid size={{ xs: 12, md: 6 }}>
<B4Switch
label={t("sets.dns.fragmentQuery")}
checked={dnsConfig.fragment_query || false}
onChange={(checked: boolean) =>
onChange("dns.fragment_query", checked)
}
description={t("sets.dns.fragmentQueryDesc")}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<B4TextField
label={t("sets.dns.serverIp")}
value={dnsConfig.target_dns}
onChange={(e) => onChange("dns.target_dns", e.target.value)}
placeholder={t("sets.dns.serverIpPlaceholder")}
helperText={t("sets.dns.serverIpHelper")}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
{selectedServer && (
<Box
sx={{
p: 2,
bgcolor: colors.background.paper,
borderRadius: 1,
border: `1px solid ${colors.border.default}`,
height: "100%",
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<DnsIcon sx={{ color: colors.secondary }} />
<Typography variant="subtitle2">
{selectedServer.name}
</Typography>
{selectedServer.dnssec && (
<B4Badge
icon={<SecurityIcon />}
label="DNSSEC"
variant="outlined"
color="secondary"
/>
)}
</Stack>
<Typography variant="caption" color="text.secondary">
{selectedServer.desc}
</Typography>
</Box>
)}
</Grid>
{/* DNS server list */}
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("sets.dns.recommendedServers")}
<Typography
variant="caption"
color="text.secondary"
component="div"
sx={{ mb: 1 }}
>
{t("sets.dns.modeLabel")}
</Typography>
<Box
<ToggleButtonGroup
exclusive
size="small"
value={mode}
onChange={handleModeChange}
sx={{
border: `1px solid ${colors.border.default}`,
borderRadius: 1,
bgcolor: colors.background.paper,
maxHeight: 320,
overflow: "auto",
"& .MuiToggleButton-root.Mui-selected": {
bgcolor: `${colors.secondary}22`,
color: colors.secondary,
borderColor: colors.secondary,
"&:hover": { bgcolor: `${colors.secondary}33` },
},
}}
>
<List dense disablePadding>
{POPULAR_DNS.filter((server) =>
ipv6 ? server.ipv6 : !server.ipv6,
).map((server) => (
<ListItemButton
key={server.ip}
selected={dnsConfig.target_dns === server.ip}
onClick={() => handleServerSelect(server.ip)}
<ToggleButton value="udp">{t("sets.dns.modeUdp")}</ToggleButton>
<ToggleButton value="doh">{t("sets.dns.modeDoH")}</ToggleButton>
</ToggleButtonGroup>
</Grid>
{mode === "udp" && (
<>
<Grid size={{ xs: 12, md: 6 }}>
<B4Switch
label={t("sets.dns.fragmentQuery")}
checked={dnsConfig.fragment_query || false}
onChange={(checked: boolean) =>
onChange("dns.fragment_query", checked)
}
description={t("sets.dns.fragmentQueryDesc")}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<B4TextField
label={t("sets.dns.serverIp")}
value={dnsConfig.target_dns}
onChange={(e) => onChange("dns.target_dns", e.target.value)}
placeholder={t("sets.dns.serverIpPlaceholder")}
helperText={t("sets.dns.serverIpHelper")}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
{selectedServer && (
<Box
sx={{
borderLeft: server.warn
? `3px solid ${colors.quaternary}`
: "3px solid transparent",
"&.Mui-selected": {
bgcolor: `${colors.secondary}22`,
borderLeftColor: colors.secondary,
"&:hover": { bgcolor: `${colors.secondary}33` },
},
p: 2,
bgcolor: colors.background.paper,
borderRadius: 1,
border: `1px solid ${colors.border.default}`,
height: "100%",
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{(() => {
if (dnsConfig.target_dns === server.ip) {
return (
<CheckIcon
sx={{
color: colors.secondary,
fontSize: 20,
}}
/>
);
}
if (server.warn) {
return (
<BlockIcon
sx={{
color: colors.secondary,
fontSize: 20,
}}
/>
);
}
return (
<DnsIcon
sx={{
color: colors.text.secondary,
fontSize: 20,
}}
/>
);
})()}
</ListItemIcon>
<ListItemText
primary={
<Stack direction="row" alignItems="center" spacing={1}>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
color: server.warn ? colors.secondary : "inherit",
}}
>
{server.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{server.ip}
</Typography>
{server.tags.includes("fast") && (
<SpeedIcon
sx={{
fontSize: 14,
color: colors.secondary,
}}
/>
)}
{server.tags.includes("adblock") && (
<BlockIcon
sx={{
fontSize: 14,
color: colors.secondary,
}}
/>
)}
</Stack>
}
secondary={server.desc}
slotProps={{
secondary: {
variant: "caption",
sx: {
color: server.warn ? colors.secondary : undefined,
<Stack direction="row" alignItems="center" spacing={1}>
<DnsIcon sx={{ color: colors.secondary }} />
<Typography variant="subtitle2">
{selectedServer.name}
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary">
{selectedServer.desc}
</Typography>
</Box>
)}
</Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("sets.dns.recommendedServers")}
</Typography>
<Box
sx={{
border: `1px solid ${colors.border.default}`,
borderRadius: 1,
bgcolor: colors.background.paper,
maxHeight: 320,
overflow: "auto",
}}
>
<List dense disablePadding>
{POPULAR_DNS.filter((server) =>
ipv6 ? server.ipv6 : !server.ipv6,
).map((server) => (
<ListItemButton
key={server.ip}
selected={dnsConfig.target_dns === server.ip}
onClick={() => onChange("dns.target_dns", server.ip)}
sx={{
borderLeft: server.warn
? `3px solid ${colors.quaternary}`
: "3px solid transparent",
"&.Mui-selected": {
bgcolor: `${colors.secondary}22`,
borderLeftColor: colors.secondary,
"&:hover": { bgcolor: `${colors.secondary}33` },
},
},
}}
/>
</ListItemButton>
))}
</List>
</Box>
</Grid>
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{(() => {
if (dnsConfig.target_dns === server.ip) {
return (
<CheckIcon
sx={{ color: colors.secondary, fontSize: 20 }}
/>
);
}
if (server.warn) {
return (
<BlockIcon
sx={{ color: colors.secondary, fontSize: 20 }}
/>
);
}
return (
<DnsIcon
sx={{ color: colors.text.secondary, fontSize: 20 }}
/>
);
})()}
</ListItemIcon>
<ListItemText
primary={
<Stack
direction="row"
alignItems="center"
spacing={1}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
color: server.warn
? colors.secondary
: "inherit",
}}
>
{server.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{server.ip}
</Typography>
</Stack>
}
secondary={server.desc}
slotProps={{
secondary: {
variant: "caption",
sx: {
color: server.warn ? colors.secondary : undefined,
},
},
}}
/>
</ListItemButton>
))}
</List>
</Box>
</Grid>
</>
)}
{mode === "doh" && (
<>
<Grid size={{ xs: 12 }}>
<B4TextField
label={t("sets.dns.dohUrl")}
value={dnsConfig.doh_url || ""}
onChange={(e) => onChange("dns.doh_url", e.target.value)}
placeholder={t("sets.dns.dohUrlPlaceholder")}
helperText={t("sets.dns.dohUrlHelper")}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
{selectedDoH && (
<Box
sx={{
p: 2,
bgcolor: colors.background.paper,
borderRadius: 1,
border: `1px solid ${colors.border.default}`,
height: "100%",
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<DnsIcon sx={{ color: colors.secondary }} />
<Typography variant="subtitle2">
{selectedDoH.name}
</Typography>
</Stack>
<Typography variant="caption" color="text.secondary">
{selectedDoH.desc}
</Typography>
</Box>
)}
</Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("sets.dns.recommendedDoH")}
</Typography>
<Box
sx={{
border: `1px solid ${colors.border.default}`,
borderRadius: 1,
bgcolor: colors.background.paper,
maxHeight: 320,
overflow: "auto",
}}
>
<List dense disablePadding>
{POPULAR_DOH.map((server) => (
<ListItemButton
key={server.url}
selected={dnsConfig.doh_url === server.url}
onClick={() => onChange("dns.doh_url", server.url)}
sx={{
borderLeft: "3px solid transparent",
"&.Mui-selected": {
bgcolor: `${colors.secondary}22`,
borderLeftColor: colors.secondary,
"&:hover": { bgcolor: `${colors.secondary}33` },
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{dnsConfig.doh_url === server.url ? (
<CheckIcon
sx={{ color: colors.secondary, fontSize: 20 }}
/>
) : (
<DnsIcon
sx={{ color: colors.text.secondary, fontSize: 20 }}
/>
)}
</ListItemIcon>
<ListItemText
primary={
<Stack
direction="row"
alignItems="center"
spacing={1}
>
<Typography variant="body2">
{server.name}
</Typography>
</Stack>
}
secondary={
<Stack component="span">
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: "monospace" }}
>
{server.url}
</Typography>
<Typography
variant="caption"
color="text.secondary"
>
{server.desc}
</Typography>
</Stack>
}
slotProps={{ secondary: { component: "div" } }}
/>
</ListItemButton>
))}
</List>
</Box>
</Grid>
</>
)}
{/* Visual explanation */}
<Grid size={{ xs: 12 }}>
@ -288,10 +428,10 @@ export const DnsRedirect = ({ config, ipv6, onChange }: DnsRedirectProps) => {
/>
<Typography variant="caption"></Typography>
<B4Badge
label={dnsConfig.target_dns || t("sets.dns.vizSelectDns")}
label={activeResolver || t("sets.dns.vizSelectDns")}
size="small"
sx={{
bgcolor: dnsConfig.target_dns
bgcolor: activeResolver
? colors.tertiary
: colors.accent.primary,
}}
@ -304,13 +444,15 @@ export const DnsRedirect = ({ config, ipv6, onChange }: DnsRedirectProps) => {
</Grid>
{/* Warnings */}
{!dnsConfig.target_dns && (
{!activeResolver && (
<B4Alert severity="warning" sx={{ m: 0 }}>
{t("sets.dns.noServerWarning")}
{mode === "doh"
? t("sets.dns.noDohWarning")
: t("sets.dns.noServerWarning")}
</B4Alert>
)}
{dnsConfig.target_dns === "8.8.8.8" && (
{mode === "udp" && dnsConfig.target_dns === "8.8.8.8" && (
<B4Alert severity="warning" sx={{ m: 0 }}>
{t("sets.dns.googleWarning")}
</B4Alert>

View file

@ -96,6 +96,7 @@ export function useDiscovery() {
payloadFiles: string[] = [],
validationTries: number = 1,
tlsVersion: string = "auto",
ipVersion: string = "auto",
): Promise<ApiResponse<void>> => {
setError(null);
setSuite(null);
@ -121,6 +122,7 @@ export function useDiscovery() {
payloadFiles,
validationTries,
tlsVersion,
ipVersion,
);
setSuiteId(res.id);
return { success: true };

View file

@ -718,7 +718,10 @@
"sectionDescription": "Configure domain matching for DPI bypass and blocking",
"tlsVersionFilter": "TLS Version Filter",
"tlsAny": "Any",
"tlsHelperText": "Match only specific TLS version",
"tlsHelperText": "Prefer a specific TLS version (used as a fallback when no exact-version match exists)",
"ipVersionFilter": "IP Version Filter",
"ipVersionAny": "Any",
"ipVersionHelperText": "Prefer IPv4 or IPv6 traffic (used as a fallback when no exact-version match exists)",
"escalateTo": "Escalate to set",
"escalateNone": "None",
"escalateHelper": "If this set keeps failing for a site, b4 will use the chosen set for that site instead. After a while, b4 tries this set again.",
@ -1205,11 +1208,19 @@
"alert": "Some ISPs intercept DNS queries (especially to 8.8.8.8) and return fake IPs for blocked domains. DNS redirect transparently rewrites your DNS queries to use an unpoisoned resolver.",
"enable": "Enable DNS Redirect",
"enableDesc": "Redirect DNS queries for domains in this set to specified DNS server",
"modeLabel": "Resolver type",
"modeUdp": "Plain DNS (UDP)",
"modeDoH": "DNS-over-HTTPS",
"recommendedDoH": "Recommended DoH Servers",
"noDohWarning": "Select or enter a DoH URL to enable redirect.",
"fragmentQuery": "Fragment DNS Queries",
"fragmentQueryDesc": "Split DNS packets using IP fragmentation to bypass DPI that pattern-matches domain names in queries",
"serverIp": "DNS Server IP",
"serverIpPlaceholder": "e.g., 9.9.9.9",
"serverIpHelper": "Select below or enter custom IP",
"dohUrl": "DNS-over-HTTPS URL",
"dohUrlPlaceholder": "e.g., https://xbox-dns.ru/dns-query",
"dohUrlHelper": "Resolve matched domains over encrypted DoH. Takes precedence over the DNS server IP above. Useful for resolvers that only serve DoH or whose plain DNS is spoofed by your ISP.",
"recommendedServers": "Recommended DNS Servers",
"howItWorks": "HOW IT WORKS",
"vizApp": "App",
@ -1430,6 +1441,8 @@
"clearCache": "Clear Cache",
"tlsVersion": "TLS Version",
"tlsVersionHint": "TLS version used for discovery probes. Some DPI systems handle TLS 1.2 and 1.3 differently.",
"ipVersion": "IP Version",
"ipVersionHint": "IP version used for discovery probes. The found strategy is saved scoped to this version. Some DPI systems handle IPv4 and IPv6 differently.",
"customPayloads": "Custom Payloads",
"customPayloadsHint": "Test with generated TLS ClientHello (SNI-first) instead of built-in payloads",
"selectPayloads": "Select captured payloads...",
@ -1811,6 +1824,7 @@
"geoip_path_missing": "Geoip path must be configured to use geoip categories",
"invalid_routing_mode": "Set \"{{set}}\": unknown routing mode \"{{mode}}\"",
"socks5_loop": "Set \"{{set}}\": upstream proxy points to b4's own SOCKS5 server (loop)",
"doh_url_must_be_https": "Set \"{{set}}\": DNS-over-HTTPS URL must start with https://",
"mark_conflict": "Mark value {{mark}} conflicts with reserved bits"
}
}

View file

@ -715,7 +715,10 @@
"sectionDescription": "Настройка доменного соответствия для обхода DPI и блокировки",
"tlsVersionFilter": "Фильтр версии TLS",
"tlsAny": "Любая",
"tlsHelperText": "Соответствие только определённой версии TLS",
"tlsHelperText": "Предпочитать определённую версию TLS (используется как запасной вариант при отсутствии точного совпадения)",
"ipVersionFilter": "Фильтр версии IP",
"ipVersionAny": "Любая",
"ipVersionHelperText": "Предпочитать трафик IPv4 или IPv6 (используется как запасной вариант при отсутствии точного совпадения)",
"escalateTo": "Переключаться на набор",
"escalateNone": "Нет",
"escalateHelper": "Если этот набор перестаёт справляться с сайтом, b4 будет использовать выбранный набор для этого сайта. Через некоторое время b4 снова пробует этот набор.",
@ -1202,11 +1205,19 @@
"alert": "Некоторые провайдеры перехватывают DNS-запросы (особенно к 8.8.8.8) и возвращают фейковые IP для заблокированных доменов. Перенаправление DNS прозрачно переписывает ваши DNS-запросы на использование незаражённого резолвера.",
"enable": "Включить перенаправление DNS",
"enableDesc": "Перенаправлять DNS-запросы для доменов этого сета на указанный DNS-сервер",
"modeLabel": "Тип резолвера",
"modeUdp": "Обычный DNS (UDP)",
"modeDoH": "DNS-over-HTTPS",
"recommendedDoH": "Рекомендуемые DoH-серверы",
"noDohWarning": "Выберите или введите URL DoH, чтобы включить перенаправление.",
"fragmentQuery": "Фрагментировать DNS-запросы",
"fragmentQueryDesc": "Разделять DNS-пакеты с помощью IP-фрагментации для обхода DPI, который ищет доменные имена в запросах",
"serverIp": "IP DNS-сервера",
"serverIpPlaceholder": "напр., 9.9.9.9",
"serverIpHelper": "Выберите ниже или введите свой IP",
"dohUrl": "URL DNS-over-HTTPS",
"dohUrlPlaceholder": "напр., https://xbox-dns.ru/dns-query",
"dohUrlHelper": "Резолвить домены этого сета через зашифрованный DoH. Имеет приоритет над IP DNS-сервера выше. Полезно для резолверов, которые работают только по DoH или чей обычный DNS подменяет провайдер.",
"recommendedServers": "Рекомендуемые DNS-серверы",
"howItWorks": "КАК ЭТО РАБОТАЕТ",
"vizApp": "Приложение",
@ -1428,6 +1439,8 @@
"clearCache": "Очистить кэш",
"tlsVersion": "Версия TLS",
"tlsVersionHint": "Версия TLS для проб поиска. Некоторые DPI-системы обрабатывают TLS 1.2 и 1.3 по-разному.",
"ipVersion": "Версия IP",
"ipVersionHint": "Версия IP для проб поиска. Найденная стратегия сохраняется с привязкой к этой версии. Некоторые DPI-системы обрабатывают IPv4 и IPv6 по-разному.",
"customPayloads": "Пользовательские payloads",
"customPayloadsHint": "Тестировать с сгенерированным TLS ClientHello (SNI-first) вместо встроенных payloads",
"selectPayloads": "Выберите захваченные payloads...",
@ -1809,6 +1822,7 @@
"geoip_path_missing": "Для использования категорий geoip необходимо указать путь к geoip",
"invalid_routing_mode": "Сет \"{{set}}\": неизвестный режим маршрутизации \"{{mode}}\"",
"socks5_loop": "Сет \"{{set}}\": upstream-прокси указывает на собственный SOCKS5-сервер b4 (петля)",
"doh_url_must_be_https": "Сет \"{{set}}\": URL DNS-over-HTTPS должен начинаться с https://",
"mark_conflict": "Значение mark {{mark}} конфликтует с зарезервированными битами"
}
}

View file

@ -101,6 +101,7 @@ export interface TargetsConfig {
geoip_categories: string[];
source_devices?: string[];
tls?: string;
ip_version?: string;
}
export interface DomainStatisticsConfig {
@ -379,6 +380,7 @@ export interface DisorderFragConfig {
export interface DNSConfig {
enabled: boolean;
target_dns: string;
doh_url: string;
fragment_query: boolean;
}

View file

@ -19,6 +19,7 @@ type connInfo struct {
responseSeen bool
rstCount int
serverHasOpts bool
established bool
}
type tlsInfo struct {
@ -481,6 +482,25 @@ func (t *connStateTracker) CheckRST(clientIP string, clientPort uint16, serverIP
return false, ""
}
func (t *connStateTracker) MarkEstablished(connKey string) {
t.mu.Lock()
defer t.mu.Unlock()
if info, ok := t.conns[connKey]; ok {
info.established = true
info.lastSeen = time.Now()
}
}
func (t *connStateTracker) ShouldDropOutboundRST(connKey string) bool {
t.mu.Lock()
defer t.mu.Unlock()
info, ok := t.conns[connKey]
if !ok {
return false
}
return !info.established
}
func (t *connStateTracker) GetSetForIncoming(clientIP string, clientPort uint16, serverIP string, serverPort uint16) *config.SetConfig {
outKey := fmt.Sprintf("%s:%d->%s:%d", clientIP, clientPort, serverIP, serverPort)

View file

@ -1,8 +1,12 @@
package nfq
import (
"context"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/dns"
@ -12,6 +16,27 @@ import (
"github.com/florianl/go-nfqueue"
)
const dohRedirectTimeout = 5 * time.Second
var (
dohClientMu sync.Mutex
dohClientMark int
dohClient *http.Client
)
func getDoHClient(mark int) *http.Client {
dohClientMu.Lock()
defer dohClientMu.Unlock()
if dohClient == nil || dohClientMark != mark {
if dohClient != nil {
dohClient.CloseIdleConnections()
}
dohClient = dns.MarkedDoHClient(mark, dohRedirectTimeout)
dohClientMark = mark
}
return dohClient
}
// parseDNSName parses a DNS domain name from msg starting at the given offset.
func parseDNSName(msg []byte, offset int) (string, bool) {
if offset < 0 || offset >= len(msg) {
@ -65,6 +90,7 @@ func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, pa
matcher := w.getMatcher()
if matchedSet, set := matcher.MatchSNIWithSource(domain, srcMac); matchedSet {
cfg := w.getConfig()
log.Tracef("DNS query: %s matched set %s (src %s)", domain, set.Name, srcMac)
if set.Routing.Enabled && config.RoutingIsBlock(set.Routing.Mode) && !cfg.Queue.IsDiscovery {
if config.NormalizeBlockAction(set.Routing.BlockAction) == config.BlockActionDrop {
@ -121,19 +147,25 @@ func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, pa
}
}
if !(set.DNS.Enabled && set.DNS.TargetDNS != "") {
useDoH := set.DNS.DoHURL != ""
if !(set.DNS.Enabled && (set.DNS.TargetDNS != "" || useDoH)) {
log.Tracef("DNS redirect: %s matched set %s but no redirect target configured, passing through", domain, set.Name)
if err := w.q.SetVerdict(id, nfqueue.NfAccept); err != nil {
log.Tracef("failed to set verdict on packet %d: %v", id, err)
}
return 0
}
targetIP := net.ParseIP(set.DNS.TargetDNS)
if targetIP == nil {
if err := w.q.SetVerdict(id, nfqueue.NfAccept); err != nil {
log.Tracef("failed to set verdict on packet %d: %v", id, err)
var targetIP net.IP
if !useDoH {
targetIP = net.ParseIP(set.DNS.TargetDNS)
if targetIP == nil {
if err := w.q.SetVerdict(id, nfqueue.NfAccept); err != nil {
log.Tracef("failed to set verdict on packet %d: %v", id, err)
}
return 0
}
return 0
}
if ipVersion == IPv6 && !cfg.Queue.IPv6Enabled {
@ -158,6 +190,12 @@ func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, pa
clientPort := sport
delay := config.ResolveSeg2Delay(set.UDP.Seg2Delay, set.UDP.Seg2DelayMax)
target := set.DNS.TargetDNS
if useDoH {
target = set.DNS.DoHURL
}
log.Tracef("DNS redirect: intercepting %s -> %s (set %s)", domain, target, set.Name)
if err := w.q.SetVerdict(id, nfqueue.NfDrop); err != nil {
log.Tracef("failed to set drop verdict on packet %d: %v", id, err)
}
@ -213,16 +251,26 @@ func (w *Worker) processDnsPacket(ipVersion byte, sport uint16, dport uint16, pa
}
func (w *Worker) resolveDNSRedirect(ipVersion byte, set *config.SetConfig, cfg *config.Config, query []byte, clientIP net.IP, clientPort uint16, originalDst, targetIP net.IP, delay int) {
resp, err := dns.ResolveUpstream(query, targetIP, dns.ForwardOptions{
Sender: w.sock,
Fragment: set.DNS.FragmentQuery,
Seg2Delay: delay,
ReverseOrder: set.Fragmentation.ReverseOrder,
Mark: int(cfg.Queue.Mark),
})
if err != nil {
log.Tracef("DNS redirect: upstream %s failed: %v", set.DNS.TargetDNS, err)
return
var resp []byte
var err error
if set.DNS.DoHURL != "" {
resp, err = w.resolveDoHRedirect(set.DNS.DoHURL, int(cfg.Queue.Mark), query)
if err != nil {
log.Tracef("DNS redirect: DoH %s failed: %v", set.DNS.DoHURL, err)
return
}
} else {
resp, err = dns.ResolveUpstream(query, targetIP, dns.ForwardOptions{
Sender: w.sock,
Fragment: set.DNS.FragmentQuery,
Seg2Delay: delay,
ReverseOrder: set.Fragmentation.ReverseOrder,
Mark: int(cfg.Queue.Mark),
})
if err != nil {
log.Tracef("DNS redirect: upstream %s failed: %v", set.DNS.TargetDNS, err)
return
}
}
if ipVersion == IPv4 {
@ -241,5 +289,15 @@ func (w *Worker) resolveDNSRedirect(ipVersion byte, set *config.SetConfig, cfg *
}
}
log.Tracef("DNS redirect: %s -> %s answered for %s (set: %s)", originalDst, set.DNS.TargetDNS, clientIP, set.Name)
upstream := set.DNS.TargetDNS
if set.DNS.DoHURL != "" {
upstream = set.DNS.DoHURL
}
log.Tracef("DNS redirect: %s -> %s answered for %s with %d IPs (set: %s)", originalDst, upstream, clientIP, len(dns.ParseResponseIPs(resp)), set.Name)
}
func (w *Worker) resolveDoHRedirect(serverURL string, mark int, query []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(w.ctx, dohRedirectTimeout)
defer cancel()
return dns.ResolveDoH(ctx, getDoHClient(mark), serverURL, query)
}

View file

@ -236,7 +236,9 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
}
}
if matched && cfg.IsTCPPort(dport) && set.TCP.Duplicate.Enabled && set.TCP.Duplicate.Count > 0 {
routeTProxy := matched && set != nil && set.Routing.Enabled && config.RoutingUsesTProxy(set.Routing.Mode)
if matched && !routeTProxy && cfg.IsTCPPort(dport) && set.TCP.Duplicate.Enabled && set.TCP.Duplicate.Count > 0 {
log.Tracef("TCP duplicate to %s:%d (%d copies, set: %s)", pkt.dstStr, dport, set.TCP.Duplicate.Count, set.Name)
dupConnKey := fmt.Sprintf(connKeyFormat, pkt.srcStr, sport, pkt.dstStr, dport)
@ -270,10 +272,26 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
isAck := (tcpFlags & 0x10) != 0
isRst := (tcpFlags & 0x04) != 0
if isRst && cfg.IsTCPPort(dport) {
log.Tracef("RST received from %s:%d", pkt.dstStr, dport)
log.Tracef("RST to %s:%d", pkt.dstStr, dport)
if matched && set != nil && set.TCP.RSTProtection.Enabled {
connKey := fmt.Sprintf(connKeyFormat, pkt.srcStr, sport, pkt.dstStr, dport)
if w.connTracker.ShouldDropOutboundRST(connKey) {
log.Warnf("RST protection: dropped outbound RST to %s:%d — connection not established", pkt.dstStr, dport)
metrics.GetMetricsCollector().RecordRSTDrop()
if err := q.SetVerdict(id, nfqueue.NfDrop); err != nil {
log.Tracef("failed to drop outbound RST packet %d: %v", id, err)
}
return 0
}
}
}
if isSyn && !isAck && cfg.IsTCPPort(dport) && matched && !set.TCP.Duplicate.Enabled && needsTCPSynInjection(set) {
if isAck && !isSyn && !isRst && cfg.IsTCPPort(dport) && matched && set != nil && set.TCP.RSTProtection.Enabled {
connKey := fmt.Sprintf(connKeyFormat, pkt.srcStr, sport, pkt.dstStr, dport)
w.connTracker.MarkEstablished(connKey)
}
if isSyn && !isAck && !routeTProxy && cfg.IsTCPPort(dport) && matched && !set.TCP.Duplicate.Enabled && needsTCPSynInjection(set) {
log.Tracef("TCP SYN to %s:%d (set: %s)", pkt.dstStr, dport, set.Name)
m := metrics.GetMetricsCollector()
@ -297,6 +315,11 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
_ = w.sock.SendIPv6(pkt.raw, pkt.dst)
}
if set.TCP.Incoming.Mode != config.ConfigOff || set.TCP.RSTProtection.Enabled || set.Escalate.To != "" {
connKey := fmt.Sprintf(connKeyFormat, pkt.srcStr, sport, pkt.dstStr, dport)
w.connTracker.RegisterOutgoing(connKey, set)
}
if err := q.SetVerdict(id, nfqueue.NfDrop); err != nil {
log.Tracef("failed to set drop verdict on packet %d: %v", id, err)
}
@ -336,7 +359,7 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
}
if host != "" {
if mSNI, stSNI := matcher.MatchSNIWithSourceTLS(host, pkt.srcMac, tlsVersion); mSNI {
if mSNI, stSNI := matcher.MatchSNIWithSourceTLS(host, pkt.srcMac, tlsVersion, pkt.ver); mSNI {
// If SNI-matched set has a port filter, verify port matches (AND logic)
if stSNI.MatchesTCPDPort(dport) {
matchedSNI = true
@ -466,7 +489,7 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
if matched {
ibdOn := set.TCP.IPBlockDetect.Enabled
canEscalate := set.Escalate.To != ""
if isClientHello && (ibdOn || canEscalate) && host != "" && cfg.IsTCPPort(dport) {
if isClientHello && !routeTProxy && (ibdOn || canEscalate) && host != "" && cfg.IsTCPPort(dport) {
ibd := &set.TCP.IPBlockDetect
dstIPPort := fmt.Sprintf("%s:%d", pkt.dstStr, dport)
ibConnKey := fmt.Sprintf(connKeyFormat, pkt.srcStr, sport, pkt.dstStr, dport)
@ -529,7 +552,7 @@ func (w *Worker) handleTCPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
w.connTracker.RegisterOutgoing(connKey, set)
}
if !needsTCPInjection(set) {
if routeTProxy || !needsTCPInjection(set) {
return accept(q, id)
}
@ -644,7 +667,7 @@ func (w *Worker) handleUDPPacket(q *nfqueue.Nfqueue, id uint32, pkt *pktInfo, cf
}
if host != "" {
if mSNI, sniSet := matcher.MatchSNIWithSourceTLS(host, pkt.srcMac, 0x0304); mSNI { // QUIC is always TLS 1.3
if mSNI, sniSet := matcher.MatchSNIWithSourceTLS(host, pkt.srcMac, 0x0304, pkt.ver); mSNI { // QUIC is always TLS 1.3
// If SNI-matched set has a port filter, verify port matches (AND logic)
if sniSet.MatchesUDPDPort(dport) {
matchedQUIC = true

View file

@ -561,38 +561,38 @@ func setMatchesSource(set *config.SetConfig, srcMAC string) bool {
}
func (s *SuffixSet) MatchSNIWithSource(host string, srcMAC string) (bool, *config.SetConfig) {
return s.MatchSNIWithSourceTLS(host, srcMAC, 0)
return s.MatchSNIWithSourceTLS(host, srcMAC, 0, 0)
}
// MatchSNIWithSourceTLS matches a domain with source MAC and TLS version filtering.
// MatchSNIWithSourceTLS matches a domain with source MAC, TLS version and IP version filtering.
// tlsVersion is the client's max TLS version in wire format (0x0303=1.2, 0x0304=1.3).
// Pass 0 to skip TLS version filtering.
func (s *SuffixSet) MatchSNIWithSourceTLS(host string, srcMAC string, tlsVersion uint16) (bool, *config.SetConfig) {
// ipVersion is the packet's IP version (4 or 6). Pass 0 for either to skip that filter.
func (s *SuffixSet) MatchSNIWithSourceTLS(host string, srcMAC string, tlsVersion uint16, ipVersion uint8) (bool, *config.SetConfig) {
if s == nil || (len(s.sets) == 0 && len(s.regexes) == 0) || host == "" {
return false, nil
}
lower := strings.ToLower(host)
if matched, set := s.matchDomainWithSourceTLS(lower, srcMAC, tlsVersion); matched {
if matched, set := s.matchDomainWithSourceTLS(lower, srcMAC, tlsVersion, ipVersion); matched {
return true, set
}
if len(s.regexes) > 0 {
return s.matchRegexWithSourceTLS(lower, srcMAC, tlsVersion)
return s.matchRegexWithSourceTLS(lower, srcMAC, tlsVersion, ipVersion)
}
return false, nil
}
func (s *SuffixSet) matchDomainWithSourceTLS(host string, srcMAC string, tlsVersion uint16) (bool, *config.SetConfig) {
func (s *SuffixSet) matchDomainWithSourceTLS(host string, srcMAC string, tlsVersion uint16, ipVersion uint8) (bool, *config.SetConfig) {
candidates := s.findDomainCandidates(host)
if len(candidates) == 0 {
return false, nil
}
return selectSetBySourceAndTLS(candidates, srcMAC, tlsVersion)
return selectSetBySourceAndTLS(candidates, srcMAC, tlsVersion, ipVersion)
}
func (s *SuffixSet) findDomainCandidates(host string) []*config.SetConfig {
@ -614,7 +614,7 @@ func (s *SuffixSet) findDomainCandidates(host string) []*config.SetConfig {
return nil
}
func (s *SuffixSet) matchRegexWithSourceTLS(host string, srcMAC string, tlsVersion uint16) (bool, *config.SetConfig) {
func (s *SuffixSet) matchRegexWithSourceTLS(host string, srcMAC string, tlsVersion uint16, ipVersion uint8) (bool, *config.SetConfig) {
var candidates []*config.SetConfig
for _, rws := range s.regexes {
if rws.regex.MatchString(host) {
@ -624,7 +624,7 @@ func (s *SuffixSet) matchRegexWithSourceTLS(host string, srcMAC string, tlsVersi
if len(candidates) == 0 {
return false, nil
}
return selectSetBySourceAndTLS(candidates, srcMAC, tlsVersion)
return selectSetBySourceAndTLS(candidates, srcMAC, tlsVersion, ipVersion)
}
func (s *SuffixSet) MatchIPWithSource(ip net.IP, srcMAC string) (bool, *config.SetConfig) {
@ -645,7 +645,17 @@ func (s *SuffixSet) MatchIPWithSource(ip net.IP, srcMAC string) (bool, *config.S
candidates = append(candidates, e.(*ipRange).set)
}
return selectSetBySource(candidates, srcMAC)
return selectSetBySource(candidates, srcMAC, ipVersionOf(ip))
}
func ipVersionOf(ip net.IP) uint8 {
if ip == nil {
return 0
}
if ip.To4() != nil {
return 4
}
return 6
}
// sortEntriesBySpecificity sorts CIDR entries by prefix length descending,
@ -692,7 +702,7 @@ func (s *SuffixSet) MatchLearnedIPWithSource(ip net.IP, srcMAC string) (bool, *c
}
if !setMatchesSource(entry.set, srcMAC) {
if matched, altSet := s.MatchSNIWithSource(entry.domain, srcMAC); matched {
if matched, altSet := s.MatchSNIWithSourceTLS(entry.domain, srcMAC, 0, ipVersionOf(ip)); matched {
entry.learnedAt = time.Now()
s.learnedIPCacheLRU.MoveToFront(entry.element)
return true, altSet, entry.domain
@ -705,29 +715,29 @@ func (s *SuffixSet) MatchLearnedIPWithSource(ip net.IP, srcMAC string) (bool, *c
return true, entry.set, entry.domain
}
func selectSetBySource(candidates []*config.SetConfig, srcMAC string) (bool, *config.SetConfig) {
return selectSetBySourceAndTLS(candidates, srcMAC, 0)
func selectSetBySource(candidates []*config.SetConfig, srcMAC string, ipVersion uint8) (bool, *config.SetConfig) {
return selectSetBySourceAndTLS(candidates, srcMAC, 0, ipVersion)
}
func selectSetBySourceAndTLS(candidates []*config.SetConfig, srcMAC string, tlsVersion uint16) (bool, *config.SetConfig) {
func selectSetBySourceAndTLS(candidates []*config.SetConfig, srcMAC string, tlsVersion uint16, ipVersion uint8) (bool, *config.SetConfig) {
if len(candidates) == 0 {
return false, nil
}
for _, set := range candidates {
if len(set.Targets.SourceDevices) > 0 && setMatchesSource(set, srcMAC) && set.MatchesTLSVersion(tlsVersion) {
if len(set.Targets.SourceDevices) > 0 && setMatchesSource(set, srcMAC) && set.MatchesTLSVersion(tlsVersion) && set.MatchesIPVersion(ipVersion) {
return true, set
}
}
for _, set := range candidates {
if len(set.Targets.SourceDevices) == 0 && set.MatchesTLSVersion(tlsVersion) {
if len(set.Targets.SourceDevices) == 0 && set.MatchesTLSVersion(tlsVersion) && set.MatchesIPVersion(ipVersion) {
return true, set
}
}
if tlsVersion != 0 {
return selectSetBySourceAndTLS(candidates, srcMAC, 0)
if tlsVersion != 0 || ipVersion != 0 {
return selectSetBySourceAndTLS(candidates, srcMAC, 0, 0)
}
return false, nil

View file

@ -538,7 +538,7 @@ func TestMatchSNIWithSourceTLS_TLSFilter(t *testing.T) {
s.Targets.TLSVersion = "1.2"
ss := NewSuffixSet([]*config.SetConfig{s})
matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0x0303)
matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0x0303, 0)
if !matched {
t.Error("expected match for TLS 1.2")
}
@ -551,12 +551,12 @@ func TestMatchSNIWithSourceTLS_PrefersExactTLSMatch(t *testing.T) {
tls13.Targets.TLSVersion = "1.3"
ss := NewSuffixSet([]*config.SetConfig{tls12, tls13})
_, set := ss.MatchSNIWithSourceTLS("example.com", "", 0x0304)
_, set := ss.MatchSNIWithSourceTLS("example.com", "", 0x0304, 0)
if set.Name != "tls13" {
t.Errorf("expected tls13 set for TLS 1.3 client, got %s", set.Name)
}
_, set = ss.MatchSNIWithSourceTLS("example.com", "", 0x0303)
_, set = ss.MatchSNIWithSourceTLS("example.com", "", 0x0303, 0)
if set.Name != "tls12" {
t.Errorf("expected tls12 set for TLS 1.2 client, got %s", set.Name)
}
@ -569,7 +569,7 @@ func TestMatchSNIWithSourceTLS_FallbackWhenNoTLSMatch(t *testing.T) {
tls12.Targets.TLSVersion = "1.2"
ss := NewSuffixSet([]*config.SetConfig{tls12})
matched, set := ss.MatchSNIWithSourceTLS("example.com", "", 0x0304)
matched, set := ss.MatchSNIWithSourceTLS("example.com", "", 0x0304, 0)
if !matched {
t.Error("expected fallback match (retry with tlsVersion=0)")
}
@ -583,12 +583,90 @@ func TestMatchSNIWithSourceTLS_ZeroTLSMatchesAny(t *testing.T) {
s.Targets.TLSVersion = "1.2"
ss := NewSuffixSet([]*config.SetConfig{s})
matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0)
matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0, 0)
if !matched {
t.Error("tlsVersion=0 should match any set")
}
}
// --- IP version filtering ---
func TestMatchSNIWithSourceTLS_IPVersionFilter(t *testing.T) {
s := makeSetWithDomains("v6only", "example.com")
s.Targets.IPVersion = "6"
ss := NewSuffixSet([]*config.SetConfig{s})
matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0, 6)
if !matched {
t.Error("expected match for IPv6 packet")
}
}
func TestMatchSNIWithSourceTLS_PrefersExactIPVersion(t *testing.T) {
v4 := makeSetWithDomains("v4", "example.com")
v4.Targets.IPVersion = "4"
v6 := makeSetWithDomains("v6", "example.com")
v6.Targets.IPVersion = "6"
ss := NewSuffixSet([]*config.SetConfig{v4, v6})
_, set := ss.MatchSNIWithSourceTLS("example.com", "", 0, 4)
if set == nil || set.Name != "v4" {
t.Errorf("expected v4 set for IPv4 packet, got %v", set)
}
_, set = ss.MatchSNIWithSourceTLS("example.com", "", 0, 6)
if set == nil || set.Name != "v6" {
t.Errorf("expected v6 set for IPv6 packet, got %v", set)
}
}
func TestMatchSNIWithSourceTLS_IPVersionFallback(t *testing.T) {
v4 := makeSetWithDomains("v4", "example.com")
v4.Targets.IPVersion = "4"
ss := NewSuffixSet([]*config.SetConfig{v4})
matched, set := ss.MatchSNIWithSourceTLS("example.com", "", 0, 6)
if !matched || set == nil || set.Name != "v4" {
t.Errorf("expected fallback to v4 set when only mismatched-version set exists, got %v", set)
}
}
func TestMatchSNIWithSourceTLS_ZeroIPVersionMatchesAny(t *testing.T) {
s := makeSetWithDomains("v6only", "example.com")
s.Targets.IPVersion = "6"
ss := NewSuffixSet([]*config.SetConfig{s})
matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0, 0)
if !matched {
t.Error("ipVersion=0 should match any set")
}
}
func TestMatchSNIWithSourceTLS_InvalidIPVersionNeverMatches(t *testing.T) {
s := makeSetWithDomains("bad", "example.com")
s.Targets.IPVersion = "ipv6" // invalid: should be "6"
ss := NewSuffixSet([]*config.SetConfig{s})
if matched, _ := ss.MatchSNIWithSourceTLS("example.com", "", 0, 4); matched {
t.Error("invalid ip_version must not match, even via the ipVersion=0 fallback")
}
}
func TestMatchIPWithSource_IPVersionDispatch(t *testing.T) {
v4 := makeSetWithIPs("v4", "203.0.113.0/24")
v4.Targets.IPVersion = "4"
v6 := makeSetWithIPs("v6", "2001:db8::/32")
v6.Targets.IPVersion = "6"
ss := NewSuffixSet([]*config.SetConfig{v4, v6})
if _, set := ss.MatchIPWithSource(net.ParseIP("203.0.113.5"), ""); set == nil || set.Name != "v4" {
t.Errorf("expected v4 set for IPv4 destination, got %v", set)
}
if _, set := ss.MatchIPWithSource(net.ParseIP("2001:db8::1"), ""); set == nil || set.Name != "v6" {
t.Errorf("expected v6 set for IPv6 destination, got %v", set)
}
}
// --- MatchIPWithSource ---
func TestMatchIPWithSource_SourceFilter(t *testing.T) {
@ -768,7 +846,7 @@ func TestGetCacheStats_Nil(t *testing.T) {
// --- selectSetBySourceAndTLS ---
func TestSelectSetBySourceAndTLS_NoMatchReturnsNil(t *testing.T) {
matched, set := selectSetBySourceAndTLS(nil, "", 0)
matched, set := selectSetBySourceAndTLS(nil, "", 0, 0)
if matched || set != nil {
t.Error("empty candidates should not match")
}

View file

@ -97,7 +97,10 @@ func applyGroup(cfg *config.Config, group []domainWithSet) {
if !set.Enabled {
continue
}
if setContainsAnyDomain(set, groupDomains) {
if set.Routing.Enabled {
continue
}
if setListsAnyDomain(set, groupDomains) {
existingSet = set
break
}
@ -136,13 +139,44 @@ func applyGroup(cfg *config.Config, group []domainWithSet) {
}
}
func normalizeDomain(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func setListsAnyDomain(set *config.SetConfig, domains []string) bool {
for _, rawSNI := range set.Targets.SNIDomains {
sni := normalizeDomain(rawSNI)
if sni == "" {
continue
}
for _, rawDomain := range domains {
domain := normalizeDomain(rawDomain)
if domain == "" {
continue
}
if sni == domain || (len(domain) > len(sni) && strings.HasSuffix(domain, "."+sni)) {
return true
}
}
}
return false
}
func setContainsAnyDomain(set *config.SetConfig, domains []string) bool {
matchList := set.Targets.DomainsToMatch
if len(matchList) == 0 {
matchList = set.Targets.SNIDomains
}
for _, target := range matchList {
for _, domain := range domains {
for _, rawTarget := range matchList {
target := normalizeDomain(rawTarget)
if target == "" {
continue
}
for _, rawDomain := range domains {
domain := normalizeDomain(rawDomain)
if domain == "" {
continue
}
if target == domain || domainMatchesSuffix(domain, target) {
return true
}
@ -162,8 +196,9 @@ func domainMatchesSuffix(domain, target string) bool {
}
func domainInSNIList(sniList []string, domain string) bool {
target := normalizeDomain(domain)
for _, sni := range sniList {
if sni == domain {
if normalizeDomain(sni) == target {
return true
}
}

View file

@ -172,6 +172,26 @@ func TestSetContainsAnyDomain(t *testing.T) {
t.Error("should match via DomainsToMatch")
}
})
t.Run("case-insensitive query", func(t *testing.T) {
if !setContainsAnyDomain(set, []string{"YouTube.com"}) {
t.Error("should match regardless of case")
}
})
t.Run("whitespace trimmed query", func(t *testing.T) {
if !setContainsAnyDomain(set, []string{" youtube.com "}) {
t.Error("should match after trimming whitespace")
}
})
t.Run("case-insensitive stored domain", func(t *testing.T) {
mixed := &config.SetConfig{}
mixed.Targets.SNIDomains = []string{"YouTube.COM"}
if !setContainsAnyDomain(mixed, []string{"youtube.com"}) {
t.Error("should match a mixed-case stored domain")
}
})
}
func TestDomainMatchesSuffix(t *testing.T) {
@ -264,6 +284,48 @@ func TestApplyGroup_ExistingSet(t *testing.T) {
}
}
func TestApplyGroup_SkipsRoutingSetMatchedViaGeosite(t *testing.T) {
adblock := config.NewSetConfig()
adblock.Name = "adblock"
adblock.Enabled = true
adblock.Routing.Enabled = true
adblock.Routing.Mode = config.RoutingModeBlock
adblock.Targets.SNIDomains = []string{"ad.doubleclick.net"}
adblock.Targets.DomainsToMatch = []string{"ad.doubleclick.net", "ads.youtube.com", "s2.youtube.com"}
youtube := config.NewSetConfig()
youtube.Name = "YouTubenew"
youtube.Enabled = true
youtube.Targets.SNIDomains = []string{"youtube.com"}
youtube.Targets.DomainsToMatch = []string{"youtube.com"}
youtube.Fragmentation.Strategy = "tcp"
cfg := &config.Config{
Sets: []*config.SetConfig{&adblock, &youtube},
}
refSet := &config.SetConfig{}
refSet.Fragmentation.Strategy = "combo"
group := []domainWithSet{
{domain: "youtube.com", set: refSet},
}
applyGroup(cfg, group)
if len(cfg.Sets) != 2 {
t.Fatalf("should reuse YouTube set, got %d sets", len(cfg.Sets))
}
for _, sni := range adblock.Targets.SNIDomains {
if sni == "youtube.com" {
t.Fatalf("youtube.com must not be added to the routing/block set")
}
}
if youtube.Fragmentation.Strategy != "combo" {
t.Errorf("youtube set should be healed to combo, got %s", youtube.Fragmentation.Strategy)
}
}
func TestApplyGroup_SkipsDisabledSet(t *testing.T) {
disabledSet := config.NewSetConfig()
disabledSet.Name = "Disabled"
@ -288,3 +350,81 @@ func TestApplyGroup_SkipsDisabledSet(t *testing.T) {
t.Fatalf("should create new set (not reuse disabled), got %d sets", len(cfg.Sets))
}
}
func TestSetListsAnyDomain(t *testing.T) {
set := &config.SetConfig{}
set.Targets.SNIDomains = []string{"YouTube.com", " discord.com "}
tests := []struct {
name string
domains []string
expected bool
}{
{"exact after trim", []string{"discord.com"}, true},
{"case-insensitive", []string{"youtube.com"}, true},
{"whitespace trimmed", []string{" youtube.com "}, true},
{"subdomain", []string{"www.youtube.com"}, true},
{"unrelated", []string{"twitter.com"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setListsAnyDomain(set, tt.domains); got != tt.expected {
t.Errorf("setListsAnyDomain(%v) = %v, want %v", tt.domains, got, tt.expected)
}
})
}
}
func TestDomainInSNIList(t *testing.T) {
list := []string{"YouTube.com", " discord.com "}
tests := []struct {
name string
domain string
expected bool
}{
{"case-insensitive present", "youtube.com", true},
{"whitespace trimmed present", "discord.com", true},
{"absent", "twitter.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := domainInSNIList(list, tt.domain); got != tt.expected {
t.Errorf("domainInSNIList(%q) = %v, want %v", tt.domain, got, tt.expected)
}
})
}
}
func TestApplyGroup_ExistingSet_CaseInsensitive(t *testing.T) {
existingSet := config.NewSetConfig()
existingSet.Name = "MyYouTube"
existingSet.Enabled = true
existingSet.Targets.SNIDomains = []string{"YouTube.com"}
existingSet.Targets.DomainsToMatch = []string{"YouTube.com"}
existingSet.Fragmentation.Strategy = "tcp"
cfg := &config.Config{
Sets: []*config.SetConfig{&existingSet},
}
refSet := &config.SetConfig{}
refSet.Fragmentation.Strategy = "combo"
group := []domainWithSet{
{domain: "youtube.com", set: refSet},
}
applyGroup(cfg, group)
if len(cfg.Sets) != 1 {
t.Fatalf("should reuse existing set despite case difference, got %d sets", len(cfg.Sets))
}
if len(cfg.Sets[0].Targets.SNIDomains) != 1 {
t.Errorf("should not append a case-variant duplicate, got %d: %v",
len(cfg.Sets[0].Targets.SNIDomains), cfg.Sets[0].Targets.SNIDomains)
}
if cfg.Sets[0].Fragmentation.Strategy != "combo" {
t.Errorf("strategy should be healed to combo, got %s", cfg.Sets[0].Fragmentation.Strategy)
}
}