Skip to content

Commit 9f2a177

Browse files
committed
Add two-phase e2e: fast SOCKS check on all, full curl on top 100
Splits e2e into Phase 1 (SOCKS-only handshake check on all resolvers) and Phase 2 (full curl verification on top 100). Dramatically faster for large resolver lists on filtered networks like Iran.
1 parent fa06778 commit 9f2a177

4 files changed

Lines changed: 126 additions & 0 deletions

File tree

cmd/scan.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ var stepDescriptions = map[string]string{
3131
"nxdomain": "Detecting DNS hijacking on non-existent domains",
3232
"edns": "Testing EDNS0 support and buffer sizes",
3333
"resolve/tunnel": "Verifying resolvers forward queries to your tunnel domain",
34+
"e2e/socks": "Quick SOCKS handshake test via DNSTT",
3435
"e2e/dnstt": "Full tunnel connectivity test via DNSTT",
3536
"e2e/slipstream": "Full tunnel connectivity test via Slipstream",
3637
"doh/resolve": "Checking DoH resolver connectivity",
@@ -69,6 +70,7 @@ func init() {
6970
scanCmd.Flags().Int("query-size", 0, "cap dnstt-client upstream query size in bytes (0 = max, try 50-80 if e2e fails)")
7071
scanCmd.Flags().StringSlice("cidr", nil, "CIDR range(s) to scan (e.g. --cidr 5.52.0.0/16)")
7172
scanCmd.Flags().String("output-ips", "", "write plain IP list (one per line) to this file")
73+
scanCmd.Flags().Int("e2e-top", 100, "number of top SOCKS-passing resolvers to full-verify with curl")
7274
scanCmd.Flags().Int("top", 10, "number of top results to display")
7375
rootCmd.AddCommand(scanCmd)
7476
}
@@ -84,6 +86,7 @@ func runScan(cmd *cobra.Command, args []string) error {
8486
skipNXD, _ := cmd.Flags().GetBool("skip-nxdomain")
8587
ednsMode, _ := cmd.Flags().GetBool("edns")
8688
topN, _ := cmd.Flags().GetInt("top")
89+
e2eTop, _ := cmd.Flags().GetInt("e2e-top")
8790
outputIPs, _ := cmd.Flags().GetString("output-ips")
8891

8992
ednsSize, _ := cmd.Flags().GetInt("edns-size")
@@ -227,6 +230,13 @@ func runScan(cmd *cobra.Command, args []string) error {
227230
})
228231
}
229232
if domain != "" && pubkey != "" {
233+
// Phase 1: fast SOCKS-only check on ALL resolvers (Noise handshake only)
234+
steps = append(steps, scanner.Step{
235+
Name: "e2e/socks", Timeout: time.Duration(e2eTimeout) * time.Second,
236+
Check: scanner.DnsttSOCKSCheckBin(dnsttBin, domain, pubkey, ports), SortBy: "socks_ms",
237+
Limit: e2eTop,
238+
})
239+
// Phase 2: full curl verification on top N from Phase 1
230240
steps = append(steps, scanner.Step{
231241
Name: "e2e/dnstt", Timeout: time.Duration(e2eTimeout) * time.Second,
232242
Check: scanner.DnsttCheckBin(dnsttBin, domain, pubkey, testURL, proxyAuth, ports), SortBy: "e2e_ms",

internal/scanner/chain.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type Step struct {
1313
Timeout time.Duration
1414
Check CheckFunc
1515
SortBy string
16+
Limit int // if > 0, only pass top N results to the next step
1617
}
1718

1819
type StepResult struct {
@@ -117,6 +118,11 @@ func runChain(ctx context.Context, ips []string, workers int, steps []Step, newP
117118
step.Name+":", sr.Tested, sr.Passed, sr.Failed, sr.Seconds)
118119
}
119120

121+
// Apply limit: only pass top N to next step
122+
if step.Limit > 0 && len(nextIPs) > step.Limit {
123+
nextIPs = nextIPs[:step.Limit]
124+
}
125+
120126
current = nextIPs
121127
}
122128

internal/scanner/e2e.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,109 @@ func slipstreamCheck(bin, domain, certPath, testURL, proxyAuth string, ports cha
254254
}
255255
}
256256

257+
// DnsttSOCKSCheckBin is a fast e2e check: it only verifies that dnstt-client
258+
// opens the SOCKS port (i.e. the Noise handshake completes). No curl/HTTP test.
259+
// This is much faster than the full e2e check and suitable for testing all resolvers.
260+
func DnsttSOCKSCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc {
261+
var diagOnce atomic.Bool
262+
263+
return func(ip string, timeout time.Duration) (bool, Metrics) {
264+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
265+
defer cancel()
266+
267+
var port int
268+
select {
269+
case port = <-ports:
270+
case <-ctx.Done():
271+
return false, nil
272+
}
273+
274+
start := time.Now()
275+
276+
var stderrBuf bytes.Buffer
277+
args := []string{
278+
"-udp", net.JoinHostPort(ip, "53"),
279+
"-pubkey", pubkey,
280+
}
281+
if DnsttMTU > 0 {
282+
args = append(args, "-mtu", strconv.Itoa(DnsttMTU))
283+
}
284+
args = append(args, domain, fmt.Sprintf("127.0.0.1:%d", port))
285+
cmd := execCommandContext(ctx, bin, args...)
286+
cmd.Stdout = io.Discard
287+
cmd.Stderr = &stderrBuf
288+
if err := cmd.Start(); err != nil {
289+
ports <- port
290+
if diagOnce.CompareAndSwap(false, true) {
291+
setDiag("e2e/socks: cannot start %s: %v", bin, err)
292+
}
293+
return false, nil
294+
}
295+
296+
exited := make(chan struct{})
297+
go func() {
298+
cmd.Wait()
299+
close(exited)
300+
}()
301+
302+
defer func() {
303+
cmd.Process.Kill()
304+
select {
305+
case <-exited:
306+
case <-time.After(2 * time.Second):
307+
}
308+
time.Sleep(300 * time.Millisecond)
309+
ports <- port
310+
}()
311+
312+
// Just wait for SOCKS port to accept a TCP connection
313+
addr := fmt.Sprintf("127.0.0.1:%d", port)
314+
for {
315+
select {
316+
case <-ctx.Done():
317+
if diagOnce.CompareAndSwap(false, true) {
318+
cmd.Process.Kill()
319+
select {
320+
case <-exited:
321+
case <-time.After(2 * time.Second):
322+
}
323+
stderr := strings.TrimSpace(stderrBuf.String())
324+
if stderr != "" {
325+
setDiag("e2e/socks first failure (ip=%s): dnstt-client stderr: %s", ip, truncate(stderr, 300))
326+
} else {
327+
setDiag("e2e/socks first failure (ip=%s): SOCKS port did not open within %v", ip, timeout)
328+
}
329+
}
330+
return false, nil
331+
case <-exited:
332+
if diagOnce.CompareAndSwap(false, true) {
333+
stderr := strings.TrimSpace(stderrBuf.String())
334+
if stderr != "" {
335+
setDiag("e2e/socks first failure (ip=%s): dnstt-client exited early: %s", ip, truncate(stderr, 300))
336+
} else {
337+
setDiag("e2e/socks first failure (ip=%s): dnstt-client exited early with no stderr", ip)
338+
}
339+
}
340+
return false, nil
341+
default:
342+
}
343+
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
344+
if err == nil {
345+
conn.Close()
346+
ms := roundMs(float64(time.Since(start).Microseconds()) / 1000.0)
347+
return true, Metrics{"socks_ms": ms}
348+
}
349+
select {
350+
case <-ctx.Done():
351+
return false, nil
352+
case <-exited:
353+
return false, nil
354+
case <-time.After(300 * time.Millisecond):
355+
}
356+
}
357+
}
358+
}
359+
257360
func nullDevice() string {
258361
if runtime.GOOS == "windows" {
259362
return "NUL"

internal/tui/screen_running.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) {
118118
})
119119
}
120120
if cfg.Domain != "" && cfg.Pubkey != "" {
121+
// Phase 1: fast SOCKS-only check on ALL resolvers
122+
steps = append(steps, scanner.Step{
123+
Name: "e2e/socks", Timeout: e2eDur,
124+
Check: scanner.DnsttSOCKSCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports), SortBy: "socks_ms",
125+
Limit: 100,
126+
})
127+
// Phase 2: full curl verification on top 100
121128
steps = append(steps, scanner.Step{
122129
Name: "e2e/dnstt", Timeout: e2eDur,
123130
Check: scanner.DnsttCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, cfg.TestURL, cfg.ProxyAuth, ports), SortBy: "e2e_ms",

0 commit comments

Comments
 (0)