From 0048d89feec86158fca763aeefcdba74b34dce78 Mon Sep 17 00:00:00 2001 From: Daniel Lavrushin Date: Sat, 29 Nov 2025 13:43:59 +0100 Subject: [PATCH] feat(discovery): Implement hierarchical discovery framework with API and UI integration - Added types and structures for discovery checks, results, and configurations in `src/discovery/types.go`. - Developed API endpoints for starting, checking status, canceling, and adding presets in `src/http/handler/discovery.go`. - Created request and response types for discovery operations in `src/http/handler/discovery_types.go`. - Built the Discovery UI component to manage domain testing, display results, and handle user interactions in `src/http/ui/src/components/organisms/discovery/Discovery.tsx`. - Integrated the Discovery component into the main Discovery page with appropriate alerts and information in `src/http/ui/src/components/pages/Discovery.tsx`. --- changelog.md | 3 +- src/checker/discovery.go | 337 -------- src/checker/preset.go | 597 -------------- src/config/config.go | 14 +- src/config/types.go | 7 +- src/discovery/cluster.go | 250 ++++++ src/discovery/discovery.go | 664 +++++++++++++++ src/discovery/preset.go | 597 ++++++++++++++ src/{checker => discovery}/runner.go | 4 +- src/{checker => discovery}/types.go | 84 +- src/go.mod | 2 +- src/http/handler/common.go | 2 +- src/http/handler/{check.go => discovery.go} | 114 ++- .../{check_types.go => discovery_types.go} | 8 +- src/http/ui/src/App.tsx | 21 +- .../src/components/organisms/check/Runner.tsx | 512 ------------ .../{check => discovery}/Discovery.tsx | 757 +++++++++--------- .../components/organisms/settings/Checker.tsx | 31 +- src/http/ui/src/components/pages/Checker.tsx | 81 -- .../ui/src/components/pages/Discovery.tsx | 38 + src/http/ui/src/components/pages/Settings.tsx | 20 +- src/http/ui/src/models/Config.ts | 4 +- 22 files changed, 2146 insertions(+), 2001 deletions(-) delete mode 100644 src/checker/discovery.go delete mode 100644 src/checker/preset.go create mode 100644 src/discovery/cluster.go create mode 100644 src/discovery/discovery.go create mode 100644 src/discovery/preset.go rename src/{checker => discovery}/runner.go (98%) rename src/{checker => discovery}/types.go (51%) rename src/http/handler/{check.go => discovery.go} (62%) rename src/http/handler/{check_types.go => discovery_types.go} (72%) delete mode 100644 src/http/ui/src/components/organisms/check/Runner.tsx rename src/http/ui/src/components/organisms/{check => discovery}/Discovery.tsx (54%) delete mode 100644 src/http/ui/src/components/pages/Checker.tsx create mode 100644 src/http/ui/src/components/pages/Discovery.tsx 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 && ( - - )} - {running && ( - - )} - {suite && !running && ( - - )} - - - - {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 && ( + + + )} + + {/* 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";