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
1 change: 1 addition & 0 deletions README.fa.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ go build -o dnscan .
| `--output` | stdout | ذخیره نتایج در فایل |
| `--progress` | true | نمایش نوار پیشرفت |
| `--verify` | - | مسیر باینری slipstream-client |
| `--json` | false | خروجی به فرمت JSON |

## حالت‌های اسکن

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
115 changes: 92 additions & 23 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"os"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -196,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
Expand Down Expand Up @@ -273,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 {
Expand Down Expand Up @@ -389,35 +415,78 @@ 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{
Servers: []JSONServer{},
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 = "<domain>"
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 = "<domain>"
}
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")
}
}
49 changes: 49 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -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")
}
}