@@ -68,7 +68,9 @@ func init() {
6868 scanCmd .Flags ().StringSlice ("cidr" , nil , "CIDR range(s) to scan (e.g. --cidr 5.52.0.0/16)" )
6969 scanCmd .Flags ().String ("cidr-file" , "" , "text file with one CIDR range per line to scan" )
7070 scanCmd .Flags ().String ("output-ips" , "" , "write plain IP list (one per line) to this file" )
71- scanCmd .Flags ().Int ("top" , 10 , "number of top results to display" )
71+ scanCmd .Flags ().Int ("top" , 10 , "number of top results to display" )
72+ scanCmd .Flags ().Int ("batch" , 0 , "scan N resolvers at a time, saving after each batch (0 = all at once)" )
73+ scanCmd .Flags ().Bool ("resume" , false , "skip IPs already in the output file (resume a previous scan)" )
7274 rootCmd .AddCommand (scanCmd )
7375}
7476
@@ -81,7 +83,9 @@ func runScan(cmd *cobra.Command, args []string) error {
8183 skipNXD , _ := cmd .Flags ().GetBool ("skip-nxdomain" )
8284 ednsMode , _ := cmd .Flags ().GetBool ("edns" )
8385 topN , _ := cmd .Flags ().GetInt ("top" )
84- outputIPs , _ := cmd .Flags ().GetString ("output-ips" )
86+ outputIPs , _ := cmd .Flags ().GetString ("output-ips" )
87+ batchSize , _ := cmd .Flags ().GetInt ("batch" )
88+ resume , _ := cmd .Flags ().GetBool ("resume" )
8589
8690 ednsSize , _ := cmd .Flags ().GetInt ("edns-size" )
8791 querySize , _ := cmd .Flags ().GetInt ("query-size" )
@@ -243,7 +247,7 @@ outputIPs, _ := cmd.Flags().GetString("output-ips")
243247 // handshake succeeds — proving bidirectional tunnel data flow.
244248 steps = append (steps , scanner.Step {
245249 Name : "e2e/dnstt" , Timeout : time .Duration (e2eTimeout ) * time .Second ,
246- Check : scanner .DnsttSOCKSCheckBin (dnsttBin , domain , pubkey , ports ), SortBy : "socks_ms" ,
250+ Check : scanner .DnsttCheckBin (dnsttBin , domain , pubkey , ports ), SortBy : "socks_ms" ,
247251 })
248252 }
249253 if domain != "" && certPath != "" {
@@ -258,6 +262,52 @@ outputIPs, _ := cmd.Flags().GetString("output-ips")
258262 return fmt .Errorf ("no scan steps configured" )
259263 }
260264
265+ // --resume: load existing results and skip already-scanned IPs
266+ var allPassed []scanner.IPRecord
267+ if resume {
268+ if existing , err := scanner .LoadChainReport (outputFile ); err == nil {
269+ seen := make (map [string ]bool , len (existing .Passed )+ len (existing .Failed ))
270+ for _ , r := range existing .Passed {
271+ seen [r .IP ] = true
272+ allPassed = append (allPassed , r )
273+ }
274+ for _ , r := range existing .Failed {
275+ seen [r .IP ] = true
276+ }
277+ filtered := ips [:0 ]
278+ for _ , ip := range ips {
279+ if ! seen [ip ] {
280+ filtered = append (filtered , ip )
281+ }
282+ }
283+ skipped := len (ips ) - len (filtered )
284+ ips = filtered
285+ fmt .Fprintf (os .Stderr , " %s✔ Resume: skipping %d already-scanned IPs, %d remaining%s\n " ,
286+ colorGreen , skipped , len (ips ), colorReset )
287+ if len (ips ) == 0 {
288+ fmt .Fprintf (os .Stderr , " %s✔ All IPs already scanned%s\n " , colorGreen , colorReset )
289+ return nil
290+ }
291+ }
292+ }
293+
294+ if outputIPs == "" {
295+ outputIPs = strings .TrimSuffix (outputFile , ".json" ) + "_ips.txt"
296+ }
297+
298+ // saveResults writes current results to JSON + IP list
299+ saveResults := func (report scanner.ChainReport ) {
300+ // Merge with previously loaded results from --resume
301+ merged := report
302+ if len (allPassed ) > 0 {
303+ merged .Passed = append (allPassed , report .Passed ... )
304+ }
305+ scanner .WriteChainReport (merged , outputFile )
306+ if len (merged .Passed ) > 0 {
307+ scanner .WriteIPList (merged .Passed , outputIPs )
308+ }
309+ }
310+
261311 printBanner (len (ips ), dohMode , domain , steps )
262312 printPreFlight (len (ips ), domain , dnsttBin , slipstreamBin , steps )
263313
@@ -266,6 +316,61 @@ outputIPs, _ := cmd.Flags().GetString("output-ips")
266316
267317 scanner .ResetE2EDiagnostic ()
268318 scanStart := time .Now ()
319+
320+ // --batch: split IPs into chunks, save after each batch
321+ if batchSize > 0 && len (ips ) > batchSize {
322+ var combinedReport scanner.ChainReport
323+ totalBatches := (len (ips ) + batchSize - 1 ) / batchSize
324+ for i := 0 ; i < len (ips ); i += batchSize {
325+ if ctx .Err () != nil {
326+ break
327+ }
328+ end := i + batchSize
329+ if end > len (ips ) {
330+ end = len (ips )
331+ }
332+ batchNum := i / batchSize + 1
333+ chunk := ips [i :end ]
334+ fmt .Fprintf (os .Stderr , "\n %s━━━ Batch %d/%d (%d IPs) ━━━%s\n \n " ,
335+ colorCyan , batchNum , totalBatches , len (chunk ), colorReset )
336+
337+ report := scanner .RunChainQuietCtx (ctx , chunk , workers , steps ,
338+ newScanProgressFactory (len (steps ), stepDescriptions ))
339+
340+ // Merge into combined report
341+ combinedReport .Passed = append (combinedReport .Passed , report .Passed ... )
342+ combinedReport .Failed = append (combinedReport .Failed , report .Failed ... )
343+ if len (combinedReport .Steps ) == 0 {
344+ combinedReport .Steps = report .Steps
345+ } else {
346+ for j := range combinedReport .Steps {
347+ if j < len (report .Steps ) {
348+ combinedReport .Steps [j ].Tested += report .Steps [j ].Tested
349+ combinedReport .Steps [j ].Passed += report .Steps [j ].Passed
350+ combinedReport .Steps [j ].Failed += report .Steps [j ].Failed
351+ combinedReport .Steps [j ].Seconds += report .Steps [j ].Seconds
352+ }
353+ }
354+ }
355+
356+ // Save after each batch
357+ saveResults (combinedReport )
358+ totalPassed := len (allPassed ) + len (combinedReport .Passed )
359+ fmt .Fprintf (os .Stderr , " %s✔ Batch %d done — %d passed so far — saved to %s%s\n " ,
360+ colorGreen , batchNum , totalPassed , outputFile , colorReset )
361+ }
362+
363+ totalTime := time .Since (scanStart )
364+ if ctx .Err () != nil {
365+ fmt .Fprintf (os .Stderr , "\n %s⚠ Interrupted — partial results saved to %s%s\n " , colorYellow , outputFile , colorReset )
366+ }
367+ printSummary (combinedReport , topN , totalTime , domain )
368+ totalPassed := len (allPassed ) + len (combinedReport .Passed )
369+ fmt .Fprintf (os .Stderr , " %s✔ IP list written to %s (%d IPs)%s\n " , colorGreen , outputIPs , totalPassed , colorReset )
370+ return nil
371+ }
372+
373+ // No batching — scan all at once
269374 report := scanner .RunChainQuietCtx (ctx , ips , workers , steps , newScanProgressFactory (len (steps ), stepDescriptions ))
270375 totalTime := time .Since (scanStart )
271376
@@ -274,19 +379,11 @@ outputIPs, _ := cmd.Flags().GetString("output-ips")
274379 }
275380
276381 printSummary (report , topN , totalTime , domain )
382+ saveResults (report )
277383
278- if err := scanner .WriteChainReport (report , outputFile ); err != nil {
279- return err
280- }
281- // Auto-generate _ips.txt alongside JSON (same as TUI behavior)
282- if outputIPs == "" && len (report .Passed ) > 0 {
283- outputIPs = strings .TrimSuffix (outputFile , ".json" ) + "_ips.txt"
284- }
285- if outputIPs != "" && len (report .Passed ) > 0 {
286- if err := scanner .WriteIPList (report .Passed , outputIPs ); err != nil {
287- return fmt .Errorf ("writing IP list: %w" , err )
288- }
289- fmt .Fprintf (os .Stderr , " %s✔ IP list written to %s (%d IPs)%s\n " , colorGreen , outputIPs , len (report .Passed ), colorReset )
384+ totalPassed := len (allPassed ) + len (report .Passed )
385+ if totalPassed > 0 {
386+ fmt .Fprintf (os .Stderr , " %s✔ IP list written to %s (%d IPs)%s\n " , colorGreen , outputIPs , totalPassed , colorReset )
290387 }
291388 return nil
292389}
0 commit comments