From 0a38fd5a326dcedaefe3acf7c1cf065ca1e4d41c Mon Sep 17 00:00:00 2001 From: Night Owl Nerd <256460992+nightowlnerd@users.noreply.github.com> Date: Sun, 1 Feb 2026 09:45:59 +0100 Subject: [PATCH 1/4] feat: add --json flag for structured output --- main.go | 111 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 89 insertions(+), 22 deletions(-) diff --git a/main.go b/main.go index 43ad5ea..6f7424f 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "flag" "fmt" "os" @@ -38,6 +39,29 @@ var ( version = "dev" ) +// JSONOutput is the structured output format for --json flag +type JSONOutput struct { + Servers []JSONServer `json:"servers"` + Scan JSONScan `json:"scan"` +} + +// JSONServer represents a single DNS server in JSON output +type JSONServer struct { + IP string `json:"ip"` + QPS float64 `json:"qps,omitempty"` + SuccessRate float64 `json:"success_rate,omitempty"` + LatencyP50 int64 `json:"latency_p50_ms,omitempty"` +} + +// JSONScan contains scan metadata +type JSONScan struct { + Country string `json:"country,omitempty"` + Mode string `json:"mode,omitempty"` + TotalScanned int64 `json:"total_scanned"` + Found int64 `json:"found"` + DurationMs int64 `json:"duration_ms"` +} + // verifyWithSlipstream tests if a DNS server actually works with slipstream-client func verifyWithSlipstream(clientPath, domain, ip string, timeout time.Duration) bool { ctx, cancel := context.WithTimeout(context.Background(), timeout*3) @@ -97,6 +121,7 @@ func main() { domain := flag.String("domain", "", "Tunnel domain to verify (e.g., t.example.com). Required for slipstream compatibility.") verify := flag.String("verify", "", "Path to slipstream-client binary to verify candidates actually work") showVersion := flag.Bool("version", false, "Show version") + jsonOutput := flag.Bool("json", false, "Output results as JSON") flag.Parse() // Set data directory @@ -389,35 +414,77 @@ resultLoop: } } + // Get final stats + scanned, _, _, elapsed := prog.Stats() + // Write results - if len(workingDNS) > 0 { - if *progress { - fmt.Fprintf(os.Stderr, "---\n") + if *jsonOutput { + output := JSONOutput{ + Scan: JSONScan{ + TotalScanned: scanned, + Found: int64(len(workingDNS)), + DurationMs: elapsed.Milliseconds(), + }, } - for _, ip := range workingDNS { - if outFile != nil { - fmt.Fprintln(outFile, ip) - } else { - fmt.Println(ip) + if *inputFile == "" { + output.Scan.Country = *country + output.Scan.Mode = *mode + } + + // Build server list with stats if available + if len(burstResults) > 0 { + for _, r := range burstResults { + output.Servers = append(output.Servers, JSONServer{ + IP: r.IP, + QPS: r.QPS(), + SuccessRate: r.SuccessRate(), + LatencyP50: r.P50().Milliseconds(), + }) + } + } else { + for _, ip := range workingDNS { + output.Servers = append(output.Servers, JSONServer{IP: ip}) } } - } - // Print usage hint - if *progress && len(workingDNS) > 0 { - showDomain := *domain - if showDomain == "" { - showDomain = "" + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if outFile != nil { + enc = json.NewEncoder(outFile) + enc.SetIndent("", " ") } - max := 10 - if len(workingDNS) < max { - max = len(workingDNS) + enc.Encode(output) + } else { + // Plain text output (default) + if len(workingDNS) > 0 { + if *progress { + fmt.Fprintf(os.Stderr, "---\n") + } + for _, ip := range workingDNS { + if outFile != nil { + fmt.Fprintln(outFile, ip) + } else { + fmt.Println(ip) + } + } } - fmt.Fprintf(os.Stderr, "\nUsage:\n slipstream-client \\\n") - for i := 0; i < max; i++ { - fmt.Fprintf(os.Stderr, " --resolver %s:53 \\\n", workingDNS[i]) + + // Print usage hint + if *progress && len(workingDNS) > 0 { + showDomain := *domain + if showDomain == "" { + showDomain = "" + } + max := 10 + if len(workingDNS) < max { + max = len(workingDNS) + } + fmt.Fprintf(os.Stderr, "\nUsage:\n slipstream-client \\\n") + for i := 0; i < max; i++ { + fmt.Fprintf(os.Stderr, " --resolver %s:53 \\\n", workingDNS[i]) + } + fmt.Fprintf(os.Stderr, " --domain %s \\\n", showDomain) + fmt.Fprintf(os.Stderr, " --tcp-listen-port 7000\n") } - fmt.Fprintf(os.Stderr, " --domain %s \\\n", showDomain) - fmt.Fprintf(os.Stderr, " --tcp-listen-port 7000\n") } } From 768de0c33c54efdf35f0e57969b0c48e49527b22 Mon Sep 17 00:00:00 2001 From: Night Owl Nerd <256460992+nightowlnerd@users.noreply.github.com> Date: Sun, 1 Feb 2026 09:46:44 +0100 Subject: [PATCH 2/4] docs: add --json flag to README --- README.fa.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.fa.md b/README.fa.md index 48ed970..1020889 100644 --- a/README.fa.md +++ b/README.fa.md @@ -61,6 +61,7 @@ go build -o dnscan . | `--output` | stdout | ذخیره نتایج در فایل | | `--progress` | true | نمایش نوار پیشرفت | | `--verify` | - | مسیر باینری slipstream-client | +| `--json` | false | خروجی به فرمت JSON | ## حالت‌های اسکن diff --git a/README.md b/README.md index 0e85191..d2525d3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ go build -o dnscan . | `--output` | stdout | Save results to file | | `--progress` | true | Show progress bar | | `--verify` | - | Path to slipstream-client binary | +| `--json` | false | Output results as JSON | ## Scan Modes From 7c7ccd56abc5e2c46c20b4ec2022016126e541cb Mon Sep 17 00:00:00 2001 From: Night Owl Nerd <256460992+nightowlnerd@users.noreply.github.com> Date: Sun, 1 Feb 2026 10:10:29 +0100 Subject: [PATCH 3/4] fix(cli): polish progress output and json null handling --- main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 6f7424f..eb96684 100644 --- a/main.go +++ b/main.go @@ -221,6 +221,8 @@ func main() { } fmt.Fprintf(os.Stderr, "IPs to scan: %d\n", totalIPs) fmt.Fprintf(os.Stderr, "---\n") + } else { + fmt.Fprintf(os.Stderr, "Scanning %d IPs...\n", totalIPs) } // Setup context with signal handling @@ -298,7 +300,6 @@ resultLoop: if *progress { scanned, found, _, elapsed := prog.Stats() fmt.Fprintf(os.Stderr, "\r \r") - fmt.Fprintf(os.Stderr, "---\n") fmt.Fprintf(os.Stderr, "Completed: %d IPs in %v\n", scanned, elapsed.Round(time.Millisecond)) fmt.Fprintf(os.Stderr, "Found: %d DNS candidates\n", found) if suspiciousCount > 0 { @@ -420,6 +421,7 @@ resultLoop: // Write results if *jsonOutput { output := JSONOutput{ + Servers: []JSONServer{}, Scan: JSONScan{ TotalScanned: scanned, Found: int64(len(workingDNS)), From 66fee057aa800615bbebab9ecbc52d500c1021b4 Mon Sep 17 00:00:00 2001 From: Night Owl Nerd <256460992+nightowlnerd@users.noreply.github.com> Date: Sun, 1 Feb 2026 17:24:19 +0100 Subject: [PATCH 4/4] test: add json output tests --- e2e_test.go | 31 +++++++++++++++++++++++++++++++ main_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/e2e_test.go b/e2e_test.go index 2e716ca..04d531e 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -252,3 +252,34 @@ func TestE2EBurstQPS(t *testing.T) { t.Errorf("P50 should be positive, got %v", result.P50()) } } + +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) + + server := JSONServer{ + IP: result.IP, + QPS: result.QPS(), + SuccessRate: result.SuccessRate(), + LatencyP50: result.P50().Milliseconds(), + } + + if server.IP != mock.ip { + t.Errorf("IP mismatch: got %s, expected %s", server.IP, mock.ip) + } + if server.QPS <= 0 { + t.Error("QPS should be positive") + } + if server.SuccessRate < 70 { + t.Errorf("SuccessRate too low: %.1f", server.SuccessRate) + } + // LatencyP50 can be 0ms for localhost mock (sub-millisecond response) + if server.LatencyP50 < 0 { + t.Error("LatencyP50 should not be negative") + } +} diff --git a/main_test.go b/main_test.go index 2657886..8f78153 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "os" "os/exec" "path/filepath" @@ -166,3 +167,51 @@ func TestReadIPsFromFile(t *testing.T) { t.Errorf("unexpected IPs: %v", ips) } } + +func TestJSONOutputFlag(t *testing.T) { + inputFile, _ := os.CreateTemp("", "input-*.txt") + inputFile.WriteString("8.8.8.8\n") + inputFile.Close() + defer os.Remove(inputFile.Name()) + + cmd := exec.Command(binaryPath, + "--file", inputFile.Name(), + "--timeout", "100ms", + "--json", + ) + out, _ := cmd.Output() + + var result JSONOutput + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, out) + } + + if result.Scan.TotalScanned != 1 { + t.Errorf("Expected total_scanned=1, got %d", result.Scan.TotalScanned) + } +} + +func TestJSONOutputStructure(t *testing.T) { + inputFile, _ := os.CreateTemp("", "input-*.txt") + inputFile.WriteString("8.8.8.8\n") + inputFile.Close() + defer os.Remove(inputFile.Name()) + + cmd := exec.Command(binaryPath, + "--file", inputFile.Name(), + "--timeout", "100ms", + "--progress=false", + "--json", + ) + out, _ := cmd.CombinedOutput() + + if !strings.Contains(string(out), `"servers"`) { + t.Error("JSON output missing 'servers' field") + } + if !strings.Contains(string(out), `"scan"`) { + t.Error("JSON output missing 'scan' field") + } + if !strings.Contains(string(out), `"total_scanned"`) { + t.Error("JSON output missing 'total_scanned' field") + } +}