diff --git a/changelog.md b/changelog.md
index 6acc87d8..f3cecce0 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,11 +1,12 @@
# B4 - Bye Bye Big Bro
-## [1.1x.x] - 2025-1x-xx
+## [1.19.x] - 2025-1x-xx
- ADDED: Filter for configuration sets - search by `name`, `SNI` domains, `geosite` categories, or `geoip` categories.
- ADDED: Compare sets feature - side-by-side diff view showing differences between two configuration sets, grouped by section (TCP, UDP, Fragmentation, Faking, Targets).
- FIXED: Settings tab navigation losing selected tab on page refresh.
- CHANGED: New configuration sets are now added to the top of the list instead of the bottom.
+- CHANGED: `Discovery` configuration refactoring.
## [1.18.5] - 2025-11-27
diff --git a/src/checker/discovery.go b/src/checker/discovery.go
deleted file mode 100644
index 0d433dff..00000000
--- a/src/checker/discovery.go
+++ /dev/null
@@ -1,337 +0,0 @@
-package checker
-
-import (
- "fmt"
- "sync"
- "time"
-
- "github.com/daniellavrushin/b4/config"
- "github.com/daniellavrushin/b4/log"
- "github.com/daniellavrushin/b4/nfq"
-)
-
-const MAX_PRESETS_PER_DOMAIN = 3
-const DISCOVERY_TIMEOUT = 3 * time.Second
-
-type DiscoverySuite struct {
- *CheckSuite
- pool *nfq.Pool
- originalConfig *config.Config
- presets []ConfigPreset
- domainResults map[string]*DomainDiscoveryResult // domain -> results for all presets
- mu sync.RWMutex
-}
-
-func NewDiscoverySuite(checkConfig CheckConfig, pool *nfq.Pool, presets []ConfigPreset) *DiscoverySuite {
- checkConfig.Timeout = DISCOVERY_TIMEOUT
-
- suite := NewCheckSuite(checkConfig)
- suite.DomainDiscoveryResults = make(map[string]*DomainDiscoveryResult)
-
- return &DiscoverySuite{
- CheckSuite: suite,
- pool: pool,
- presets: presets,
- domainResults: make(map[string]*DomainDiscoveryResult),
- }
-}
-
-func (ds *DiscoverySuite) RunDiscovery(domains []string) {
- // Register in activeSuites so status endpoint can find it
- suitesMu.Lock()
- activeSuites[ds.Id] = ds.CheckSuite
- suitesMu.Unlock()
-
- defer func() {
- ds.EndTime = time.Now()
-
- // Keep in memory for 5 minutes
- time.AfterFunc(5*time.Minute, func() {
- suitesMu.Lock()
- delete(activeSuites, ds.Id)
- suitesMu.Unlock()
- })
- }()
-
- // Set initial status
- ds.CheckSuite.mu.Lock()
- ds.Status = CheckStatusRunning
- ds.TotalChecks = len(domains) * len(ds.presets)
- ds.CheckSuite.mu.Unlock()
-
- // Store original configuration
- ds.originalConfig = ds.pool.GetFirstWorkerConfig()
- if ds.originalConfig == nil {
- log.Errorf("Failed to get original configuration")
- ds.CheckSuite.mu.Lock()
- ds.Status = CheckStatusFailed
- ds.CheckSuite.mu.Unlock()
- return
- }
-
- log.Infof("Starting domain-centric discovery for %d domains across %d presets",
- len(domains), len(ds.presets))
- log.Warnf("Service traffic will be affected during discovery testing")
-
- // Initialize domain results
- for _, domain := range domains {
- ds.mu.Lock()
- ds.domainResults[domain] = &DomainDiscoveryResult{
- Domain: domain,
- Results: make(map[string]*DomainPresetResult),
- }
- ds.mu.Unlock()
- }
-
- for _, domain := range domains {
- select {
- case <-ds.cancel:
- log.Infof("Discovery suite %s canceled", ds.Id)
- ds.CheckSuite.mu.Lock()
- ds.Status = CheckStatusCanceled
- ds.CheckSuite.mu.Unlock()
- return
- default:
- }
-
- log.Infof("Testing domain: %s (will stop after %d successful configs)", domain, MAX_PRESETS_PER_DOMAIN)
-
- successfulCount := 0
- testedCount := 0
-
- for _, preset := range ds.presets {
- select {
- case <-ds.cancel:
- return
- default:
- }
-
- // Stop testing this domain if we found enough successful configs
- if successfulCount >= MAX_PRESETS_PER_DOMAIN {
- log.Infof(" Domain %s: Found %d successful configs, skipping remaining %d presets",
- domain, successfulCount, len(ds.presets)-testedCount)
-
- // Update total checks to reflect skipped presets
- ds.CheckSuite.mu.Lock()
- ds.TotalChecks -= (len(ds.presets) - testedCount)
- ds.CheckSuite.mu.Unlock()
- break
- }
-
- testedCount++
- log.Tracef(" Testing %s with preset %d/%d: %s", domain, testedCount, len(ds.presets), preset.Name)
-
- // Apply preset configuration by RESTARTING pool
- testConfig := ds.buildTestConfig(preset, domain)
-
- log.Infof(" Applying preset %s config...", preset.Name)
- if err := ds.pool.UpdateConfig(testConfig); err != nil {
- log.Errorf("Failed to update config for preset %s: %v", preset.Name, err)
- ds.CheckSuite.mu.Lock()
- ds.CompletedChecks++
- ds.CheckSuite.mu.Unlock()
- continue
- }
-
- // Small delay to let config propagate to all workers
- time.Sleep(1000 * time.Millisecond)
-
- var result CheckResult
- for attempt := 0; attempt < 2; attempt++ {
- result = ds.testDomain(domain)
- result.Set = testConfig.MainSet
-
- // If successful or it's the last attempt, use this result
- if result.Status == CheckStatusComplete || attempt == 1 {
- break
- }
-
- // First attempt failed, wait a bit longer for config to propagate
- log.Tracef(" First attempt failed, retrying after additional delay...")
- time.Sleep(300 * time.Millisecond)
- }
-
- // Store result for this domain+preset combination
- ds.mu.Lock()
- ds.domainResults[domain].Results[preset.Name] = &DomainPresetResult{
- PresetName: preset.Name,
- Status: result.Status,
- Duration: result.Duration,
- Speed: result.Speed,
- BytesRead: result.BytesRead,
- Error: result.Error,
- StatusCode: result.StatusCode,
- Set: result.Set,
- }
- ds.mu.Unlock()
-
- // Count successful results
- if result.Status == CheckStatusComplete {
- successfulCount++
- log.Infof(" ✓ %s with %s: %.2f KB/s (success %d/%d)",
- domain, preset.Name, result.Speed/1024, successfulCount, MAX_PRESETS_PER_DOMAIN)
- } else {
- log.Tracef(" ✗ %s with %s: %s",
- domain, preset.Name, result.Status)
- }
-
- // Update progress
- ds.CheckSuite.mu.Lock()
- ds.CompletedChecks++
- ds.CheckSuite.mu.Unlock()
- }
-
- // Determine best preset for this domain
- ds.determineBestPresetForDomain(domain)
-
- log.Infof("Domain %s complete: tested %d presets, found %d successful configs",
- domain, testedCount, successfulCount)
- }
-
- // Copy results to CheckSuite for JSON serialization
- ds.CheckSuite.mu.Lock()
- ds.CheckSuite.DomainDiscoveryResults = ds.domainResults
- ds.CheckSuite.mu.Unlock()
-
- // Restore original configuration
- log.Infof("Restoring original configuration")
- if err := ds.pool.UpdateConfig(ds.originalConfig); err != nil {
- log.Errorf("Failed to restore original configuration: %v", err)
- }
-
- ds.CheckSuite.mu.Lock()
- ds.Status = CheckStatusComplete
- ds.CheckSuite.mu.Unlock()
-
- // Log summary
- ds.logDiscoverySummary()
-}
-
-func (ds *DiscoverySuite) determineBestPresetForDomain(domain string) {
- ds.mu.Lock()
- defer ds.mu.Unlock()
-
- domainResult := ds.domainResults[domain]
- if domainResult == nil {
- return
- }
-
- var bestPreset string
- var bestSpeed float64
- var bestSuccess bool
-
- for presetName, result := range domainResult.Results {
- // Prioritize success status first, then speed
- isSuccess := result.Status == CheckStatusComplete
-
- if !bestSuccess && isSuccess {
- // First successful result
- bestSuccess = true
- bestPreset = presetName
- bestSpeed = result.Speed
- } else if bestSuccess == isSuccess {
- // Both succeeded or both failed - compare speed
- if result.Speed > bestSpeed {
- bestPreset = presetName
- bestSpeed = result.Speed
- }
- }
- // If current best is successful but this one failed, skip
- }
-
- domainResult.BestPreset = bestPreset
- domainResult.BestSpeed = bestSpeed
- domainResult.BestSuccess = bestSuccess
-}
-
-func (ds *DiscoverySuite) buildTestConfig(preset ConfigPreset, testDomain string) *config.Config {
- cfg := &config.Config{
- ConfigPath: ds.originalConfig.ConfigPath,
- Queue: ds.originalConfig.Queue,
- System: ds.originalConfig.System,
- }
-
- mainSet := &config.SetConfig{
- Id: ds.originalConfig.MainSet.Id,
- Name: ds.originalConfig.MainSet.Name,
- Enabled: true,
- TCP: preset.Config.TCP,
- UDP: preset.Config.UDP,
- Fragmentation: preset.Config.Fragmentation,
- Faking: preset.Config.Faking,
- Targets: config.TargetsConfig{
- SNIDomains: []string{testDomain},
- DomainsToMatch: []string{testDomain},
- IPs: []string{},
- IpsToMatch: []string{},
- GeoSiteCategories: []string{},
- GeoIpCategories: []string{},
- },
- }
-
- if mainSet.Faking.SNIMutation.Mode == "" {
- mainSet.Faking.SNIMutation.Mode = "off"
- }
- if mainSet.TCP.WinMode == "" {
- mainSet.TCP.WinMode = "off"
- }
- if mainSet.TCP.DesyncMode == "" {
- mainSet.TCP.DesyncMode = "off"
- }
- if mainSet.TCP.WinValues == nil {
- mainSet.TCP.WinValues = []int{0, 1460, 8192, 65535}
- }
-
- cfg.MainSet = mainSet
- cfg.Sets = []*config.SetConfig{mainSet}
-
- return cfg
-}
-
-func (ds *DiscoverySuite) logDiscoverySummary() {
- log.Infof("\n=== Discovery Results Summary ===")
-
- ds.mu.RLock()
- defer ds.mu.RUnlock()
-
- for _, domain := range ds.sortedDomains() {
- result := ds.domainResults[domain]
- if result.BestSuccess {
- log.Infof("✓ %s: %s (%.2f KB/s)",
- domain, result.BestPreset, result.BestSpeed/1024)
- } else {
- log.Warnf("✗ %s: No successful configuration found", domain)
- }
- }
-}
-
-func (ds *DiscoverySuite) sortedDomains() []string {
- domains := make([]string, 0, len(ds.domainResults))
- for domain := range ds.domainResults {
- domains = append(domains, domain)
- }
- return domains
-}
-
-// GetDiscoveryReport returns formatted report
-func (ds *DiscoverySuite) GetDiscoveryReport() string {
- ds.mu.RLock()
- defer ds.mu.RUnlock()
-
- report := "Domain-Specific Configuration Discovery:\n"
- report += "=========================================\n\n"
-
- for _, domain := range ds.sortedDomains() {
- result := ds.domainResults[domain]
- report += fmt.Sprintf("Domain: %s\n", domain)
- if result.BestSuccess {
- report += fmt.Sprintf(" Best Config: %s\n", result.BestPreset)
- report += fmt.Sprintf(" Speed: %.2f KB/s\n", result.BestSpeed/1024)
- } else {
- report += " Status: No successful configuration\n"
- }
- report += "\n"
- }
-
- return report
-}
diff --git a/src/checker/preset.go b/src/checker/preset.go
deleted file mode 100644
index a403f67c..00000000
--- a/src/checker/preset.go
+++ /dev/null
@@ -1,597 +0,0 @@
-package checker
-
-import (
- "fmt"
-
- "github.com/daniellavrushin/b4/config"
-)
-
-type ConfigPreset struct {
- Name string
- Description string
- Config config.SetConfig
-}
-
-// GetTestPresets generates focused preset combinations
-func GetTestPresets() []ConfigPreset {
- presets := []ConfigPreset{}
-
- // 1. BASELINE - Always test first to compare
- presets = append(presets, ConfigPreset{
- Name: "baseline",
- Description: "No bypass techniques",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 0, FakeLen: 0, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: false, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "none"},
- Faking: config.FakingConfig{SNI: false, TTL: 8, Strategy: "none", SeqOffset: 0, SNISeqLength: 0, SNIType: 2},
- },
- })
-
- // 2. ConnBytesLimit Variations (CRITICAL - when bypass triggers)
- connBytesConfigs := []struct {
- tcp int
- udp int
- }{
- {1, 1}, // Immediate bypass
- {10, 5}, // Early bypass
- {19, 8}, // Default
- {50, 25}, // Late bypass
- {100, 50}, // Very late
- }
-
- for _, cb := range connBytesConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("connbytes-tcp%d-udp%d", cb.tcp, cb.udp),
- Description: fmt.Sprintf("Trigger at TCP:%d UDP:%d bytes", cb.tcp, cb.udp),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: cb.tcp},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: cb.udp},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 3. SYN Fake with Combined Strategies
- synConfigs := []struct {
- synLen int
- strategy string
- ttl uint8
- }{
- {64, "ttl", 3},
- {64, "pastseq", 8},
- {256, "randseq", 5},
- {256, "tcp_check", 8},
- {512, "md5sum", 3},
- }
-
- for _, sc := range synConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("syn-%d-%s", sc.synLen, sc.strategy),
- Description: fmt.Sprintf("SYN fake len=%d with %s", sc.synLen, sc.strategy),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, SynFake: true, SynFakeLen: sc.synLen},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1},
- Faking: config.FakingConfig{SNI: true, TTL: sc.ttl, Strategy: sc.strategy, SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 4. SNIType Variations (IMPORTANT - different payload types)
- sniTypeConfigs := []struct {
- sniType int
- payload string
- name string
- }{
- {0, "", "random"},
- {1, "GET / HTTP/1.1\r\nHost: ", "http"},
- {1, "\x00\x00\x00\x00\x00\x00\x00\x00", "nulls"},
- {1, "\xff\xff\xff\xff\xff\xff\xff\xff", "ones"},
- {1, "CONNECT ", "connect"},
- {2, "", "default"},
- }
-
- for _, st := range sniTypeConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("snitype-%s", st.name),
- Description: fmt.Sprintf("SNI payload type: %s", st.name),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 2, SNIType: st.sniType, CustomPayload: st.payload},
- },
- })
- }
-
- // 5. TCP Fragmentation with Extreme Positions
- fragConfigs := []struct {
- pos int
- reverse bool
- middle bool
- }{
- {1, false, false}, // Very early
- {1, true, false}, // Reverse
- {1, false, true}, // Middle SNI split
- {3, false, false}, // After version
- {11, false, false}, // After TLS header
- {50, false, false}, // Deep in handshake
- {100, true, false}, // Very deep + reverse
- }
-
- for _, fc := range fragConfigs {
- name := fmt.Sprintf("tcp-pos%d", fc.pos)
- if fc.reverse {
- name += "-rev"
- }
- if fc.middle {
- name += "-mid"
- }
-
- presets = append(presets, ConfigPreset{
- Name: name,
- Description: fmt.Sprintf("TCP frag pos=%d reverse=%v middle=%v", fc.pos, fc.reverse, fc.middle),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, Seg2Delay: 0},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: fc.pos, ReverseOrder: fc.reverse, MiddleSNI: fc.middle},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 6. OOB (Out-of-Band) Variations - NEW
- oobConfigs := []struct {
- pos int
- reverse bool
- char byte
- name string
- }{
- {1, false, 'x', "pos1"},
- {1, true, 'x', "pos1-rev"},
- {2, false, 'x', "pos2"},
- {3, false, 'x', "pos3"},
- {5, false, 'x', "pos5"},
- {1, false, 'a', "pos1-char-a"},
- {1, false, 0x00, "pos1-null"},
- {1, false, 0xFF, "pos1-xff"},
- {2, true, 'y', "pos2-rev-y"},
- {5, true, 'x', "pos5-rev"},
- }
-
- for _, oc := range oobConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("oob-%s", oc.name),
- Description: fmt.Sprintf("OOB pos=%d reverse=%v char=%#x", oc.pos, oc.reverse, oc.char),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "oob", OOBPosition: oc.pos, ReverseOrder: oc.reverse, OOBChar: oc.char},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 7. OOB + Different Faking Strategies
- oobFakeConfigs := []struct {
- strategy string
- ttl uint8
- seqLen int
- pos int
- }{
- {"ttl", 3, 1, 1},
- {"ttl", 5, 2, 2},
- {"pastseq", 8, 2, 1},
- {"pastseq", 5, 3, 3},
- {"randseq", 8, 1, 1},
- {"tcp_check", 8, 2, 2},
- }
-
- for _, ofc := range oobFakeConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("oob-fake-%s-pos%d", ofc.strategy, ofc.pos),
- Description: fmt.Sprintf("OOB pos=%d + fake %s seqLen=%d", ofc.pos, ofc.strategy, ofc.seqLen),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "oob", OOBPosition: ofc.pos, ReverseOrder: false, OOBChar: 'x'},
- Faking: config.FakingConfig{SNI: true, TTL: ofc.ttl, Strategy: ofc.strategy, SeqOffset: 10000, SNISeqLength: ofc.seqLen, SNIType: 2},
- },
- })
- }
-
- // 7a. SACK Dropping Variations
- sackConfigs := []struct {
- strategy string
- fragPos int
- reverse bool
- }{
- {"tcp", 1, false},
- {"tcp", 1, true},
- {"tcp", 3, false},
- {"ip", 1, false},
- {"oob", 1, false},
- {"oob", 2, true},
- }
-
- for _, sc := range sackConfigs {
- name := fmt.Sprintf("sack-%s", sc.strategy)
- if sc.reverse {
- name += "-rev"
- }
- if sc.fragPos > 1 {
- name += fmt.Sprintf("-pos%d", sc.fragPos)
- }
-
- fragConfig := config.FragmentationConfig{Strategy: sc.strategy, SNIPosition: sc.fragPos, ReverseOrder: sc.reverse}
- if sc.strategy == "oob" {
- fragConfig.OOBPosition = sc.fragPos
- fragConfig.OOBChar = 'x'
- }
-
- presets = append(presets, ConfigPreset{
- Name: name,
- Description: fmt.Sprintf("SACK drop + %s frag pos=%d", sc.strategy, sc.fragPos),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, DropSACK: true},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: fragConfig,
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 7b. SACK + SYN Fake combinations
- presets = append(presets, ConfigPreset{
- Name: "sack-syn-aggressive",
- Description: "SACK drop + SYN fake + TCP frag",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, DropSACK: true, SynFake: true, SynFakeLen: 256},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1, ReverseOrder: true},
- Faking: config.FakingConfig{SNI: true, TTL: 5, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 2, SNIType: 2},
- },
- })
-
- presets = append(presets, ConfigPreset{
- Name: "sack-oob-ultra",
- Description: "SACK drop + OOB + immediate trigger",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 1, DropSACK: true},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 1},
- Fragmentation: config.FragmentationConfig{Strategy: "oob", OOBPosition: 1, ReverseOrder: true, OOBChar: 'x'},
- Faking: config.FakingConfig{SNI: true, TTL: 3, Strategy: "pastseq", SeqOffset: 50000, SNISeqLength: 3, SNIType: 2},
- },
- })
-
- // 8. OOB + Early Triggering
- presets = append(presets, ConfigPreset{
- Name: "oob-immediate",
- Description: "OOB with immediate trigger (connbytes=1)",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 1},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 1},
- Fragmentation: config.FragmentationConfig{Strategy: "oob", OOBPosition: 1, ReverseOrder: false, OOBChar: 'x'},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 2, SNIType: 2},
- },
- })
-
- // 9. Faking Strategies with SeqOffset variations
- fakeConfigs := []struct {
- strategy string
- ttl uint8
- seqLen int
- offset int32
- }{
- {"ttl", 3, 1, 0},
- {"ttl", 5, 3, 0},
- {"ttl", 8, 5, 0},
- {"pastseq", 8, 1, 10000},
- {"pastseq", 5, 2, 50000},
- {"pastseq", 3, 3, 100000},
- {"randseq", 8, 1, 10000},
- {"randseq", 5, 2, 100000},
- {"md5sum", 8, 1, 0},
- {"tcp_check", 8, 2, 0},
- }
-
- for _, fc := range fakeConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("fake-%s-ttl%d-len%d", fc.strategy, fc.ttl, fc.seqLen),
- Description: fmt.Sprintf("Fake %s TTL=%d seqLen=%d", fc.strategy, fc.ttl, fc.seqLen),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1},
- Faking: config.FakingConfig{SNI: true, TTL: fc.ttl, Strategy: fc.strategy, SeqOffset: fc.offset, SNISeqLength: fc.seqLen, SNIType: 2},
- },
- })
- }
-
- // 10. UDP/QUIC with Port Filtering
- udpConfigs := []struct {
- mode string
- fakeLen int
- fakeSeq int
- strategy string
- quicFilter string
- dPort string
- }{
- {"fake", 64, 6, "ttl", "disabled", ""},
- {"fake", 128, 10, "checksum", "parse", ""},
- {"fake", 256, 12, "none", "all", ""},
- {"fake", 64, 8, "ttl", "parse", "443"},
- {"fake", 128, 10, "checksum", "all", "80,443"},
- {"drop", 0, 0, "none", "all", "443"},
- {"fake", 64, 6, "none", "disabled", ""},
- }
-
- for _, uc := range udpConfigs {
- name := fmt.Sprintf("udp-%s", uc.mode)
- if uc.quicFilter != "" {
- name += fmt.Sprintf("-q%s", uc.quicFilter)
- }
- if uc.dPort != "" {
- name += fmt.Sprintf("-p%s", uc.dPort)
- }
-
- presets = append(presets, ConfigPreset{
- Name: name,
- Description: fmt.Sprintf("UDP %s QUIC=%s ports=%s", uc.mode, uc.quicFilter, uc.dPort),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: uc.mode, FakeSeqLength: uc.fakeSeq, FakeLen: uc.fakeLen, FakingStrategy: uc.strategy, FilterQUIC: uc.quicFilter, FilterSTUN: uc.dPort == "", ConnBytesLimit: 8, DPortFilter: uc.dPort},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 11. IP Fragmentation variations
- ipFragConfigs := []struct {
- pos int
- reverse bool
- }{
- {1, false},
- {1, true},
- {8, false},
- {20, true},
- }
-
- for _, ifc := range ipFragConfigs {
- name := fmt.Sprintf("ip-frag-pos%d", ifc.pos)
- if ifc.reverse {
- name += "-rev"
- }
-
- presets = append(presets, ConfigPreset{
- Name: name,
- Description: fmt.Sprintf("IP frag at %d reverse=%v", ifc.pos, ifc.reverse),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "ip", SNIPosition: ifc.pos, ReverseOrder: ifc.reverse},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 11a. TLS Record Splitting Variations
- tlsConfigs := []struct {
- pos int
- reverse bool
- name string
- }{
- {1, false, "early"},
- {1, true, "early-rev"},
- {5, false, "mid"},
- {5, true, "mid-rev"},
- {10, false, "deep"},
- {20, false, "late"},
- {50, true, "late-rev"},
- {100, false, "extreme"},
- }
-
- for _, tc := range tlsConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("tls-%s", tc.name),
- Description: fmt.Sprintf("TLS record split at %d bytes reverse=%v", tc.pos, tc.reverse),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: tc.pos, ReverseOrder: tc.reverse},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
- }
-
- // 11b. TLS + Faking Strategy combinations
- tlsFakeConfigs := []struct {
- tlsPos int
- strategy string
- ttl uint8
- seqLen int
- }{
- {1, "ttl", 3, 1},
- {5, "ttl", 5, 2},
- {1, "pastseq", 8, 2},
- {10, "pastseq", 5, 3},
- {1, "randseq", 8, 1},
- {5, "tcp_check", 8, 2},
- {20, "md5sum", 8, 1},
- }
-
- for _, tfc := range tlsFakeConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("tls-pos%d-%s", tfc.tlsPos, tfc.strategy),
- Description: fmt.Sprintf("TLS pos=%d + fake %s TTL=%d", tfc.tlsPos, tfc.strategy, tfc.ttl),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: tfc.tlsPos, ReverseOrder: false},
- Faking: config.FakingConfig{SNI: true, TTL: tfc.ttl, Strategy: tfc.strategy, SeqOffset: 10000, SNISeqLength: tfc.seqLen, SNIType: 2},
- },
- })
- }
-
- // 11c. TLS + SYN Fake combinations
- tlsSynConfigs := []struct {
- tlsPos int
- synLen int
- ttl uint8
- }{
- {1, 64, 3},
- {5, 256, 5},
- {10, 512, 8},
- }
-
- for _, tsc := range tlsSynConfigs {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("tls-syn-pos%d-len%d", tsc.tlsPos, tsc.synLen),
- Description: fmt.Sprintf("TLS pos=%d + SYN fake len=%d", tsc.tlsPos, tsc.synLen),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, SynFake: true, SynFakeLen: tsc.synLen},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: tsc.tlsPos, ReverseOrder: true},
- Faking: config.FakingConfig{SNI: true, TTL: tsc.ttl, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 2, SNIType: 2},
- },
- })
- }
-
- // 11d. TLS + SACK combinations
- presets = append(presets, ConfigPreset{
- Name: "tls-sack-basic",
- Description: "TLS record split + SACK drop",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, DropSACK: true},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: 1, ReverseOrder: false},
- Faking: config.FakingConfig{SNI: true, TTL: 8, Strategy: "pastseq", SeqOffset: 10000, SNISeqLength: 1, SNIType: 2},
- },
- })
-
- presets = append(presets, ConfigPreset{
- Name: "tls-sack-aggressive",
- Description: "TLS + SACK + SYN fake + immediate trigger",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 1, DropSACK: true, SynFake: true, SynFakeLen: 256},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 10, FakeLen: 128, FakingStrategy: "checksum", FilterQUIC: "all", FilterSTUN: true, ConnBytesLimit: 1},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: 5, ReverseOrder: true},
- Faking: config.FakingConfig{SNI: true, TTL: 3, Strategy: "randseq", SeqOffset: 100000, SNISeqLength: 3, SNIType: 0},
- },
- })
-
- // 12. Delay variations
- delays := []int{5, 10, 20, 50}
- for _, delay := range delays {
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("delay-%d", delay),
- Description: fmt.Sprintf("Segment delay %dms", delay),
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, Seg2Delay: delay},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8, Seg2Delay: delay},
- Fragmentation: config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1, ReverseOrder: true},
- Faking: config.FakingConfig{SNI: true, TTL: 5, Strategy: "randseq", SeqOffset: 50000, SNISeqLength: 3, SNIType: 2},
- },
- })
- }
-
- // 13. Aggressive combinations
- aggressiveConfigs := []struct {
- name string
- synLen int
- udpSeq int
- ttl uint8
- strat string
- }{
- {"max-tcp", 256, 15, 3, "tcp"},
- {"max-oob", 512, 20, 1, "oob"},
- {"ultra-tcp", 512, 20, 1, "tcp"},
- {"ultra-oob", 512, 25, 1, "oob"},
- {"max-tls", 256, 15, 3, "tls"},
- {"max-tls-sack", 512, 20, 1, "tls"},
- {"ultra-tls", 512, 20, 1, "tls"},
- {"ultra-tls-sack", 512, 20, 1, "tls"},
- }
-
- for _, ac := range aggressiveConfigs {
- var fragConfig config.FragmentationConfig
- var tcpConfig config.TCPConfig
-
- switch ac.strat {
- case "oob":
- fragConfig = config.FragmentationConfig{Strategy: "oob", OOBPosition: 1, ReverseOrder: true, OOBChar: 'x'}
- tcpConfig = config.TCPConfig{ConnBytesLimit: 1, Seg2Delay: 10, SynFake: true, SynFakeLen: ac.synLen}
- case "tls":
- fragConfig = config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: 5, ReverseOrder: true}
- tcpConfig = config.TCPConfig{ConnBytesLimit: 1, Seg2Delay: 10, SynFake: true, SynFakeLen: ac.synLen}
- // Add SACK for "sack" variants
- if len(ac.name) > 4 && ac.name[len(ac.name)-4:] == "sack" {
- tcpConfig.DropSACK = true
- }
- default:
- fragConfig = config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1, ReverseOrder: true, MiddleSNI: true}
- tcpConfig = config.TCPConfig{ConnBytesLimit: 1, Seg2Delay: 10, SynFake: true, SynFakeLen: ac.synLen}
- }
-
- presets = append(presets, ConfigPreset{
- Name: fmt.Sprintf("aggressive-%s", ac.name),
- Description: fmt.Sprintf("%s bypass: all techniques", ac.name),
- Config: config.SetConfig{
- TCP: tcpConfig,
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: ac.udpSeq, FakeLen: 256, FakingStrategy: "checksum", FilterQUIC: "all", FilterSTUN: true, ConnBytesLimit: 1},
- Fragmentation: fragConfig,
- Faking: config.FakingConfig{SNI: true, TTL: ac.ttl, Strategy: "pastseq", SeqOffset: 100000, SNISeqLength: 5, SNIType: 0},
- },
- })
- }
-
- // 14. Special edge cases
- presets = append(presets, ConfigPreset{
- Name: "tls-only",
- Description: "TLS record split only, no fake SNI",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: 1, ReverseOrder: false},
- Faking: config.FakingConfig{SNI: false},
- },
- })
-
- presets = append(presets, ConfigPreset{
- Name: "sack-only",
- Description: "SACK drop only, no fragmentation",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, DropSACK: true},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 0, FakeLen: 0, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "none"},
- Faking: config.FakingConfig{SNI: false},
- },
- })
-
- presets = append(presets, ConfigPreset{
- Name: "tls-sack-only",
- Description: "TLS + SACK only, no fake SNI",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19, DropSACK: true},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 0, FakeLen: 0, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: 5, ReverseOrder: false},
- Faking: config.FakingConfig{SNI: false},
- },
- })
-
- presets = append(presets, ConfigPreset{
- Name: "oob-only",
- Description: "OOB only, no fake SNI",
- Config: config.SetConfig{
- TCP: config.TCPConfig{ConnBytesLimit: 19},
- UDP: config.UDPConfig{Mode: "fake", FakeSeqLength: 6, FakeLen: 64, FakingStrategy: "none", FilterQUIC: "disabled", FilterSTUN: true, ConnBytesLimit: 8},
- Fragmentation: config.FragmentationConfig{Strategy: "oob", OOBPosition: 1, ReverseOrder: false, OOBChar: 'x'},
- Faking: config.FakingConfig{SNI: false},
- },
- })
-
- return presets
-}
diff --git a/src/config/config.go b/src/config/config.go
index a24edd3f..f1e8a1c7 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -131,12 +131,20 @@ var DefaultConfig = Config{
},
Checker: CheckerConfig{
- TimeoutSeconds: 15,
- MaxConcurrent: 4,
- Domains: []string{},
+ Domains: []string{},
+ DiscoveryTimeoutSec: 5,
+ ConfigPropagateMs: 1500,
},
API: ApiConfig{
IPInfoToken: "",
},
},
}
+
+func NewSetConfig() SetConfig {
+ return DefaultSetConfig
+}
+
+func NewConfig() Config {
+ return DefaultConfig
+}
diff --git a/src/config/types.go b/src/config/types.go
index c7ede6c5..7215a9a4 100644
--- a/src/config/types.go
+++ b/src/config/types.go
@@ -110,9 +110,10 @@ type WebServerConfig struct {
}
type CheckerConfig struct {
- TimeoutSeconds int `json:"timeout" bson:"timeout"`
- Domains []string `json:"domains" bson:"domains"`
- MaxConcurrent int `json:"max_concurrent" bson:"max_concurrent"`
+ Domains []string `yaml:"domains" json:"domains"`
+ // Discovery settings
+ DiscoveryTimeoutSec int `yaml:"discovery_timeout" json:"discovery_timeout"`
+ ConfigPropagateMs int `yaml:"config_propagate_ms" json:"config_propagate_ms"`
}
type Logging struct {
diff --git a/src/discovery/cluster.go b/src/discovery/cluster.go
new file mode 100644
index 00000000..5a8926b2
--- /dev/null
+++ b/src/discovery/cluster.go
@@ -0,0 +1,250 @@
+package discovery
+
+import (
+ "net"
+ "sort"
+ "strings"
+
+ "golang.org/x/net/publicsuffix"
+)
+
+func ClusterDomains(domains []string) []*DomainCluster {
+ if len(domains) == 0 {
+ return nil
+ }
+
+ // Group by registrable domain (eTLD+1)
+ groups := make(map[string][]string)
+ ungrouped := []string{}
+
+ for _, domain := range domains {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ continue
+ }
+
+ // Get eTLD+1 (e.g., "youtube.com" from "www.youtube.com")
+ etld, err := publicsuffix.EffectiveTLDPlusOne(domain)
+ if err != nil {
+ ungrouped = append(ungrouped, domain)
+ continue
+ }
+
+ groups[etld] = append(groups[etld], domain)
+ }
+
+ clusters := make([]*DomainCluster, 0, len(groups)+len(ungrouped))
+
+ // Create clusters for grouped domains
+ for etld, domainList := range groups {
+ // Sort to ensure consistent representative selection
+ sort.Strings(domainList)
+
+ // Choose representative: prefer the shortest domain, or the eTLD itself
+ representative := domainList[0]
+ for _, d := range domainList {
+ // Prefer exact eTLD match
+ if d == etld {
+ representative = d
+ break
+ }
+ // Otherwise prefer shorter domains
+ if len(d) < len(representative) {
+ representative = d
+ }
+ }
+
+ clusters = append(clusters, &DomainCluster{
+ ID: etld,
+ Domains: domainList,
+ Representative: representative,
+ })
+ }
+
+ // Add ungrouped domains as individual clusters
+ for _, d := range ungrouped {
+ clusters = append(clusters, &DomainCluster{
+ ID: d,
+ Domains: []string{d},
+ Representative: d,
+ })
+ }
+
+ // Sort clusters by number of domains (larger clusters first - more value to test)
+ sort.Slice(clusters, func(i, j int) bool {
+ return len(clusters[i].Domains) > len(clusters[j].Domains)
+ })
+
+ return clusters
+}
+
+// MergeClustersByIP groups clusters that resolve to the same IP ranges
+// This is useful for CDNs where different domains share infrastructure
+func MergeClustersByIP(clusters []*DomainCluster) []*DomainCluster {
+ if len(clusters) <= 1 {
+ return clusters
+ }
+
+ // Resolve representative domains to IPs
+ ipToCluster := make(map[string][]*DomainCluster)
+
+ for _, cluster := range clusters {
+ ips, err := net.LookupIP(cluster.Representative)
+ if err != nil || len(ips) == 0 {
+ continue
+ }
+
+ // Use first IP's /24 as key (rough grouping)
+ ip := ips[0].To4()
+ if ip == nil {
+ ip = ips[0].To16()
+ }
+ if ip == nil {
+ continue
+ }
+
+ // Create /24 key for IPv4, /64 for IPv6
+ var key string
+ if len(ip) == 4 {
+ key = ip[:3].String() + ".0/24"
+ } else {
+ key = ip[:8].String() + "/64"
+ }
+
+ ipToCluster[key] = append(ipToCluster[key], cluster)
+ }
+
+ // Merge clusters that share IP ranges
+ merged := make([]*DomainCluster, 0)
+ seen := make(map[string]bool)
+
+ for _, clusterGroup := range ipToCluster {
+ if len(clusterGroup) <= 1 {
+ continue
+ }
+
+ // Merge all clusters in this IP group
+ mergedCluster := &DomainCluster{
+ ID: clusterGroup[0].ID + "+merged",
+ Domains: []string{},
+ }
+
+ for _, c := range clusterGroup {
+ mergedCluster.Domains = append(mergedCluster.Domains, c.Domains...)
+ seen[c.ID] = true
+ }
+
+ // Choose representative from merged cluster
+ sort.Strings(mergedCluster.Domains)
+ mergedCluster.Representative = mergedCluster.Domains[0]
+ for _, d := range mergedCluster.Domains {
+ if len(d) < len(mergedCluster.Representative) {
+ mergedCluster.Representative = d
+ }
+ }
+
+ merged = append(merged, mergedCluster)
+ }
+
+ // Add unmerged clusters
+ for _, c := range clusters {
+ if !seen[c.ID] {
+ merged = append(merged, c)
+ }
+ }
+
+ return merged
+}
+
+// GetKnownCDNGroups returns domain patterns that belong to known CDNs
+// Domains matching these patterns can be grouped together
+var knownCDNPatterns = map[string][]string{
+ "google": {
+ "google.com", "googleapis.com", "gstatic.com", "googleusercontent.com",
+ "googlevideo.com", "youtube.com", "ytimg.com", "ggpht.com",
+ },
+ "cloudflare": {
+ "cloudflare.com", "cloudflare-dns.com", "cloudflareinsights.com",
+ },
+ "amazon": {
+ "amazonaws.com", "cloudfront.net", "amazon.com", "aws.amazon.com",
+ },
+ "microsoft": {
+ "microsoft.com", "msn.com", "live.com", "office.com", "azure.com",
+ "windows.net", "microsoftonline.com",
+ },
+ "meta": {
+ "facebook.com", "fbcdn.net", "instagram.com", "whatsapp.com", "whatsapp.net",
+ },
+ "twitter": {
+ "twitter.com", "x.com", "twimg.com", "t.co",
+ },
+}
+
+// GetCDNGroup returns the CDN group name if domain belongs to a known CDN
+func GetCDNGroup(domain string) string {
+ domain = strings.ToLower(domain)
+
+ for group, patterns := range knownCDNPatterns {
+ for _, pattern := range patterns {
+ if domain == pattern || strings.HasSuffix(domain, "."+pattern) {
+ return group
+ }
+ }
+ }
+ return ""
+}
+
+// ClusterByKnownCDN groups domains by known CDN patterns before falling back to eTLD
+func ClusterByKnownCDN(domains []string) []*DomainCluster {
+ cdnGroups := make(map[string][]string)
+ remaining := []string{}
+
+ for _, domain := range domains {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ continue
+ }
+
+ cdnGroup := GetCDNGroup(domain)
+ if cdnGroup != "" {
+ cdnGroups[cdnGroup] = append(cdnGroups[cdnGroup], domain)
+ } else {
+ remaining = append(remaining, domain)
+ }
+ }
+
+ clusters := make([]*DomainCluster, 0)
+
+ // Create clusters for CDN groups
+ for cdnName, domainList := range cdnGroups {
+ sort.Strings(domainList)
+
+ // Choose shortest domain as representative
+ representative := domainList[0]
+ for _, d := range domainList {
+ if len(d) < len(representative) {
+ representative = d
+ }
+ }
+
+ clusters = append(clusters, &DomainCluster{
+ ID: "cdn:" + cdnName,
+ Domains: domainList,
+ Representative: representative,
+ })
+ }
+
+ // Cluster remaining domains by eTLD
+ if len(remaining) > 0 {
+ remainingClusters := ClusterDomains(remaining)
+ clusters = append(clusters, remainingClusters...)
+ }
+
+ // Sort by cluster size
+ sort.Slice(clusters, func(i, j int) bool {
+ return len(clusters[i].Domains) > len(clusters[j].Domains)
+ })
+
+ return clusters
+}
diff --git a/src/discovery/discovery.go b/src/discovery/discovery.go
new file mode 100644
index 00000000..bd26faea
--- /dev/null
+++ b/src/discovery/discovery.go
@@ -0,0 +1,664 @@
+package discovery
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/daniellavrushin/b4/config"
+ "github.com/daniellavrushin/b4/log"
+ "github.com/daniellavrushin/b4/nfq"
+)
+
+const (
+ // Timeouts
+ QUICK_FAIL_TIMEOUT = 1500 * time.Millisecond // Fast fail for non-responsive
+ MAX_PRESETS_PER_DOMAIN = 3 // Stop after N successful configs
+
+ // Parallelism
+ DEFAULT_PARALLEL_TESTS = 3
+)
+
+type DiscoverySuite struct {
+ *CheckSuite
+ pool *nfq.Pool
+ originalConfig *config.Config
+
+ // Hierarchical discovery state
+ clusters []*DomainCluster
+ workingFamilies map[string][]StrategyFamily // cluster -> working families
+ bestParams map[string]map[StrategyFamily]ConfigPreset // cluster -> family -> best preset
+ domainResults map[string]*DomainDiscoveryResult
+
+ mu sync.RWMutex
+}
+
+func NewDiscoverySuite(checkConfig CheckConfig, pool *nfq.Pool) *DiscoverySuite {
+
+ suite := NewCheckSuite(checkConfig)
+ suite.DomainDiscoveryResults = make(map[string]*DomainDiscoveryResult)
+
+ return &DiscoverySuite{
+ CheckSuite: suite,
+ pool: pool,
+ workingFamilies: make(map[string][]StrategyFamily),
+ bestParams: make(map[string]map[StrategyFamily]ConfigPreset),
+ domainResults: make(map[string]*DomainDiscoveryResult),
+ }
+}
+
+func (ds *DiscoverySuite) RunDiscovery(domains []string) {
+ // Register in activeSuites
+ suitesMu.Lock()
+ activeSuites[ds.Id] = ds.CheckSuite
+ suitesMu.Unlock()
+
+ defer func() {
+ ds.EndTime = time.Now()
+ time.AfterFunc(5*time.Minute, func() {
+ suitesMu.Lock()
+ delete(activeSuites, ds.Id)
+ suitesMu.Unlock()
+ })
+ }()
+
+ ds.CheckSuite.mu.Lock()
+ ds.Status = CheckStatusRunning
+ ds.CheckSuite.mu.Unlock()
+
+ // Store original configuration
+ ds.originalConfig = ds.pool.GetFirstWorkerConfig()
+ if ds.originalConfig == nil {
+ log.Errorf("Failed to get original configuration")
+ ds.setStatus(CheckStatusFailed)
+ return
+ }
+
+ // Step 1: Cluster domains
+ log.Infof("Clustering %d domains...", len(domains))
+ ds.clusters = ClusterByKnownCDN(domains)
+ log.Infof("Created %d clusters from %d domains", len(ds.clusters), len(domains))
+
+ for _, c := range ds.clusters {
+ log.Tracef(" Cluster %s: %d domains, representative: %s", c.ID, len(c.Domains), c.Representative)
+ }
+
+ // Initialize domain results for all domains
+ for _, domain := range domains {
+ ds.mu.Lock()
+ ds.domainResults[domain] = &DomainDiscoveryResult{
+ Domain: domain,
+ Results: make(map[string]*DomainPresetResult),
+ }
+ ds.mu.Unlock()
+ }
+
+ // Set initial total as phase 1 only - will update as we discover working families
+ phase1Presets := GetPhase1Presets()
+ ds.CheckSuite.mu.Lock()
+ ds.TotalChecks = len(ds.clusters) * len(phase1Presets)
+ ds.CheckSuite.mu.Unlock()
+
+ log.Infof("Starting hierarchical discovery for %d clusters", len(ds.clusters))
+ log.Warnf("Service traffic will be affected during discovery testing")
+
+ // Step 2: For each cluster, run hierarchical discovery
+ for _, cluster := range ds.clusters {
+ select {
+ case <-ds.cancel:
+ log.Infof("Discovery suite %s canceled", ds.Id)
+ ds.setStatus(CheckStatusCanceled)
+ ds.restoreConfig()
+ return
+ default:
+ }
+
+ ds.discoverCluster(cluster)
+ }
+
+ // Step 3: Apply cluster results to all domains in each cluster
+ ds.applyClusterResults()
+
+ // Restore original configuration
+ ds.restoreConfig()
+
+ // Copy results to CheckSuite
+ ds.CheckSuite.mu.Lock()
+ ds.CheckSuite.DomainDiscoveryResults = ds.domainResults
+ ds.Status = CheckStatusComplete
+ ds.CheckSuite.mu.Unlock()
+
+ ds.logDiscoverySummary()
+}
+
+func (ds *DiscoverySuite) discoverCluster(cluster *DomainCluster) {
+ domain := cluster.Representative
+ log.Infof("=== Discovering cluster %s (representative: %s, %d domains) ===",
+ cluster.ID, domain, len(cluster.Domains))
+
+ // Phase 1: Strategy Detection
+ ds.setPhase(PhaseStrategy)
+ workingFamilies := ds.runPhase1(domain)
+
+ ds.mu.Lock()
+ ds.workingFamilies[cluster.ID] = workingFamilies
+ if ds.bestParams[cluster.ID] == nil {
+ ds.bestParams[cluster.ID] = make(map[StrategyFamily]ConfigPreset)
+ }
+ ds.mu.Unlock()
+
+ if len(workingFamilies) == 0 {
+ log.Warnf("No working bypass strategies found for cluster %s", cluster.ID)
+ return
+ }
+
+ log.Infof("Phase 1 complete: %d working families: %v", len(workingFamilies), workingFamilies)
+
+ // Phase 2: Optimization (only for working families)
+ ds.setPhase(PhaseOptimize)
+ ds.runPhase2(domain, cluster.ID, workingFamilies)
+
+ // Phase 3: Combinations (if multiple families work)
+ if len(workingFamilies) >= 2 {
+ ds.setPhase(PhaseCombination)
+ ds.runPhase3(domain, cluster.ID)
+ }
+
+ // Mark cluster as tested and store best result
+ cluster.Tested = true
+ ds.determineBestForCluster(cluster)
+}
+
+func (ds *DiscoverySuite) runPhase1(domain string) []StrategyFamily {
+ presets := GetPhase1Presets()
+ workingFamilies := []StrategyFamily{}
+ familyResults := make(map[StrategyFamily]*StrategyResult)
+
+ log.Infof("Phase 1: Testing %d strategy families", len(presets))
+
+ // Test baseline first (without any bypass)
+ baselinePreset := presets[0]
+ baselineResult := ds.testPreset(domain, baselinePreset)
+ ds.storeResult(domain, baselinePreset, baselineResult)
+
+ baselineWorks := baselineResult.Status == CheckStatusComplete
+ var baselineSpeed float64
+ if baselineWorks {
+ baselineSpeed = baselineResult.Speed
+ log.Infof(" Baseline: SUCCESS (%.2f KB/s) - DPI bypass may not be needed", baselineSpeed/1024)
+
+ // Store baseline speed for improvement calculation
+ ds.mu.Lock()
+ if dr := ds.domainResults[domain]; dr != nil {
+ dr.BaselineSpeed = baselineSpeed
+ }
+ ds.mu.Unlock()
+ } else {
+ log.Infof(" Baseline: FAILED - DPI bypass needed")
+ }
+
+ // Test each strategy family
+ for _, preset := range presets[1:] { // Skip baseline
+ select {
+ case <-ds.cancel:
+ return workingFamilies
+ default:
+ }
+
+ result := ds.testPreset(domain, preset)
+ ds.storeResult(domain, preset, result)
+
+ sr := &StrategyResult{
+ Family: preset.Family,
+ Works: result.Status == CheckStatusComplete,
+ Speed: result.Speed,
+ Preset: preset.Name,
+ Latency: result.Duration,
+ }
+ familyResults[preset.Family] = sr
+
+ if sr.Works {
+ // Only count as "working" if it's better than baseline or baseline failed
+ if !baselineWorks || sr.Speed > baselineSpeed*0.8 {
+ workingFamilies = append(workingFamilies, preset.Family)
+ log.Infof(" %s: SUCCESS (%.2f KB/s)", preset.Name, sr.Speed/1024)
+ } else {
+ log.Infof(" %s: SUCCESS but slower than baseline (%.2f vs %.2f KB/s)",
+ preset.Name, sr.Speed/1024, baselineSpeed/1024)
+ }
+ } else {
+ log.Tracef(" %s: FAILED (%s)", preset.Name, result.Error)
+ }
+ }
+
+ return workingFamilies
+}
+
+func (ds *DiscoverySuite) runPhase2(domain string, clusterID string, families []StrategyFamily) {
+ // Calculate actual phase 2 preset count and update total
+ totalPhase2Presets := 0
+ for _, family := range families {
+ totalPhase2Presets += len(GetPhase2Presets(family))
+ }
+
+ ds.CheckSuite.mu.Lock()
+ ds.TotalChecks += totalPhase2Presets
+ ds.CheckSuite.mu.Unlock()
+
+ log.Infof("Phase 2: Optimizing %d working families (%d presets)", len(families), totalPhase2Presets)
+
+ for _, family := range families {
+ select {
+ case <-ds.cancel:
+ return
+ default:
+ }
+
+ presets := GetPhase2Presets(family)
+ if len(presets) == 0 {
+ continue
+ }
+
+ log.Infof(" Optimizing %s (%d variants)", family, len(presets))
+
+ var bestPreset ConfigPreset
+ var bestSpeed float64
+ successCount := 0
+
+ for _, preset := range presets {
+ select {
+ case <-ds.cancel:
+ return
+ default:
+ }
+
+ // Stop early if we found enough good configs
+ if successCount >= 3 {
+ log.Tracef(" Found %d good configs for %s, skipping rest", successCount, family)
+ break
+ }
+
+ result := ds.testPreset(domain, preset)
+ ds.storeResult(domain, preset, result)
+
+ if result.Status == CheckStatusComplete {
+ successCount++
+ if result.Speed > bestSpeed {
+ bestSpeed = result.Speed
+ bestPreset = preset
+ }
+ log.Tracef(" %s: %.2f KB/s", preset.Name, result.Speed/1024)
+ }
+ }
+
+ if bestSpeed > 0 {
+ ds.mu.Lock()
+ ds.bestParams[clusterID][family] = bestPreset
+ ds.mu.Unlock()
+ log.Infof(" Best %s config: %s (%.2f KB/s)", family, bestPreset.Name, bestSpeed/1024)
+ }
+ }
+}
+
+func (ds *DiscoverySuite) runPhase3(domain string, clusterID string) {
+ ds.mu.RLock()
+ workingFamilies := ds.workingFamilies[clusterID]
+ bestParams := ds.bestParams[clusterID]
+ ds.mu.RUnlock()
+
+ presets := GetCombinationPresets(workingFamilies, bestParams)
+ if len(presets) == 0 {
+ return
+ }
+
+ // Update total count
+ ds.CheckSuite.mu.Lock()
+ ds.TotalChecks += len(presets)
+ ds.CheckSuite.mu.Unlock()
+
+ log.Infof("Phase 3: Testing %d combination presets", len(presets))
+
+ for _, preset := range presets {
+ select {
+ case <-ds.cancel:
+ return
+ default:
+ }
+
+ result := ds.testPreset(domain, preset)
+ ds.storeResult(domain, preset, result)
+
+ if result.Status == CheckStatusComplete {
+ log.Infof(" %s: SUCCESS (%.2f KB/s)", preset.Name, result.Speed/1024)
+ } else {
+ log.Tracef(" %s: FAILED", preset.Name)
+ }
+ }
+}
+
+func (ds *DiscoverySuite) testPreset(domain string, preset ConfigPreset) CheckResult {
+ // Build test config
+ testConfig := ds.buildTestConfig(preset, domain)
+
+ // Apply config to pool
+ if err := ds.pool.UpdateConfig(testConfig); err != nil {
+ log.Errorf("Failed to apply preset %s: %v", preset.Name, err)
+ return CheckResult{
+ Domain: domain,
+ Status: CheckStatusFailed,
+ Error: err.Error(),
+ }
+ }
+
+ // Brief delay for config propagation
+ time.Sleep(time.Duration(ds.Config.ConfigPropagateTimeout) * time.Millisecond)
+
+ // Test with quick fail first
+ result := ds.quickTest(domain)
+
+ // If quick test failed but not timeout, try full test
+ if result.Status == CheckStatusFailed && result.BytesRead == 0 {
+ // Give it another shot with full timeout
+ result = ds.fullTest(domain)
+ }
+
+ result.Set = testConfig.MainSet
+
+ // Update progress
+ ds.CheckSuite.mu.Lock()
+ ds.CompletedChecks++
+ ds.CheckSuite.mu.Unlock()
+
+ return result
+}
+
+func (ds *DiscoverySuite) quickTest(domain string) CheckResult {
+ return ds.fetchWithTimeout(domain, QUICK_FAIL_TIMEOUT)
+}
+
+func (ds *DiscoverySuite) fullTest(domain string) CheckResult {
+ return ds.fetchWithTimeout(domain, ds.Config.Timeout)
+}
+
+func (ds *DiscoverySuite) fetchWithTimeout(domain string, timeout time.Duration) CheckResult {
+ result := CheckResult{
+ Domain: domain,
+ Status: CheckStatusRunning,
+ Timestamp: time.Now(),
+ }
+
+ testURL := fmt.Sprintf("https://%s/", domain)
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ client := &http.Client{
+ Timeout: timeout,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ ResponseHeaderTimeout: timeout,
+ IdleConnTimeout: timeout,
+ DialContext: (&net.Dialer{
+ Timeout: timeout / 2,
+ KeepAlive: timeout,
+ }).DialContext,
+ },
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", testURL, nil)
+ if err != nil {
+ result.Status = CheckStatusFailed
+ result.Error = err.Error()
+ return result
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
+
+ start := time.Now()
+ resp, err := client.Do(req)
+ if err != nil {
+ result.Status = CheckStatusFailed
+ result.Error = err.Error()
+ result.Duration = time.Since(start)
+ return result
+ }
+ defer resp.Body.Close()
+
+ result.StatusCode = resp.StatusCode
+
+ // Read up to 100KB
+ bytesRead, _ := io.CopyN(io.Discard, resp.Body, 100*1024)
+ duration := time.Since(start)
+
+ result.Duration = duration
+ result.BytesRead = bytesRead
+
+ if bytesRead > 0 {
+ result.Status = CheckStatusComplete
+ if duration.Seconds() > 0 {
+ result.Speed = float64(bytesRead) / duration.Seconds()
+ }
+ } else {
+ result.Status = CheckStatusFailed
+ result.Error = "no data received"
+ }
+
+ return result
+}
+
+func (ds *DiscoverySuite) storeResult(domain string, preset ConfigPreset, result CheckResult) {
+ ds.mu.Lock()
+ defer ds.mu.Unlock()
+
+ dr := ds.domainResults[domain]
+ if dr == nil {
+ dr = &DomainDiscoveryResult{
+ Domain: domain,
+ Results: make(map[string]*DomainPresetResult),
+ }
+ ds.domainResults[domain] = dr
+ }
+
+ dr.Results[preset.Name] = &DomainPresetResult{
+ PresetName: preset.Name,
+ Family: preset.Family,
+ Phase: preset.Phase,
+ Status: result.Status,
+ Duration: result.Duration,
+ Speed: result.Speed,
+ BytesRead: result.BytesRead,
+ Error: result.Error,
+ StatusCode: result.StatusCode,
+ Set: result.Set,
+ }
+}
+
+func (ds *DiscoverySuite) determineBestForCluster(cluster *DomainCluster) {
+ ds.mu.Lock()
+ defer ds.mu.Unlock()
+
+ dr := ds.domainResults[cluster.Representative]
+ if dr == nil {
+ return
+ }
+
+ var bestPreset string
+ var bestSpeed float64
+ var bestSuccess bool
+
+ for presetName, result := range dr.Results {
+ if result.Status == CheckStatusComplete {
+ if !bestSuccess || result.Speed > bestSpeed {
+ bestSuccess = true
+ bestPreset = presetName
+ bestSpeed = result.Speed
+ }
+ }
+ }
+
+ dr.BestPreset = bestPreset
+ dr.BestSpeed = bestSpeed
+ dr.BestSuccess = bestSuccess
+
+ // Calculate improvement over baseline
+ if dr.BaselineSpeed > 0 && bestSpeed > 0 {
+ dr.Improvement = ((bestSpeed - dr.BaselineSpeed) / dr.BaselineSpeed) * 100
+ }
+
+ cluster.BestPreset = bestPreset
+ cluster.BestSpeed = bestSpeed
+}
+
+func (ds *DiscoverySuite) applyClusterResults() {
+ ds.mu.Lock()
+ defer ds.mu.Unlock()
+
+ for _, cluster := range ds.clusters {
+ if !cluster.Tested {
+ continue
+ }
+
+ repResult := ds.domainResults[cluster.Representative]
+ if repResult == nil {
+ continue
+ }
+
+ // Apply representative's results to all domains in cluster
+ for _, domain := range cluster.Domains {
+ if domain == cluster.Representative {
+ continue
+ }
+
+ dr := ds.domainResults[domain]
+ if dr == nil {
+ dr = &DomainDiscoveryResult{
+ Domain: domain,
+ Results: make(map[string]*DomainPresetResult),
+ }
+ ds.domainResults[domain] = dr
+ }
+
+ // Copy best result from representative
+ dr.BestPreset = repResult.BestPreset
+ dr.BestSpeed = repResult.BestSpeed
+ dr.BestSuccess = repResult.BestSuccess
+ dr.WorkingFamilies = repResult.WorkingFamilies
+
+ // Copy the best preset's config
+ if bestResult, ok := repResult.Results[repResult.BestPreset]; ok {
+ dr.Results[repResult.BestPreset] = bestResult
+ }
+ }
+ }
+}
+
+func (ds *DiscoverySuite) buildTestConfig(preset ConfigPreset, testDomain string) *config.Config {
+ mainSet := config.NewSetConfig()
+
+ mainSet.Id = ds.originalConfig.MainSet.Id
+ mainSet.Name = preset.Name
+ mainSet.TCP = preset.Config.TCP
+ mainSet.UDP = preset.Config.UDP
+ mainSet.Fragmentation = preset.Config.Fragmentation
+ mainSet.Faking = preset.Config.Faking
+ mainSet.Targets.SNIDomains = []string{testDomain}
+ mainSet.Targets.DomainsToMatch = []string{testDomain}
+
+ return &config.Config{
+ ConfigPath: ds.originalConfig.ConfigPath,
+ Queue: ds.originalConfig.Queue,
+ System: ds.originalConfig.System,
+ MainSet: &mainSet,
+ Sets: []*config.SetConfig{&mainSet},
+ }
+}
+
+func (ds *DiscoverySuite) setStatus(status CheckStatus) {
+ ds.CheckSuite.mu.Lock()
+ ds.Status = status
+ ds.CheckSuite.mu.Unlock()
+}
+
+func (ds *DiscoverySuite) setPhase(phase DiscoveryPhase) {
+ ds.CheckSuite.mu.Lock()
+ ds.CurrentPhase = phase
+ ds.CheckSuite.mu.Unlock()
+}
+
+func (ds *DiscoverySuite) restoreConfig() {
+ log.Infof("Restoring original configuration")
+ if err := ds.pool.UpdateConfig(ds.originalConfig); err != nil {
+ log.Errorf("Failed to restore original configuration: %v", err)
+ }
+}
+
+func (ds *DiscoverySuite) logDiscoverySummary() {
+ log.Infof("\n=== Discovery Results Summary ===")
+
+ ds.mu.RLock()
+ defer ds.mu.RUnlock()
+
+ // Sort domains for consistent output
+ domains := make([]string, 0, len(ds.domainResults))
+ for d := range ds.domainResults {
+ domains = append(domains, d)
+ }
+ sort.Strings(domains)
+
+ successCount := 0
+ for _, domain := range domains {
+ result := ds.domainResults[domain]
+ if result.BestSuccess {
+ successCount++
+ improvement := ""
+ if result.Improvement > 0 {
+ improvement = fmt.Sprintf(" (+%.0f%%)", result.Improvement)
+ }
+ log.Infof("✓ %s: %s (%.2f KB/s%s)",
+ domain, result.BestPreset, result.BestSpeed/1024, improvement)
+ } else {
+ log.Warnf("✗ %s: No successful configuration found", domain)
+ }
+ }
+
+ log.Infof("=== %d/%d domains with working configurations ===", successCount, len(domains))
+}
+
+// GetDiscoveryReport returns formatted report
+func (ds *DiscoverySuite) GetDiscoveryReport() string {
+ ds.mu.RLock()
+ defer ds.mu.RUnlock()
+
+ report := "Domain-Specific Configuration Discovery:\n"
+ report += "=========================================\n\n"
+
+ domains := make([]string, 0, len(ds.domainResults))
+ for d := range ds.domainResults {
+ domains = append(domains, d)
+ }
+ sort.Strings(domains)
+
+ for _, domain := range domains {
+ result := ds.domainResults[domain]
+ report += fmt.Sprintf("Domain: %s\n", domain)
+ if result.BestSuccess {
+ report += fmt.Sprintf(" Best Config: %s\n", result.BestPreset)
+ report += fmt.Sprintf(" Speed: %.2f KB/s\n", result.BestSpeed/1024)
+ if result.Improvement > 0 {
+ report += fmt.Sprintf(" Improvement: +%.0f%%\n", result.Improvement)
+ }
+ } else {
+ report += " Status: No successful configuration\n"
+ }
+ report += "\n"
+ }
+
+ return report
+}
diff --git a/src/discovery/preset.go b/src/discovery/preset.go
new file mode 100644
index 00000000..d126d549
--- /dev/null
+++ b/src/discovery/preset.go
@@ -0,0 +1,597 @@
+package discovery
+
+import (
+ "fmt"
+
+ "github.com/daniellavrushin/b4/config"
+)
+
+// GetPhase1Presets returns minimal presets for strategy family detection
+// These are the "does this approach work at all?" tests
+// IMPORTANT: Most DPI requires COMBINATIONS of techniques, not single techniques
+func GetPhase1Presets() []ConfigPreset {
+ return []ConfigPreset{
+ // 0. Proven working config - this is the baseline that works for most Russian DPI
+ {
+ Name: "proven-combo",
+ Description: "Proven combination: TCP frag + reverse + middle SNI + fake pastseq",
+ Family: FamilyNone,
+ Phase: PhaseBaseline,
+ Priority: 0,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: config.UDPConfig{
+ Mode: "fake",
+ FakeSeqLength: 6,
+ FakeLen: 64,
+ FakingStrategy: "none",
+ FilterQUIC: "disabled",
+ FilterSTUN: true,
+ ConnBytesLimit: 8,
+ },
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "tcp",
+ ReverseOrder: true,
+ MiddleSNI: true,
+ SNIPosition: 1,
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 8,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+
+ // 1. Raw baseline - no bypass at all (to detect if DPI even blocks)
+ {
+ Name: "no-bypass",
+ Description: "No bypass techniques - test raw connectivity",
+ Family: FamilyNone,
+ Phase: PhaseBaseline,
+ Priority: 1,
+ Config: baselineConfig(),
+ },
+
+ // 2. TCP Frag + Fake (common combo)
+ {
+ Name: "tcp-frag-fake",
+ Description: "TCP fragmentation with fake SNI",
+ Family: FamilyTCPFrag,
+ Phase: PhaseStrategy,
+ Priority: 2,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: defaultUDP(),
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "tcp",
+ SNIPosition: 1,
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 8,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+
+ // 3. TCP Frag + Reverse + Fake
+ {
+ Name: "tcp-frag-rev-fake",
+ Description: "TCP frag reverse order with fake SNI",
+ Family: FamilyTCPFrag,
+ Phase: PhaseStrategy,
+ Priority: 3,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: defaultUDP(),
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "tcp",
+ SNIPosition: 1,
+ ReverseOrder: true,
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 8,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+
+ // 4. TLS Record + Fake
+ {
+ Name: "tls-rec-fake",
+ Description: "TLS record splitting with fake SNI",
+ Family: FamilyTLSRec,
+ Phase: PhaseStrategy,
+ Priority: 4,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: defaultUDP(),
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "tls",
+ TLSRecordPosition: 1,
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 8,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+
+ // 5. OOB + Fake
+ {
+ Name: "oob-fake",
+ Description: "Out-of-band with fake SNI",
+ Family: FamilyOOB,
+ Phase: PhaseStrategy,
+ Priority: 5,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: defaultUDP(),
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "oob",
+ OOBPosition: 1,
+ OOBChar: 'x',
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 8,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+
+ // 6. Fake only (low TTL)
+ {
+ Name: "fake-ttl-low",
+ Description: "Fake SNI with low TTL (no fragmentation)",
+ Family: FamilyFakeSNI,
+ Phase: PhaseStrategy,
+ Priority: 6,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: defaultUDP(),
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "none",
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 3,
+ Strategy: "ttl",
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+
+ // 7. SACK Drop + TCP Frag + Fake
+ {
+ Name: "sack-frag-fake",
+ Description: "SACK drop with TCP frag and fake",
+ Family: FamilySACK,
+ Phase: PhaseStrategy,
+ Priority: 7,
+ Config: config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ DropSACK: true,
+ },
+ UDP: defaultUDP(),
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "tcp",
+ SNIPosition: 1,
+ ReverseOrder: true,
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 8,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ },
+ },
+ },
+ }
+}
+
+func defaultUDP() config.UDPConfig {
+ return config.UDPConfig{
+ Mode: "fake",
+ FakeSeqLength: 6,
+ FakeLen: 64,
+ FakingStrategy: "none",
+ FilterQUIC: "disabled",
+ FilterSTUN: true,
+ ConnBytesLimit: 8,
+ }
+}
+
+// GetPhase2Presets generates optimization presets for a specific working family
+func GetPhase2Presets(family StrategyFamily) []ConfigPreset {
+ base := baseConfig()
+ presets := []ConfigPreset{}
+
+ switch family {
+ case FamilyTCPFrag:
+ positions := []int{1, 2, 3, 5, 10}
+ for _, pos := range positions {
+ for _, reverse := range []bool{false, true} {
+ name := formatName("tcp-pos%d", pos)
+ if reverse {
+ name += "-rev"
+ }
+ presets = append(presets, ConfigPreset{
+ Name: name,
+ Family: FamilyTCPFrag,
+ Phase: PhaseOptimize,
+ Priority: pos,
+ Config: withFragmentation(base, config.FragmentationConfig{
+ Strategy: "tcp",
+ SNIPosition: pos,
+ ReverseOrder: reverse,
+ }),
+ })
+ }
+ }
+ // Add middle SNI variant
+ presets = append(presets, ConfigPreset{
+ Name: "tcp-middle-sni",
+ Family: FamilyTCPFrag,
+ Phase: PhaseOptimize,
+ Priority: 10,
+ Config: withFragmentation(base, config.FragmentationConfig{
+ Strategy: "tcp",
+ SNIPosition: 1,
+ MiddleSNI: true,
+ }),
+ })
+
+ case FamilyTLSRec:
+ positions := []int{1, 5, 10, 20, 50}
+ for _, pos := range positions {
+ for _, reverse := range []bool{false, true} {
+ name := formatName("tls-pos%d", pos)
+ if reverse {
+ name += "-rev"
+ }
+ presets = append(presets, ConfigPreset{
+ Name: name,
+ Family: FamilyTLSRec,
+ Phase: PhaseOptimize,
+ Priority: pos,
+ Config: withFragmentation(base, config.FragmentationConfig{
+ Strategy: "tls",
+ TLSRecordPosition: pos,
+ ReverseOrder: reverse,
+ }),
+ })
+ }
+ }
+
+ case FamilyOOB:
+ positions := []int{1, 2, 3, 5}
+ chars := []byte{'x', 'a', 0x00, 0xFF}
+ for _, pos := range positions {
+ for _, ch := range chars {
+ name := formatName("oob-pos%d-0x%02x", pos, ch)
+ presets = append(presets, ConfigPreset{
+ Name: name,
+ Family: FamilyOOB,
+ Phase: PhaseOptimize,
+ Priority: pos,
+ Config: withFragmentation(base, config.FragmentationConfig{
+ Strategy: "oob",
+ OOBPosition: pos,
+ OOBChar: ch,
+ }),
+ })
+ }
+ }
+
+ case FamilyFakeSNI:
+ // TTL variations
+ ttls := []uint8{1, 2, 3, 5, 8}
+ for _, ttl := range ttls {
+ presets = append(presets, ConfigPreset{
+ Name: formatName("fake-ttl%d", ttl),
+ Family: FamilyFakeSNI,
+ Phase: PhaseOptimize,
+ Priority: int(ttl),
+ Config: withFaking(base, config.FakingConfig{
+ SNI: true,
+ TTL: ttl,
+ Strategy: "ttl",
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ }),
+ })
+ }
+
+ // Sequence length variations
+ seqLens := []int{1, 2, 3, 5}
+ for _, sl := range seqLens {
+ presets = append(presets, ConfigPreset{
+ Name: formatName("fake-seq%d", sl),
+ Family: FamilyFakeSNI,
+ Phase: PhaseOptimize,
+ Priority: sl + 10,
+ Config: withFaking(base, config.FakingConfig{
+ SNI: true,
+ TTL: 3,
+ Strategy: "pastseq",
+ SeqOffset: 10000,
+ SNISeqLength: sl,
+ SNIType: config.FakePayloadDefault,
+ }),
+ })
+ }
+
+ // Strategy variations
+ strategies := []string{"ttl", "pastseq", "randseq", "tcp_check", "md5sum"}
+ for i, strat := range strategies {
+ presets = append(presets, ConfigPreset{
+ Name: formatName("fake-%s", strat),
+ Family: FamilyFakeSNI,
+ Phase: PhaseOptimize,
+ Priority: i + 20,
+ Config: withFaking(base, config.FakingConfig{
+ SNI: true,
+ TTL: 3,
+ Strategy: strat,
+ SeqOffset: 10000,
+ SNISeqLength: 1,
+ SNIType: config.FakePayloadDefault,
+ }),
+ })
+ }
+
+ case FamilyIPFrag:
+ positions := []int{1, 8, 16, 24}
+ for _, pos := range positions {
+ for _, reverse := range []bool{false, true} {
+ name := formatName("ip-pos%d", pos)
+ if reverse {
+ name += "-rev"
+ }
+ presets = append(presets, ConfigPreset{
+ Name: name,
+ Family: FamilyIPFrag,
+ Phase: PhaseOptimize,
+ Priority: pos,
+ Config: withFragmentation(base, config.FragmentationConfig{
+ Strategy: "ip",
+ SNIPosition: pos,
+ ReverseOrder: reverse,
+ }),
+ })
+ }
+ }
+
+ case FamilySACK:
+ // SACK + different fragmentation strategies
+ fragStrategies := []string{"tcp", "tls", "oob"}
+ for i, fs := range fragStrategies {
+ cfg := withTCP(base, config.TCPConfig{
+ ConnBytesLimit: 19,
+ DropSACK: true,
+ })
+ switch fs {
+ case "tcp":
+ cfg = withFragmentation(cfg, config.FragmentationConfig{Strategy: "tcp", SNIPosition: 1})
+ case "tls":
+ cfg = withFragmentation(cfg, config.FragmentationConfig{Strategy: "tls", TLSRecordPosition: 1})
+ case "oob":
+ cfg = withFragmentation(cfg, config.FragmentationConfig{Strategy: "oob", OOBPosition: 1, OOBChar: 'x'})
+ }
+ presets = append(presets, ConfigPreset{
+ Name: formatName("sack-%s", fs),
+ Family: FamilySACK,
+ Phase: PhaseOptimize,
+ Priority: i,
+ Config: cfg,
+ })
+ }
+ }
+
+ return presets
+}
+
+// GetCombinationPresets generates presets combining multiple working families
+func GetCombinationPresets(workingFamilies []StrategyFamily, bestParams map[StrategyFamily]ConfigPreset) []ConfigPreset {
+ presets := []ConfigPreset{}
+
+ // If we have both fragmentation and faking working, combine them
+ hasFrag := containsFamily(workingFamilies, FamilyTCPFrag) || containsFamily(workingFamilies, FamilyTLSRec) || containsFamily(workingFamilies, FamilyOOB)
+ hasFake := containsFamily(workingFamilies, FamilyFakeSNI)
+ hasSACK := containsFamily(workingFamilies, FamilySACK)
+
+ base := baseConfig()
+
+ if hasFrag && hasFake {
+ // Combine best frag with best fake
+ var fragConfig config.FragmentationConfig
+ var fakingConfig config.FakingConfig
+
+ // Get best fragmentation params
+ for _, fam := range []StrategyFamily{FamilyTCPFrag, FamilyTLSRec, FamilyOOB} {
+ if bp, ok := bestParams[fam]; ok {
+ fragConfig = bp.Config.Fragmentation
+ break
+ }
+ }
+
+ // Get best faking params
+ if bp, ok := bestParams[FamilyFakeSNI]; ok {
+ fakingConfig = bp.Config.Faking
+ }
+
+ combined := withFragmentation(base, fragConfig)
+ combined = withFaking(combined, fakingConfig)
+
+ presets = append(presets, ConfigPreset{
+ Name: "combo-frag-fake",
+ Description: "Combined fragmentation + fake SNI",
+ Family: FamilyNone,
+ Phase: PhaseCombination,
+ Priority: 1,
+ Config: combined,
+ })
+ }
+
+ if hasSACK && hasFrag {
+ // SACK + fragmentation
+ var fragConfig config.FragmentationConfig
+ for _, fam := range []StrategyFamily{FamilyTCPFrag, FamilyTLSRec, FamilyOOB} {
+ if bp, ok := bestParams[fam]; ok {
+ fragConfig = bp.Config.Fragmentation
+ break
+ }
+ }
+
+ combined := withTCP(base, config.TCPConfig{ConnBytesLimit: 19, DropSACK: true})
+ combined = withFragmentation(combined, fragConfig)
+
+ presets = append(presets, ConfigPreset{
+ Name: "combo-sack-frag",
+ Description: "SACK drop + fragmentation",
+ Family: FamilyNone,
+ Phase: PhaseCombination,
+ Priority: 2,
+ Config: combined,
+ })
+ }
+
+ // Aggressive combo - everything together
+ if len(workingFamilies) >= 2 {
+ aggressive := config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 1,
+ Seg2Delay: 5,
+ DropSACK: hasSACK,
+ SynFake: true,
+ SynFakeLen: 256,
+ },
+ UDP: config.UDPConfig{
+ Mode: "fake",
+ FakeSeqLength: 10,
+ FakeLen: 128,
+ FakingStrategy: "checksum",
+ FilterQUIC: "all",
+ FilterSTUN: true,
+ ConnBytesLimit: 1,
+ },
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "tcp",
+ SNIPosition: 1,
+ ReverseOrder: true,
+ MiddleSNI: true,
+ },
+ Faking: config.FakingConfig{
+ SNI: true,
+ TTL: 3,
+ Strategy: "pastseq",
+ SeqOffset: 50000,
+ SNISeqLength: 3,
+ SNIType: config.FakePayloadDefault,
+ },
+ }
+
+ presets = append(presets, ConfigPreset{
+ Name: "aggressive",
+ Description: "All bypass techniques combined",
+ Family: FamilyNone,
+ Phase: PhaseCombination,
+ Priority: 10,
+ Config: aggressive,
+ })
+ }
+
+ return presets
+}
+
+// Helper functions
+
+func baseConfig() config.SetConfig {
+ return config.NewSetConfig()
+}
+
+func baselineConfig() config.SetConfig {
+ return config.SetConfig{
+ TCP: config.TCPConfig{
+ ConnBytesLimit: 19,
+ },
+ UDP: config.UDPConfig{
+ Mode: "fake",
+ FakeSeqLength: 0,
+ FakeLen: 0,
+ FakingStrategy: "none",
+ FilterQUIC: "disabled",
+ FilterSTUN: false,
+ ConnBytesLimit: 8,
+ },
+ Fragmentation: config.FragmentationConfig{
+ Strategy: "none",
+ },
+ Faking: config.FakingConfig{
+ SNI: false,
+ },
+ }
+}
+
+func withFragmentation(base config.SetConfig, frag config.FragmentationConfig) config.SetConfig {
+ base.Fragmentation = frag
+ return base
+}
+
+func withFaking(base config.SetConfig, faking config.FakingConfig) config.SetConfig {
+ base.Faking = faking
+ return base
+}
+
+func withTCP(base config.SetConfig, tcp config.TCPConfig) config.SetConfig {
+ base.TCP = tcp
+ return base
+}
+
+func formatName(format string, args ...interface{}) string {
+ return fmt.Sprintf(format, args...)
+}
+
+func containsFamily(families []StrategyFamily, target StrategyFamily) bool {
+ for _, f := range families {
+ if f == target {
+ return true
+ }
+ }
+ return false
+}
diff --git a/src/checker/runner.go b/src/discovery/runner.go
similarity index 98%
rename from src/checker/runner.go
rename to src/discovery/runner.go
index 2d58a3cc..bfd9594e 100644
--- a/src/checker/runner.go
+++ b/src/discovery/runner.go
@@ -1,4 +1,4 @@
-package checker
+package discovery
import (
"context"
@@ -273,6 +273,8 @@ func (ts *CheckSuite) GetSnapshot() *CheckSuite {
Config: ts.Config,
PresetResults: ts.PresetResults,
DomainDiscoveryResults: ts.DomainDiscoveryResults,
+ CurrentPhase: ts.CurrentPhase,
+ WorkingFamilies: ts.WorkingFamilies,
}
snapshot.Results = make([]CheckResult, len(ts.Results))
diff --git a/src/checker/types.go b/src/discovery/types.go
similarity index 51%
rename from src/checker/types.go
rename to src/discovery/types.go
index 8a9331ca..bc123c3e 100644
--- a/src/checker/types.go
+++ b/src/discovery/types.go
@@ -1,4 +1,4 @@
-package checker
+package discovery
import (
"sync"
@@ -17,6 +17,30 @@ const (
CheckStatusCanceled CheckStatus = "canceled"
)
+// DiscoveryPhase represents the current phase of hierarchical discovery
+type DiscoveryPhase string
+
+const (
+ PhaseBaseline DiscoveryPhase = "baseline"
+ PhaseStrategy DiscoveryPhase = "strategy_detection"
+ PhaseOptimize DiscoveryPhase = "optimization"
+ PhaseCombination DiscoveryPhase = "combination"
+)
+
+// StrategyFamily groups related bypass techniques
+type StrategyFamily string
+
+const (
+ FamilyNone StrategyFamily = "none"
+ FamilyTCPFrag StrategyFamily = "tcp_frag"
+ FamilyTLSRec StrategyFamily = "tls_record"
+ FamilyOOB StrategyFamily = "oob"
+ FamilyIPFrag StrategyFamily = "ip_frag"
+ FamilyFakeSNI StrategyFamily = "fake_sni"
+ FamilySACK StrategyFamily = "sack"
+ FamilySynFake StrategyFamily = "syn_fake"
+)
+
type CheckResult struct {
Domain string `json:"domain"`
Category string `json:"category"`
@@ -48,6 +72,10 @@ type CheckSuite struct {
mu sync.RWMutex `json:"-"`
cancel chan struct{} `json:"-"`
Config CheckConfig `json:"config"`
+
+ // Hierarchical discovery fields
+ CurrentPhase DiscoveryPhase `json:"current_phase,omitempty"`
+ WorkingFamilies []string `json:"working_families,omitempty"`
}
type CheckSummary struct {
@@ -59,10 +87,11 @@ type CheckSummary struct {
}
type CheckConfig struct {
- CheckURL string `json:"check_url"`
- Timeout time.Duration `json:"timeout"`
- SamplesPerDomain int `json:"samples_per_domain"`
- MaxConcurrent int `json:"max_concurrent"`
+ CheckURL string `json:"check_url"`
+ Timeout time.Duration `json:"timeout"`
+ ConfigPropagateTimeout time.Duration `json:"config_propagate_timeout"`
+ SamplesPerDomain int `json:"samples_per_domain"`
+ MaxConcurrent int `json:"max_concurrent"`
}
type DomainSample struct {
@@ -79,6 +108,8 @@ type ConfigTestMode struct {
type DomainPresetResult struct {
PresetName string `json:"preset_name"`
+ Family StrategyFamily `json:"family,omitempty"`
+ Phase DiscoveryPhase `json:"phase,omitempty"`
Status CheckStatus `json:"status"`
Duration time.Duration `json:"duration"`
Speed float64 `json:"speed"`
@@ -89,9 +120,42 @@ type DomainPresetResult struct {
}
type DomainDiscoveryResult struct {
- Domain string `json:"domain"`
- BestPreset string `json:"best_preset"`
- BestSpeed float64 `json:"best_speed"`
- BestSuccess bool `json:"best_success"`
- Results map[string]*DomainPresetResult `json:"results"`
+ Domain string `json:"domain"`
+ BestPreset string `json:"best_preset"`
+ BestSpeed float64 `json:"best_speed"`
+ BestSuccess bool `json:"best_success"`
+ Results map[string]*DomainPresetResult `json:"results"`
+ WorkingFamilies []StrategyFamily `json:"working_families,omitempty"`
+ BaselineSpeed float64 `json:"baseline_speed,omitempty"`
+ Improvement float64 `json:"improvement,omitempty"`
+}
+
+// DomainCluster groups domains that likely need the same bypass config
+type DomainCluster struct {
+ ID string `json:"id"`
+ Domains []string `json:"domains"`
+ Representative string `json:"representative"` // Domain we actually test
+ BestPreset string `json:"best_preset,omitempty"`
+ BestSpeed float64 `json:"best_speed,omitempty"`
+ Tested bool `json:"tested"`
+}
+
+// ConfigPreset represents a bypass configuration to test
+type ConfigPreset struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Family StrategyFamily `json:"family"`
+ Phase DiscoveryPhase `json:"phase"`
+ Config config.SetConfig `json:"config"`
+ Priority int `json:"priority"` // Lower = test first
+ ConfigPropagateTimeout int `json:"propagate_timeout"`
+}
+
+// StrategyResult tracks whether a strategy family works
+type StrategyResult struct {
+ Family StrategyFamily
+ Works bool
+ Speed float64
+ Preset string
+ Latency time.Duration
}
diff --git a/src/go.mod b/src/go.mod
index f3635e6b..4fdcf08e 100644
--- a/src/go.mod
+++ b/src/go.mod
@@ -8,7 +8,6 @@ require (
github.com/josharian/native v1.1.0 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
- golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
)
@@ -22,6 +21,7 @@ require (
github.com/urlesistiana/v2dat v0.0.0-20221215035016-47b8ee51fb52
github.com/yl2chen/cidranger v1.0.2
golang.org/x/crypto v0.43.0
+ golang.org/x/net v0.46.0
golang.org/x/sys v0.37.0
google.golang.org/protobuf v1.33.0
)
diff --git a/src/http/handler/common.go b/src/http/handler/common.go
index b9df04ca..68eebe9f 100644
--- a/src/http/handler/common.go
+++ b/src/http/handler/common.go
@@ -86,7 +86,7 @@ func (api *API) RegisterEndpoints(mux *http.ServeMux, cfg *config.Config) {
api.RegisterGeositeApi()
api.RegisterGeoipApi()
api.RegisterSystemApi()
- api.RegisterCheckApi()
+ api.RegisterDiscoveryApi()
api.RegisterIntegrationApi()
api.RegisterGeodatApi()
api.RegisterCaptureApi()
diff --git a/src/http/handler/check.go b/src/http/handler/discovery.go
similarity index 62%
rename from src/http/handler/check.go
rename to src/http/handler/discovery.go
index ebb866f3..e1b38c54 100644
--- a/src/http/handler/check.go
+++ b/src/http/handler/discovery.go
@@ -6,19 +6,19 @@ import (
"net/http"
"time"
- "github.com/daniellavrushin/b4/checker"
"github.com/daniellavrushin/b4/config"
+ "github.com/daniellavrushin/b4/discovery"
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/utils"
"github.com/google/uuid"
)
-func (api *API) RegisterCheckApi() {
- api.mux.HandleFunc("/api/check/start", api.handleStartCheck)
- api.mux.HandleFunc("/api/check/discovery", api.handleStartDiscovery)
- api.mux.HandleFunc("/api/check/status", api.handleCheckStatus)
- api.mux.HandleFunc("/api/check/cancel", api.handleCancelCheck)
- api.mux.HandleFunc("/api/check/add", api.handleAddPresetAsSet)
+func (api *API) RegisterDiscoveryApi() {
+ api.mux.HandleFunc("/api/discovery", api.handleStartDiscovery)
+ api.mux.HandleFunc("/api/discovery/start", api.handleStartCheck)
+ api.mux.HandleFunc("/api/discovery/status", api.handleCheckStatus)
+ api.mux.HandleFunc("/api/discovery/cancel", api.handleCancelCheck)
+ api.mux.HandleFunc("/api/discovery/add", api.handleAddPresetAsSet)
}
func (api *API) handleStartCheck(w http.ResponseWriter, r *http.Request) {
@@ -35,13 +35,6 @@ func (api *API) handleStartCheck(w http.ResponseWriter, r *http.Request) {
return
}
- if req.Timeout <= 0 {
- req.Timeout = time.Duration(chckCfg.TimeoutSeconds) * time.Second
- }
- if req.MaxConcurrent <= 0 {
- req.MaxConcurrent = chckCfg.MaxConcurrent
- }
-
domains := req.Domains
if len(domains) == 0 {
if len(api.cfg.Sets) > 0 {
@@ -59,13 +52,13 @@ func (api *API) handleStartCheck(w http.ResponseWriter, r *http.Request) {
http.Error(w, "No domains provided. Please specify domains to test.", http.StatusBadRequest)
return
}
- config := checker.CheckConfig{
- CheckURL: req.CheckURL,
- Timeout: req.Timeout,
- MaxConcurrent: req.MaxConcurrent,
+ config := discovery.CheckConfig{
+ CheckURL: req.CheckURL,
+ Timeout: time.Duration(api.cfg.System.Checker.DiscoveryTimeoutSec) * time.Second,
+ ConfigPropagateTimeout: time.Duration(api.cfg.System.Checker.ConfigPropagateMs),
}
- suite := checker.NewCheckSuite(config)
+ suite := discovery.NewCheckSuite(config)
go suite.Run(domains)
@@ -92,7 +85,7 @@ func (api *API) handleCheckStatus(w http.ResponseWriter, r *http.Request) {
return
}
- suite, ok := checker.GetCheckSuite(testID)
+ suite, ok := discovery.GetCheckSuite(testID)
if !ok {
http.Error(w, "Check suite not found", http.StatusNotFound)
return
@@ -116,7 +109,7 @@ func (api *API) handleCancelCheck(w http.ResponseWriter, r *http.Request) {
return
}
- if err := checker.CancelCheckSuite(testID); err != nil {
+ if err := discovery.CancelCheckSuite(testID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
@@ -145,13 +138,6 @@ func (api *API) handleStartDiscovery(w http.ResponseWriter, r *http.Request) {
return
}
- if req.Timeout <= 0 {
- req.Timeout = time.Duration(chckCfg.TimeoutSeconds) * time.Second
- }
- if req.MaxConcurrent <= 0 {
- req.MaxConcurrent = chckCfg.MaxConcurrent
- }
-
domains := req.Domains
if len(domains) == 0 {
if len(api.cfg.Sets) > 0 {
@@ -169,27 +155,35 @@ func (api *API) handleStartDiscovery(w http.ResponseWriter, r *http.Request) {
http.Error(w, "No domains provided. Please specify domains to test.", http.StatusBadRequest)
return
}
- config := checker.CheckConfig{
- CheckURL: req.CheckURL,
- Timeout: req.Timeout,
- MaxConcurrent: req.MaxConcurrent,
+
+ config := discovery.CheckConfig{
+ CheckURL: req.CheckURL,
+ Timeout: time.Duration(api.cfg.System.Checker.DiscoveryTimeoutSec) * time.Second,
+ ConfigPropagateTimeout: time.Duration(api.cfg.System.Checker.ConfigPropagateMs),
}
- presets := checker.GetTestPresets()
+ // Pass geodata manager for geosite-based clustering
+ suite := discovery.NewDiscoverySuite(config, globalPool)
- suite := checker.NewDiscoverySuite(config, globalPool, presets)
+ clusters := discovery.ClusterByKnownCDN(domains)
+
+ phase1Count := len(discovery.GetPhase1Presets())
+ estimatedTests := len(clusters) * (phase1Count + 10)
go func() {
suite.RunDiscovery(domains)
- log.Infof("Discovery complete for %d domains", len(domains))
+ log.Infof("Discovery complete for %d domains (%d clusters)", len(domains), len(clusters))
log.Infof("\n%s", suite.GetDiscoveryReport())
}()
- response := StartCheckResponse{
- Id: suite.Id,
- TotalChecks: len(domains) * len(presets),
- Message: fmt.Sprintf("Discovery started: %d domains × %d presets", len(domains), len(presets)),
+ response := DiscoveryStartResponse{
+ Id: suite.Id,
+ TotalDomains: len(domains),
+ TotalClusters: len(clusters),
+ EstimatedTests: estimatedTests,
+ Message: fmt.Sprintf("Hierarchical discovery started: %d domains in %d clusters (~%d tests)",
+ len(domains), len(clusters), estimatedTests),
}
setJsonHeader(w)
@@ -222,6 +216,39 @@ func (api *API) handleAddPresetAsSet(w http.ResponseWriter, r *http.Request) {
set.Name = set.Targets.SNIDomains[0]
set.Targets.DomainsToMatch = []string{set.Targets.SNIDomains[0]}
+ // Ensure all target arrays are initialized (not null)
+ if set.Targets.IPs == nil {
+ set.Targets.IPs = []string{}
+ }
+ if set.Targets.IpsToMatch == nil {
+ set.Targets.IpsToMatch = []string{}
+ }
+ if set.Targets.GeoSiteCategories == nil {
+ set.Targets.GeoSiteCategories = []string{}
+ }
+ if set.Targets.GeoIpCategories == nil {
+ set.Targets.GeoIpCategories = []string{}
+ }
+
+ // Ensure TCP WinValues is initialized
+ if set.TCP.WinValues == nil {
+ set.TCP.WinValues = []int{0, 1460, 8192, 65535}
+ }
+ if set.TCP.WinMode == "" {
+ set.TCP.WinMode = "off"
+ }
+ if set.TCP.DesyncMode == "" {
+ set.TCP.DesyncMode = "off"
+ }
+
+ // Ensure Faking SNIMutation is initialized
+ if set.Faking.SNIMutation.Mode == "" {
+ set.Faking.SNIMutation.Mode = "off"
+ }
+ if set.Faking.SNIMutation.FakeSNIs == nil {
+ set.Faking.SNIMutation.FakeSNIs = []string{}
+ }
+
api.cfg.Sets = append([]*config.SetConfig{&set}, api.cfg.Sets...)
if api.cfg.MainSet == nil {
@@ -242,3 +269,12 @@ func (api *API) handleAddPresetAsSet(w http.ResponseWriter, r *http.Request) {
"message": fmt.Sprintf("Added '%s' configuration", set.Name),
})
}
+
+// DiscoveryStartResponse includes cluster information
+type DiscoveryStartResponse struct {
+ Id string `json:"id"`
+ TotalDomains int `json:"total_domains"`
+ TotalClusters int `json:"total_clusters"`
+ EstimatedTests int `json:"estimated_tests"`
+ Message string `json:"message"`
+}
diff --git a/src/http/handler/check_types.go b/src/http/handler/discovery_types.go
similarity index 72%
rename from src/http/handler/check_types.go
rename to src/http/handler/discovery_types.go
index ff0378a3..b7cd6c23 100644
--- a/src/http/handler/check_types.go
+++ b/src/http/handler/discovery_types.go
@@ -1,12 +1,8 @@
package handler
-import "time"
-
type StartCheckRequest struct {
- CheckURL string `json:"check_url,omitempty"`
- Timeout time.Duration `json:"timeout"`
- MaxConcurrent int `json:"max_concurrent"`
- Domains []string `json:"domains,omitempty"`
+ CheckURL string `json:"check_url,omitempty"`
+ Domains []string `json:"domains,omitempty"`
}
type StartCheckResponse struct {
diff --git a/src/http/ui/src/App.tsx b/src/http/ui/src/App.tsx
index 490df8c2..7292a756 100644
--- a/src/http/ui/src/App.tsx
+++ b/src/http/ui/src/App.tsx
@@ -23,21 +23,24 @@ import {
Divider,
Badge,
} from "@mui/material";
-import MenuIcon from "@mui/icons-material/Menu";
-import SettingsIcon from "@mui/icons-material/Settings";
-import LanguageIcon from "@mui/icons-material/Language";
-import SpeedIcon from "@mui/icons-material/Speed";
-import AssessmentIcon from "@mui/icons-material/Assessment";
-import ScienceIcon from "@mui/icons-material/Science";
+
+import {
+ Menu as MenuIcon,
+ Settings as SettingsIcon,
+ Language as LanguageIcon,
+ Speed as SpeedIcon,
+ Assessment as AssessmentIcon,
+ Science as ScienceIcon,
+} from "@mui/icons-material";
import Dashboard from "@pages/Dashboard";
import Logs from "@pages/Logs";
import Domains from "@pages/Domains";
import Settings from "@pages/Settings";
-import Test from "@pages/Checker";
import { theme, colors } from "@design";
import Logo from "@molecules/Logo";
import Version from "@organisms/version/Version";
import { useWebSocket } from "@ctx/B4WsProvider";
+import Discovery from "@pages/Discovery";
const DRAWER_WIDTH = 240;
@@ -50,7 +53,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ path: "/dashboard", label: "Dashboard", icon: },
{ path: "/domains", label: "Domains", icon: },
- { path: "/test", label: "Test", icon: },
+ { path: "/discovery", label: "Discovery", icon: },
{ path: "/logs", label: "Logs", icon: },
{ path: "/settings", label: "Settings", icon: },
];
@@ -181,7 +184,7 @@ export default function App() {
} />
} />
} />
- } />
+ } />
} />
} />
} />
diff --git a/src/http/ui/src/components/organisms/check/Runner.tsx b/src/http/ui/src/components/organisms/check/Runner.tsx
deleted file mode 100644
index a75e4dfc..00000000
--- a/src/http/ui/src/components/organisms/check/Runner.tsx
+++ /dev/null
@@ -1,512 +0,0 @@
-import React, { useState, useEffect } from "react";
-import {
- Box,
- Button,
- Stack,
- Typography,
- LinearProgress,
- Alert,
- Paper,
- Divider,
- Grid,
- Chip,
- IconButton,
-} from "@mui/material";
-import {
- PlayArrow as StartIcon,
- Stop as StopIcon,
- Refresh as RefreshIcon,
- Add as AddIcon,
-} from "@mui/icons-material";
-import { button_secondary, colors } from "@design";
-import { TestResultCard } from "@molecules/check/ResultCard";
-import { TestStatus } from "@atoms/check/Badge";
-import { useConfigLoad } from "@hooks/useConfig";
-import SettingTextField from "@atoms/common/B4TextField";
-import { useTestDomains } from "@hooks/useTestDomains";
-
-interface TestResult {
- domain: string;
- status: TestStatus;
- duration: number;
- speed: number;
- bytes_read: number;
- error?: string;
- timestamp: string;
- is_baseline: boolean;
- improvement: number;
- status_code: number;
-}
-
-interface TestSuite {
- id: string;
- status: TestStatus;
- start_time: string;
- end_time: string;
- total_checks: number;
- completed_checks: number;
- successful_checks: number;
- failed_checks: number;
- results: TestResult[];
- summary: {
- average_speed: number;
- average_improvement: number;
- fastest_domain: string;
- slowest_domain: string;
- success_rate: number;
- };
-}
-
-interface TestRunnerProps {
- onStart?: () => void;
- onComplete?: (suite: TestSuite) => void;
-}
-
-export const TestRunner: React.FC = ({
- onStart,
- onComplete,
-}) => {
- const [running, setRunning] = useState(false);
- const [testId, setTestId] = useState(null);
- const [suite, setSuite] = useState(null);
- const [error, setError] = useState(null);
- const { config } = useConfigLoad();
- const { domains, addDomain, removeDomain, clearDomains, resetToDefaults } =
- useTestDomains();
- const [newDomain, setNewDomain] = useState("");
-
- // Poll for test status
- useEffect(() => {
- if (!testId || !running) return;
-
- const fetchStatus = async () => {
- try {
- const response = await fetch(`/api/check/status?id=${testId}`);
- if (!response.ok) {
- throw new Error("Failed to fetch test status");
- }
-
- const data: TestSuite = (await response.json()) as TestSuite;
- setSuite(data);
-
- if (
- data.status === "complete" ||
- data.status === "failed" ||
- data.status === "canceled"
- ) {
- setRunning(false);
- if (onComplete) {
- onComplete(data);
- }
- }
- } catch (err) {
- console.error("Failed to fetch test status:", err);
- setError(err instanceof Error ? err.message : "Unknown error");
- setRunning(false);
- }
- };
-
- const interval = setInterval(() => {
- void fetchStatus();
- }, 1000);
-
- return () => clearInterval(interval);
- }, [testId, running, onComplete]);
-
- const startTest = async () => {
- if (domains.length === 0) {
- setError("Add at least one domain to test");
- return;
- }
-
- setError(null);
- setRunning(true);
- setSuite(null);
-
- if (onStart) {
- onStart();
- }
-
- try {
- const timeout = (config?.system.checker.timeout || 15) * 1e9;
- const maxConcurrent = config?.system.checker.max_concurrent || 5;
-
- const response = await fetch("/api/check/start", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- timeout: timeout,
- max_concurrent: maxConcurrent,
- domains: domains,
- }),
- });
-
- if (!response.ok) {
- const text = await response.text();
- throw new Error(text || "Failed to start test");
- }
-
- const data = (await response.json()) as { id: string };
- setTestId(data.id);
- } catch (err) {
- console.error("Failed to start test:", err);
- setError(err instanceof Error ? err.message : "Failed to start test");
- setRunning(false);
- }
- };
-
- const cancelTest = async () => {
- if (!testId) return;
-
- try {
- await fetch(`/api/check/cancel?id=${testId}`, { method: "DELETE" });
- setRunning(false);
- } catch (err) {
- console.error("Failed to cancel test:", err);
- }
- };
-
- const resetTest = () => {
- setTestId(null);
- setSuite(null);
- setError(null);
- setRunning(false);
- };
-
- const progress = suite
- ? (suite.completed_checks / suite.total_checks) * 100
- : 0;
-
- return (
-
- {/* Control Panel */}
-
-
- {/* Header with actions */}
-
-
- DPI Bypass Test Suite
-
-
- {!running && !suite && (
- }
- onClick={() => {
- void startTest();
- }}
- disabled={domains.length === 0}
- sx={{
- bgcolor: colors.secondary,
- "&:hover": { bgcolor: colors.primary },
- "&:disabled": {
- bgcolor: colors.accent.secondary,
- color: colors.text.secondary,
- },
- }}
- >
- Start Test
-
- )}
- {running && (
- }
- onClick={() => {
- void cancelTest();
- }}
- sx={{
- borderColor: colors.quaternary,
- color: colors.quaternary,
- }}
- >
- Cancel
-
- )}
- {suite && !running && (
- }
- onClick={resetTest}
- sx={{
- borderColor: colors.secondary,
- color: colors.secondary,
- }}
- >
- New Test
-
- )}
-
-
-
- {error && {error}}
-
- {/* Domain Management Section */}
-
-
-
- Domains to Test
-
-
-
-
-
-
-
-
-
- {/* Domain Input */}
-
- setNewDomain(e.target.value)}
- onKeyDown={(e) => {
- if (
- e.key === "Enter" ||
- e.key === "," ||
- e.key === "Tab"
- ) {
- e.preventDefault();
- addDomain(newDomain);
- setNewDomain("");
- }
- }}
- placeholder="youtube.com"
- disabled={running}
- helperText="Press Enter or comma to add"
- />
- {
- addDomain(newDomain);
- setNewDomain("");
- }}
- disabled={running || !newDomain.trim()}
- sx={{
- bgcolor: colors.accent.secondary,
- color: colors.secondary,
- "&:hover": {
- bgcolor: colors.accent.secondaryHover,
- },
- }}
- >
-
-
-
-
-
- {/* Domain Chips */}
-
- {domains.length === 0 ? (
-
- No domains added. Add domains above or click "Reset to
- Defaults"
-
- ) : (
- domains.map((domain) => (
- removeDomain(domain)}
- disabled={running}
- sx={{
- bgcolor: colors.accent.primary,
- color: colors.secondary,
- "& .MuiChip-deleteIcon": {
- color: colors.secondary,
- },
- }}
- />
- ))
- )}
-
-
-
-
-
- {/* Progress indicator */}
- {running && suite && (
-
-
-
- Testing {suite.completed_checks} of {suite.total_checks}{" "}
- domains
-
-
- {progress.toFixed(0)}%
-
-
-
-
- )}
-
-
-
- {/* Summary */}
- {suite && !running && suite.status === "complete" && (
-
-
- Test Summary
-
-
-
-
-
-
- {suite.successful_checks}
-
-
- Successful
-
-
-
-
-
-
- {suite.failed_checks}
-
-
- Failed
-
-
-
-
-
-
- {suite.summary.success_rate.toFixed(1)}%
-
-
- Success Rate
-
-
-
-
-
-
- {(suite.summary.average_speed / 1024 / 1024).toFixed(2)}
-
-
- Avg Speed (MB/s)
-
-
-
-
-
- )}
-
- {/* Results Grid */}
- {suite?.results && suite.results.length > 0 && (
-
-
- Test Results
-
-
- {suite.results.map((result) => (
-
-
-
- ))}
-
-
- )}
-
- );
-};
diff --git a/src/http/ui/src/components/organisms/check/Discovery.tsx b/src/http/ui/src/components/organisms/discovery/Discovery.tsx
similarity index 54%
rename from src/http/ui/src/components/organisms/check/Discovery.tsx
rename to src/http/ui/src/components/organisms/discovery/Discovery.tsx
index 1af17593..a699e273 100644
--- a/src/http/ui/src/components/organisms/check/Discovery.tsx
+++ b/src/http/ui/src/components/organisms/discovery/Discovery.tsx
@@ -14,6 +14,7 @@ import {
Tooltip,
Snackbar,
CircularProgress,
+ Collapse,
} from "@mui/material";
import {
PlayArrow as StartIcon,
@@ -21,17 +22,38 @@ import {
Refresh as RefreshIcon,
Add as AddIcon,
Speed as SpeedIcon,
+ ExpandMore as ExpandIcon,
+ ExpandLess as CollapseIcon,
+ TrendingUp as ImprovementIcon,
} from "@mui/icons-material";
import { button_secondary, colors } from "@design";
-import { useConfigLoad } from "@hooks/useConfig";
import { useTestDomains } from "@hooks/useTestDomains";
import { B4SetConfig } from "@/models/Config";
import SettingTextField from "@atoms/common/B4TextField";
import { AddSniModal } from "@organisms/domains/AddSniModal";
import { generateDomainVariants } from "@utils";
+// Strategy family types matching backend
+type StrategyFamily =
+ | "none"
+ | "tcp_frag"
+ | "tls_record"
+ | "oob"
+ | "ip_frag"
+ | "fake_sni"
+ | "sack"
+ | "syn_fake";
+
+type DiscoveryPhase =
+ | "baseline"
+ | "strategy_detection"
+ | "optimization"
+ | "combination";
+
interface DomainPresetResult {
preset_name: string;
+ family?: StrategyFamily;
+ phase?: DiscoveryPhase;
status: "complete" | "failed";
duration: number;
speed: number;
@@ -47,6 +69,9 @@ interface DomainDiscoveryResult {
best_speed: number;
best_success: boolean;
results: Record;
+ working_families?: StrategyFamily[];
+ baseline_speed?: number;
+ improvement?: number;
}
interface DiscoverySuite {
@@ -56,28 +81,57 @@ interface DiscoverySuite {
end_time: string;
total_checks: number;
completed_checks: number;
+ current_phase?: DiscoveryPhase;
+ working_families?: string[];
domain_discovery_results?: Record;
}
-interface ConfigDetail {
- category: string;
- settings: Array<{ label: string; value: string }>;
+interface DiscoveryStartResponse {
+ id: string;
+ total_domains: number;
+ total_clusters: number;
+ estimated_tests: number;
+ message: string;
}
+// Friendly names for strategy families
+const familyNames: Record = {
+ none: "Baseline",
+ tcp_frag: "TCP Fragmentation",
+ tls_record: "TLS Record Split",
+ oob: "Out-of-Band",
+ ip_frag: "IP Fragmentation",
+ fake_sni: "Fake SNI",
+ sack: "SACK Drop",
+ syn_fake: "SYN Fake",
+};
+
+// Friendly names for phases
+const phaseNames: Record = {
+ baseline: "Baseline Test",
+ strategy_detection: "Strategy Detection",
+ optimization: "Optimization",
+ combination: "Combination Test",
+};
+
export const DiscoveryRunner: React.FC = () => {
const [running, setRunning] = useState(false);
const [suiteId, setSuiteId] = useState(null);
const [suite, setSuite] = useState(null);
+
const [error, setError] = useState(null);
const [addingPreset, setAddingPreset] = useState(null);
const [variants, setVariants] = useState([]);
const [selectedVariant, setSelectedVariant] = useState(null);
+ const [expandedDomains, setExpandedDomains] = useState>(
+ new Set()
+ );
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: "success" | "error";
}>({ open: false, message: "", severity: "success" });
- const { config } = useConfigLoad();
+
const { domains, addDomain, removeDomain, clearDomains, resetToDefaults } =
useTestDomains();
const [newDomain, setNewDomain] = useState("");
@@ -95,13 +149,25 @@ export const DiscoveryRunner: React.FC = () => {
setVariantModal({ open: true, domain, result });
};
+ const toggleDomainExpand = (domain: string) => {
+ setExpandedDomains((prev) => {
+ const next = new Set(prev);
+ if (next.has(domain)) {
+ next.delete(domain);
+ } else {
+ next.add(domain);
+ }
+ return next;
+ });
+ };
+
// Poll for discovery status
useEffect(() => {
if (!suiteId || !running) return;
const fetchStatus = async () => {
try {
- const response = await fetch(`/api/check/status?id=${suiteId}`);
+ const response = await fetch(`/api/discovery/status?id=${suiteId}`);
if (!response.ok) throw new Error("Failed to fetch discovery status");
const data = (await response.json()) as DiscoverySuite;
@@ -119,7 +185,7 @@ export const DiscoveryRunner: React.FC = () => {
const interval = setInterval(() => {
void fetchStatus();
- }, 2000);
+ }, 1500); // Faster polling for better UX
return () => clearInterval(interval);
}, [suiteId, running]);
@@ -135,15 +201,10 @@ export const DiscoveryRunner: React.FC = () => {
setSuite(null);
try {
- const timeout = (config?.system.checker.timeout || 15) * 1e9;
- const maxConcurrent = config?.system.checker.max_concurrent || 3;
-
- const response = await fetch("/api/check/discovery", {
+ const response = await fetch("/api/discovery", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- timeout: timeout,
- max_concurrent: maxConcurrent,
domains: domains,
}),
});
@@ -153,7 +214,7 @@ export const DiscoveryRunner: React.FC = () => {
throw new Error(text || "Failed to start discovery");
}
- const data = (await response.json()) as { id: string; message: string };
+ const data = (await response.json()) as DiscoveryStartResponse;
setSuiteId(data.id);
} catch (err) {
console.error("Failed to start discovery:", err);
@@ -168,7 +229,7 @@ export const DiscoveryRunner: React.FC = () => {
if (!suiteId) return;
try {
- await fetch(`/api/check/cancel?id=${suiteId}`, { method: "DELETE" });
+ await fetch(`/api/discovery/cancel?id=${suiteId}`, { method: "DELETE" });
setRunning(false);
} catch (err) {
console.error("Failed to cancel discovery:", err);
@@ -180,6 +241,7 @@ export const DiscoveryRunner: React.FC = () => {
setSuite(null);
setError(null);
setRunning(false);
+ setExpandedDomains(new Set());
};
const confirmAddStrategy = async () => {
@@ -197,7 +259,7 @@ export const DiscoveryRunner: React.FC = () => {
`${variantModal.domain}-${variantModal.result.preset_name}`
);
- const response = await fetch("/api/check/add", {
+ const response = await fetch("/api/discovery/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(configToAdd),
@@ -231,131 +293,22 @@ export const DiscoveryRunner: React.FC = () => {
? (suite.completed_checks / suite.total_checks) * 100
: 0;
- function formatConfigDetails(presetName: string): ConfigDetail[] {
- const details: ConfigDetail[] = [];
+ // Group results by phase for display
+ const groupResultsByPhase = (results: Record) => {
+ const grouped: Record = {
+ baseline: [],
+ strategy_detection: [],
+ optimization: [],
+ combination: [],
+ };
- // TCP
- details.push({
- category: "TCP Configuration",
- settings: [
- { label: "Connection Bytes", value: "19 bytes" },
- {
- label: "Segment Delay",
- value: presetName.includes("delay") ? "5-10ms" : "0ms",
- },
- ],
+ Object.values(results).forEach((result) => {
+ const phase = result.phase || "strategy_detection";
+ grouped[phase].push(result);
});
- // Fragmentation
- if (presetName.includes("tcp-frag")) {
- const position = presetName.includes("pos1")
- ? "1"
- : presetName.includes("pos2")
- ? "2"
- : "Variable";
- details.push({
- category: "Fragmentation",
- settings: [
- { label: "Strategy", value: "TCP" },
- { label: "SNI Position", value: position },
- {
- label: "Reverse Order",
- value: presetName.includes("reverse") ? "Yes" : "No",
- },
- {
- label: "Middle SNI",
- value: presetName.includes("middle") ? "Yes" : "No",
- },
- ],
- });
- } else if (presetName.includes("ip-frag")) {
- details.push({
- category: "Fragmentation",
- settings: [
- { label: "Strategy", value: "IP-level" },
- { label: "SNI Position", value: "1" },
- {
- label: "Reverse Order",
- value: presetName.includes("reverse") ? "Yes" : "No",
- },
- ],
- });
- } else if (presetName.includes("no-frag")) {
- details.push({
- category: "Fragmentation",
- settings: [{ label: "Strategy", value: "None" }],
- });
- }
-
- // Faking
- if (presetName.includes("no-fake")) {
- details.push({
- category: "Fake Packets",
- settings: [{ label: "Status", value: "Disabled" }],
- });
- } else if (presetName.includes("fake")) {
- const ttl = presetName.includes("ttl-low") ? "3" : "5-8";
- const strategy = presetName.includes("randseq")
- ? "Random Seq"
- : presetName.includes("md5sum")
- ? "MD5"
- : "Past Seq";
- const count = presetName.includes("multi")
- ? "5"
- : presetName.includes("aggressive")
- ? "3-5"
- : "1-2";
-
- details.push({
- category: "Fake Packets",
- settings: [
- { label: "TTL", value: ttl },
- { label: "Strategy", value: strategy },
- { label: "Count", value: count },
- ],
- });
- } else {
- details.push({
- category: "Fake Packets",
- settings: [
- { label: "TTL", value: "8" },
- { label: "Strategy", value: "Past Seq" },
- { label: "Count", value: "1" },
- ],
- });
- }
-
- // UDP/QUIC
- if (presetName.includes("quic-drop")) {
- details.push({
- category: "UDP/QUIC",
- settings: [
- { label: "Mode", value: "Drop" },
- { label: "QUIC Filter", value: "All" },
- ],
- });
- } else if (presetName.includes("quic-fake")) {
- details.push({
- category: "UDP/QUIC",
- settings: [
- { label: "Mode", value: "Fake & Frag" },
- { label: "Fake Count", value: "10" },
- { label: "Fake Size", value: "128 bytes" },
- ],
- });
- } else {
- details.push({
- category: "UDP/QUIC",
- settings: [
- { label: "Mode", value: "Fake & Frag" },
- { label: "Fake Count", value: "6" },
- { label: "QUIC Filter", value: "Disabled" },
- ],
- });
- }
-
- return details;
- }
+ return grouped;
+ };
return (
@@ -378,9 +331,18 @@ export const DiscoveryRunner: React.FC = () => {
justifyContent: "space-between",
}}
>
-
- Configuration Discovery
-
+
+
+ Configuration Discovery
+
+
+ Hierarchical testing: Strategy Detection → Optimization →
+ Combination
+
+
{!running && !suite && (
-
- Warning: Discovery mode will temporarily apply
- different configurations to test effectiveness. This may briefly
- affect your service traffic during testing.
-
-
{error && {error}}
{/* Domain Management Section */}
@@ -477,7 +433,6 @@ export const DiscoveryRunner: React.FC = () => {
- {/* Domain Input */}
{
- {/* Domain Chips */}
{
border: `1px solid ${colors.border.default}`,
borderRadius: 1,
bgcolor: colors.background.dark,
+ maxHeight: 120,
+ overflowY: "auto",
}}
>
{domains.length === 0 ? (
@@ -584,10 +540,23 @@ export const DiscoveryRunner: React.FC = () => {
mb: 1,
}}
>
-
- Testing configurations: {suite.completed_checks} of{" "}
- {suite.total_checks} checks completed
-
+
+
+ {suite.current_phase && (
+
+ )}
+ {suite.completed_checks} of {suite.total_checks} checks
+
+
{progress.toFixed(0)}%
@@ -610,16 +579,21 @@ export const DiscoveryRunner: React.FC = () => {
- {/* Results Table */}
+ {/* Results */}
{suite?.domain_discovery_results &&
Object.keys(suite.domain_discovery_results).length > 0 && (
{Object.values(suite.domain_discovery_results)
.sort((a, b) => b.best_speed - a.best_speed)
.map((domainResult) => {
- const configDetails = domainResult.best_success
- ? formatConfigDetails(domainResult.best_preset)
- : [];
+ const isExpanded = expandedDomains.has(domainResult.domain);
+ const groupedResults = groupResultsByPhase(
+ domainResult.results
+ );
+ const successCount = Object.values(domainResult.results).filter(
+ (r) => r.status === "complete"
+ ).length;
+ const totalCount = Object.keys(domainResult.results).length;
return (
{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
+ cursor: "pointer",
}}
+ onClick={() => toggleDomainExpand(domainResult.domain)}
>
+
+ {isExpanded ? : }
+
{
}}
/>
)}
+
+ {domainResult.improvement &&
+ domainResult.improvement > 0 && (
+ }
+ label={`+${domainResult.improvement.toFixed(0)}%`}
+ size="small"
+ sx={{
+ bgcolor: colors.accent.secondary,
+ color: colors.secondary,
+ "& .MuiChip-icon": { color: colors.secondary },
+ }}
+ />
+ )}
{
? `${(domainResult.best_speed / 1024 / 1024).toFixed(
2
)} MB/s`
- : "No successful config"}
+ : "No working config"}
- {/* Configuration Details */}
+ {/* Best Configuration Quick View (always visible) */}
{domainResult.best_success && (
-
+
-
+
+
Best Configuration
- }
- label={`${domainResult.best_preset} • ${(
- domainResult.best_speed /
- 1024 /
- 1024
- ).toFixed(2)} MB/s`}
+
+ >
+ {domainResult.best_preset}
+ {domainResult.results[domainResult.best_preset]
+ ?.family && (
+
+ )}
+
-
-
- ) : (
-
- )
- }
- onClick={() => {
- const bestResult =
- domainResult.results[domainResult.best_preset];
- void handleAddStrategy(
- domainResult.domain,
- bestResult
- );
- }}
- disabled={
- addingPreset ===
- `${domainResult.domain}-${domainResult.best_preset}`
- }
- sx={{
- bgcolor: colors.secondary,
- color: colors.background.default,
- fontWeight: 600,
- "&:hover": {
- bgcolor: colors.primary,
- transform: "translateY(-2px)",
- boxShadow: `0 4px 8px ${colors.secondary}44`,
- },
- transition: "all 0.2s",
- "&:disabled": {
- bgcolor: colors.accent.secondary,
- },
- }}
- >
- {addingPreset ===
- `${domainResult.domain}-${domainResult.best_preset}`
- ? "Adding..."
- : "Use This Strategy"}
-
+
+ ) : (
+
+ )
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ const bestResult =
+ domainResult.results[domainResult.best_preset];
+ void handleAddStrategy(
+ domainResult.domain,
+ bestResult
+ );
+ }}
+ disabled={
+ addingPreset ===
+ `${domainResult.domain}-${domainResult.best_preset}`
+ }
+ sx={{
+ bgcolor: colors.secondary,
+ color: colors.background.default,
+ "&:hover": { bgcolor: colors.primary },
+ }}
+ >
+ Use This Strategy
+
+
+ )}
+
+ {/* Expanded Details */}
+
+
+ {/* Working Families */}
+ {domainResult.working_families &&
+ domainResult.working_families.length > 0 && (
+
+
+ Working Strategy Families
+
+
+ {domainResult.working_families.map((family) => (
+
+ ))}
+
+
+ )}
-
- {configDetails.map((detail, idx) => (
-
- groupedResults[phase].length > 0)
+ .map((phase) => (
+
+
-
- {detail.category}
-
-
- {detail.settings.map((setting, i) => (
-
-
+
+
+ {groupedResults[phase]
+ .sort((a, b) => b.speed - a.speed)
+ .map((result) => (
+
+
- {setting.label}
-
-
- {setting.value}
-
+
+
+ {result.status === "complete" &&
+ result.preset_name !==
+ domainResult.best_preset && (
+
+ {
+ void handleAddStrategy(
+ domainResult.domain,
+ result
+ );
+ }}
+ disabled={
+ addingPreset ===
+ `${domainResult.domain}-${result.preset_name}`
+ }
+ sx={{
+ p: 0.5,
+ bgcolor: colors.background.dark,
+ border: `1px solid ${colors.border.light}`,
+ "&:hover": {
+ bgcolor:
+ colors.accent.secondary,
+ borderColor: colors.secondary,
+ },
+ }}
+ >
+
+
+
+ )}
))}
-
-
-
+
+
))}
-
-
- {/* All Tested Configs */}
-
-
- All Tested Configurations
-
-
- {Object.values(domainResult.results)
- .sort((a, b) => b.speed - a.speed)
- .map((result) => (
-
-
- {result.status === "complete" &&
- result.preset_name !==
- domainResult.best_preset && (
-
- {
- void handleAddStrategy(
- domainResult.domain,
- result
- );
- }}
- disabled={
- addingPreset ===
- `${domainResult.domain}-${result.preset_name}`
- }
- sx={{
- p: 0.5,
- bgcolor: colors.background.dark,
- border: `1px solid ${colors.border.light}`,
- "&:hover": {
- bgcolor: colors.accent.secondary,
- borderColor: colors.secondary,
- },
- }}
- >
-
-
-
- )}
-
- ))}
-
-
- )}
+
{/* Failed state */}
{!domainResult.best_success && (
diff --git a/src/http/ui/src/components/organisms/settings/Checker.tsx b/src/http/ui/src/components/organisms/settings/Checker.tsx
index 8d46f1ba..90759df2 100644
--- a/src/http/ui/src/components/organisms/settings/Checker.tsx
+++ b/src/http/ui/src/components/organisms/settings/Checker.tsx
@@ -25,27 +25,30 @@ export const CheckerSettings: React.FC = ({
- onChange("system.checker.max_concurrent", value)
+ onChange("system.checker.discovery_timeout", value)
}
- min={1}
- max={20}
+ min={3}
+ max={30}
step={1}
- helperText="Maximum number of concurrent tests"
+ valueSuffix=" sec"
+ helperText="Timeout per preset during discovery"
/>
onChange("system.checker.timeout", value)}
- min={1}
- max={120}
- step={1}
- valueSuffix=" sec"
- helperText="Domain request timeout"
+ label="Config Propagation Delay"
+ value={config.system.checker.config_propagate_ms || 1500}
+ onChange={(value) =>
+ onChange("system.checker.config_propagate_ms", value)
+ }
+ min={500}
+ max={5000}
+ step={100}
+ valueSuffix=" ms"
+ helperText="Delay for config to propagate to workers (increase on slow devices)"
/>
diff --git a/src/http/ui/src/components/pages/Checker.tsx b/src/http/ui/src/components/pages/Checker.tsx
deleted file mode 100644
index ccb4a5e4..00000000
--- a/src/http/ui/src/components/pages/Checker.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { Container, Alert, Stack, Tabs, Tab } from "@mui/material";
-import { useState } from "react";
-import { TestRunner } from "@organisms/check/Runner";
-import { DiscoveryRunner } from "@organisms/check/Discovery";
-import { colors } from "@design";
-
-export default function Test() {
- const [activeTab, setActiveTab] = useState(0);
-
- return (
-
-
- setActiveTab(newValue)}
- sx={{
- borderBottom: `1px solid ${colors.border.default}`,
- "& .MuiTab-root": {
- color: colors.text.secondary,
- "&.Mui-selected": {
- color: colors.secondary,
- },
- },
- }}
- >
-
-
-
-
- {activeTab === 0 && (
- <>
-
- Quick Test: Test your current configuration
- against the domains you specify below. This validates your
- existing DPI bypass settings without making any changes.
-
-
- >
- )}
-
- {activeTab === 1 && (
- <>
-
- This feature is EXPERIMENTAL and may affect your current
- configuration.
-
-
- Configuration Discovery: Automatically test
- multiple configuration presets to find the most effective DPI
- bypass settings for the domains you specify below. B4 will
- temporarily apply different configurations and measure their
- performance.
-
-
- >
- )}
-
-
- );
-}
diff --git a/src/http/ui/src/components/pages/Discovery.tsx b/src/http/ui/src/components/pages/Discovery.tsx
new file mode 100644
index 00000000..9ac54a16
--- /dev/null
+++ b/src/http/ui/src/components/pages/Discovery.tsx
@@ -0,0 +1,38 @@
+import { Container, Alert, Stack } from "@mui/material";
+import { DiscoveryRunner } from "@/components/organisms/discovery/Discovery";
+import { colors } from "@design";
+
+export default function Test() {
+ return (
+
+
+
+ This feature is EXPERIMENTAL and may affect your current
+ configuration.
+
+
+ Configuration Discovery: Automatically test multiple
+ configuration presets to find the most effective DPI bypass settings
+ for the domains you specify below. B4 will temporarily apply different
+ configurations and measure their performance.
+
+
+
+
+ );
+}
diff --git a/src/http/ui/src/components/pages/Settings.tsx b/src/http/ui/src/components/pages/Settings.tsx
index c45973e4..b3bfb998 100644
--- a/src/http/ui/src/components/pages/Settings.tsx
+++ b/src/http/ui/src/components/pages/Settings.tsx
@@ -25,7 +25,7 @@ import {
Settings as SettingsIcon,
Warning as WarningIcon,
Layers as LayersIcon,
- Monitor as MonitorIcon,
+ Science as DiscoveryIcon,
Language as LanguageIcon,
Cloud as ApiIcon,
CameraAlt as CaptureIcon,
@@ -34,7 +34,7 @@ import { CaptureSettings } from "@organisms/settings/Capture";
import { NetworkSettings } from "@organisms/settings/Network";
import { LoggingSettings } from "@organisms/settings/Logging";
import { FeatureSettings } from "@organisms/settings/Feature";
-import { CheckerSettings } from "@organisms/settings/Checker";
+import { CheckerSettings } from "@/components/organisms/settings/Checker";
import { ControlSettings } from "@organisms/settings/Control";
import {
SetsManager,
@@ -78,7 +78,7 @@ enum TABS {
SETS = 0,
GENERAL,
DOMAINS,
- TESTING,
+ DISCOVERY,
API,
CAPTURE,
}
@@ -110,10 +110,10 @@ const SETTING_CATEGORIES = [
requiresRestart: false,
},
{
- id: TABS.TESTING,
- path: "testing",
- label: "Testing",
- icon: ,
+ id: TABS.DISCOVERY,
+ path: "discovery",
+ label: "Discovery",
+ icon: ,
description: "DPI bypass domains testing",
requiresRestart: false,
},
@@ -212,8 +212,8 @@ export default function Settings() {
JSON.stringify(config.system.geo) !==
JSON.stringify(originalConfig.system.geo),
- // Testing
- [TABS.TESTING]:
+ // Discovery
+ [TABS.DISCOVERY]:
JSON.stringify(config.system.checker) !==
JSON.stringify(originalConfig.system.checker),
@@ -549,7 +549,7 @@ export default function Settings() {
-
+
diff --git a/src/http/ui/src/models/Config.ts b/src/http/ui/src/models/Config.ts
index 068e1806..35b3c65e 100644
--- a/src/http/ui/src/models/Config.ts
+++ b/src/http/ui/src/models/Config.ts
@@ -108,9 +108,9 @@ export interface QueueConfig {
}
export interface CheckerConfig {
- timeout: number;
- max_concurrent: number;
domains: string[];
+ discovery_timeout: number;
+ config_propagate_ms: number;
}
export type WindowMode = "off" | "oscillate" | "zero" | "random" | "escalate";