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`.
This commit is contained in:
Daniel Lavrushin 2025-11-29 13:43:59 +01:00
parent 6562be9732
commit 0048d89fee
No known key found for this signature in database
GPG key ID: 57F1CAB57AD35056
22 changed files with 2146 additions and 2001 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

250
src/discovery/cluster.go Normal file
View file

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

664
src/discovery/discovery.go Normal file
View file

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

597
src/discovery/preset.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: <SpeedIcon /> },
{ path: "/domains", label: "Domains", icon: <LanguageIcon /> },
{ path: "/test", label: "Test", icon: <ScienceIcon /> },
{ path: "/discovery", label: "Discovery", icon: <ScienceIcon /> },
{ path: "/logs", label: "Logs", icon: <AssessmentIcon /> },
{ path: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
@ -181,7 +184,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/domains" element={<Domains />} />
<Route path="/test" element={<Test />} />
<Route path="/discovery" element={<Discovery />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />

View file

@ -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<TestRunnerProps> = ({
onStart,
onComplete,
}) => {
const [running, setRunning] = useState(false);
const [testId, setTestId] = useState<string | null>(null);
const [suite, setSuite] = useState<TestSuite | null>(null);
const [error, setError] = useState<string | null>(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 (
<Stack spacing={3}>
{/* Control Panel */}
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: colors.background.paper,
border: `1px solid ${colors.border.default}`,
borderRadius: 2,
}}
>
<Stack spacing={2}>
{/* Header with actions */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="h6" sx={{ color: colors.text.primary }}>
DPI Bypass Test Suite
</Typography>
<Stack direction="row" spacing={1}>
{!running && !suite && (
<Button
variant="contained"
startIcon={<StartIcon />}
onClick={() => {
void startTest();
}}
disabled={domains.length === 0}
sx={{
bgcolor: colors.secondary,
"&:hover": { bgcolor: colors.primary },
"&:disabled": {
bgcolor: colors.accent.secondary,
color: colors.text.secondary,
},
}}
>
Start Test
</Button>
)}
{running && (
<Button
variant="outlined"
startIcon={<StopIcon />}
onClick={() => {
void cancelTest();
}}
sx={{
borderColor: colors.quaternary,
color: colors.quaternary,
}}
>
Cancel
</Button>
)}
{suite && !running && (
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={resetTest}
sx={{
borderColor: colors.secondary,
color: colors.secondary,
}}
>
New Test
</Button>
)}
</Stack>
</Box>
{error && <Alert severity="error">{error}</Alert>}
{/* Domain Management Section */}
<Box>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ mb: 1 }}
>
<Typography
variant="subtitle2"
sx={{ color: colors.text.primary }}
>
Domains to Test
</Typography>
<Stack direction="row" spacing={1}>
<Button
size="small"
onClick={resetToDefaults}
disabled={running}
sx={{ ...button_secondary, textTransform: "none" }}
>
Reset to Defaults
</Button>
<Button
size="small"
onClick={clearDomains}
disabled={running || domains.length === 0}
sx={{ ...button_secondary, textTransform: "none" }}
>
Clear All
</Button>
</Stack>
</Stack>
<Grid container spacing={2}>
<Grid size={{ sm: 12, md: 6 }}>
{/* Domain Input */}
<Box
sx={{
display: "flex",
gap: 1,
pb: 2,
width: "100%",
alignItems: "flex-start",
}}
>
<SettingTextField
fullWidth
label="Add domain"
value={newDomain}
onChange={(e) => 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"
/>
<IconButton
onClick={() => {
addDomain(newDomain);
setNewDomain("");
}}
disabled={running || !newDomain.trim()}
sx={{
bgcolor: colors.accent.secondary,
color: colors.secondary,
"&:hover": {
bgcolor: colors.accent.secondaryHover,
},
}}
>
<AddIcon />
</IconButton>
</Box>
</Grid>
<Grid size={{ sm: 12, md: 6 }}>
{/* Domain Chips */}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
p: 2,
width: "100%",
border: `1px solid ${colors.border.default}`,
borderRadius: 1,
bgcolor: colors.background.dark,
}}
>
{domains.length === 0 ? (
<Typography
variant="body2"
sx={{
color: colors.text.secondary,
width: "100%",
textAlign: "center",
}}
>
No domains added. Add domains above or click "Reset to
Defaults"
</Typography>
) : (
domains.map((domain) => (
<Chip
size="small"
key={domain}
label={domain}
onDelete={() => removeDomain(domain)}
disabled={running}
sx={{
bgcolor: colors.accent.primary,
color: colors.secondary,
"& .MuiChip-deleteIcon": {
color: colors.secondary,
},
}}
/>
))
)}
</Box>
</Grid>
</Grid>
</Box>
{/* Progress indicator */}
{running && suite && (
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 1,
}}
>
<Typography variant="body2" color="text.secondary">
Testing {suite.completed_checks} of {suite.total_checks}{" "}
domains
</Typography>
<Typography variant="body2" color="text.secondary">
{progress.toFixed(0)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 8,
borderRadius: 4,
bgcolor: colors.background.dark,
"& .MuiLinearProgress-bar": {
bgcolor: colors.secondary,
borderRadius: 4,
},
}}
/>
</Box>
)}
</Stack>
</Paper>
{/* Summary */}
{suite && !running && suite.status === "complete" && (
<Paper
elevation={0}
sx={{
p: 3,
bgcolor: colors.background.paper,
border: `1px solid ${colors.border.default}`,
borderRadius: 2,
}}
>
<Typography variant="h6" sx={{ mb: 2, color: colors.text.primary }}>
Test Summary
</Typography>
<Divider sx={{ mb: 2, borderColor: colors.border.default }} />
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Box sx={{ textAlign: "center" }}>
<Typography variant="h4" color="primary">
{suite.successful_checks}
</Typography>
<Typography variant="caption" color="text.secondary">
Successful
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Box sx={{ textAlign: "center" }}>
<Typography variant="h4" color="error">
{suite.failed_checks}
</Typography>
<Typography variant="caption" color="text.secondary">
Failed
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Box sx={{ textAlign: "center" }}>
<Typography variant="h4" color="secondary">
{suite.summary.success_rate.toFixed(1)}%
</Typography>
<Typography variant="caption" color="text.secondary">
Success Rate
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Box sx={{ textAlign: "center" }}>
<Typography variant="h4" sx={{ color: colors.secondary }}>
{(suite.summary.average_speed / 1024 / 1024).toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary">
Avg Speed (MB/s)
</Typography>
</Box>
</Grid>
</Grid>
</Paper>
)}
{/* Results Grid */}
{suite?.results && suite.results.length > 0 && (
<Box>
<Typography variant="h6" sx={{ mb: 2, color: colors.text.primary }}>
Test Results
</Typography>
<Grid container spacing={2}>
{suite.results.map((result) => (
<Grid key={result.domain} size={{ xs: 12, md: 6, lg: 4 }}>
<TestResultCard
domain={result.domain}
status={result.status}
duration={result.duration / 1000000}
speed={result.speed}
improvement={result.improvement}
error={result.error}
status_code={result.status_code}
/>
</Grid>
))}
</Grid>
</Box>
)}
</Stack>
);
};

View file

@ -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<string, DomainPresetResult>;
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<string, DomainDiscoveryResult>;
}
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<StrategyFamily, string> = {
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<DiscoveryPhase, string> = {
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<string | null>(null);
const [suite, setSuite] = useState<DiscoverySuite | null>(null);
const [error, setError] = useState<string | null>(null);
const [addingPreset, setAddingPreset] = useState<string | null>(null);
const [variants, setVariants] = useState<string[]>([]);
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
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<string, DomainPresetResult>) => {
const grouped: Record<DiscoveryPhase, DomainPresetResult[]> = {
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 (
<Stack spacing={3}>
@ -378,9 +331,18 @@ export const DiscoveryRunner: React.FC = () => {
justifyContent: "space-between",
}}
>
<Typography variant="h6" sx={{ color: colors.text.primary }}>
Configuration Discovery
</Typography>
<Box>
<Typography variant="h6" sx={{ color: colors.text.primary }}>
Configuration Discovery
</Typography>
<Typography
variant="caption"
sx={{ color: colors.text.secondary }}
>
Hierarchical testing: Strategy Detection Optimization
Combination
</Typography>
</Box>
<Stack direction="row" spacing={1}>
{!running && !suite && (
<Button
@ -433,12 +395,6 @@ export const DiscoveryRunner: React.FC = () => {
</Stack>
</Box>
<Alert severity="warning" sx={{ bgcolor: colors.accent.tertiary }}>
<strong>Warning:</strong> Discovery mode will temporarily apply
different configurations to test effectiveness. This may briefly
affect your service traffic during testing.
</Alert>
{error && <Alert severity="error">{error}</Alert>}
{/* Domain Management Section */}
@ -477,7 +433,6 @@ export const DiscoveryRunner: React.FC = () => {
<Grid container spacing={2}>
<Grid size={{ sm: 12, md: 6 }}>
{/* Domain Input */}
<Box
sx={{
display: "flex",
@ -526,7 +481,6 @@ export const DiscoveryRunner: React.FC = () => {
</Box>
</Grid>
<Grid size={{ sm: 12, md: 6 }}>
{/* Domain Chips */}
<Box
sx={{
display: "flex",
@ -537,6 +491,8 @@ export const DiscoveryRunner: React.FC = () => {
border: `1px solid ${colors.border.default}`,
borderRadius: 1,
bgcolor: colors.background.dark,
maxHeight: 120,
overflowY: "auto",
}}
>
{domains.length === 0 ? (
@ -584,10 +540,23 @@ export const DiscoveryRunner: React.FC = () => {
mb: 1,
}}
>
<Typography variant="body2" color="text.secondary">
Testing configurations: {suite.completed_checks} of{" "}
{suite.total_checks} checks completed
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="body2" color="text.secondary">
{suite.current_phase && (
<Chip
label={phaseNames[suite.current_phase]}
size="small"
sx={{
mr: 1,
bgcolor: colors.accent.secondary,
color: colors.secondary,
fontWeight: 600,
}}
/>
)}
{suite.completed_checks} of {suite.total_checks} checks
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{progress.toFixed(0)}%
</Typography>
@ -610,16 +579,21 @@ export const DiscoveryRunner: React.FC = () => {
</Stack>
</Paper>
{/* Results Table */}
{/* Results */}
{suite?.domain_discovery_results &&
Object.keys(suite.domain_discovery_results).length > 0 && (
<Stack spacing={2}>
{Object.values(suite.domain_discovery_results)
.sort((a, b) => b.best_speed - a.best_speed)
.map((domainResult) => {
const configDetails = domainResult.best_success
? formatConfigDetails(domainResult.best_preset)
: [];
const isExpanded = expandedDomains.has(domainResult.domain);
const groupedResults = groupResultsByPhase(
domainResult.results
);
const successCount = Object.values(domainResult.results).filter(
(r) => r.status === "complete"
).length;
const totalCount = Object.keys(domainResult.results).length;
return (
<Paper
@ -640,11 +614,16 @@ export const DiscoveryRunner: React.FC = () => {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
}}
onClick={() => toggleDomainExpand(domainResult.domain)}
>
<Box
sx={{ display: "flex", alignItems: "center", gap: 2 }}
>
<IconButton size="small">
{isExpanded ? <CollapseIcon /> : <ExpandIcon />}
</IconButton>
<Typography
variant="h6"
sx={{ color: colors.text.primary }}
@ -670,6 +649,25 @@ export const DiscoveryRunner: React.FC = () => {
}}
/>
)}
<Chip
label={`${successCount}/${totalCount} configs`}
size="small"
variant="outlined"
sx={{ borderColor: colors.border.light }}
/>
{domainResult.improvement &&
domainResult.improvement > 0 && (
<Chip
icon={<ImprovementIcon />}
label={`+${domainResult.improvement.toFixed(0)}%`}
size="small"
sx={{
bgcolor: colors.accent.secondary,
color: colors.secondary,
"& .MuiChip-icon": { color: colors.secondary },
}}
/>
)}
</Box>
<Typography
variant="h6"
@ -679,251 +677,262 @@ export const DiscoveryRunner: React.FC = () => {
? `${(domainResult.best_speed / 1024 / 1024).toFixed(
2
)} MB/s`
: "No successful config"}
: "No working config"}
</Typography>
</Box>
{/* Configuration Details */}
{/* Best Configuration Quick View (always visible) */}
{domainResult.best_success && (
<Box sx={{ p: 3 }}>
<Box
sx={{
p: 2,
bgcolor: colors.background.default,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: `1px solid ${colors.border.default}`,
}}
>
<Box
sx={{
mb: 2,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
sx={{ display: "flex", alignItems: "center", gap: 2 }}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
}}
>
<SpeedIcon sx={{ color: colors.secondary }} />
<Box>
<Typography
variant="subtitle2"
sx={{
color: colors.text.secondary,
textTransform: "uppercase",
fontSize: "0.7rem",
}}
variant="caption"
sx={{ color: colors.text.secondary }}
>
Best Configuration
</Typography>
<Chip
icon={<SpeedIcon />}
label={`${domainResult.best_preset}${(
domainResult.best_speed /
1024 /
1024
).toFixed(2)} MB/s`}
<Typography
variant="body1"
sx={{
bgcolor: colors.accent.secondary,
color: colors.secondary,
color: colors.text.primary,
fontWeight: 600,
"& .MuiChip-icon": {
color: colors.secondary,
},
}}
/>
>
{domainResult.best_preset}
{domainResult.results[domainResult.best_preset]
?.family && (
<Chip
label={
familyNames[
domainResult.results[
domainResult.best_preset
].family!
]
}
size="small"
sx={{ ml: 1, bgcolor: colors.accent.primary }}
/>
)}
</Typography>
</Box>
<Button
variant="contained"
startIcon={
addingPreset ===
`${domainResult.domain}-${domainResult.best_preset}` ? (
<CircularProgress size={18} color="inherit" />
) : (
<AddIcon />
)
}
onClick={() => {
const bestResult =
domainResult.results[domainResult.best_preset];
void handleAddStrategy(
domainResult.domain,
bestResult
);
}}
disabled={
addingPreset ===
`${domainResult.domain}-${domainResult.best_preset}`
}
sx={{
bgcolor: colors.secondary,
color: colors.background.default,
fontWeight: 600,
"&:hover": {
bgcolor: colors.primary,
transform: "translateY(-2px)",
boxShadow: `0 4px 8px ${colors.secondary}44`,
},
transition: "all 0.2s",
"&:disabled": {
bgcolor: colors.accent.secondary,
},
}}
>
{addingPreset ===
`${domainResult.domain}-${domainResult.best_preset}`
? "Adding..."
: "Use This Strategy"}
</Button>
</Box>
<Button
variant="contained"
startIcon={
addingPreset ===
`${domainResult.domain}-${domainResult.best_preset}` ? (
<CircularProgress size={18} color="inherit" />
) : (
<AddIcon />
)
}
onClick={(e) => {
e.stopPropagation();
const bestResult =
domainResult.results[domainResult.best_preset];
void handleAddStrategy(
domainResult.domain,
bestResult
);
}}
disabled={
addingPreset ===
`${domainResult.domain}-${domainResult.best_preset}`
}
sx={{
bgcolor: colors.secondary,
color: colors.background.default,
"&:hover": { bgcolor: colors.primary },
}}
>
Use This Strategy
</Button>
</Box>
)}
{/* Expanded Details */}
<Collapse in={isExpanded}>
<Box sx={{ p: 3 }}>
{/* Working Families */}
{domainResult.working_families &&
domainResult.working_families.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{
color: colors.text.secondary,
mb: 1,
textTransform: "uppercase",
fontSize: "0.7rem",
}}
>
Working Strategy Families
</Typography>
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
gap={1}
>
{domainResult.working_families.map((family) => (
<Chip
key={family}
label={familyNames[family]}
sx={{
bgcolor: colors.accent.secondary,
color: colors.secondary,
}}
/>
))}
</Stack>
</Box>
)}
<Divider
sx={{ my: 2, borderColor: colors.border.default }}
/>
<Grid container spacing={2}>
{configDetails.map((detail, idx) => (
<Grid key={idx} size={{ xs: 12, sm: 6, md: 3 }}>
<Box
{/* Results by Phase */}
{(
[
"baseline",
"strategy_detection",
"optimization",
"combination",
] as DiscoveryPhase[]
)
.filter((phase) => groupedResults[phase].length > 0)
.map((phase) => (
<Box key={phase} sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{
p: 2,
bgcolor: colors.background.dark,
borderRadius: 1,
border: `1px solid ${colors.border.light}`,
color: colors.text.secondary,
mb: 1.5,
textTransform: "uppercase",
fontSize: "0.7rem",
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<Typography
variant="subtitle2"
sx={{
color: colors.secondary,
fontWeight: 600,
mb: 1.5,
textTransform: "uppercase",
fontSize: "0.7rem",
}}
>
{detail.category}
</Typography>
<Stack spacing={1}>
{detail.settings.map((setting, i) => (
<Box key={i}>
<Typography
variant="caption"
sx={{
color: colors.text.secondary,
display: "block",
fontSize: "0.7rem",
}}
{phaseNames[phase]}
<Chip
label={groupedResults[phase].length}
size="small"
sx={{ height: 18, fontSize: "0.65rem" }}
/>
</Typography>
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
gap={1}
>
{groupedResults[phase]
.sort((a, b) => b.speed - a.speed)
.map((result) => (
<Box
key={result.preset_name}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
}}
>
<Tooltip
title={
result.status === "complete"
? `${result.preset_name}: ${(
result.speed /
1024 /
1024
).toFixed(2)} MB/s`
: `${result.preset_name}: ${
result.error || "Failed"
}`
}
>
{setting.label}
</Typography>
<Typography
variant="body2"
sx={{
color: colors.text.primary,
fontWeight: 500,
}}
>
{setting.value}
</Typography>
<Chip
label={`${result.preset_name}: ${
result.status === "complete"
? `${(
result.speed /
1024 /
1024
).toFixed(2)} MB/s`
: "Failed"
}`}
size="small"
sx={{
bgcolor:
result.preset_name ===
domainResult.best_preset
? colors.accent.secondary
: colors.background.dark,
color:
result.status === "complete"
? colors.text.primary
: colors.quaternary,
border:
result.preset_name ===
domainResult.best_preset
? `2px solid ${colors.secondary}`
: `1px solid ${colors.border.light}`,
}}
/>
</Tooltip>
{result.status === "complete" &&
result.preset_name !==
domainResult.best_preset && (
<Tooltip title="Use this configuration">
<IconButton
size="small"
onClick={() => {
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,
},
}}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</Stack>
</Box>
</Grid>
</Stack>
</Box>
))}
</Grid>
{/* All Tested Configs */}
<Box sx={{ mt: 3 }}>
<Typography
variant="subtitle2"
sx={{
color: colors.text.secondary,
mb: 1,
textTransform: "uppercase",
fontSize: "0.7rem",
}}
>
All Tested Configurations
</Typography>
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
gap={1}
>
{Object.values(domainResult.results)
.sort((a, b) => b.speed - a.speed)
.map((result) => (
<Box
key={result.preset_name}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
}}
>
<Chip
label={`${result.preset_name}: ${
result.status === "complete"
? `${(
result.speed /
1024 /
1024
).toFixed(2)} MB/s`
: "Failed"
}`}
size="small"
sx={{
bgcolor:
result.preset_name ===
domainResult.best_preset
? colors.accent.secondary
: colors.background.dark,
color:
result.status === "complete"
? colors.text.primary
: colors.quaternary,
border:
result.preset_name ===
domainResult.best_preset
? `2px solid ${colors.secondary}`
: `1px solid ${colors.border.light}`,
}}
/>
{result.status === "complete" &&
result.preset_name !==
domainResult.best_preset && (
<Tooltip title="Use this configuration">
<IconButton
size="small"
onClick={() => {
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,
},
}}
>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
))}
</Stack>
</Box>
</Box>
)}
</Collapse>
{/* Failed state */}
{!domainResult.best_success && (

View file

@ -25,27 +25,30 @@ export const CheckerSettings: React.FC<CheckerSettingsProps> = ({
<Grid container spacing={2}>
<Grid size={{ xs: 12, lg: 6 }}>
<B4Slider
label="Max Concurrent Tests"
value={config.system.checker.max_concurrent}
label="Discovery Timeout"
value={config.system.checker.discovery_timeout || 5}
onChange={(value) =>
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"
/>
</Grid>
<Grid size={{ xs: 12, lg: 6 }}>
<B4Slider
label="Test Timeout"
value={config.system.checker.timeout}
onChange={(value) => 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)"
/>
</Grid>
</Grid>

View file

@ -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 (
<Container
maxWidth={false}
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
py: 3,
}}
>
<Stack spacing={3}>
<Tabs
value={activeTab}
onChange={(_, newValue: number) => setActiveTab(newValue)}
sx={{
borderBottom: `1px solid ${colors.border.default}`,
"& .MuiTab-root": {
color: colors.text.secondary,
"&.Mui-selected": {
color: colors.secondary,
},
},
}}
>
<Tab label="Quick Test" />
<Tab label="Discovery" />
</Tabs>
{activeTab === 0 && (
<>
<Alert
severity="info"
sx={{
bgcolor: colors.accent.primary,
border: `1px solid ${colors.secondary}44`,
}}
>
<strong>Quick Test:</strong> Test your current configuration
against the domains you specify below. This validates your
existing DPI bypass settings without making any changes.
</Alert>
<TestRunner />
</>
)}
{activeTab === 1 && (
<>
<Alert severity="warning">
This feature is EXPERIMENTAL and may affect your current
configuration.
</Alert>
<Alert
severity="info"
sx={{
bgcolor: colors.accent.primary,
border: `1px solid ${colors.secondary}44`,
}}
>
<strong>Configuration Discovery:</strong> 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.
</Alert>
<DiscoveryRunner />
</>
)}
</Stack>
</Container>
);
}

View file

@ -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 (
<Container
maxWidth={false}
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
py: 3,
}}
>
<Stack spacing={3}>
<Alert severity="warning">
This feature is EXPERIMENTAL and may affect your current
configuration.
</Alert>
<Alert
severity="info"
sx={{
bgcolor: colors.accent.primary,
border: `1px solid ${colors.secondary}44`,
}}
>
<strong>Configuration Discovery:</strong> 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.
</Alert>
<DiscoveryRunner />
</Stack>
</Container>
);
}

View file

@ -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: <MonitorIcon />,
id: TABS.DISCOVERY,
path: "discovery",
label: "Discovery",
icon: <DiscoveryIcon />,
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() {
<ApiSettings config={config} onChange={handleChange} />
</TabPanel>
<TabPanel value={validTab} index={TABS.TESTING}>
<TabPanel value={validTab} index={TABS.DISCOVERY}>
<CheckerSettings config={config} onChange={handleChange} />
</TabPanel>

View file

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