Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestE2EBurstTest(t *testing.T) {
}
defer mock.Close()

result := BurstTest(mock.ip, "test.example.com", mock.port, 2*time.Second)
result := BurstTest(context.Background(), mock.ip, "test.example.com", mock.port, 2*time.Second)

if result.Queries != BurstQueries {
t.Errorf("Expected %d queries, got %d", BurstQueries, result.Queries)
Expand Down Expand Up @@ -243,7 +243,7 @@ func TestE2EBurstQPS(t *testing.T) {
}
defer mock.Close()

result := BurstTest(mock.ip, "test.example.com", mock.port, 2*time.Second)
result := BurstTest(context.Background(), mock.ip, "test.example.com", mock.port, 2*time.Second)

if result.QPS() <= 0 {
t.Errorf("QPS should be positive, got %.2f", result.QPS())
Expand All @@ -253,14 +253,92 @@ func TestE2EBurstQPS(t *testing.T) {
}
}

func TestE2EParallelBurstTest(t *testing.T) {
// Start 3 mock servers
var mocks []*mockDNSServer
var ips []string
for i := 0; i < 3; i++ {
mock, err := newMockDNSServer("93.184.216.34")
if err != nil {
t.Fatalf("Failed to start mock %d: %v", i, err)
}
mocks = append(mocks, mock)
ips = append(ips, mock.ip)
}
defer func() {
for _, m := range mocks {
m.Close()
}
}()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// All mocks use same port pattern, so we use first mock's port
resultChan := ParallelBurstTest(ctx, ips, "test.example.com", mocks[0].port, 2*time.Second, 3)

var count, passed int
for r := range resultChan {
count++
if r.Passed() {
passed++
}
}

if count != 3 {
t.Errorf("Expected 3 results, got %d", count)
}
if passed != 3 {
t.Errorf("Expected 3 passed, got %d", passed)
}
}

func TestE2EBurstTestContextCancellation(t *testing.T) {
mock, err := newMockDNSServer("93.184.216.34")
if err != nil {
t.Fatalf("Failed to start mock DNS: %v", err)
}
defer mock.Close()

// Cancel immediately
ctx, cancel := context.WithCancel(context.Background())
cancel()

result := BurstTest(ctx, mock.ip, "test.example.com", mock.port, 2*time.Second)

// Should return early with partial/no results
if result.Successful == BurstQueries {
t.Error("Expected early termination with cancelled context")
}
}

func TestBurstProgress(t *testing.T) {
prog := NewBurstProgress(10, true)

prog.Tested()
prog.Tested()
prog.Passed()

tested, passed, total := prog.Stats()
if tested != 2 {
t.Errorf("Expected 2 tested, got %d", tested)
}
if passed != 1 {
t.Errorf("Expected 1 passed, got %d", passed)
}
if total != 10 {
t.Errorf("Expected total 10, got %d", total)
}
}

func TestE2EJSONServerFromBurstResult(t *testing.T) {
mock, err := newMockDNSServer("93.184.216.34")
if err != nil {
t.Fatalf("Failed to start mock DNS: %v", err)
}
defer mock.Close()

result := BurstTest(mock.ip, "test.example.com", mock.port, 2*time.Second)
result := BurstTest(context.Background(), mock.ip, "test.example.com", mock.port, 2*time.Second)

server := JSONServer{
IP: result.IP,
Expand Down
131 changes: 93 additions & 38 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ func main() {
// Set data directory
DataDir = *dataDir

// JSON mode disables progress - machine output only
if *jsonOutput {
*progress = false
}

if *showVersion {
fmt.Printf("dnscan %s\n", version)
os.Exit(0)
Expand Down Expand Up @@ -355,60 +360,111 @@ resultLoop:
// Phase 3: Burst test to verify servers handle concurrent load
var burstResults []*BurstResult
if *domain != "" && len(workingDNS) > 0 {
if *progress {
fmt.Fprintf(os.Stderr, "\nBurst testing %d candidates (%d queries, %d%% required)...\n",
len(workingDNS), BurstQueries, BurstMinSuccess)
}

total := len(workingDNS)
width := len(fmt.Sprintf("%d", total))
for i, ip := range workingDNS {
// Check for interrupt
select {
case <-ctx.Done():
if *progress {
fmt.Fprintf(os.Stderr, "\nInterrupted during burst test\n")
}
goto burstDone
default:
}

if total <= 5 {
// Sequential for small lists - nicer per-IP output
if *progress {
fmt.Fprintf(os.Stderr, "[%*d/%d] %-15s ", width, i+1, total, ip)
fmt.Fprintf(os.Stderr, "\nBurst testing %d candidates (%d queries, %d%% required)...\n",
total, BurstQueries, BurstMinSuccess)
}

result := BurstTest(ip, *domain, 53, *timeout)
width := len(fmt.Sprintf("%d", total))
for i, ip := range workingDNS {
select {
case <-ctx.Done():
if *progress {
fmt.Fprintf(os.Stderr, "\nInterrupted during burst test\n")
}
goto burstDone
default:
}

if result.Passed() {
burstResults = append(burstResults, result)
if *progress {
// Green for >=85%, yellow for 70-84%
color := "\033[33m" // yellow
if result.SuccessRate() >= 85 {
color = "\033[32m" // green
fmt.Fprintf(os.Stderr, "[%*d/%d] %-15s ", width, i+1, total, ip)
}

result := BurstTest(ctx, ip, *domain, 53, *timeout)

if result.Passed() {
burstResults = append(burstResults, result)
if *progress {
color := "\033[33m"
if result.SuccessRate() >= 85 {
color = "\033[32m"
}
fmt.Fprintf(os.Stderr, "%sOK %.0f%% (%.1f qps, p50=%v)\033[0m\n",
color, result.SuccessRate(), result.QPS(), result.P50().Round(time.Millisecond))
}
} else {
if *progress {
fmt.Fprintf(os.Stderr, "FAIL %.0f%%\n", result.SuccessRate())
}
fmt.Fprintf(os.Stderr, "%sOK %.0f%% (%.1f qps, p50=%v)\033[0m\n",
color, result.SuccessRate(), result.QPS(), result.P50().Round(time.Millisecond))
}
} else {
if *progress {
fmt.Fprintf(os.Stderr, "FAIL %.0f%%\n", result.SuccessRate())
}
} else {
// Parallel for larger lists
burstWorkers := min(total, 10)
if *progress {
fmt.Fprintf(os.Stderr, "\nBurst testing %d candidates in parallel (%d workers)...\n",
total, burstWorkers)
}

burstProg := NewBurstProgress(total, *progress)
var progressDone chan struct{}

if *progress {
progressDone = make(chan struct{})
go func() {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
tested, passed, tot := burstProg.Stats()
fmt.Fprintf(os.Stderr, "\rBurst testing: %d/%d tested, %d passed ", tested, tot, passed)
case <-ctx.Done():
return
case <-progressDone:
return
}
}
}()
}

resultChan := ParallelBurstTest(ctx, workingDNS, *domain, 53, *timeout, burstWorkers)
for result := range resultChan {
burstProg.Tested()
if result.Passed() {
burstProg.Passed()
burstResults = append(burstResults, result)
}
}

if progressDone != nil {
close(progressDone)
fmt.Fprintf(os.Stderr, "\r \r")
}
}
burstDone:

// Sort by QPS descending (highest throughput first)
sort.Slice(burstResults, func(i, j int) bool {
return burstResults[i].QPS() > burstResults[j].QPS()
})

if *progress {
fmt.Fprintf(os.Stderr, "---\n")
fmt.Fprintf(os.Stderr, "Burst test: %d/%d passed (sorted by throughput)\n", len(burstResults), len(workingDNS))
for _, r := range burstResults {
color := "\033[33m"
if r.SuccessRate() >= 85 {
color = "\033[32m"
}
fmt.Fprintf(os.Stderr, "%s%-15s OK %.0f%% (%.1f qps, p50=%v)\033[0m\n",
color, r.IP, r.SuccessRate(), r.QPS(), r.P50().Round(time.Millisecond))
}
}

// Extract sorted IPs
workingDNS = nil
for _, r := range burstResults {
workingDNS = append(workingDNS, r.IP)
Expand Down Expand Up @@ -457,15 +513,14 @@ resultLoop:
}
enc.Encode(output)
} else {
// Plain text output (default)
// Plain text output - skip stdout when progress shows colored stats
if len(workingDNS) > 0 {
if *progress {
fmt.Fprintf(os.Stderr, "---\n")
}
for _, ip := range workingDNS {
if outFile != nil {
if outFile != nil {
for _, ip := range workingDNS {
fmt.Fprintln(outFile, ip)
} else {
}
} else if !*progress {
for _, ip := range workingDNS {
fmt.Println(ip)
}
}
Expand Down
86 changes: 85 additions & 1 deletion scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ func (r *BurstResult) Passed() bool {
}

// BurstTest runs concurrent DNS queries to test server reliability under load
func BurstTest(ip, domain string, port int, timeout time.Duration) *BurstResult {
func BurstTest(ctx context.Context, ip, domain string, port int, timeout time.Duration) *BurstResult {
if port == 0 {
port = 53
}
Expand All @@ -308,13 +308,29 @@ func BurstTest(ip, domain string, port int, timeout time.Duration) *BurstResult
start := time.Now()

for i := 0; i < BurstQueries; i++ {
select {
case <-ctx.Done():
result.Duration = time.Since(start)
return result
default:
}

wg.Add(1)
sem <- struct{}{}

go func() {
defer wg.Done()
defer func() { <-sem }()

select {
case <-ctx.Done():
mu.Lock()
result.Failed++
mu.Unlock()
return
default:
}

subdomain := randomSlipstreamSubdomain()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(subdomain+"."+domain), dns.TypeTXT)
Expand All @@ -338,3 +354,71 @@ func BurstTest(ip, domain string, port int, timeout time.Duration) *BurstResult
result.Duration = time.Since(start)
return result
}

// BurstProgress tracks parallel burst test progress with atomic counters
type BurstProgress struct {
total int64
tested int64
passed int64
enabled bool
}

func NewBurstProgress(total int, enabled bool) *BurstProgress {
return &BurstProgress{total: int64(total), enabled: enabled}
}

func (p *BurstProgress) Tested() { atomic.AddInt64(&p.tested, 1) }
func (p *BurstProgress) Passed() { atomic.AddInt64(&p.passed, 1) }
func (p *BurstProgress) Stats() (tested, passed, total int64) {
return atomic.LoadInt64(&p.tested), atomic.LoadInt64(&p.passed), p.total
}

// ParallelBurstTest runs burst tests on multiple IPs concurrently
func ParallelBurstTest(ctx context.Context, ips []string, domain string, port int,
timeout time.Duration, workers int) <-chan *BurstResult {

results := make(chan *BurstResult, workers)
ipChan := make(chan string, len(ips))

go func() {
defer close(ipChan)
for _, ip := range ips {
select {
case ipChan <- ip:
case <-ctx.Done():
return
}
}
}()

var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case ip, ok := <-ipChan:
if !ok {
return
}
result := BurstTest(ctx, ip, domain, port, timeout)
select {
case results <- result:
case <-ctx.Done():
return
}
}
}
}()
}

go func() {
wg.Wait()
close(results)
}()

return results
}