From 9a83a720f6a0524add8ebeff34abd895cc098f8f Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Thu, 19 Mar 2026 01:07:29 +0330 Subject: [PATCH 1/8] Add neighbor discovery (--discover) and throughput test (--throughput) Neighbor discovery: when a resolver passes all scan steps, its /24 subnet is automatically expanded and queued for scanning. Runs in rounds (default max 3) until no new subnets are found. This exploits the observation that working resolvers tend to cluster in the same /24 block. Throughput test: goes beyond the e2e handshake by performing a full HTTP GET request through the SOCKS5 tunnel proxy. Verifies that real payload (not just a 4-byte handshake reply) flows bidirectionally through the DNS tunnel. Reports throughput_bytes and throughput_ms metrics. Also: - Add MergeChainReports helper to DRY up batch merge logic - Refactor scan.go batch/non-batch paths to share a single report variable - Add throughput/dnstt step to chain command - Update README (EN + FA) with new features, flags, and examples - Remove stale --test-url, --proxy-auth, and curl references from README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 84 +++++++++++++--- cmd/chain.go | 16 ++- cmd/scan.go | 126 +++++++++++++++++------- internal/scanner/chain.go | 18 ++++ internal/scanner/discover.go | 51 ++++++++++ internal/scanner/e2e.go | 183 +++++++++++++++++++++++++++++++++++ 6 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 internal/scanner/discover.go diff --git a/README.md b/README.md index 8ba4ab9..04fb8aa 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Supports both **UDP** and **DoH (DNS-over-HTTPS)** resolvers with end-to-end tun | Feature | Description | |---------|-------------| | 🔄 **UDP + DoH Scanning** | Test both plain DNS (port 53) and DNS-over-HTTPS (port 443) | -| 🔗 **Full Scan Pipeline** | Ping → Resolve → NXDOMAIN → EDNS → Tunnel → E2E in one command | +| 🔗 **Full Scan Pipeline** | Ping → Resolve → NXDOMAIN → EDNS → Tunnel → E2E → Throughput in one command | | 🛡️ **Hijack Detection** | Detect DNS resolvers that inject fake answers (NXDOMAIN check) | | 📏 **EDNS Payload Testing** | Find resolvers that support large DNS payloads (faster tunnels) | | 🚇 **E2E Tunnel Verification** | Actually launches DNSTT/Slipstream clients to verify real connectivity | @@ -24,6 +24,8 @@ Supports both **UDP** and **DoH (DNS-over-HTTPS)** resolvers with end-to-end tun | ⚡ **High Concurrency** | 50 parallel workers by default — scans thousands of resolvers in minutes | | 📋 **JSON Pipeline** | Output from one scan feeds into the next for multi-stage filtering | | 🌐 **CIDR Input** | Accept IP ranges like `185.51.200.0/24` — auto-expanded to individual hosts | +| 🔎 **Neighbor Discovery** | When a resolver passes, auto-scan its /24 subnet to find more working resolvers | +| 📦 **Throughput Test** | Verify real payload transfer (HTTP GET) through the tunnel, not just handshake | | 🖥️ **Interactive TUI** | Full terminal UI with guided setup — no flags to remember | | 🔌 **Fully Offline** | Zero-config: auto-loads bundled resolvers, no `-i` or `-o` needed | @@ -85,8 +87,6 @@ chmod +x findns-linux-amd64 - **Go 1.24+** for building from source - **dnstt-client** — only for e2e tunnel tests (`--pubkey`). Install: `go install www.bamsoftware.com/git/dnstt.git/dnstt-client@latest` - **slipstream-client** — only for e2e Slipstream tests (`--cert`) -- **curl** — for e2e connectivity verification - > **Finding binaries:** findns automatically searches for `dnstt-client` and `slipstream-client` in three places: 1) `PATH` 2) current directory 3) next to the findns executable. The simplest approach: place the binary next to findns. > > Without `--pubkey`, the scanner still finds resolvers compatible with DNS tunneling — it tests ping, resolve, NXDOMAIN, EDNS, and tunnel delegation without needing dnstt-client. @@ -141,7 +141,6 @@ Use `.\findns.exe` instead of `findns` in all commands: ### Prerequisites -- **curl** — included by default in Windows 10/11 - **dnstt-client.exe** — place next to `findns.exe` or in a folder in your `PATH` (only for e2e DNSTT tests) - **slipstream-client.exe** — same as above (only for e2e Slipstream tests) @@ -221,6 +220,15 @@ findns scan --cidr 5.52.0.0/16 --cidr 185.51.200.0/24 --domain t.example.com # 📏 Custom EDNS buffer size (lower if you hit fragmentation) findns scan --domain t.example.com --edns --edns-size 900 + +# 🔎 Discover neighbor resolvers automatically +findns scan --domain t.example.com --pubkey --discover + +# 📦 Include throughput test (verify real data transfer) +findns scan --domain t.example.com --pubkey --throughput + +# 🔎📦 Both: discover + throughput +findns scan --domain t.example.com --pubkey --discover --throughput ``` ### 3️⃣ Check Results @@ -268,7 +276,7 @@ findns scan --domain t.example.com > `-i` and `-o` are optional. Without `-i`, bundled Iranian resolvers are used. Without `-o`, results save to `results.json`. -**UDP mode pipeline:** `ping → nxdomain → resolve/tunnel → e2e` (add `--edns` for EDNS payload check) +**UDP mode pipeline:** `ping → nxdomain → resolve/tunnel → e2e` (add `--edns` for EDNS, `--throughput` for payload test, `--discover` for neighbor scanning) **DoH mode pipeline:** `doh/resolve/tunnel → doh/e2e` > When `--domain` is set, the basic `resolve` step (A record for google.com) is skipped — tunnel domains have no A record, so findns goes straight to `resolve/tunnel`. @@ -278,16 +286,47 @@ findns scan --domain t.example.com | `--domain` | Tunnel domain (enables tunnel/e2e steps) | — | | `--pubkey` | DNSTT server public key (enables e2e test) | — | | `--cert` | Slipstream cert path (enables Slipstream e2e) | — | -| `--test-url` | URL to fetch through tunnel for e2e test | `http://httpbin.org/ip` | -| `--proxy-auth` | SOCKS proxy auth as `user:pass` (for e2e tests) | — | | `--doh` | Scan DoH resolvers instead of UDP | `false` | | `--edns` | Include EDNS payload size check | `false` | | `--edns-size` | EDNS0 UDP payload size in bytes (larger = better throughput) | `1232` | +| `--throughput` | Include payload transfer test after e2e (requires `--pubkey`) | `false` | +| `--discover` | Auto-discover neighbor /24 subnets when resolvers pass all steps | `false` | +| `--discover-rounds` | Max neighbor discovery rounds | `3` | | `--cidr` | Scan a CIDR range directly (e.g. `--cidr 5.52.0.0/16`) | — | | `--skip-ping` | Skip ICMP ping step | `false` | | `--skip-nxdomain` | Skip NXDOMAIN hijack check | `false` | | `--top` | Number of top results to display | `10` | | `--output-ips` | Write plain IP list alongside JSON | auto | +| `--batch` | Scan N resolvers at a time, saving after each batch | `0` (all) | +| `--resume` | Skip IPs already in the output file | `false` | + +--- + +### 🔎 Neighbor Discovery (`--discover`) + +When a resolver passes all scan steps, its **/24 subnet** (e.g. `1.2.3.0/24`) is automatically expanded and queued for scanning. In practice, if `1.2.3.4` is a working resolver, its neighbors like `1.2.3.7`, `1.2.3.99` are often working too. + +```bash +# Scan with neighbor discovery (up to 3 rounds by default) +findns scan --domain t.example.com --pubkey --discover + +# Limit to 5 discovery rounds +findns scan --domain t.example.com --pubkey --discover --discover-rounds 5 +``` + +Discovery runs in rounds: each round collects new /24s from passed IPs, expands them, filters out already-scanned IPs, and runs the full pipeline. Stops when no new subnets are found or the round limit is reached. + +--- + +### 📦 Throughput Test (`--throughput`) + +Goes beyond the e2e handshake — actually transfers data through the tunnel by performing an HTTP GET request through the SOCKS5 proxy. This catches resolvers that pass the handshake but can't carry real payload (rate limiting, packet filtering, etc.). + +```bash +findns scan --domain t.example.com --pubkey --throughput +``` + +**Metrics:** `throughput_bytes` (total bytes received), `throughput_ms` (total transfer time) --- @@ -411,7 +450,7 @@ findns edns -i resolvers.txt -o result.json --domain t.example.com --edns-size 4 ### 🚇 `e2e dnstt` — End-to-End DNSTT Test (UDP) -Actually launches `dnstt-client`, creates a SOCKS tunnel, and verifies connectivity with `curl`. +Actually launches `dnstt-client`, creates a SOCKS tunnel, and performs a SOCKS5 handshake to verify bidirectional data flow through the DNS tunnel. ```bash findns e2e dnstt -i resolvers.txt -o result.json \ @@ -496,6 +535,7 @@ findns chain -i doh-resolvers.txt -o result.json \ | `edns` | `domain` | `edns_max` | EDNS payload size support | | `e2e/dnstt` | `domain`, `pubkey` | `e2e_ms` | Real DNSTT tunnel test | | `e2e/slipstream` | `domain`, `cert` | `e2e_ms` | Real Slipstream tunnel test | +| `throughput/dnstt` | `domain`, `pubkey` | `throughput_bytes`, `throughput_ms` | Payload transfer through DNSTT tunnel | | `doh/resolve` | `domain` | `resolve_ms` | DoH DNS resolution | | `doh/resolve/tunnel` | `domain` | `resolve_ms` | DoH NS delegation | | `doh/e2e` | `domain`, `pubkey` | `e2e_ms` | Real DNSTT tunnel via DoH | @@ -637,7 +677,7 @@ MIT | امکان | توضیح | |-------|-------| | 🔄 **اسکن UDP + DoH** | تست هم DNS ساده (پورت 53) و هم DNS-over-HTTPS (پورت 443) | -| 🔗 **پایپلاین کامل** | Ping → Resolve → NXDOMAIN → EDNS → Tunnel → E2E با یک دستور | +| 🔗 **پایپلاین کامل** | Ping → Resolve → NXDOMAIN → EDNS → Tunnel → E2E → Throughput با یک دستور | | 🛡️ **تشخیص هایجک** | شناسایی resolverهایی که جواب جعلی برمی‌گردانند | | 📏 **تست EDNS** | پیدا کردن resolverهایی که payload بزرگ پشتیبانی می‌کنند (تانل سریع‌تر) | | 🚇 **تست واقعی تانل** | واقعاً کلاینت DNSTT/Slipstream را اجرا می‌کند و اتصال را تأیید می‌کند | @@ -646,6 +686,8 @@ MIT | ⚡ **همزمانی بالا** | 50 worker موازی — هزاران resolver در چند دقیقه اسکن می‌شود | | 📋 **خروجی JSON** | خروجی هر اسکن ورودی اسکن بعدی می‌شود | | 🌐 **ورودی CIDR** | رنج آی‌پی مثل `185.51.200.0/24` را می‌خواند و به صورت خودکار باز می‌کند | +| 🔎 **کشف همسایه** | وقتی resolveری پاس شد، خودکار /24 همسایه‌اش اسکن می‌شود | +| 📦 **تست Throughput** | تست ارسال و دریافت واقعی دیتا از تانل (HTTP GET)، نه فقط هندشیک | | 🖥️ **رابط کاربری ترمینال (TUI)** | رابط تعاملی کامل — بدون نیاز به حفظ فلگ‌ها | | 🔌 **کاملاً آفلاین** | بدون تنظیم: resolverهای داخلی خودکار بارگذاری می‌شوند، نیازی به `-i` یا `-o` نیست | @@ -723,8 +765,6 @@ chmod +x findns-linux-amd64 - **Go 1.24+** برای بیلد از سورس - **dnstt-client** — فقط برای تست e2e تانل (`--pubkey`). نصب: `go install www.bamsoftware.com/git/dnstt.git/dnstt-client@latest` - **slipstream-client** — فقط برای تست e2e Slipstream (`--cert`) -- **curl** — برای تأیید اتصال e2e - > **پیدا کردن باینری:** findns به صورت خودکار `dnstt-client` و `slipstream-client` را در سه مسیر جستجو می‌کند: ۱) `PATH` سیستم ۲) پوشه فعلی ۳) کنار فایل findns. ساده‌ترین روش: فایل را کنار findns بگذارید. > > بدون `--pubkey` هم اسکنر resolverهای سازگار با تانل DNS را پیدا می‌کند (ping, resolve, nxdomain, edns, tunnel delegation بدون نیاز به dnstt-client). @@ -791,7 +831,6 @@ go build -o findns.exe ./cmd ### پیش‌نیازها -- **curl** — در ویندوز 10/11 به صورت پیش‌فرض نصب است - **dnstt-client.exe** — کنار `findns.exe` قرار دهید یا در PATH اضافه کنید (فقط برای تست e2e DNSTT) - **slipstream-client.exe** — مثل بالا (فقط برای تست e2e Slipstream) @@ -885,6 +924,15 @@ findns scan --cidr 5.52.0.0/16 --cidr 185.51.200.0/24 --domain t.example.com # 📏 تنظیم سایز بافر EDNS (کمتر کنید اگر فرگمنتیشن دارید) findns scan --domain t.example.com --edns --edns-size 900 + +# 🔎 کشف خودکار همسایه‌ها +findns scan --domain t.example.com --pubkey --discover + +# 📦 تست انتقال واقعی دیتا از تانل +findns scan --domain t.example.com --pubkey --throughput + +# 🔎📦 هر دو: کشف همسایه + تست throughput +findns scan --domain t.example.com --pubkey --discover --throughput ```
@@ -935,7 +983,7 @@ findns tui به صورت خودکار مراحل مناسب را بر اساس فلگ‌ها ترتیب می‌دهد. -**حالت UDP:** `ping → nxdomain → resolve/tunnel → e2e` (با `--edns` مرحله EDNS اضافه می‌شود) +**حالت UDP:** `ping → nxdomain → resolve/tunnel → e2e` (با `--edns` مرحله EDNS، با `--throughput` تست انتقال دیتا، با `--discover` کشف همسایه اضافه می‌شود) **حالت DoH:** `doh/resolve/tunnel → doh/e2e` > وقتی `--domain` تنظیم شود، مرحله `resolve` ساده (رکورد A برای google.com) رد می‌شود — دامنه‌های تانل رکورد A ندارند، بنابراین findns مستقیم به `resolve/tunnel` می‌رود. @@ -945,16 +993,19 @@ findns tui | `--domain` | دامنه تانل (فعال‌سازی تست تانل/e2e) | — | | `--pubkey` | کلید عمومی سرور DNSTT (فعال‌سازی تست e2e) | — | | `--cert` | مسیر گواهی Slipstream (فعال‌سازی تست Slipstream) | — | -| `--test-url` | آدرس برای تست اتصال e2e | `http://httpbin.org/ip` | -| `--proxy-auth` | احراز هویت پروکسی SOCKS به صورت `user:pass` (برای تست e2e) | — | | `--doh` | اسکن DoH به جای UDP | `false` | | `--edns` | فعال‌سازی تست سایز EDNS payload | `false` | | `--edns-size` | سایز بافر EDNS0 به بایت (بزرگتر = سرعت بیشتر) | `1232` | +| `--throughput` | تست انتقال واقعی دیتا بعد از e2e (نیاز به `--pubkey`) | `false` | +| `--discover` | کشف خودکار /24 همسایه وقتی resolver پاس شد | `false` | +| `--discover-rounds` | حداکثر تعداد دورهای کشف همسایه | `3` | | `--cidr` | اسکن مستقیم رنج CIDR (مثلاً `--cidr 5.52.0.0/16`) | — | | `--skip-ping` | رد کردن مرحله ping | `false` | | `--skip-nxdomain` | رد کردن بررسی هایجک | `false` | | `--top` | تعداد نتایج برتر برای نمایش | `10` | | `--output-ips` | خروجی لیست آی‌پی ساده کنار JSON | خودکار | +| `--batch` | اسکن N ریزالور در هر دسته (ذخیره بعد هر دسته) | `0` (همه) | +| `--resume` | رد کردن آی‌پی‌هایی که قبلاً اسکن شده‌اند | `false` | --- @@ -1105,7 +1156,7 @@ findns edns -i resolvers.txt -o result.json --domain t.example.com --edns-size 4 ### 🚇 `e2e dnstt` — تست واقعی تانل DNSTT (UDP) -واقعاً `dnstt-client` را اجرا می‌کند، تانل SOCKS ایجاد می‌کند و اتصال را با `curl` تأیید می‌کند. +واقعاً `dnstt-client` را اجرا می‌کند، تانل SOCKS ایجاد می‌کند و با هندشیک SOCKS5 جریان دوطرفه دیتا از تانل DNS را تأیید می‌کند.
@@ -1218,6 +1269,7 @@ findns chain -i doh-resolvers.txt -o result.json \ | `edns` | `domain` | `edns_max` | تست سایز payload EDNS | | `e2e/dnstt` | `domain`, `pubkey` | `e2e_ms` | تست واقعی تانل DNSTT | | `e2e/slipstream` | `domain`, `cert` | `e2e_ms` | تست واقعی تانل Slipstream | +| `throughput/dnstt` | `domain`, `pubkey` | `throughput_bytes`, `throughput_ms` | تست انتقال واقعی دیتا از تانل DNSTT | | `doh/resolve` | `domain` | `resolve_ms` | resolve از طریق DoH | | `doh/resolve/tunnel` | `domain` | `resolve_ms` | NS delegation از طریق DoH | | `doh/e2e` | `domain`, `pubkey` | `e2e_ms` | تست واقعی تانل از طریق DoH | diff --git a/cmd/chain.go b/cmd/chain.go index 3505836..bc683ef 100644 --- a/cmd/chain.go +++ b/cmd/chain.go @@ -110,6 +110,17 @@ func buildStep(cfg stepConfig, defaultTimeout, defaultCount int, ports chan int, cert := cfg.params["cert"] return scanner.Step{Name: "e2e/slipstream", Timeout: dur, Check: scanner.SlipstreamCheckBin(binPaths["slipstream-client"], domain, cert, ports), SortBy: "e2e_ms"}, nil + case "throughput/dnstt": + domain, ok := cfg.params["domain"] + if !ok || domain == "" { + return scanner.Step{}, fmt.Errorf("step %q: missing required param 'domain'", cfg.name) + } + pubkey, ok := cfg.params["pubkey"] + if !ok || pubkey == "" { + return scanner.Step{}, fmt.Errorf("step %q: missing required param 'pubkey'", cfg.name) + } + return scanner.Step{Name: "throughput/dnstt", Timeout: dur, Check: scanner.ThroughputCheckBin(binPaths["dnstt-client"], domain, pubkey, ports), SortBy: "throughput_ms"}, nil + case "nxdomain": return scanner.Step{Name: "nxdomain", Timeout: dur, Check: scanner.NXDomainCheck(stepCount), SortBy: "hijack"}, nil @@ -168,7 +179,7 @@ func runChain(cmd *cobra.Command, args []string) error { binPaths := make(map[string]string) // "dnstt-client" -> resolved path for _, cfg := range configs { switch cfg.name { - case "e2e/dnstt", "doh/e2e": + case "e2e/dnstt", "doh/e2e", "throughput/dnstt": if _, ok := binPaths["dnstt-client"]; !ok { bin, err := findBinary("dnstt-client") if err != nil { @@ -176,9 +187,6 @@ func runChain(cmd *cobra.Command, args []string) error { } binPaths["dnstt-client"] = bin } - if _, err := findBinary("curl"); err != nil { - return fmt.Errorf("step %q requires curl in PATH (not found)", cfg.name) - } case "e2e/slipstream": if _, ok := binPaths["slipstream-client"]; !ok { bin, err := findBinary("slipstream-client") diff --git a/cmd/scan.go b/cmd/scan.go index 11a127f..2bbd3b2 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -33,6 +33,7 @@ var stepDescriptions = map[string]string{ "resolve/tunnel": "Verifying resolvers forward queries to your tunnel domain", "e2e/dnstt": "End-to-end DNSTT tunnel test (Noise handshake through resolver)", "e2e/slipstream": "Full tunnel connectivity test via Slipstream", + "throughput/dnstt": "Testing real payload transfer through DNSTT tunnel", "doh/resolve": "Checking DoH resolver connectivity", "doh/resolve/tunnel": "Verifying DoH resolvers forward to your tunnel domain", "doh/e2e": "Full DoH tunnel connectivity test via DNSTT", @@ -71,6 +72,9 @@ func init() { scanCmd.Flags().Int("top", 10, "number of top results to display") scanCmd.Flags().Int("batch", 0, "scan N resolvers at a time, saving after each batch (0 = all at once)") scanCmd.Flags().Bool("resume", false, "skip IPs already in the output file (resume a previous scan)") + scanCmd.Flags().Bool("discover", false, "auto-discover neighbor /24 subnets when IPs pass all steps") + scanCmd.Flags().Int("discover-rounds", 3, "max neighbor discovery rounds (default 3)") + scanCmd.Flags().Bool("throughput", false, "include payload transfer test after e2e (requires --pubkey)") rootCmd.AddCommand(scanCmd) } @@ -91,6 +95,9 @@ func runScan(cmd *cobra.Command, args []string) error { querySize, _ := cmd.Flags().GetInt("query-size") cidrRanges, _ := cmd.Flags().GetStringSlice("cidr") cidrFile, _ := cmd.Flags().GetString("cidr-file") + discover, _ := cmd.Flags().GetBool("discover") + discoverRounds, _ := cmd.Flags().GetInt("discover-rounds") + throughput, _ := cmd.Flags().GetBool("throughput") // Load additional CIDRs from file if provided if cidrFile != "" { @@ -249,6 +256,12 @@ func runScan(cmd *cobra.Command, args []string) error { Name: "e2e/dnstt", Timeout: time.Duration(e2eTimeout) * time.Second, Check: scanner.DnsttCheckBin(dnsttBin, domain, pubkey, ports), SortBy: "socks_ms", }) + if throughput { + steps = append(steps, scanner.Step{ + Name: "throughput/dnstt", Timeout: time.Duration(e2eTimeout) * time.Second, + Check: scanner.ThroughputCheckBin(dnsttBin, domain, pubkey, ports), SortBy: "throughput_ms", + }) + } } if domain != "" && certPath != "" { steps = append(steps, scanner.Step{ @@ -264,19 +277,19 @@ func runScan(cmd *cobra.Command, args []string) error { // --resume: load existing results and skip already-scanned IPs var allPassed []scanner.IPRecord + scanned := make(map[string]struct{}) // tracks all IPs seen (for --discover dedup) if resume { if existing, err := scanner.LoadChainReport(outputFile); err == nil { - seen := make(map[string]bool, len(existing.Passed)+len(existing.Failed)) for _, r := range existing.Passed { - seen[r.IP] = true + scanned[r.IP] = struct{}{} allPassed = append(allPassed, r) } for _, r := range existing.Failed { - seen[r.IP] = true + scanned[r.IP] = struct{}{} } filtered := ips[:0] for _, ip := range ips { - if !seen[ip] { + if _, ok := scanned[ip]; !ok { filtered = append(filtered, ip) } } @@ -290,6 +303,10 @@ func runScan(cmd *cobra.Command, args []string) error { } } } + // Add current IPs to scanned set + for _, ip := range ips { + scanned[ip] = struct{}{} + } if outputIPs == "" { outputIPs = strings.TrimSuffix(outputFile, ".json") + "_ips.txt" @@ -317,9 +334,10 @@ func runScan(cmd *cobra.Command, args []string) error { scanner.ResetE2EDiagnostic() scanStart := time.Now() + var report scanner.ChainReport + // --batch: split IPs into chunks, save after each batch if batchSize > 0 && len(ips) > batchSize { - var combinedReport scanner.ChainReport totalBatches := (len(ips) + batchSize - 1) / batchSize for i := 0; i < len(ips); i += batchSize { if ctx.Err() != nil { @@ -334,48 +352,90 @@ func runScan(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n %s━━━ Batch %d/%d (%d IPs) ━━━%s\n\n", colorCyan, batchNum, totalBatches, len(chunk), colorReset) - report := scanner.RunChainQuietCtx(ctx, chunk, workers, steps, + batchReport := scanner.RunChainQuietCtx(ctx, chunk, workers, steps, newScanProgressFactory(len(steps), stepDescriptions)) - // Merge into combined report - combinedReport.Passed = append(combinedReport.Passed, report.Passed...) - combinedReport.Failed = append(combinedReport.Failed, report.Failed...) - if len(combinedReport.Steps) == 0 { - combinedReport.Steps = report.Steps - } else { - for j := range combinedReport.Steps { - if j < len(report.Steps) { - combinedReport.Steps[j].Tested += report.Steps[j].Tested - combinedReport.Steps[j].Passed += report.Steps[j].Passed - combinedReport.Steps[j].Failed += report.Steps[j].Failed - combinedReport.Steps[j].Seconds += report.Steps[j].Seconds - } - } - } + scanner.MergeChainReports(&report, batchReport) // Save after each batch - saveResults(combinedReport) - totalPassed := len(allPassed) + len(combinedReport.Passed) + saveResults(report) + totalPassed := len(allPassed) + len(report.Passed) fmt.Fprintf(os.Stderr, " %s✔ Batch %d done — %d passed so far — saved to %s%s\n", colorGreen, batchNum, totalPassed, outputFile, colorReset) } + } else { + // No batching — scan all at once + report = scanner.RunChainQuietCtx(ctx, ips, workers, steps, + newScanProgressFactory(len(steps), stepDescriptions)) + } + + // --discover: find neighbor /24 subnets from passed IPs and scan them + if discover && !dohMode && ctx.Err() == nil && len(report.Passed) > 0 { + knownSubnets := make(map[string]struct{}) + + for round := 1; round <= discoverRounds; round++ { + if ctx.Err() != nil { + break + } + + // Collect /24s from ALL passed IPs (including previous rounds) + passedIPs := make([]string, len(report.Passed)) + for i, r := range report.Passed { + passedIPs[i] = r.IP + } + newSubnets := scanner.SubnetsFromIPs(passedIPs) + + // Filter out already-known subnets + var toExpand []string + for _, cidr := range newSubnets { + if _, ok := knownSubnets[cidr]; !ok { + knownSubnets[cidr] = struct{}{} + toExpand = append(toExpand, cidr) + } + } + if len(toExpand) == 0 { + fmt.Fprintf(os.Stderr, "\n %s✔ Discovery: no new /24 subnets found%s\n", colorGreen, colorReset) + break + } + + // Expand to individual IPs, excluding already scanned + neighborIPs := scanner.ExpandSubnets(toExpand, scanned) + if len(neighborIPs) == 0 { + fmt.Fprintf(os.Stderr, "\n %s✔ Discovery round %d: %d subnets but all IPs already scanned%s\n", + colorGreen, round, len(toExpand), colorReset) + break + } + + // Mark as scanned + for _, ip := range neighborIPs { + scanned[ip] = struct{}{} + } - totalTime := time.Since(scanStart) - if ctx.Err() != nil { - fmt.Fprintf(os.Stderr, "\n %s⚠ Interrupted — partial results saved to %s%s\n", colorYellow, outputFile, colorReset) + fmt.Fprintf(os.Stderr, "\n %s━━━ Discovery round %d: %d new /24 subnets → %d IPs ━━━%s\n\n", + colorCyan, round, len(toExpand), len(neighborIPs), colorReset) + + roundReport := scanner.RunChainQuietCtx(ctx, neighborIPs, workers, steps, + newScanProgressFactory(len(steps), stepDescriptions)) + + scanner.MergeChainReports(&report, roundReport) + + // Save after each discovery round + saveResults(report) + totalPassed := len(allPassed) + len(report.Passed) + fmt.Fprintf(os.Stderr, " %s✔ Round %d: %d new resolvers — %d total passed — saved to %s%s\n", + colorGreen, round, len(roundReport.Passed), totalPassed, outputFile, colorReset) + + if len(roundReport.Passed) == 0 { + fmt.Fprintf(os.Stderr, " %s✔ Discovery: no new resolvers found, stopping%s\n", colorGreen, colorReset) + break + } } - printSummary(combinedReport, topN, totalTime, domain) - totalPassed := len(allPassed) + len(combinedReport.Passed) - fmt.Fprintf(os.Stderr, " %s✔ IP list written to %s (%d IPs)%s\n", colorGreen, outputIPs, totalPassed, colorReset) - return nil } - // No batching — scan all at once - report := scanner.RunChainQuietCtx(ctx, ips, workers, steps, newScanProgressFactory(len(steps), stepDescriptions)) totalTime := time.Since(scanStart) if ctx.Err() != nil { - fmt.Fprintf(os.Stderr, "\n\n %s⚠ Interrupted — saving partial results to %s%s\n", colorYellow, outputFile, colorReset) + fmt.Fprintf(os.Stderr, "\n %s⚠ Interrupted — partial results saved to %s%s\n", colorYellow, outputFile, colorReset) } printSummary(report, topN, totalTime, domain) diff --git a/internal/scanner/chain.go b/internal/scanner/chain.go index 89e88e0..1b00ff4 100644 --- a/internal/scanner/chain.go +++ b/internal/scanner/chain.go @@ -166,6 +166,24 @@ func WriteChainReport(report ChainReport, path string) error { return os.WriteFile(path, data, 0644) } +// MergeChainReports merges src into dst, accumulating step counts and results. +func MergeChainReports(dst *ChainReport, src ChainReport) { + dst.Passed = append(dst.Passed, src.Passed...) + dst.Failed = append(dst.Failed, src.Failed...) + if len(dst.Steps) == 0 { + dst.Steps = src.Steps + } else { + for i := range dst.Steps { + if i < len(src.Steps) { + dst.Steps[i].Tested += src.Steps[i].Tested + dst.Steps[i].Passed += src.Steps[i].Passed + dst.Steps[i].Failed += src.Steps[i].Failed + dst.Steps[i].Seconds += src.Steps[i].Seconds + } + } + } +} + // LoadChainReport reads a previously saved ChainReport from disk. func LoadChainReport(path string) (ChainReport, error) { raw, err := os.ReadFile(path) diff --git a/internal/scanner/discover.go b/internal/scanner/discover.go new file mode 100644 index 0000000..402b645 --- /dev/null +++ b/internal/scanner/discover.go @@ -0,0 +1,51 @@ +package scanner + +import ( + "fmt" + "net" +) + +// SubnetsFromIPs returns unique /24 CIDRs derived from the given IP list. +func SubnetsFromIPs(ips []string) []string { + seen := make(map[string]struct{}) + var cidrs []string + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + ip = ip.To4() + if ip == nil { + continue + } + cidr := fmt.Sprintf("%d.%d.%d.0/24", ip[0], ip[1], ip[2]) + if _, ok := seen[cidr]; !ok { + seen[cidr] = struct{}{} + cidrs = append(cidrs, cidr) + } + } + return cidrs +} + +// ExpandSubnets expands /24 CIDRs to individual host IPs (.1 through .254), +// excluding any IPs present in the exclude set. +func ExpandSubnets(cidrs []string, exclude map[string]struct{}) []string { + var ips []string + for _, cidr := range cidrs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + ip := ipNet.IP.To4() + if ip == nil { + continue + } + for i := 1; i <= 254; i++ { + candidate := fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], i) + if _, ok := exclude[candidate]; !ok { + ips = append(ips, candidate) + } + } + } + return ips +} diff --git a/internal/scanner/e2e.go b/internal/scanner/e2e.go index c80f97a..59801a3 100644 --- a/internal/scanner/e2e.go +++ b/internal/scanner/e2e.go @@ -330,6 +330,189 @@ func slipstreamCheck(bin, domain, certPath string, ports chan int) CheckFunc { } +// ThroughputCheckBin tests actual data transfer through the DNS tunnel by +// performing an HTTP GET request via the SOCKS5 proxy. This goes beyond the +// e2e handshake test — it verifies that meaningful payload (1-2KB+) flows +// bidirectionally through the tunnel. +func ThroughputCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc { + var diagOnce atomic.Bool + + return func(ip string, timeout time.Duration) (bool, Metrics) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var port int + select { + case port = <-ports: + case <-ctx.Done(): + return false, nil + } + + start := time.Now() + + var stderrBuf bytes.Buffer + args := []string{ + "-udp", net.JoinHostPort(ip, "53"), + "-pubkey", pubkey, + } + if DnsttMTU > 0 { + args = append(args, "-mtu", strconv.Itoa(DnsttMTU)) + } + args = append(args, domain, fmt.Sprintf("127.0.0.1:%d", port)) + cmd := execCommandContext(ctx, bin, args...) + cmd.Stdout = io.Discard + cmd.Stderr = &stderrBuf + if err := cmd.Start(); err != nil { + ports <- port + return false, nil + } + + exited := make(chan struct{}) + go func() { + cmd.Wait() + close(exited) + }() + + defer func() { + cmd.Process.Kill() + select { + case <-exited: + case <-time.After(2 * time.Second): + } + time.Sleep(300 * time.Millisecond) + ports <- port + }() + + transferred, ok := waitAndTestThroughput(ctx, port, exited) + if !ok { + if diagOnce.CompareAndSwap(false, true) { + cmd.Process.Kill() + select { + case <-exited: + case <-time.After(2 * time.Second): + } + stderr := strings.TrimSpace(stderrBuf.String()) + if stderr != "" { + setDiag("throughput first failure (ip=%s): %s", ip, truncate(stderr, 300)) + } else { + setDiag("throughput first failure (ip=%s): could not transfer data within %v", ip, timeout) + } + } + return false, nil + } + ms := roundMs(float64(time.Since(start).Microseconds()) / 1000.0) + return true, Metrics{ + "throughput_bytes": float64(transferred), + "throughput_ms": ms, + } + } +} + +// waitAndTestThroughput waits for the SOCKS port to open, performs a full +// SOCKS5 CONNECT to example.com:80, sends an HTTP GET request, and reads +// the response. This proves that real data (not just a handshake) can flow +// through the DNS tunnel. +func waitAndTestThroughput(ctx context.Context, port int, exited <-chan struct{}) (int, bool) { + addr := fmt.Sprintf("127.0.0.1:%d", port) + + for { + select { + case <-ctx.Done(): + return 0, false + case <-exited: + return 0, false + default: + } + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err != nil { + select { + case <-ctx.Done(): + return 0, false + case <-exited: + return 0, false + case <-time.After(300 * time.Millisecond): + } + continue + } + + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + } + + // Step 1: SOCKS5 auth + if _, err = conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { + conn.Close() + return 0, false + } + authResp := make([]byte, 2) + if _, err = io.ReadFull(conn, authResp); err != nil || authResp[0] != 0x05 { + conn.Close() + return 0, false + } + + // Step 2: SOCKS5 CONNECT to example.com:80 + target := "example.com" + connectReq := make([]byte, 0, 7+len(target)) + connectReq = append(connectReq, 0x05, 0x01, 0x00, 0x03) + connectReq = append(connectReq, byte(len(target))) + connectReq = append(connectReq, []byte(target)...) + connectReq = append(connectReq, 0x00, 0x50) // port 80 + if _, err = conn.Write(connectReq); err != nil { + conn.Close() + return 0, false + } + + // Step 3: Read SOCKS5 CONNECT reply header + hdr := make([]byte, 4) // VER, REP, RSV, ATYP + if _, err = io.ReadFull(conn, hdr); err != nil { + conn.Close() + return 0, false + } + if hdr[0] != 0x05 || hdr[1] != 0x00 { + // CONNECT failed — server may lack internet access + conn.Close() + return 0, false + } + // Drain remaining CONNECT reply based on ATYP + switch hdr[3] { + case 0x01: // IPv4: 4 addr + 2 port + io.ReadFull(conn, make([]byte, 6)) + case 0x03: // Domain: 1 len + domain + 2 port + lenBuf := make([]byte, 1) + if _, err = io.ReadFull(conn, lenBuf); err == nil { + io.ReadFull(conn, make([]byte, int(lenBuf[0])+2)) + } + case 0x04: // IPv6: 16 addr + 2 port + io.ReadFull(conn, make([]byte, 18)) + } + + // Step 4: Send HTTP GET request through the tunnel + httpReq := "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" + if _, err = conn.Write([]byte(httpReq)); err != nil { + conn.Close() + return 0, false + } + + // Step 5: Read HTTP response + buf := make([]byte, 65536) + totalRead := 0 + for { + n, readErr := conn.Read(buf[totalRead:]) + totalRead += n + if readErr != nil || totalRead >= len(buf) { + break + } + } + conn.Close() + + // Need at least 100 bytes to confirm real data transfer + if totalRead < 100 { + return totalRead, false + } + return totalRead, true + } +} + func truncate(s string, maxLen int) string { if idx := strings.IndexByte(s, '\n'); idx >= 0 { s = s[:idx] From d180eb360f63c24bbe80ad6325fcdb5f4e7c0ac8 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Thu, 19 Mar 2026 01:47:03 +0330 Subject: [PATCH 2/8] Switch scan pipeline from BFS to DFS: results arrive immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the scan processed ALL IPs through step 1 (ping), waited for ALL to finish, then ALL survivors through step 2 (nxdomain), etc. With 1M IPs this meant waiting hours before seeing any results. Now each worker takes ONE IP and runs it through the entire pipeline (ping → nxdomain → resolve → e2e). If any step fails, the worker immediately moves to the next IP. Results appear as soon as individual IPs complete all steps. - New RunPipeline() in scanner package: channel-based DFS pipeline - New runPipelineScan() in scan.go: live display of passed IPs with metrics, single progress bar with pass/fail counts - Each passed IP is printed immediately as it's discovered - Works with --batch, --discover, and --resume Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/scan.go | 142 +++++++++++++++++++++++++++++++---- internal/scanner/pipeline.go | 97 ++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 internal/scanner/pipeline.go diff --git a/cmd/scan.go b/cmd/scan.go index 2bbd3b2..82c5e2f 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -352,21 +352,16 @@ func runScan(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n %s━━━ Batch %d/%d (%d IPs) ━━━%s\n\n", colorCyan, batchNum, totalBatches, len(chunk), colorReset) - batchReport := scanner.RunChainQuietCtx(ctx, chunk, workers, steps, - newScanProgressFactory(len(steps), stepDescriptions)) - + batchReport := runPipelineScan(ctx, chunk, workers, steps) scanner.MergeChainReports(&report, batchReport) - // Save after each batch saveResults(report) totalPassed := len(allPassed) + len(report.Passed) fmt.Fprintf(os.Stderr, " %s✔ Batch %d done — %d passed so far — saved to %s%s\n", colorGreen, batchNum, totalPassed, outputFile, colorReset) } } else { - // No batching — scan all at once - report = scanner.RunChainQuietCtx(ctx, ips, workers, steps, - newScanProgressFactory(len(steps), stepDescriptions)) + report = runPipelineScan(ctx, ips, workers, steps) } // --discover: find neighbor /24 subnets from passed IPs and scan them @@ -378,14 +373,12 @@ func runScan(cmd *cobra.Command, args []string) error { break } - // Collect /24s from ALL passed IPs (including previous rounds) passedIPs := make([]string, len(report.Passed)) for i, r := range report.Passed { passedIPs[i] = r.IP } newSubnets := scanner.SubnetsFromIPs(passedIPs) - // Filter out already-known subnets var toExpand []string for _, cidr := range newSubnets { if _, ok := knownSubnets[cidr]; !ok { @@ -398,7 +391,6 @@ func runScan(cmd *cobra.Command, args []string) error { break } - // Expand to individual IPs, excluding already scanned neighborIPs := scanner.ExpandSubnets(toExpand, scanned) if len(neighborIPs) == 0 { fmt.Fprintf(os.Stderr, "\n %s✔ Discovery round %d: %d subnets but all IPs already scanned%s\n", @@ -406,7 +398,6 @@ func runScan(cmd *cobra.Command, args []string) error { break } - // Mark as scanned for _, ip := range neighborIPs { scanned[ip] = struct{}{} } @@ -414,12 +405,9 @@ func runScan(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n %s━━━ Discovery round %d: %d new /24 subnets → %d IPs ━━━%s\n\n", colorCyan, round, len(toExpand), len(neighborIPs), colorReset) - roundReport := scanner.RunChainQuietCtx(ctx, neighborIPs, workers, steps, - newScanProgressFactory(len(steps), stepDescriptions)) - + roundReport := runPipelineScan(ctx, neighborIPs, workers, steps) scanner.MergeChainReports(&report, roundReport) - // Save after each discovery round saveResults(report) totalPassed := len(allPassed) + len(report.Passed) fmt.Fprintf(os.Stderr, " %s✔ Round %d: %d new resolvers — %d total passed — saved to %s%s\n", @@ -684,3 +672,127 @@ func formatMetric(v float64) string { func digitCount(n int) int { return len(fmt.Sprintf("%d", n)) } + +// runPipelineScan runs the DFS-style pipeline where each worker processes +// one IP through ALL steps sequentially. Results appear as soon as individual +// IPs complete the pipeline — no waiting for all IPs to finish a step. +func runPipelineScan(ctx context.Context, ips []string, workers int, steps []scanner.Step) scanner.ChainReport { + ch := scanner.RunPipeline(ctx, ips, workers, steps) + + w := os.Stderr + start := time.Now() + tty := isTTY() + + // Print pipeline banner + if tty { + fmt.Fprintf(w, " %s── Pipeline: ", colorDim) + for i, s := range steps { + if i > 0 { + fmt.Fprintf(w, " → ") + } + fmt.Fprintf(w, "%s", s.Name) + } + fmt.Fprintf(w, " ──%s\n\n", colorReset) + } + + stepTested := make([]int, len(steps)) + stepPassed := make([]int, len(steps)) + stepFailed := make([]int, len(steps)) + + var report scanner.ChainReport + var done, pass, fail int + total := len(ips) + + for r := range ch { + done++ + + if r.FailedStep == -1 { + // Passed all steps + for si := range steps { + stepTested[si]++ + stepPassed[si]++ + } + pass++ + report.Passed = append(report.Passed, scanner.IPRecord{IP: r.IP, Metrics: r.Metrics}) + + // Show passed IP immediately + if tty { + var parts []string + if r.Metrics != nil { + keys := make([]string, 0, len(r.Metrics)) + for k := range r.Metrics { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", k, formatMetric(r.Metrics[k]))) + } + } + fmt.Fprintf(w, "\r\033[2K %s✔%s %-15s %s%s%s\n", + colorGreen, colorReset, r.IP, colorDim, strings.Join(parts, " "), colorReset) + } + } else { + // Failed — update per-step stats up to the failed step + for si := 0; si <= r.FailedStep; si++ { + stepTested[si]++ + if si < r.FailedStep { + stepPassed[si]++ + } else { + stepFailed[si]++ + } + } + fail++ + report.Failed = append(report.Failed, scanner.IPRecord{IP: r.IP}) + } + + // Progress bar (always the last line, updated with \r) + if tty && total > 0 { + pct := done * 100 / total + elapsed := time.Since(start).Truncate(time.Second) + bar := progressBar(pct, 20) + fmt.Fprintf(w, "\r\033[2K %sscanning%s %s %d/%d %s✔ %d%s %s✘ %d%s %s%s%s", + colorBold, colorReset, bar, done, total, + colorGreen, pass, colorReset, + colorRed, fail, colorReset, + colorDim, elapsed, colorReset) + } + } + + // Final completion line + if tty { + elapsed := time.Since(start).Truncate(time.Second) + fmt.Fprintf(w, "\r\033[2K %s✔%s Pipeline complete %s%d/%d passed%s %s%s%s\n", + colorGreen, colorReset, + colorGreen, pass, total, colorReset, + colorDim, elapsed, colorReset) + } + + // Build step results for the report + report.Steps = make([]scanner.StepResult, len(steps)) + for i, step := range steps { + report.Steps[i] = scanner.StepResult{ + Name: step.Name, + Tested: stepTested[i], + Passed: stepPassed[i], + Failed: stepFailed[i], + } + } + + if report.Passed == nil { + report.Passed = []scanner.IPRecord{} + } + + // Sort passed results by the last step's primary metric + if len(steps) > 0 { + lastSort := steps[len(steps)-1].SortBy + if lastSort != "" && len(report.Passed) > 1 { + sort.SliceStable(report.Passed, func(i, j int) bool { + vi := report.Passed[i].Metrics[lastSort] + vj := report.Passed[j].Metrics[lastSort] + return vi < vj + }) + } + } + + return report +} diff --git a/internal/scanner/pipeline.go b/internal/scanner/pipeline.go new file mode 100644 index 0000000..ea1e70b --- /dev/null +++ b/internal/scanner/pipeline.go @@ -0,0 +1,97 @@ +package scanner + +import ( + "context" +) + +// PipelineResult is the outcome of running one IP through the full step pipeline. +type PipelineResult struct { + IP string + OK bool + Metrics Metrics + FailedStep int // index of the step where it failed; -1 if passed all +} + +// RunPipeline processes each IP through all steps sequentially per-IP (DFS). +// Unlike RunChain which processes all IPs through step 1, then step 2 (BFS), +// each worker takes one IP and runs it through the entire pipeline. +// Results are emitted to the returned channel as each IP completes. +// The channel is closed when all IPs are processed or the context is cancelled. +func RunPipeline(ctx context.Context, ips []string, workers int, steps []Step) <-chan PipelineResult { + out := make(chan PipelineResult, workers) + + go func() { + defer close(out) + + jobs := make(chan string) + bufSize := workers * 4 + if bufSize > len(ips) { + bufSize = len(ips) + } + if bufSize < 1 { + bufSize = 1 + } + results := make(chan PipelineResult, bufSize) + + // Launch workers — each takes one IP and runs ALL steps on it + for i := 0; i < workers; i++ { + go func() { + for ip := range jobs { + func() { + defer func() { + if r := recover(); r != nil { + results <- PipelineResult{IP: ip, OK: false, FailedStep: 0} + } + }() + + m := make(Metrics) + for si, step := range steps { + if ctx.Err() != nil { + results <- PipelineResult{IP: ip, OK: false, FailedStep: si} + return + } + ok, sm := step.Check(ip, step.Timeout) + if !ok { + results <- PipelineResult{IP: ip, OK: false, FailedStep: si} + return + } + for k, v := range sm { + m[k] = v + } + } + results <- PipelineResult{IP: ip, OK: true, Metrics: m, FailedStep: -1} + }() + } + }() + } + + // Feed IPs to workers + go func() { + for _, ip := range ips { + select { + case jobs <- ip: + case <-ctx.Done(): + close(jobs) + return + } + } + close(jobs) + }() + + // Forward results to output channel + for i := 0; i < len(ips); i++ { + select { + case r := <-results: + select { + case out <- r: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + + return out +} From e4315fa05e2911b825672eae7df0277ae3143d42 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Thu, 19 Mar 2026 02:00:58 +0330 Subject: [PATCH 3/8] Update TUI: DFS pipeline, live results, discover/throughput toggles - Switch TUI scan from BFS (RunChainQuietCtx) to DFS (RunPipeline) - Show overall progress bar with pass/fail counts - Per-step breakdown shows tested/passed/rate for each step - Live display of last 8 passed IPs with their metrics - Add Discover and Throughput toggles to config screen - Add throughput/dnstt step to pipeline when enabled Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/tui/messages.go | 13 ++ internal/tui/screen_config.go | 22 ++- internal/tui/screen_running.go | 253 +++++++++++++++++++++++++-------- internal/tui/tui.go | 20 ++- 4 files changed, 243 insertions(+), 65 deletions(-) diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 3e06c55..8cff15d 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -24,6 +24,19 @@ type scanDoneMsg struct { type scanStartedMsg struct { progressCh chan progressMsg doneCh chan scanDoneMsg + pipelineCh chan pipelineProgressMsg +} + +type pipelineProgressMsg struct { + done int + total int + passed int + failed int + latestIP string + latestMetrics scanner.Metrics + stepTested []int + stepPassed []int + stepFailed []int } type inputLoadedMsg struct { diff --git a/internal/tui/screen_config.go b/internal/tui/screen_config.go index be23e8c..81f91b6 100644 --- a/internal/tui/screen_config.go +++ b/internal/tui/screen_config.go @@ -45,6 +45,8 @@ const ( fPubkey // e2e fields below fCert fE2ETimeout + fThroughput + fDiscover fStart ) @@ -73,6 +75,8 @@ var allFields = []fieldDef{ {fPubkey, "Pubkey", "", "Hex public key for dnstt. Requires dnstt-client in PATH.", txtPubkey}, {fCert, "Cert", "", "Path to slipstream TLS cert. Requires slipstream-client in PATH.", txtCert}, {fE2ETimeout, "E2E Timeout (s)", "", "Seconds to wait for each e2e tunnel connectivity test.", txtE2ETimeout}, + {fThroughput, "Throughput Test", "", "Test real payload transfer through tunnel after e2e (HTTP GET through SOCKS).", -1}, + {fDiscover, "Discover Neighbors", "Discovery", "Auto-scan /24 subnets when a resolver passes. Finds nearby working resolvers.", -1}, {fStart, "Start Scan", "", "Run the scan with the settings above.", -1}, } @@ -88,6 +92,9 @@ func visibleFields(cfg ScanConfig) []fieldDef { if e2eSubFields[f.id] && !cfg.E2E { continue } + if throughputSubFields[f.id] && !cfg.E2E { + continue + } // slipstream-client has no Windows binary — hide Cert field on Windows if f.id == fCert && runtime.GOOS == "windows" { continue @@ -151,8 +158,13 @@ func initConfigInputs() []textinput.Model { return inputs } +// e2eSubFields extended to include throughput +var throughputSubFields = map[fieldID]bool{ + fThroughput: true, +} + func isToggle(id fieldID) bool { - return id == fSkipPing || id == fSkipNXD || id == fEDNS || id == fE2E + return id == fSkipPing || id == fSkipNXD || id == fEDNS || id == fE2E || id == fThroughput || id == fDiscover } func currentField(m Model) fieldDef { @@ -240,6 +252,10 @@ func toggleField(m *Model, id fieldID) { break } } + case fThroughput: + m.config.Throughput = !m.config.Throughput + case fDiscover: + m.config.Discover = !m.config.Discover } } @@ -395,6 +411,10 @@ func getToggleValue(m Model, id fieldID) bool { return m.config.EDNS case fE2E: return m.config.E2E + case fThroughput: + return m.config.Throughput + case fDiscover: + return m.config.Discover } return false } diff --git a/internal/tui/screen_running.go b/internal/tui/screen_running.go index 242681c..628a657 100644 --- a/internal/tui/screen_running.go +++ b/internal/tui/screen_running.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "sort" "strings" "time" @@ -22,9 +23,9 @@ type stepProgress struct { func (m Model) startScan() tea.Cmd { return func() tea.Msg { - progressCh := make(chan progressMsg, 200) + pipelineCh := make(chan pipelineProgressMsg, 200) doneCh := make(chan scanDoneMsg, 1) - return scanStartedMsg{progressCh: progressCh, doneCh: doneCh} + return scanStartedMsg{progressCh: nil, doneCh: doneCh, pipelineCh: pipelineCh} } } @@ -60,8 +61,6 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) { } if cfg.DoH { - // When domain is set, skip basic resolve (A record for google.com) — - // tunnel domains have no A record. Go straight to resolve/tunnel. if cfg.Domain == "" { steps = append(steps, scanner.Step{ Name: "doh/resolve", Timeout: dur, @@ -112,14 +111,16 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) { }) } if cfg.Domain != "" && cfg.Pubkey != "" { - // E2E tunnel test: verify dnstt Noise handshake completes through - // each resolver. The SOCKS port only opens after the cryptographic - // handshake succeeds through the DNS tunnel — proving the resolver - // carries tunnel traffic bidirectionally. Fast (~2-5s per resolver). steps = append(steps, scanner.Step{ Name: "e2e/dnstt", Timeout: e2eDur, Check: scanner.DnsttCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports), SortBy: "socks_ms", }) + if cfg.Throughput { + steps = append(steps, scanner.Step{ + Name: "throughput/dnstt", Timeout: e2eDur, + Check: scanner.ThroughputCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports), SortBy: "throughput_ms", + }) + } } if cfg.Domain != "" && cfg.Cert != "" { steps = append(steps, scanner.Step{ @@ -131,53 +132,101 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) { return steps, nil } -func launchScan(ctx context.Context, ips []string, cfg ScanConfig, steps []scanner.Step, progressCh chan progressMsg, doneCh chan scanDoneMsg) { +func launchScan(ctx context.Context, ips []string, cfg ScanConfig, steps []scanner.Step, pipelineCh chan pipelineProgressMsg, doneCh chan scanDoneMsg) { // Apply EDNS buffer size before scanning if cfg.EDNSSize > 0 { scanner.EDNSBufSize = uint16(cfg.EDNSSize) } - // Apply query size cap (dnstt-client MTU) scanner.DnsttMTU = cfg.QuerySize if len(steps) == 0 { doneCh <- scanDoneMsg{err: fmt.Errorf("no scan steps configured")} - close(progressCh) + close(pipelineCh) return } - stepIdx := 0 - factory := func(stepName string) scanner.ProgressFunc { - idx := stepIdx - stepIdx++ - return func(done, total, passed, failed int) { - select { - case progressCh <- progressMsg{ - stepIndex: idx, - done: done, - total: total, - passed: passed, - failed: failed, - }: - default: - // Drop update if buffer full — avoids blocking the scanner - } - } - } - go func() { - defer close(progressCh) + defer close(pipelineCh) defer func() { if r := recover(); r != nil { doneCh <- scanDoneMsg{err: fmt.Errorf("scan panicked: %v", r)} } }() + start := time.Now() - report := scanner.RunChainQuietCtx(ctx, ips, cfg.Workers, steps, factory) + ch := scanner.RunPipeline(ctx, ips, cfg.Workers, steps) + + stepTested := make([]int, len(steps)) + stepPassed := make([]int, len(steps)) + stepFailed := make([]int, len(steps)) + + var report scanner.ChainReport + var done, pass, fail int + total := len(ips) + + for r := range ch { + done++ + + var latestIP string + var latestMetrics scanner.Metrics + + if r.FailedStep == -1 { + for si := range steps { + stepTested[si]++ + stepPassed[si]++ + } + pass++ + report.Passed = append(report.Passed, scanner.IPRecord{IP: r.IP, Metrics: r.Metrics}) + latestIP = r.IP + latestMetrics = r.Metrics + } else { + for si := 0; si <= r.FailedStep; si++ { + stepTested[si]++ + if si < r.FailedStep { + stepPassed[si]++ + } else { + stepFailed[si]++ + } + } + fail++ + report.Failed = append(report.Failed, scanner.IPRecord{IP: r.IP}) + } + + // Send progress update + select { + case pipelineCh <- pipelineProgressMsg{ + done: done, + total: total, + passed: pass, + failed: fail, + latestIP: latestIP, + latestMetrics: latestMetrics, + stepTested: append([]int{}, stepTested...), + stepPassed: append([]int{}, stepPassed...), + stepFailed: append([]int{}, stepFailed...), + }: + default: + } + } + + // Build step results + report.Steps = make([]scanner.StepResult, len(steps)) + for i, step := range steps { + report.Steps[i] = scanner.StepResult{ + Name: step.Name, + Tested: stepTested[i], + Passed: stepPassed[i], + Failed: stepFailed[i], + } + } + if report.Passed == nil { + report.Passed = []scanner.IPRecord{} + } + elapsed := time.Since(start) var writeErr error if cfg.OutputFile != "" { writeErr = scanner.WriteChainReport(report, cfg.OutputFile) - // Also write plain IP list alongside JSON if writeErr == nil && len(report.Passed) > 0 { ipFile := strings.TrimSuffix(cfg.OutputFile, ".json") + "_ips.txt" _ = scanner.WriteIPList(report.Passed, ipFile) @@ -187,7 +236,7 @@ func launchScan(ctx context.Context, ips []string, cfg ScanConfig, steps []scann }() } -func waitForProgress(ch chan progressMsg) tea.Cmd { +func waitForPipeline(ch chan pipelineProgressMsg) tea.Cmd { return func() tea.Msg { msg, ok := <-ch if !ok { @@ -206,9 +255,14 @@ func waitForDone(ch chan scanDoneMsg) tea.Cmd { func updateRunning(m Model, msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case scanStartedMsg: - m.progressCh = msg.progressCh + m.pipelineCh = msg.pipelineCh m.doneCh = msg.doneCh m.scanStart = time.Now() + m.pipelineDone = 0 + m.pipelineTotal = len(m.ips) + m.pipelinePassed = 0 + m.pipelineFailed = 0 + m.recentPassed = nil steps, err := buildSteps(m.config) if err != nil { @@ -220,30 +274,43 @@ func updateRunning(m Model, msg tea.Msg) (Model, tea.Cmd) { for i, s := range steps { m.steps[i] = stepProgress{name: s.Name} } + m.pStepTested = make([]int, len(steps)) + m.pStepPassed = make([]int, len(steps)) + m.pStepFailed = make([]int, len(steps)) ctx, cancel := context.WithCancel(context.Background()) m.scanCancel = cancel scanner.ResetE2EDiagnostic() - launchScan(ctx, m.ips, m.config, steps, msg.progressCh, msg.doneCh) + launchScan(ctx, m.ips, m.config, steps, msg.pipelineCh, msg.doneCh) return m, tea.Batch( - waitForProgress(msg.progressCh), + waitForPipeline(msg.pipelineCh), waitForDone(msg.doneCh), tickCmd(), ) - case progressMsg: - if msg.stepIndex < len(m.steps) { - m.steps[msg.stepIndex].done = msg.done - m.steps[msg.stepIndex].total = msg.total - m.steps[msg.stepIndex].passed = msg.passed - m.steps[msg.stepIndex].failed = msg.failed - if msg.done == msg.total && msg.total > 0 { - m.steps[msg.stepIndex].finished = true + case pipelineProgressMsg: + m.pipelineDone = msg.done + m.pipelinePassed = msg.passed + m.pipelineFailed = msg.failed + + // Update per-step stats + copy(m.pStepTested, msg.stepTested) + copy(m.pStepPassed, msg.stepPassed) + copy(m.pStepFailed, msg.stepFailed) + + // Track recent passed IPs (last 8) + if msg.latestIP != "" { + m.recentPassed = append(m.recentPassed, scanner.IPRecord{ + IP: msg.latestIP, Metrics: msg.latestMetrics, + }) + if len(m.recentPassed) > 8 { + m.recentPassed = m.recentPassed[len(m.recentPassed)-8:] } } - return m, waitForProgress(m.progressCh) + + return m, waitForPipeline(m.pipelineCh) case scanDoneMsg: m.report = msg.report @@ -306,31 +373,95 @@ func viewRunning(m Model) string { b.WriteString(dimStyle.Render(elapsed.String())) b.WriteString("\n\n") - for _, step := range m.steps { + // Overall progress bar + total := m.pipelineTotal + if total == 0 { + total = len(m.ips) + } + pct := 0 + if total > 0 { + pct = m.pipelineDone * 100 / total + } + bar := progressBar(pct, 30) + b.WriteString(fmt.Sprintf(" %s %d/%d ", bar, m.pipelineDone, total)) + b.WriteString(greenStyle.Render(fmt.Sprintf("%d passed", m.pipelinePassed))) + b.WriteString(" ") + b.WriteString(redStyle.Render(fmt.Sprintf("%d failed", m.pipelineFailed))) + b.WriteString("\n\n") + + // Pipeline steps + b.WriteString(dimStyle.Render(" Pipeline: ")) + for i, step := range m.steps { + if i > 0 { + b.WriteString(dimStyle.Render(" → ")) + } + b.WriteString(dimStyle.Render(step.name)) + } + b.WriteString("\n\n") + + // Per-step breakdown + b.WriteString(dimStyle.Render(" Step breakdown:")) + b.WriteString("\n") + for i, step := range m.steps { + tested := 0 + passed := 0 + if i < len(m.pStepTested) { + tested = m.pStepTested[i] + passed = m.pStepPassed[i] + } + passRate := 0 + if tested > 0 { + passRate = passed * 100 / tested + } + icon := dimStyle.Render("○") - if step.finished { - if step.passed > 0 { + if tested > 0 { + if passRate >= 50 { icon = greenStyle.Render("✔") + } else if passRate >= 20 { + icon = yellowStyle.Render("◉") } else { icon = redStyle.Render("✘") } - } else if step.total > 0 { - icon = yellowStyle.Render("◉") } - pct := 0 - if step.total > 0 { - pct = step.done * 100 / step.total + b.WriteString(fmt.Sprintf(" %s %-18s ", icon, step.name)) + if tested > 0 { + b.WriteString(greenStyle.Render(fmt.Sprintf("%d", passed))) + b.WriteString(dimStyle.Render(fmt.Sprintf("/%d ", tested))) + b.WriteString(dimStyle.Render(fmt.Sprintf("(%d%%)", passRate))) + } else { + b.WriteString(dimStyle.Render("waiting...")) } + b.WriteString("\n") + } - bar := progressBar(pct, 20) - - b.WriteString(fmt.Sprintf(" %s %-18s %s %d/%d ", - icon, step.name, bar, step.done, step.total)) - b.WriteString(greenStyle.Render(fmt.Sprintf("%d✔", step.passed))) - b.WriteString(" ") - b.WriteString(redStyle.Render(fmt.Sprintf("%d✘", step.failed))) + // Recent passed IPs + if len(m.recentPassed) > 0 { b.WriteString("\n") + b.WriteString(dimStyle.Render(" Recent results:")) + b.WriteString("\n") + for _, r := range m.recentPassed { + var parts []string + if r.Metrics != nil { + keys := make([]string, 0, len(r.Metrics)) + for k := range r.Metrics { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := r.Metrics[k] + if v == float64(int(v)) { + parts = append(parts, fmt.Sprintf("%s=%d", k, int(v))) + } else { + parts = append(parts, fmt.Sprintf("%s=%.1f", k, v)) + } + } + } + b.WriteString(fmt.Sprintf(" %s %-15s %s\n", + greenStyle.Render("✔"), r.IP, + dimStyle.Render(strings.Join(parts, " ")))) + } } b.WriteString("\n") diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4a6621c..1ce12f3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -33,9 +33,12 @@ type ScanConfig struct { SkipPing bool SkipNXDomain bool EDNS bool - E2E bool - DoH bool - OutputFile string + E2E bool + DoH bool + Discover bool + DiscoverRounds int + Throughput bool + OutputFile string } type Model struct { @@ -67,8 +70,19 @@ type Model struct { scanCancel context.CancelFunc cancelling bool progressCh chan progressMsg + pipelineCh chan pipelineProgressMsg doneCh chan scanDoneMsg + // Pipeline tracking + pipelineDone int + pipelineTotal int + pipelinePassed int + pipelineFailed int + recentPassed []scanner.IPRecord + pStepTested []int + pStepPassed []int + pStepFailed []int + // Results screen report scanner.ChainReport totalTime time.Duration From 92fec380334a9f1696ef4f4961447048f3fc434e Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Thu, 19 Mar 2026 11:38:37 +0330 Subject: [PATCH 4/8] Live results: scrollable table + instant file append MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keep ALL passed IPs during scan (not just last 8) - Scrollable results table in TUI with ↑/↓ keys and all metrics - Auto-scroll to latest result, manual scroll to review history - Live append each passed IP to _ips.txt immediately (both CLI and TUI) - Results file available during scan, not just at the end Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/scan.go | 16 +++++ internal/tui/screen_running.go | 127 ++++++++++++++++++++++++++------- internal/tui/tui.go | 1 + 3 files changed, 120 insertions(+), 24 deletions(-) diff --git a/cmd/scan.go b/cmd/scan.go index 82c5e2f..843ca8a 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -683,6 +683,17 @@ func runPipelineScan(ctx context.Context, ips []string, workers int, steps []sca start := time.Now() tty := isTTY() + // Open IP file for live appending + var ipLiveFile *os.File + if outputFile != "" { + ipPath := strings.TrimSuffix(outputFile, ".json") + "_ips.txt" + f, err := os.OpenFile(ipPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err == nil { + ipLiveFile = f + defer ipLiveFile.Close() + } + } + // Print pipeline banner if tty { fmt.Fprintf(w, " %s── Pipeline: ", colorDim) @@ -715,6 +726,11 @@ func runPipelineScan(ctx context.Context, ips []string, workers int, steps []sca pass++ report.Passed = append(report.Passed, scanner.IPRecord{IP: r.IP, Metrics: r.Metrics}) + // Live append to IP file + if ipLiveFile != nil { + fmt.Fprintln(ipLiveFile, r.IP) + } + // Show passed IP immediately if tty { var parts []string diff --git a/internal/tui/screen_running.go b/internal/tui/screen_running.go index 628a657..f3e30db 100644 --- a/internal/tui/screen_running.go +++ b/internal/tui/screen_running.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "os" "sort" "strings" "time" @@ -153,6 +154,17 @@ func launchScan(ctx context.Context, ips []string, cfg ScanConfig, steps []scann } }() + // Open IP list file for live appending + var ipFile *os.File + if cfg.OutputFile != "" { + ipPath := strings.TrimSuffix(cfg.OutputFile, ".json") + "_ips.txt" + f, err := os.OpenFile(ipPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err == nil { + ipFile = f + defer ipFile.Close() + } + } + start := time.Now() ch := scanner.RunPipeline(ctx, ips, cfg.Workers, steps) @@ -179,6 +191,10 @@ func launchScan(ctx context.Context, ips []string, cfg ScanConfig, steps []scann report.Passed = append(report.Passed, scanner.IPRecord{IP: r.IP, Metrics: r.Metrics}) latestIP = r.IP latestMetrics = r.Metrics + // Live append to IP file + if ipFile != nil { + fmt.Fprintln(ipFile, r.IP) + } } else { for si := 0; si <= r.FailedStep; si++ { stepTested[si]++ @@ -300,13 +316,15 @@ func updateRunning(m Model, msg tea.Msg) (Model, tea.Cmd) { copy(m.pStepPassed, msg.stepPassed) copy(m.pStepFailed, msg.stepFailed) - // Track recent passed IPs (last 8) + // Track ALL passed IPs and auto-scroll to bottom if msg.latestIP != "" { m.recentPassed = append(m.recentPassed, scanner.IPRecord{ IP: msg.latestIP, Metrics: msg.latestMetrics, }) - if len(m.recentPassed) > 8 { - m.recentPassed = m.recentPassed[len(m.recentPassed)-8:] + // Auto-scroll to show latest result + visRows := m.liveVisibleRows() + if len(m.recentPassed) > visRows { + m.resultsScroll = len(m.recentPassed) - visRows } } @@ -334,22 +352,45 @@ func updateRunning(m Model, msg tea.Msg) (Model, tea.Cmd) { return m, tickCmd() case tea.KeyMsg: - if msg.String() == "ctrl+c" { + switch msg.String() { + case "ctrl+c": if m.scanCancel != nil { m.scanCancel() } return m, tea.Quit - } - if msg.String() == "q" { + case "q": if m.scanCancel != nil && !m.cancelling { m.scanCancel() m.cancelling = true } + case "up", "k": + if m.resultsScroll > 0 { + m.resultsScroll-- + } + case "down", "j": + maxScroll := len(m.recentPassed) - m.liveVisibleRows() + if maxScroll < 0 { + maxScroll = 0 + } + if m.resultsScroll < maxScroll { + m.resultsScroll++ + } } } return m, nil } +// liveVisibleRows returns how many result rows fit during scanning. +func (m Model) liveVisibleRows() int { + // Overhead: title(2) + progress(3) + pipeline(2) + steps(N+2) + results_header(2) + scroll_hint(1) + footer(3) + overhead := 15 + len(m.steps) + rows := m.height - overhead + if rows < 3 { + rows = 3 + } + return rows +} + type tickMsg time.Time func tickCmd() tea.Cmd { @@ -436,39 +477,77 @@ func viewRunning(m Model) string { b.WriteString("\n") } - // Recent passed IPs + // Passed IPs — scrollable table with all metrics if len(m.recentPassed) > 0 { b.WriteString("\n") - b.WriteString(dimStyle.Render(" Recent results:")) + b.WriteString(greenStyle.Render(fmt.Sprintf(" Passed: %d", len(m.recentPassed)))) b.WriteString("\n") - for _, r := range m.recentPassed { - var parts []string - if r.Metrics != nil { - keys := make([]string, 0, len(r.Metrics)) - for k := range r.Metrics { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - v := r.Metrics[k] + + // Determine metric columns from first result + var metricKeys []string + if m.recentPassed[0].Metrics != nil { + for k := range m.recentPassed[0].Metrics { + metricKeys = append(metricKeys, k) + } + sort.Strings(metricKeys) + } + + // Header + header := fmt.Sprintf(" %-4s %-17s", "#", "IP") + for _, k := range metricKeys { + header += fmt.Sprintf(" %-10s", k) + } + b.WriteString(dimStyle.Render(header)) + b.WriteString("\n") + + // Visible rows + visRows := m.liveVisibleRows() + start := m.resultsScroll + end := start + visRows + if end > len(m.recentPassed) { + end = len(m.recentPassed) + } + if start > len(m.recentPassed) { + start = len(m.recentPassed) + } + + for i := start; i < end; i++ { + r := m.recentPassed[i] + row := fmt.Sprintf(" %-4d %-17s", i+1, r.IP) + for _, k := range metricKeys { + if r.Metrics == nil { + row += fmt.Sprintf(" %-10s", "-") + } else if v, ok := r.Metrics[k]; ok { if v == float64(int(v)) { - parts = append(parts, fmt.Sprintf("%s=%d", k, int(v))) + row += fmt.Sprintf(" %-10d", int(v)) } else { - parts = append(parts, fmt.Sprintf("%s=%.1f", k, v)) + row += fmt.Sprintf(" %-10.1f", v) } + } else { + row += fmt.Sprintf(" %-10s", "-") } } - b.WriteString(fmt.Sprintf(" %s %-15s %s\n", - greenStyle.Render("✔"), r.IP, - dimStyle.Render(strings.Join(parts, " ")))) + b.WriteString(greenStyle.Render(" ✔")) + b.WriteString(row[3:]) // skip leading spaces already covered by ✔ + b.WriteString("\n") + } + + if len(m.recentPassed) > visRows { + b.WriteString(dimStyle.Render(fmt.Sprintf(" Showing %d-%d of %d (↑/↓ scroll)", + start+1, end, len(m.recentPassed)))) + b.WriteString("\n") } + } else { + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Waiting for results...")) + b.WriteString("\n") } b.WriteString("\n") if m.cancelling { b.WriteString(yellowStyle.Render(" Cancelling... waiting for workers")) } else { - b.WriteString(dimStyle.Render(" q cancel ctrl+c quit")) + b.WriteString(dimStyle.Render(" ↑/↓ scroll results q cancel ctrl+c quit")) } b.WriteString("\n") diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1ce12f3..12f82a7 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -79,6 +79,7 @@ type Model struct { pipelinePassed int pipelineFailed int recentPassed []scanner.IPRecord + resultsScroll int // scroll offset for live results during scan pStepTested []int pStepPassed []int pStepFailed []int From da5902245fd79a7b1bfe7df518c14175a881acdd Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Thu, 19 Mar 2026 12:24:43 +0330 Subject: [PATCH 5/8] Fix README: add DFS pipeline docs, missing flags, correct defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DFS pipeline description (EN + FA): each worker runs one IP through all steps, results appear instantly, IPs live-appended to file - Document --query-size and --cidr-file flags (both EN and FA tables) - Fix e2e-timeout default: 20 → 30 (matches root.go) - Add live file append note to --output-ips description - Add DFS pipeline to features table (EN + FA) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 04fb8aa..8e4ec86 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Supports both **UDP** and **DoH (DNS-over-HTTPS)** resolvers with end-to-end tun | 🚇 **E2E Tunnel Verification** | Actually launches DNSTT/Slipstream clients to verify real connectivity | | 📥 **Resolver List Fetcher** | Auto-download thousands of resolvers from public sources | | 🌍 **Regional Resolver Lists** | Built-in support for regional intranet resolver lists (7,800+ IPs) | +| 🚀 **DFS Pipeline** | Each worker runs one IP through all steps — results appear in seconds, not hours | | ⚡ **High Concurrency** | 50 parallel workers by default — scans thousands of resolvers in minutes | | 📋 **JSON Pipeline** | Output from one scan feeds into the next for multi-stage filtering | | 🌐 **CIDR Input** | Accept IP ranges like `185.51.200.0/24` — auto-expanded to individual hosts | @@ -279,6 +280,8 @@ findns scan --domain t.example.com **UDP mode pipeline:** `ping → nxdomain → resolve/tunnel → e2e` (add `--edns` for EDNS, `--throughput` for payload test, `--discover` for neighbor scanning) **DoH mode pipeline:** `doh/resolve/tunnel → doh/e2e` +> **DFS pipeline:** Each worker takes one IP and runs it through ALL steps. If any step fails, the worker immediately moves to the next IP. Results appear as soon as individual IPs complete the full pipeline — no waiting for all IPs to finish a step. Passed IPs are written to `_ips.txt` in real-time. +> > When `--domain` is set, the basic `resolve` step (A record for google.com) is skipped — tunnel domains have no A record, so findns goes straight to `resolve/tunnel`. | Flag | Description | Default | @@ -293,10 +296,12 @@ findns scan --domain t.example.com | `--discover` | Auto-discover neighbor /24 subnets when resolvers pass all steps | `false` | | `--discover-rounds` | Max neighbor discovery rounds | `3` | | `--cidr` | Scan a CIDR range directly (e.g. `--cidr 5.52.0.0/16`) | — | +| `--cidr-file` | Text file with one CIDR range per line | — | +| `--query-size` | Cap dnstt-client upstream query size in bytes (0 = max) | `50` | | `--skip-ping` | Skip ICMP ping step | `false` | | `--skip-nxdomain` | Skip NXDOMAIN hijack check | `false` | | `--top` | Number of top results to display | `10` | -| `--output-ips` | Write plain IP list alongside JSON | auto | +| `--output-ips` | Write plain IP list alongside JSON (live-appended during scan) | auto | | `--batch` | Scan N resolvers at a time, saving after each batch | `0` (all) | | `--resume` | Skip IPs already in the output file | `false` | @@ -558,7 +563,7 @@ Step format: `type:key=val,key=val`. Optional params: `count`, `timeout`. | `--timeout` | `-t` | Timeout per attempt (seconds) | 3 | | `--count` | `-c` | Attempts per IP/URL | 3 | | `--workers` | | Concurrent workers | 50 | -| `--e2e-timeout` | | Timeout for e2e tests (seconds) | 20 | +| `--e2e-timeout` | | Timeout for e2e tests (seconds) | 30 | | `--include-failed` | | Also scan failed entries from JSON input | false | --- @@ -683,6 +688,7 @@ MIT | 🚇 **تست واقعی تانل** | واقعاً کلاینت DNSTT/Slipstream را اجرا می‌کند و اتصال را تأیید می‌کند | | 📥 **دانلود لیست resolver** | دانلود خودکار از منابع عمومی | | 🌍 **resolverهای محلی** | لیست داخلی 7,800+ آی‌پی resolver منطقه‌ای | +| 🚀 **پایپلاین DFS** | هر worker یک آی‌پی را از تمام مراحل رد می‌کند — نتایج در ثانیه‌ها نه ساعت‌ها | | ⚡ **همزمانی بالا** | 50 worker موازی — هزاران resolver در چند دقیقه اسکن می‌شود | | 📋 **خروجی JSON** | خروجی هر اسکن ورودی اسکن بعدی می‌شود | | 🌐 **ورودی CIDR** | رنج آی‌پی مثل `185.51.200.0/24` را می‌خواند و به صورت خودکار باز می‌کند | @@ -986,6 +992,8 @@ findns tui **حالت UDP:** `ping → nxdomain → resolve/tunnel → e2e` (با `--edns` مرحله EDNS، با `--throughput` تست انتقال دیتا، با `--discover` کشف همسایه اضافه می‌شود) **حالت DoH:** `doh/resolve/tunnel → doh/e2e` +> **پایپلاین DFS:** هر worker یک آی‌پی را از تمام مراحل رد می‌کند. اگر هر مرحله فیل شد، فوراً آی‌پی بعدی را شروع می‌کند. نتایج به محض عبور هر آی‌پی از تمام مراحل نمایش داده می‌شوند — نیازی به صبر کردن تا تمام آی‌پی‌ها مرحله اول را تمام کنند نیست. آی‌پی‌های موفق به صورت زنده در فایل `_ips.txt` نوشته می‌شوند. + > وقتی `--domain` تنظیم شود، مرحله `resolve` ساده (رکورد A برای google.com) رد می‌شود — دامنه‌های تانل رکورد A ندارند، بنابراین findns مستقیم به `resolve/tunnel` می‌رود. | فلگ | توضیح | پیش‌فرض | @@ -1000,10 +1008,12 @@ findns tui | `--discover` | کشف خودکار /24 همسایه وقتی resolver پاس شد | `false` | | `--discover-rounds` | حداکثر تعداد دورهای کشف همسایه | `3` | | `--cidr` | اسکن مستقیم رنج CIDR (مثلاً `--cidr 5.52.0.0/16`) | — | +| `--cidr-file` | فایل متنی با هر خط یک رنج CIDR | — | +| `--query-size` | محدود کردن سایز کوئری upstream (بایت، 0 = حداکثر) | `50` | | `--skip-ping` | رد کردن مرحله ping | `false` | | `--skip-nxdomain` | رد کردن بررسی هایجک | `false` | | `--top` | تعداد نتایج برتر برای نمایش | `10` | -| `--output-ips` | خروجی لیست آی‌پی ساده کنار JSON | خودکار | +| `--output-ips` | خروجی لیست آی‌پی ساده کنار JSON (زنده در حین اسکن) | خودکار | | `--batch` | اسکن N ریزالور در هر دسته (ذخیره بعد هر دسته) | `0` (همه) | | `--resume` | رد کردن آی‌پی‌هایی که قبلاً اسکن شده‌اند | `false` | From f39a81c568c96c66462923bd5a40f0303798e983 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 25 Mar 2026 13:02:08 +0300 Subject: [PATCH 6/8] Add SOCKS5 auth (username/password) and SSH probe for e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For dnstt setups requiring SOCKS5 authentication, the e2e test now supports RFC 1929 username/password negotiation via --socks-user and --socks-pass flags. When credentials are provided, the SOCKS5 handshake uses method 0x02 instead of 0x00 (no-auth). Also adds --connect-addr to configure the SOCKS5 CONNECT target. Default remains example.com:80, but setting it to host:22 enables SSH banner verification — proving the tunnel can reach an SSH server. Changes: - Add SOCKS5Opts type and socks5Handshake/socks5Connect helpers - Update all e2e/throughput check functions to accept SOCKS5Opts - Add --socks-user, --socks-pass, --connect-addr to scan, chain, e2e dnstt, e2e slipstream, and doh e2e commands - Add SOCKS User/Pass/Connect Addr fields to TUI config (E2E section) - Update README (EN + FA) with new flags and examples Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 22 ++++ cmd/chain.go | 12 +- cmd/doh_e2e.go | 9 +- cmd/e2e_dnstt.go | 9 +- cmd/e2e_slipstream.go | 9 +- cmd/scan.go | 15 ++- internal/scanner/doh.go | 12 +- internal/scanner/e2e.go | 213 +++++++++++++++++++++------------ internal/tui/screen_config.go | 32 +++++ internal/tui/screen_running.go | 10 +- internal/tui/tui.go | 3 + 11 files changed, 250 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 8e4ec86..df114f0 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,9 @@ findns scan --domain t.example.com | `--edns` | Include EDNS payload size check | `false` | | `--edns-size` | EDNS0 UDP payload size in bytes (larger = better throughput) | `1232` | | `--throughput` | Include payload transfer test after e2e (requires `--pubkey`) | `false` | +| `--socks-user` | SOCKS5 proxy username (for dnstt setups requiring auth) | (empty) | +| `--socks-pass` | SOCKS5 proxy password | (empty) | +| `--connect-addr` | `host:port` for SOCKS5 CONNECT probe (use `host:22` for SSH) | `example.com:80` | | `--discover` | Auto-discover neighbor /24 subnets when resolvers pass all steps | `false` | | `--discover-rounds` | Max neighbor discovery rounds | `3` | | `--cidr` | Scan a CIDR range directly (e.g. `--cidr 5.52.0.0/16`) | — | @@ -335,6 +338,22 @@ findns scan --domain t.example.com --pubkey --throughput --- +### 🔐 SOCKS5 Authentication (`--socks-user`, `--socks-pass`) + +For dnstt setups that require SOCKS5 username/password authentication (RFC 1929): + +```bash +# E2E with SOCKS5 auth +findns scan --domain t.example.com --pubkey --socks-user myuser --socks-pass mypass + +# SSH probe — test connectivity to SSH server through the tunnel +findns scan --domain t.example.com --pubkey --socks-user myuser --socks-pass mypass --connect-addr localhost:22 +``` + +When `--connect-addr` targets port 22, findns reads the SSH banner (`SSH-2.0-...`) after the SOCKS5 CONNECT succeeds, providing stronger proof of tunnel connectivity. Default target is `example.com:80`. + +--- + ### 📥 `fetch` — Download Resolver Lists Automatically downloads and deduplicates resolver lists from public sources. @@ -1005,6 +1024,9 @@ findns tui | `--edns` | فعال‌سازی تست سایز EDNS payload | `false` | | `--edns-size` | سایز بافر EDNS0 به بایت (بزرگتر = سرعت بیشتر) | `1232` | | `--throughput` | تست انتقال واقعی دیتا بعد از e2e (نیاز به `--pubkey`) | `false` | +| `--socks-user` | نام کاربری SOCKS5 برای احراز هویت پروکسی | (خالی) | +| `--socks-pass` | رمز عبور SOCKS5 | (خالی) | +| `--connect-addr` | `host:port` برای تست CONNECT (مثلاً `host:22` برای SSH) | `example.com:80` | | `--discover` | کشف خودکار /24 همسایه وقتی resolver پاس شد | `false` | | `--discover-rounds` | حداکثر تعداد دورهای کشف همسایه | `3` | | `--cidr` | اسکن مستقیم رنج CIDR (مثلاً `--cidr 5.52.0.0/16`) | — | diff --git a/cmd/chain.go b/cmd/chain.go index bc683ef..bf9b40d 100644 --- a/cmd/chain.go +++ b/cmd/chain.go @@ -100,7 +100,8 @@ func buildStep(cfg stepConfig, defaultTimeout, defaultCount int, ports chan int, if !ok || pubkey == "" { return scanner.Step{}, fmt.Errorf("step %q: missing required param 'pubkey'", cfg.name) } - return scanner.Step{Name: "e2e/dnstt", Timeout: dur, Check: scanner.DnsttCheckBin(binPaths["dnstt-client"], domain, pubkey, ports), SortBy: "socks_ms"}, nil + opts := scanner.SOCKS5Opts{User: cfg.params["socks-user"], Pass: cfg.params["socks-pass"], ConnectAddr: cfg.params["connect-addr"]} + return scanner.Step{Name: "e2e/dnstt", Timeout: dur, Check: scanner.DnsttCheckBin(binPaths["dnstt-client"], domain, pubkey, ports, opts), SortBy: "socks_ms"}, nil case "e2e/slipstream": domain, ok := cfg.params["domain"] @@ -108,7 +109,8 @@ func buildStep(cfg stepConfig, defaultTimeout, defaultCount int, ports chan int, return scanner.Step{}, fmt.Errorf("step %q: missing required param 'domain'", cfg.name) } cert := cfg.params["cert"] - return scanner.Step{Name: "e2e/slipstream", Timeout: dur, Check: scanner.SlipstreamCheckBin(binPaths["slipstream-client"], domain, cert, ports), SortBy: "e2e_ms"}, nil + opts := scanner.SOCKS5Opts{User: cfg.params["socks-user"], Pass: cfg.params["socks-pass"], ConnectAddr: cfg.params["connect-addr"]} + return scanner.Step{Name: "e2e/slipstream", Timeout: dur, Check: scanner.SlipstreamCheckBin(binPaths["slipstream-client"], domain, cert, ports, opts), SortBy: "e2e_ms"}, nil case "throughput/dnstt": domain, ok := cfg.params["domain"] @@ -119,7 +121,8 @@ func buildStep(cfg stepConfig, defaultTimeout, defaultCount int, ports chan int, if !ok || pubkey == "" { return scanner.Step{}, fmt.Errorf("step %q: missing required param 'pubkey'", cfg.name) } - return scanner.Step{Name: "throughput/dnstt", Timeout: dur, Check: scanner.ThroughputCheckBin(binPaths["dnstt-client"], domain, pubkey, ports), SortBy: "throughput_ms"}, nil + opts := scanner.SOCKS5Opts{User: cfg.params["socks-user"], Pass: cfg.params["socks-pass"], ConnectAddr: cfg.params["connect-addr"]} + return scanner.Step{Name: "throughput/dnstt", Timeout: dur, Check: scanner.ThroughputCheckBin(binPaths["dnstt-client"], domain, pubkey, ports, opts), SortBy: "throughput_ms"}, nil case "nxdomain": return scanner.Step{Name: "nxdomain", Timeout: dur, Check: scanner.NXDomainCheck(stepCount), SortBy: "hijack"}, nil @@ -154,7 +157,8 @@ func buildStep(cfg stepConfig, defaultTimeout, defaultCount int, ports chan int, if !ok || pubkey == "" { return scanner.Step{}, fmt.Errorf("step %q: missing required param 'pubkey'", cfg.name) } - return scanner.Step{Name: "doh/e2e", Timeout: dur, Check: scanner.DoHDnsttCheckBin(binPaths["dnstt-client"], domain, pubkey, ports), SortBy: "e2e_ms"}, nil + opts := scanner.SOCKS5Opts{User: cfg.params["socks-user"], Pass: cfg.params["socks-pass"], ConnectAddr: cfg.params["connect-addr"]} + return scanner.Step{Name: "doh/e2e", Timeout: dur, Check: scanner.DoHDnsttCheckBin(binPaths["dnstt-client"], domain, pubkey, ports, opts), SortBy: "e2e_ms"}, nil default: return scanner.Step{}, fmt.Errorf("unknown step type %q", cfg.name) diff --git a/cmd/doh_e2e.go b/cmd/doh_e2e.go index 8498b9e..4a42518 100644 --- a/cmd/doh_e2e.go +++ b/cmd/doh_e2e.go @@ -20,6 +20,9 @@ var dohE2ECmd = &cobra.Command{ func init() { dohE2ECmd.Flags().String("domain", "", "DNSTT tunnel domain") dohE2ECmd.Flags().String("pubkey", "", "DNSTT server public key") + dohE2ECmd.Flags().String("socks-user", "", "SOCKS5 username for proxy auth") + dohE2ECmd.Flags().String("socks-pass", "", "SOCKS5 password for proxy auth") + dohE2ECmd.Flags().String("connect-addr", "", "host:port for SOCKS5 CONNECT probe (default example.com:80)") dohE2ECmd.MarkFlagRequired("domain") dohE2ECmd.MarkFlagRequired("pubkey") dohCmd.AddCommand(dohE2ECmd) @@ -28,6 +31,9 @@ func init() { func runDoHE2E(cmd *cobra.Command, args []string) error { domain, _ := cmd.Flags().GetString("domain") pubkey, _ := cmd.Flags().GetString("pubkey") + socksUser, _ := cmd.Flags().GetString("socks-user") + socksPass, _ := cmd.Flags().GetString("socks-pass") + connectAddr, _ := cmd.Flags().GetString("connect-addr") bin, err := findBinary("dnstt-client") if err != nil { return err @@ -40,7 +46,8 @@ func runDoHE2E(cmd *cobra.Command, args []string) error { dur := time.Duration(e2eTimeout) * time.Second ports := scanner.PortPool(30000, workers) - check := scanner.DoHDnsttCheckBin(bin, domain, pubkey, ports) + opts := scanner.SOCKS5Opts{User: socksUser, Pass: socksPass, ConnectAddr: connectAddr} + check := scanner.DoHDnsttCheckBin(bin, domain, pubkey, ports, opts) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() diff --git a/cmd/e2e_dnstt.go b/cmd/e2e_dnstt.go index ed2fb31..6c5c5b4 100644 --- a/cmd/e2e_dnstt.go +++ b/cmd/e2e_dnstt.go @@ -20,6 +20,9 @@ var e2eDnsttCmd = &cobra.Command{ func init() { e2eDnsttCmd.Flags().String("domain", "", "DNSTT tunnel domain") e2eDnsttCmd.Flags().String("pubkey", "", "DNSTT server public key") + e2eDnsttCmd.Flags().String("socks-user", "", "SOCKS5 username for proxy auth") + e2eDnsttCmd.Flags().String("socks-pass", "", "SOCKS5 password for proxy auth") + e2eDnsttCmd.Flags().String("connect-addr", "", "host:port for SOCKS5 CONNECT probe (default example.com:80)") e2eDnsttCmd.MarkFlagRequired("domain") e2eDnsttCmd.MarkFlagRequired("pubkey") e2eCmd.AddCommand(e2eDnsttCmd) @@ -28,6 +31,9 @@ func init() { func runE2EDnstt(cmd *cobra.Command, args []string) error { domain, _ := cmd.Flags().GetString("domain") pubkey, _ := cmd.Flags().GetString("pubkey") + socksUser, _ := cmd.Flags().GetString("socks-user") + socksPass, _ := cmd.Flags().GetString("socks-pass") + connectAddr, _ := cmd.Flags().GetString("connect-addr") bin, err := findBinary("dnstt-client") if err != nil { return err @@ -40,7 +46,8 @@ func runE2EDnstt(cmd *cobra.Command, args []string) error { dur := time.Duration(e2eTimeout) * time.Second ports := scanner.PortPool(30000, workers) - check := scanner.DnsttCheckBin(bin, domain, pubkey, ports) + opts := scanner.SOCKS5Opts{User: socksUser, Pass: socksPass, ConnectAddr: connectAddr} + check := scanner.DnsttCheckBin(bin, domain, pubkey, ports, opts) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() diff --git a/cmd/e2e_slipstream.go b/cmd/e2e_slipstream.go index 6344220..ed6f28a 100644 --- a/cmd/e2e_slipstream.go +++ b/cmd/e2e_slipstream.go @@ -20,6 +20,9 @@ var e2eSlipstreamCmd = &cobra.Command{ func init() { e2eSlipstreamCmd.Flags().String("domain", "", "Slipstream tunnel domain") e2eSlipstreamCmd.Flags().String("cert", "", "path to Slipstream certificate for cert pinning (optional)") + e2eSlipstreamCmd.Flags().String("socks-user", "", "SOCKS5 username for proxy auth") + e2eSlipstreamCmd.Flags().String("socks-pass", "", "SOCKS5 password for proxy auth") + e2eSlipstreamCmd.Flags().String("connect-addr", "", "host:port for SOCKS5 CONNECT probe (default example.com:80)") e2eSlipstreamCmd.MarkFlagRequired("domain") e2eCmd.AddCommand(e2eSlipstreamCmd) } @@ -27,6 +30,9 @@ func init() { func runE2ESlipstream(cmd *cobra.Command, args []string) error { domain, _ := cmd.Flags().GetString("domain") certPath, _ := cmd.Flags().GetString("cert") + socksUser, _ := cmd.Flags().GetString("socks-user") + socksPass, _ := cmd.Flags().GetString("socks-pass") + connectAddr, _ := cmd.Flags().GetString("connect-addr") bin, err := findBinary("slipstream-client") if err != nil { return err @@ -39,7 +45,8 @@ func runE2ESlipstream(cmd *cobra.Command, args []string) error { dur := time.Duration(e2eTimeout) * time.Second ports := scanner.PortPool(30000, workers) - check := scanner.SlipstreamCheckBin(bin, domain, certPath, ports) + opts := scanner.SOCKS5Opts{User: socksUser, Pass: socksPass, ConnectAddr: connectAddr} + check := scanner.SlipstreamCheckBin(bin, domain, certPath, ports, opts) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() diff --git a/cmd/scan.go b/cmd/scan.go index 843ca8a..9cd0ce0 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -75,6 +75,9 @@ func init() { scanCmd.Flags().Bool("discover", false, "auto-discover neighbor /24 subnets when IPs pass all steps") scanCmd.Flags().Int("discover-rounds", 3, "max neighbor discovery rounds (default 3)") scanCmd.Flags().Bool("throughput", false, "include payload transfer test after e2e (requires --pubkey)") + scanCmd.Flags().String("socks-user", "", "SOCKS5 username for e2e tunnel proxy auth") + scanCmd.Flags().String("socks-pass", "", "SOCKS5 password for e2e tunnel proxy auth") + scanCmd.Flags().String("connect-addr", "", "host:port for SOCKS5 CONNECT probe (default example.com:80, use host:22 for SSH)") rootCmd.AddCommand(scanCmd) } @@ -98,6 +101,10 @@ func runScan(cmd *cobra.Command, args []string) error { discover, _ := cmd.Flags().GetBool("discover") discoverRounds, _ := cmd.Flags().GetInt("discover-rounds") throughput, _ := cmd.Flags().GetBool("throughput") + socksUser, _ := cmd.Flags().GetString("socks-user") + socksPass, _ := cmd.Flags().GetString("socks-pass") + connectAddr, _ := cmd.Flags().GetString("connect-addr") + socksOpts := scanner.SOCKS5Opts{User: socksUser, Pass: socksPass, ConnectAddr: connectAddr} // Load additional CIDRs from file if provided if cidrFile != "" { @@ -211,7 +218,7 @@ func runScan(cmd *cobra.Command, args []string) error { if domain != "" && pubkey != "" { steps = append(steps, scanner.Step{ Name: "doh/e2e", Timeout: time.Duration(e2eTimeout) * time.Second, - Check: scanner.DoHDnsttCheckBin(dnsttBin, domain, pubkey, ports), SortBy: "e2e_ms", + Check: scanner.DoHDnsttCheckBin(dnsttBin, domain, pubkey, ports, socksOpts), SortBy: "e2e_ms", }) } } else { @@ -254,19 +261,19 @@ func runScan(cmd *cobra.Command, args []string) error { // handshake succeeds — proving bidirectional tunnel data flow. steps = append(steps, scanner.Step{ Name: "e2e/dnstt", Timeout: time.Duration(e2eTimeout) * time.Second, - Check: scanner.DnsttCheckBin(dnsttBin, domain, pubkey, ports), SortBy: "socks_ms", + Check: scanner.DnsttCheckBin(dnsttBin, domain, pubkey, ports, socksOpts), SortBy: "socks_ms", }) if throughput { steps = append(steps, scanner.Step{ Name: "throughput/dnstt", Timeout: time.Duration(e2eTimeout) * time.Second, - Check: scanner.ThroughputCheckBin(dnsttBin, domain, pubkey, ports), SortBy: "throughput_ms", + Check: scanner.ThroughputCheckBin(dnsttBin, domain, pubkey, ports, socksOpts), SortBy: "throughput_ms", }) } } if domain != "" && certPath != "" { steps = append(steps, scanner.Step{ Name: "e2e/slipstream", Timeout: time.Duration(e2eTimeout) * time.Second, - Check: scanner.SlipstreamCheckBin(slipstreamBin, domain, certPath, ports), SortBy: "e2e_ms", + Check: scanner.SlipstreamCheckBin(slipstreamBin, domain, certPath, ports, socksOpts), SortBy: "e2e_ms", }) } } diff --git a/internal/scanner/doh.go b/internal/scanner/doh.go index 0191249..76d7160 100644 --- a/internal/scanner/doh.go +++ b/internal/scanner/doh.go @@ -176,16 +176,16 @@ func DoHTunnelCheck(domain string, count int) CheckFunc { } // DoHDnsttCheckBin is like DoHDnsttCheck but uses an explicit binary path. -func DoHDnsttCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc { - return dohDnsttCheck(bin, domain, pubkey, ports) +func DoHDnsttCheckBin(bin, domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { + return dohDnsttCheck(bin, domain, pubkey, ports, opts) } // DoHDnsttCheck runs an e2e test using dnstt-client in DoH mode. -func DoHDnsttCheck(domain, pubkey string, ports chan int) CheckFunc { - return dohDnsttCheck("dnstt-client", domain, pubkey, ports) +func DoHDnsttCheck(domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { + return dohDnsttCheck("dnstt-client", domain, pubkey, ports, opts) } -func dohDnsttCheck(bin, domain, pubkey string, ports chan int) CheckFunc { +func dohDnsttCheck(bin, domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { var diagOnce atomic.Bool return func(url string, timeout time.Duration) (bool, Metrics) { @@ -237,7 +237,7 @@ func dohDnsttCheck(bin, domain, pubkey string, ports chan int) CheckFunc { ports <- port }() - if !waitAndTestSOCKS5Connect(ctx, port, exited) { + if !waitAndTestSOCKS5Connect(ctx, port, exited, opts) { if diagOnce.CompareAndSwap(false, true) { processExitedEarly := false select { diff --git a/internal/scanner/e2e.go b/internal/scanner/e2e.go index 59801a3..e5baf15 100644 --- a/internal/scanner/e2e.go +++ b/internal/scanner/e2e.go @@ -18,6 +18,81 @@ import ( // 0 means use dnstt-client's default (maximum capacity). var DnsttMTU int +// SOCKS5Opts holds optional SOCKS5 authentication credentials and the +// target host:port used for the CONNECT probe. +type SOCKS5Opts struct { + User string // empty = no-auth (method 0x00) + Pass string + ConnectAddr string // default "example.com:80"; use "host:22" for SSH probe +} + +// socks5Handshake performs SOCKS5 auth negotiation on conn. +// If opts.User is empty, uses no-auth (0x00). +// If opts.User is set, uses username/password auth (0x02, RFC 1929). +func socks5Handshake(conn net.Conn, opts SOCKS5Opts) error { + if opts.User != "" { + // Offer username/password method (0x02) + if _, err := conn.Write([]byte{0x05, 0x01, 0x02}); err != nil { + return err + } + resp := make([]byte, 2) + if _, err := io.ReadFull(conn, resp); err != nil { + return err + } + if resp[0] != 0x05 || resp[1] != 0x02 { + return fmt.Errorf("socks5: server rejected username/password method") + } + // RFC 1929 sub-negotiation: VER=0x01, ULEN, USER, PLEN, PASS + authReq := make([]byte, 0, 3+len(opts.User)+len(opts.Pass)) + authReq = append(authReq, 0x01) + authReq = append(authReq, byte(len(opts.User))) + authReq = append(authReq, []byte(opts.User)...) + authReq = append(authReq, byte(len(opts.Pass))) + authReq = append(authReq, []byte(opts.Pass)...) + if _, err := conn.Write(authReq); err != nil { + return err + } + authResp := make([]byte, 2) + if _, err := io.ReadFull(conn, authResp); err != nil { + return err + } + if authResp[1] != 0x00 { + return fmt.Errorf("socks5: authentication failed (status %d)", authResp[1]) + } + return nil + } + // No-auth (0x00) + if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { + return err + } + resp := make([]byte, 2) + if _, err := io.ReadFull(conn, resp); err != nil { + return err + } + if resp[0] != 0x05 { + return fmt.Errorf("socks5: invalid server version %d", resp[0]) + } + return nil +} + +// socks5Connect sends a SOCKS5 CONNECT request to the given host:port. +func socks5Connect(conn net.Conn, addr string) error { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return err + } + port := 0 + fmt.Sscanf(portStr, "%d", &port) + + req := make([]byte, 0, 7+len(host)) + req = append(req, 0x05, 0x01, 0x00, 0x03) // VER, CMD=connect, RSV, ATYP=domain + req = append(req, byte(len(host))) + req = append(req, []byte(host)...) + req = append(req, byte(port>>8), byte(port&0xff)) + _, err = conn.Write(req) + return err +} + func PortPool(base, count int) chan int { ch := make(chan int, count) for i := 0; i < count; i++ { @@ -61,15 +136,15 @@ func setDiag(format string, args ...interface{}) { } // DnsttCheckBin verifies the dnstt Noise handshake completes through a resolver. -func DnsttCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc { - return dnsttCheck(bin, domain, pubkey, ports) +func DnsttCheckBin(bin, domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { + return dnsttCheck(bin, domain, pubkey, ports, opts) } -func DnsttCheck(domain, pubkey string, ports chan int) CheckFunc { - return dnsttCheck("dnstt-client", domain, pubkey, ports) +func DnsttCheck(domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { + return dnsttCheck("dnstt-client", domain, pubkey, ports, opts) } -func dnsttCheck(bin, domain, pubkey string, ports chan int) CheckFunc { +func dnsttCheck(bin, domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { var diagOnce atomic.Bool return func(ip string, timeout time.Duration) (bool, Metrics) { @@ -124,7 +199,7 @@ func dnsttCheck(bin, domain, pubkey string, ports chan int) CheckFunc { // Wait for SOCKS port to open, then do a SOCKS5 handshake through // the tunnel. This is much faster than spawning curl — we just need // to verify that data flows bidirectionally through the DNS tunnel. - if !waitAndTestSOCKS5Connect(ctx, port, exited) { + if !waitAndTestSOCKS5Connect(ctx, port, exited, opts) { if diagOnce.CompareAndSwap(false, true) { processExitedEarly := false select { @@ -163,11 +238,13 @@ func dnsttCheck(bin, domain, pubkey string, ports chan int) CheckFunc { // bidirectional data flow through the DNS tunnel. We don't require 0x00 // (success) because the server may not have internet access — but the // reply itself proves the tunnel carried data both ways. -func waitAndTestSOCKS5Connect(ctx context.Context, port int, exited <-chan struct{}) bool { +func waitAndTestSOCKS5Connect(ctx context.Context, port int, exited <-chan struct{}, opts SOCKS5Opts) bool { addr := fmt.Sprintf("127.0.0.1:%d", port) + connectAddr := opts.ConnectAddr + if connectAddr == "" { + connectAddr = "example.com:80" + } - // Poll until SOCKS port accepts a connection, then reuse that - // connection — avoids ghost streams from close-and-reopen. for { select { case <-ctx.Done(): @@ -192,63 +269,73 @@ func waitAndTestSOCKS5Connect(ctx context.Context, port int, exited <-chan struc conn.SetDeadline(deadline) } - // Step 1: SOCKS5 auth (local to dnstt-client) - // version=5, 1 method, no-auth(0x00) - if _, err = conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { - conn.Close() - return false - } - authResp := make([]byte, 2) - if _, err = io.ReadFull(conn, authResp); err != nil { - conn.Close() - return false - } - if authResp[0] != 0x05 { + // Step 1: SOCKS5 auth (supports no-auth and username/password) + if err = socks5Handshake(conn, opts); err != nil { conn.Close() return false } - // Step 2: SOCKS5 CONNECT to example.com:80 - // This goes through the DNS tunnel — the real e2e proof. - // Format: VER=5, CMD=1(connect), RSV=0, ATYP=3(domain), - // LEN, DOMAIN, PORT_HI, PORT_LO - domain := "example.com" - connectReq := make([]byte, 0, 7+len(domain)) - connectReq = append(connectReq, 0x05, 0x01, 0x00, 0x03) - connectReq = append(connectReq, byte(len(domain))) - connectReq = append(connectReq, []byte(domain)...) - connectReq = append(connectReq, 0x00, 0x50) // port 80 - if _, err = conn.Write(connectReq); err != nil { + // Step 2: SOCKS5 CONNECT — goes through the DNS tunnel + if err = socks5Connect(conn, connectAddr); err != nil { conn.Close() return false } // Step 3: Read SOCKS5 CONNECT reply (at least 4 bytes: VER, REP, RSV, ATYP) // Any valid SOCKS5 reply proves the tunnel works — even failure codes - // like 0x01 (general failure) mean data traveled through the tunnel - // and came back. + // like 0x01 (general failure) mean data traveled through the tunnel. connectResp := make([]byte, 4) if _, err = io.ReadFull(conn, connectResp); err != nil { conn.Close() return false } + if connectResp[0] != 0x05 { + conn.Close() + return false + } + + // For SSH targets (port 22): read and verify the SSH banner + _, portStr, _ := net.SplitHostPort(connectAddr) + if portStr == "22" && connectResp[1] == 0x00 { + // Drain CONNECT reply address + drainSOCKS5Addr(conn, connectResp[3]) + // Read SSH banner (e.g. "SSH-2.0-OpenSSH_8.9") + banner := make([]byte, 32) + n, _ := conn.Read(banner) + conn.Close() + return n >= 4 && string(banner[:4]) == "SSH-" + } + conn.Close() + return true + } +} - // VER must be 0x05 = valid SOCKS5 reply came back through tunnel - return connectResp[0] == 0x05 +// drainSOCKS5Addr reads and discards the address portion of a SOCKS5 reply. +func drainSOCKS5Addr(conn net.Conn, atyp byte) { + switch atyp { + case 0x01: + io.ReadFull(conn, make([]byte, 6)) + case 0x03: + lenBuf := make([]byte, 1) + if _, err := io.ReadFull(conn, lenBuf); err == nil { + io.ReadFull(conn, make([]byte, int(lenBuf[0])+2)) + } + case 0x04: + io.ReadFull(conn, make([]byte, 18)) } } // SlipstreamCheckBin is like SlipstreamCheck but uses an explicit binary path. -func SlipstreamCheckBin(bin, domain, certPath string, ports chan int) CheckFunc { - return slipstreamCheck(bin, domain, certPath, ports) +func SlipstreamCheckBin(bin, domain, certPath string, ports chan int, opts SOCKS5Opts) CheckFunc { + return slipstreamCheck(bin, domain, certPath, ports, opts) } -func SlipstreamCheck(domain, certPath string, ports chan int) CheckFunc { - return slipstreamCheck("slipstream-client", domain, certPath, ports) +func SlipstreamCheck(domain, certPath string, ports chan int, opts SOCKS5Opts) CheckFunc { + return slipstreamCheck("slipstream-client", domain, certPath, ports, opts) } -func slipstreamCheck(bin, domain, certPath string, ports chan int) CheckFunc { +func slipstreamCheck(bin, domain, certPath string, ports chan int, opts SOCKS5Opts) CheckFunc { var diagOnce atomic.Bool return func(ip string, timeout time.Duration) (bool, Metrics) { @@ -300,7 +387,7 @@ func slipstreamCheck(bin, domain, certPath string, ports chan int) CheckFunc { ports <- port }() - if !waitAndTestSOCKS5Connect(ctx, port, exited) { + if !waitAndTestSOCKS5Connect(ctx, port, exited, opts) { if diagOnce.CompareAndSwap(false, true) { processExitedEarly := false select { @@ -319,7 +406,7 @@ func slipstreamCheck(bin, domain, certPath string, ports chan int) CheckFunc { } else if processExitedEarly { setDiag("e2e/slipstream first failure (ip=%s): process exited early with no stderr", ip) } else { - setDiag("e2e/slipstream first failure (ip=%s): curl could not get HTTP 200 through SOCKS within %v", ip, timeout) + setDiag("e2e/slipstream first failure (ip=%s): SOCKS5 handshake through tunnel timed out within %v", ip, timeout) } } return false, nil @@ -334,7 +421,7 @@ func slipstreamCheck(bin, domain, certPath string, ports chan int) CheckFunc { // performing an HTTP GET request via the SOCKS5 proxy. This goes beyond the // e2e handshake test — it verifies that meaningful payload (1-2KB+) flows // bidirectionally through the tunnel. -func ThroughputCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc { +func ThroughputCheckBin(bin, domain, pubkey string, ports chan int, opts SOCKS5Opts) CheckFunc { var diagOnce atomic.Bool return func(ip string, timeout time.Duration) (bool, Metrics) { @@ -383,7 +470,7 @@ func ThroughputCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc { ports <- port }() - transferred, ok := waitAndTestThroughput(ctx, port, exited) + transferred, ok := waitAndTestThroughput(ctx, port, exited, opts) if !ok { if diagOnce.CompareAndSwap(false, true) { cmd.Process.Kill() @@ -412,7 +499,7 @@ func ThroughputCheckBin(bin, domain, pubkey string, ports chan int) CheckFunc { // SOCKS5 CONNECT to example.com:80, sends an HTTP GET request, and reads // the response. This proves that real data (not just a handshake) can flow // through the DNS tunnel. -func waitAndTestThroughput(ctx context.Context, port int, exited <-chan struct{}) (int, bool) { +func waitAndTestThroughput(ctx context.Context, port int, exited <-chan struct{}, opts SOCKS5Opts) (int, bool) { addr := fmt.Sprintf("127.0.0.1:%d", port) for { @@ -439,52 +526,29 @@ func waitAndTestThroughput(ctx context.Context, port int, exited <-chan struct{} conn.SetDeadline(deadline) } - // Step 1: SOCKS5 auth - if _, err = conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { - conn.Close() - return 0, false - } - authResp := make([]byte, 2) - if _, err = io.ReadFull(conn, authResp); err != nil || authResp[0] != 0x05 { + // Step 1: SOCKS5 auth (supports no-auth and username/password) + if err = socks5Handshake(conn, opts); err != nil { conn.Close() return 0, false } // Step 2: SOCKS5 CONNECT to example.com:80 - target := "example.com" - connectReq := make([]byte, 0, 7+len(target)) - connectReq = append(connectReq, 0x05, 0x01, 0x00, 0x03) - connectReq = append(connectReq, byte(len(target))) - connectReq = append(connectReq, []byte(target)...) - connectReq = append(connectReq, 0x00, 0x50) // port 80 - if _, err = conn.Write(connectReq); err != nil { + if err = socks5Connect(conn, "example.com:80"); err != nil { conn.Close() return 0, false } // Step 3: Read SOCKS5 CONNECT reply header - hdr := make([]byte, 4) // VER, REP, RSV, ATYP + hdr := make([]byte, 4) if _, err = io.ReadFull(conn, hdr); err != nil { conn.Close() return 0, false } if hdr[0] != 0x05 || hdr[1] != 0x00 { - // CONNECT failed — server may lack internet access conn.Close() return 0, false } - // Drain remaining CONNECT reply based on ATYP - switch hdr[3] { - case 0x01: // IPv4: 4 addr + 2 port - io.ReadFull(conn, make([]byte, 6)) - case 0x03: // Domain: 1 len + domain + 2 port - lenBuf := make([]byte, 1) - if _, err = io.ReadFull(conn, lenBuf); err == nil { - io.ReadFull(conn, make([]byte, int(lenBuf[0])+2)) - } - case 0x04: // IPv6: 16 addr + 2 port - io.ReadFull(conn, make([]byte, 18)) - } + drainSOCKS5Addr(conn, hdr[3]) // Step 4: Send HTTP GET request through the tunnel httpReq := "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" @@ -505,7 +569,6 @@ func waitAndTestThroughput(ctx context.Context, port int, exited <-chan struct{} } conn.Close() - // Need at least 100 bytes to confirm real data transfer if totalRead < 100 { return totalRead, false } diff --git a/internal/tui/screen_config.go b/internal/tui/screen_config.go index 81f91b6..648ee80 100644 --- a/internal/tui/screen_config.go +++ b/internal/tui/screen_config.go @@ -24,6 +24,9 @@ const ( txtEDNSSize txtQuerySize txtE2ETimeout + txtSocksUser + txtSocksPass + txtConnectAddr numTextInputs ) @@ -46,6 +49,9 @@ const ( fCert fE2ETimeout fThroughput + fSocksUser + fSocksPass + fConnectAddr fDiscover fStart ) @@ -76,6 +82,9 @@ var allFields = []fieldDef{ {fCert, "Cert", "", "Path to slipstream TLS cert. Requires slipstream-client in PATH.", txtCert}, {fE2ETimeout, "E2E Timeout (s)", "", "Seconds to wait for each e2e tunnel connectivity test.", txtE2ETimeout}, {fThroughput, "Throughput Test", "", "Test real payload transfer through tunnel after e2e (HTTP GET through SOCKS).", -1}, + {fSocksUser, "SOCKS User", "", "Username for SOCKS5 proxy auth (leave empty for no-auth).", txtSocksUser}, + {fSocksPass, "SOCKS Pass", "", "Password for SOCKS5 proxy auth (leave empty for no-auth).", txtSocksPass}, + {fConnectAddr, "Connect Addr", "", "host:port for e2e CONNECT probe (default: example.com:80, use host:22 for SSH).", txtConnectAddr}, {fDiscover, "Discover Neighbors", "Discovery", "Auto-scan /24 subnets when a resolver passes. Finds nearby working resolvers.", -1}, {fStart, "Start Scan", "", "Run the scan with the settings above.", -1}, } @@ -83,6 +92,7 @@ var allFields = []fieldDef{ // e2eSubFields are only shown when E2E toggle is on. var e2eSubFields = map[fieldID]bool{ fPubkey: true, fCert: true, fE2ETimeout: true, + fSocksUser: true, fSocksPass: true, fConnectAddr: true, } // visibleFields returns the currently visible field list based on config state. @@ -154,6 +164,19 @@ func initConfigInputs() []textinput.Model { inputs[txtE2ETimeout].SetValue("30") inputs[txtE2ETimeout].CharLimit = 3 + inputs[txtSocksUser] = textinput.New() + inputs[txtSocksUser].Placeholder = "username" + inputs[txtSocksUser].CharLimit = 128 + + inputs[txtSocksPass] = textinput.New() + inputs[txtSocksPass].Placeholder = "password" + inputs[txtSocksPass].EchoMode = textinput.EchoPassword + inputs[txtSocksPass].CharLimit = 128 + + inputs[txtConnectAddr] = textinput.New() + inputs[txtConnectAddr].Placeholder = "example.com:80" + inputs[txtConnectAddr].CharLimit = 256 + inputs[txtDomain].Focus() return inputs } @@ -305,13 +328,22 @@ func applyConfig(m Model) (Model, tea.Cmd) { if v, err := strconv.Atoi(m.configInputs[txtE2ETimeout].Value()); err == nil && v > 0 { m.config.E2ETimeout = v } + m.config.SocksUser = strings.TrimSpace(m.configInputs[txtSocksUser].Value()) + m.config.SocksPass = strings.TrimSpace(m.configInputs[txtSocksPass].Value()) + m.config.ConnectAddr = strings.TrimSpace(m.configInputs[txtConnectAddr].Value()) // Clear all e2e fields if e2e is disabled if !m.config.E2E { m.config.Pubkey = "" m.config.Cert = "" + m.config.SocksUser = "" + m.config.SocksPass = "" + m.config.ConnectAddr = "" m.configInputs[txtPubkey].SetValue("") m.configInputs[txtCert].SetValue("") + m.configInputs[txtSocksUser].SetValue("") + m.configInputs[txtSocksPass].SetValue("") + m.configInputs[txtConnectAddr].SetValue("") } if m.config.OutputFile == "" { diff --git a/internal/tui/screen_running.go b/internal/tui/screen_running.go index f3e30db..6340f5b 100644 --- a/internal/tui/screen_running.go +++ b/internal/tui/screen_running.go @@ -61,6 +61,8 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) { ports = scanner.PortPool(30000, cfg.Workers) } + socksOpts := scanner.SOCKS5Opts{User: cfg.SocksUser, Pass: cfg.SocksPass, ConnectAddr: cfg.ConnectAddr} + if cfg.DoH { if cfg.Domain == "" { steps = append(steps, scanner.Step{ @@ -77,7 +79,7 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) { if cfg.Domain != "" && cfg.Pubkey != "" { steps = append(steps, scanner.Step{ Name: "doh/e2e", Timeout: e2eDur, - Check: scanner.DoHDnsttCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports), SortBy: "e2e_ms", + Check: scanner.DoHDnsttCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports, socksOpts), SortBy: "e2e_ms", }) } } else { @@ -114,19 +116,19 @@ func buildSteps(cfg ScanConfig) ([]scanner.Step, error) { if cfg.Domain != "" && cfg.Pubkey != "" { steps = append(steps, scanner.Step{ Name: "e2e/dnstt", Timeout: e2eDur, - Check: scanner.DnsttCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports), SortBy: "socks_ms", + Check: scanner.DnsttCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports, socksOpts), SortBy: "socks_ms", }) if cfg.Throughput { steps = append(steps, scanner.Step{ Name: "throughput/dnstt", Timeout: e2eDur, - Check: scanner.ThroughputCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports), SortBy: "throughput_ms", + Check: scanner.ThroughputCheckBin(dnsttBin, cfg.Domain, cfg.Pubkey, ports, socksOpts), SortBy: "throughput_ms", }) } } if cfg.Domain != "" && cfg.Cert != "" { steps = append(steps, scanner.Step{ Name: "e2e/slipstream", Timeout: e2eDur, - Check: scanner.SlipstreamCheckBin(slipstreamBin, cfg.Domain, cfg.Cert, ports), SortBy: "e2e_ms", + Check: scanner.SlipstreamCheckBin(slipstreamBin, cfg.Domain, cfg.Cert, ports, socksOpts), SortBy: "e2e_ms", }) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 12f82a7..a0cd684 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -38,6 +38,9 @@ type ScanConfig struct { Discover bool DiscoverRounds int Throughput bool + SocksUser string + SocksPass string + ConnectAddr string OutputFile string } From 15d4c99c8315ca15768ce4458a0206c39157a38b Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 25 Mar 2026 13:03:44 +0300 Subject: [PATCH 7/8] Sync TUI CLI-flags parser with all new flags The welcome screen's CLI Flags mode (paste flags to pre-fill config) was missing --discover, --throughput, --socks-user, --socks-pass, and --connect-addr. Users pasting CLI flags in the TUI now get all options applied correctly. Also updates the CLI examples shown in the welcome screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/tui/screen_welcome.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/internal/tui/screen_welcome.go b/internal/tui/screen_welcome.go index a3e5d53..87e1bcb 100644 --- a/internal/tui/screen_welcome.go +++ b/internal/tui/screen_welcome.go @@ -153,6 +153,36 @@ func parseCLIFlags(m *Model, raw string) { m.config.E2E = true case "--doh": m.config.DoH = true + case "--discover": + m.config.Discover = true + case "--discover-rounds": + if next != "" { + fmt.Sscanf(next, "%d", &m.config.DiscoverRounds) + i++ + } + case "--throughput": + m.config.Throughput = true + m.config.E2E = true + case "--socks-user": + if next != "" { + m.config.SocksUser = next + m.configInputs[txtSocksUser].SetValue(next) + m.config.E2E = true + i++ + } + case "--socks-pass": + if next != "" { + m.config.SocksPass = next + m.configInputs[txtSocksPass].SetValue(next) + i++ + } + case "--connect-addr": + if next != "" { + m.config.ConnectAddr = next + m.configInputs[txtConnectAddr].SetValue(next) + m.config.E2E = true + i++ + } } } } @@ -232,9 +262,9 @@ func viewWelcome(m Model) string { b.WriteString("\n") b.WriteString(dimStyle.Render(" --domain t.example.com --workers 100 --skip-ping")) b.WriteString("\n") - b.WriteString(dimStyle.Render(" --doh --edns --output results.json")) + b.WriteString(dimStyle.Render(" --pubkey abc123 --domain t.example.com --discover")) b.WriteString("\n") - b.WriteString(dimStyle.Render(" --e2e --pubkey abc123 --cert cert.pem")) + b.WriteString(dimStyle.Render(" --pubkey abc123 --socks-user user --socks-pass pass")) b.WriteString("\n\n") b.WriteString(dimStyle.Render(" enter confirm esc cancel")) b.WriteString("\n") From e5b5cb36310841d6af0f0c6fb14088caa36d91f8 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 25 Mar 2026 13:32:31 +0300 Subject: [PATCH 8/8] Address review: fix goroutine leak, DFS opt-in, discovery cap Fixes all issues raised in SamNet-dev/findns#7 review: 1. Fix goroutine leak in pipeline.go: add WaitGroup for workers, drain results channel on cancellation. No goroutines left behind. 2. Make DFS opt-in (--dfs flag): BFS (RunChainQuietCtx) remains the default, proven pipeline. DFS is available for users who want instant results on large scans. 3. Add --discover-max flag: caps IPs per discovery round to prevent unbounded scan explosion (e.g. --discover-max 1000). 4. Fix throughput false negatives: lower minimum from 100 bytes to any data (1+ bytes). A valid HTTP redirect or short response still proves payload flows through the tunnel. 5. Fix IP file overwrite: in DFS mode, saveResults skips WriteIPList since the file is already live-appended by the pipeline. 6. Discovery rounds use the same BFS/DFS mode as the main scan (via runScanChunk helper). Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/scan.go | 33 +++++++++++++++++++++++++++------ internal/scanner/e2e.go | 5 +++-- internal/scanner/pipeline.go | 28 +++++++++++++++++----------- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/cmd/scan.go b/cmd/scan.go index 9cd0ce0..3be206f 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -78,6 +78,8 @@ func init() { scanCmd.Flags().String("socks-user", "", "SOCKS5 username for e2e tunnel proxy auth") scanCmd.Flags().String("socks-pass", "", "SOCKS5 password for e2e tunnel proxy auth") scanCmd.Flags().String("connect-addr", "", "host:port for SOCKS5 CONNECT probe (default example.com:80, use host:22 for SSH)") + scanCmd.Flags().Bool("dfs", false, "use DFS pipeline (each worker runs one IP through all steps; results appear instantly)") + scanCmd.Flags().Int("discover-max", 0, "max total IPs per discovery round (0 = no limit)") rootCmd.AddCommand(scanCmd) } @@ -100,7 +102,9 @@ func runScan(cmd *cobra.Command, args []string) error { cidrFile, _ := cmd.Flags().GetString("cidr-file") discover, _ := cmd.Flags().GetBool("discover") discoverRounds, _ := cmd.Flags().GetInt("discover-rounds") + discoverMax, _ := cmd.Flags().GetInt("discover-max") throughput, _ := cmd.Flags().GetBool("throughput") + dfsMode, _ := cmd.Flags().GetBool("dfs") socksUser, _ := cmd.Flags().GetString("socks-user") socksPass, _ := cmd.Flags().GetString("socks-pass") connectAddr, _ := cmd.Flags().GetString("connect-addr") @@ -319,15 +323,16 @@ func runScan(cmd *cobra.Command, args []string) error { outputIPs = strings.TrimSuffix(outputFile, ".json") + "_ips.txt" } - // saveResults writes current results to JSON + IP list + // saveResults writes current results to JSON + IP list. + // In DFS mode, the IP list is live-appended by runPipelineScan, + // so we only write it in BFS mode to avoid overwriting. saveResults := func(report scanner.ChainReport) { - // Merge with previously loaded results from --resume merged := report if len(allPassed) > 0 { merged.Passed = append(allPassed, report.Passed...) } scanner.WriteChainReport(merged, outputFile) - if len(merged.Passed) > 0 { + if !dfsMode && len(merged.Passed) > 0 { scanner.WriteIPList(merged.Passed, outputIPs) } } @@ -343,6 +348,15 @@ func runScan(cmd *cobra.Command, args []string) error { var report scanner.ChainReport + // runScanChunk runs a chunk of IPs through either BFS (default) or DFS (--dfs) + runScanChunk := func(chunk []string) scanner.ChainReport { + if dfsMode { + return runPipelineScan(ctx, chunk, workers, steps) + } + return scanner.RunChainQuietCtx(ctx, chunk, workers, steps, + newScanProgressFactory(len(steps), stepDescriptions)) + } + // --batch: split IPs into chunks, save after each batch if batchSize > 0 && len(ips) > batchSize { totalBatches := (len(ips) + batchSize - 1) / batchSize @@ -359,7 +373,7 @@ func runScan(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n %s━━━ Batch %d/%d (%d IPs) ━━━%s\n\n", colorCyan, batchNum, totalBatches, len(chunk), colorReset) - batchReport := runPipelineScan(ctx, chunk, workers, steps) + batchReport := runScanChunk(chunk) scanner.MergeChainReports(&report, batchReport) saveResults(report) @@ -368,7 +382,7 @@ func runScan(cmd *cobra.Command, args []string) error { colorGreen, batchNum, totalPassed, outputFile, colorReset) } } else { - report = runPipelineScan(ctx, ips, workers, steps) + report = runScanChunk(ips) } // --discover: find neighbor /24 subnets from passed IPs and scan them @@ -405,6 +419,13 @@ func runScan(cmd *cobra.Command, args []string) error { break } + // Cap discovery IPs per round + if discoverMax > 0 && len(neighborIPs) > discoverMax { + fmt.Fprintf(os.Stderr, " %s⚠ Capping discovery from %d to %d IPs (--discover-max)%s\n", + colorYellow, len(neighborIPs), discoverMax, colorReset) + neighborIPs = neighborIPs[:discoverMax] + } + for _, ip := range neighborIPs { scanned[ip] = struct{}{} } @@ -412,7 +433,7 @@ func runScan(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n %s━━━ Discovery round %d: %d new /24 subnets → %d IPs ━━━%s\n\n", colorCyan, round, len(toExpand), len(neighborIPs), colorReset) - roundReport := runPipelineScan(ctx, neighborIPs, workers, steps) + roundReport := runScanChunk(neighborIPs) scanner.MergeChainReports(&report, roundReport) saveResults(report) diff --git a/internal/scanner/e2e.go b/internal/scanner/e2e.go index 141a0a8..48efc13 100644 --- a/internal/scanner/e2e.go +++ b/internal/scanner/e2e.go @@ -569,8 +569,9 @@ func waitAndTestThroughput(ctx context.Context, port int, exited <-chan struct{} } conn.Close() - if totalRead < 100 { - return totalRead, false + // Any data received proves payload flows through the tunnel + if totalRead == 0 { + return 0, false } return totalRead, true } diff --git a/internal/scanner/pipeline.go b/internal/scanner/pipeline.go index ea1e70b..2b88db7 100644 --- a/internal/scanner/pipeline.go +++ b/internal/scanner/pipeline.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "sync" ) // PipelineResult is the outcome of running one IP through the full step pipeline. @@ -17,6 +18,7 @@ type PipelineResult struct { // each worker takes one IP and runs it through the entire pipeline. // Results are emitted to the returned channel as each IP completes. // The channel is closed when all IPs are processed or the context is cancelled. +// All goroutines are properly cleaned up on cancellation (no leaks). func RunPipeline(ctx context.Context, ips []string, workers int, steps []Step) <-chan PipelineResult { out := make(chan PipelineResult, workers) @@ -33,9 +35,13 @@ func RunPipeline(ctx context.Context, ips []string, workers int, steps []Step) < } results := make(chan PipelineResult, bufSize) - // Launch workers — each takes one IP and runs ALL steps on it + // WaitGroup tracks all workers so we can drain results safely + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) go func() { + defer wg.Done() for ip := range jobs { func() { defer func() { @@ -71,23 +77,23 @@ func RunPipeline(ctx context.Context, ips []string, workers int, steps []Step) < select { case jobs <- ip: case <-ctx.Done(): - close(jobs) - return + break } } close(jobs) + // Wait for all workers to finish, then close results + wg.Wait() + close(results) }() - // Forward results to output channel - for i := 0; i < len(ips); i++ { + // Forward results to output channel until results is closed + for r := range results { select { - case r := <-results: - select { - case out <- r: - case <-ctx.Done(): - return - } + case out <- r: case <-ctx.Done(): + // Drain remaining results to unblock workers + for range results { + } return } }