diff --git a/README.md b/README.md index 78ec7f7..8c73c1a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ A tool to scan and identify recursive DNS resolvers compatible with DNS tunnelin ### Step 2: E2E Validation (Optional) Tests resolvers with actual tunnel connections: + - Requires Slipstream/DNSTT client binaries - Connects through each resolver to health check endpoint - Verifies complete tunnel path works @@ -83,32 +84,33 @@ dnst-scanner scan --tunnel-domain t.example.com --format json --output results.j ## Configuration -| Option | Description | Default | -|--------|-------------|---------| -| `--input` | Custom resolver IP list file | Fetch from ir-resolvers | -| `--tunnel-domain` | NS subdomain to test tunnel reachability | Required | -| `--e2e` | Enable E2E validation with actual tunnels | false | -| `--slipstream-health` | Slipstream health check domain (for E2E) | - | -| `--slipstream-fingerprint` | Slipstream TLS fingerprint (for E2E) | - | -| `--dnstt-health` | DNSTT health check domain (for E2E) | - | -| `--dnstt-pubkey` | DNSTT public key (for E2E) | - | -| `--workers` | Number of concurrent workers | 50 | -| `--timeout` | Timeout per resolver | 3s | -| `--output` | Output file path | stdout | -| `--format` | Output format: `plain` or `json` | `json` | +| Option | Description | Default | +| -------------------------- | ----------------------------------------- | ----------------------- | +| `--input` | Custom resolver IP list file | Fetch from ir-resolvers | +| `--tunnel-domain` | NS subdomain to test tunnel reachability | Required | +| `--e2e` | Enable E2E validation with actual tunnels | false | +| `--slipstream-health` | Slipstream health check domain (for E2E) | - | +| `--slipstream-fingerprint` | Slipstream TLS fingerprint (for E2E) | - | +| `--dnstt-health` | DNSTT health check domain (for E2E) | - | +| `--dnstt-pubkey` | DNSTT public key (for E2E) | - | +| `--workers` | Number of concurrent workers | 50 | +| `--timeout` | Timeout per resolver | 3s | +| `--output` | Output file path | stdout | +| `--format` | Output format: `plain` or `json` | `json` | ### Environment Variable Overrides -| Variable | Description | -|----------|-------------| -| `DNST_SCANNER_RESOLVERS_URL` | Override default ir-resolvers URL | -| `DNST_SCANNER_RESOLVERS_PATH` | Use local file (skips download) | -| `DNST_SCANNER_SLIPSTREAM_PATH` | Path to slipstream-client binary | -| `DNST_SCANNER_DNSTT_PATH` | Path to dnstt-client binary | +| Variable | Description | +| ------------------------------ | --------------------------------- | +| `DNST_SCANNER_RESOLVERS_URL` | Override default ir-resolvers URL | +| `DNST_SCANNER_RESOLVERS_PATH` | Use local file (skips download) | +| `DNST_SCANNER_SLIPSTREAM_PATH` | Path to slipstream-client binary | +| `DNST_SCANNER_DNSTT_PATH` | Path to dnstt-client binary | ## Integration with dnstc dnstc orchestrates dnst-scanner as a subprocess: + - dnstc runs dnst-scanner with appropriate flags - Scanner outputs JSON to stdout - dnstc parses results and updates resolver pool @@ -132,3 +134,13 @@ dnst-scanner scan --tunnel-domain t.example.com --format json - [dnstm](https://github.com/net2share/dnstm) - DNS tunnel server (hosts health check endpoints) - [ir-resolvers](https://github.com/net2share/ir-resolvers) - Raw resolver IP list - [go-corelib](https://github.com/net2share/go-corelib) - Shared Go library + +### Slipstream E2E (Platform Note) + +Slipstream E2E validation requires the Slipstream client binary. +At the moment, official Slipstream client binaries are only available for Linux. + +- Windows: E2E will report a clear error if the binary is not present +- Linux / CI / WSL: Full Slipstream E2E validation is supported + +This is expected behavior. diff --git a/cmd/resolvers.go b/cmd/resolvers.go new file mode 100644 index 0000000..4cab8f3 --- /dev/null +++ b/cmd/resolvers.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "bufio" + "errors" + "net" + "net/http" + "os" + "strings" +) + +const defaultResolversURL = "https://raw.githubusercontent.com/net2share/ir-resolvers/main/resolvers.txt" + +func loadResolvers() ([]string, error) { + // 1) ENV: Path + if p := os.Getenv("DNST_SCANNER_RESOLVERS_PATH"); p != "" { + return loadFromFile(p) + } + + // 2) ENV: URL + if u := os.Getenv("DNST_SCANNER_RESOLVERS_URL"); u != "" { + return loadFromURL(u) + } + + // 3) Default URL + return loadFromURL(defaultResolversURL) +} + +func loadFromFile(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + return parseIPs(f) +} + +func loadFromURL(url string) ([]string, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("failed to fetch resolvers") + } + + return parseIPs(resp.Body) +} + +func parseIPs(r interface{}) ([]string, error) { + var s *bufio.Scanner + + switch v := r.(type) { + case *os.File: + s = bufio.NewScanner(v) + case *strings.Reader: + s = bufio.NewScanner(v) + case *http.Response: + s = bufio.NewScanner(v.Body) + default: + // generic reader + s = bufio.NewScanner(r.(interface { + Read([]byte) (int, error) + })) + } + + var ips []string + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if net.ParseIP(line) == nil { + continue + } + ips = append(ips, line) + } + if err := s.Err(); err != nil { + return nil, err + } + return ips, nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..720458e --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,18 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "dnst-scanner", + Short: "DNS Tunnel resolver scanner", +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/scan.go b/cmd/scan.go new file mode 100644 index 0000000..f7db8c9 --- /dev/null +++ b/cmd/scan.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Scan resolvers (placeholder)", + RunE: func(cmd *cobra.Command, args []string) error { + ips, err := loadResolvers() + if err != nil { + return err + } + fmt.Printf("Loaded %d resolvers\n", len(ips)) + return nil + }, +} + +func init() { + rootCmd.AddCommand(scanCmd) +} diff --git a/dnst-scanner.exe b/dnst-scanner.exe new file mode 100644 index 0000000..4fc19b0 Binary files /dev/null and b/dnst-scanner.exe differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c469391 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/net2share/dnst-scanner + +go 1.25.6 + +require ( + github.com/miekg/dns v1.1.72 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4b81c62 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/scanner/analyze.go b/internal/scanner/analyze.go new file mode 100644 index 0000000..905818a --- /dev/null +++ b/internal/scanner/analyze.go @@ -0,0 +1,49 @@ +package scanner + +import ( + "net" + + "github.com/miekg/dns" +) + +func analyzeResponse(domain string, msg *dns.Msg) DomainResult { + res := DomainResult{ + Domain: domain, + Resolved: false, + Hijacked: false, + } + + if msg == nil || len(msg.Answer) == 0 { + return res + } + + res.Resolved = true + + for _, ans := range msg.Answer { + if a, ok := ans.(*dns.A); ok { + ip := a.A + if isPrivateIP(ip) { + res.Hijacked = true + return res + } + } + } + + return res +} + +func isPrivateIP(ip net.IP) bool { + privateRanges := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + } + + for _, cidr := range privateRanges { + _, block, _ := net.ParseCIDR(cidr) + if block.Contains(ip) { + return true + } + } + return false +} diff --git a/internal/scanner/analyze_test.go b/internal/scanner/analyze_test.go new file mode 100644 index 0000000..e283d0c --- /dev/null +++ b/internal/scanner/analyze_test.go @@ -0,0 +1,41 @@ +package scanner + +import ( + "net" + "testing" + + "github.com/miekg/dns" +) + +func TestIsPrivateIP(t *testing.T) { + tests := []struct { + ip string + expect bool + }{ + {"10.1.2.3", true}, + {"192.168.1.1", true}, + {"172.16.0.5", true}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + } + + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + if isPrivateIP(ip) != tt.expect { + t.Fatalf("ip %s expected %v", tt.ip, tt.expect) + } + } +} + +func TestAnalyzeResponse_HijackDetected(t *testing.T) { + msg := new(dns.Msg) + msg.Answer = append(msg.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: "facebook.com.", Rrtype: dns.TypeA}, + A: net.ParseIP("10.0.0.1"), + }) + + res := analyzeResponse("facebook.com", msg) + if !res.Hijacked { + t.Fatal("expected hijack to be detected") + } +} diff --git a/internal/scanner/basic.go b/internal/scanner/basic.go new file mode 100644 index 0000000..544421b --- /dev/null +++ b/internal/scanner/basic.go @@ -0,0 +1,35 @@ +package scanner + +import "time" + +func BasicScan(ip string, tunnelDomain string, timeout time.Duration) ScanResult { + results := make([]DomainResult, 0) + + pingOK := PingCheck(ip, timeout) + + allDomains := append([]string{}, NormalDomains...) + allDomains = append(allDomains, BlockedDomains...) + allDomains = append(allDomains, tunnelDomain) + + for _, d := range allDomains { + msg, err := ResolveWithRetry(ip, d, timeout) + if err != nil { + results = append(results, DomainResult{ + Domain: d, + Resolved: false, + Hijacked: false, + }) + continue + } + results = append(results, analyzeResponse(d, msg)) + } + + class := classify(results) + + return ScanResult{ + IP: ip, + PingOK: pingOK, + Classification: class, + Domains: results, + } +} diff --git a/internal/scanner/classify.go b/internal/scanner/classify.go new file mode 100644 index 0000000..0436019 --- /dev/null +++ b/internal/scanner/classify.go @@ -0,0 +1,32 @@ +package scanner + +func classify(results []DomainResult) Classification { + for _, r := range results { + // Tunnel-Domain ignorieren + if isTunnelDomain(r.Domain) { + continue + } + + // Normale Domain nicht auflösbar => broken + if !r.Resolved { + return ClassBroken + } + + // Hijack => censored + if r.Hijacked { + return ClassCensored + } + } + return ClassClean +} + +// Aktuell simple Heuristik, README-konform. +// Später ersetzbar durch Domain-Typen. +func isTunnelDomain(domain string) bool { + switch domain { + case "google.com", "microsoft.com", "facebook.com", "x.com": + return false + default: + return true + } +} diff --git a/internal/scanner/dns.go b/internal/scanner/dns.go new file mode 100644 index 0000000..4ca56f1 --- /dev/null +++ b/internal/scanner/dns.go @@ -0,0 +1,27 @@ +package scanner + +import ( + "context" + "time" + + "github.com/miekg/dns" +) + +func QueryAWithResponse(resolver, domain string, timeout time.Duration) (*dns.Msg, error) { + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(domain), dns.TypeA) + m.RecursionDesired = true + + c := new(dns.Client) + c.Net = "udp" + c.Timeout = timeout + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + r, _, err := c.ExchangeContext(ctx, m, resolver+":53") + if err != nil { + return nil, err + } + return r, nil +} diff --git a/internal/scanner/domains.go b/internal/scanner/domains.go new file mode 100644 index 0000000..9b19bd6 --- /dev/null +++ b/internal/scanner/domains.go @@ -0,0 +1,11 @@ +package scanner + +var NormalDomains = []string{ + "google.com", + "microsoft.com", +} + +var BlockedDomains = []string{ + "facebook.com", + "x.com", +} diff --git a/internal/scanner/e2e.go b/internal/scanner/e2e.go new file mode 100644 index 0000000..9fa5fe9 --- /dev/null +++ b/internal/scanner/e2e.go @@ -0,0 +1,30 @@ +package scanner + +import "time" + +type E2EConfig struct { + Enable bool + SlipstreamHealth string + SlipstreamFingerprint string + DNSTTHealth string + DNSTTPubKey string +} + +func RunE2E(ip string, timeout time.Duration, cfg E2EConfig) *E2EResult { + res := &E2EResult{} + + if cfg.SlipstreamHealth != "" { + ok, err := runSlipstreamE2E(ip, timeout, cfg) + res.SlipstreamOK = ok + if err != nil { + res.Error = err.Error() + return res + } + } + + if !res.SlipstreamOK { + res.Error = "slipstream e2e failed" + } + + return res +} diff --git a/internal/scanner/e2e_slipstream.go b/internal/scanner/e2e_slipstream.go new file mode 100644 index 0000000..4722a73 --- /dev/null +++ b/internal/scanner/e2e_slipstream.go @@ -0,0 +1,41 @@ +package scanner + +import ( + "context" + "errors" + "os" + "os/exec" + "time" +) + +func runSlipstreamE2E(resolver string, timeout time.Duration, cfg E2EConfig) (bool, error) { + bin := os.Getenv("DNST_SCANNER_SLIPSTREAM_PATH") + if bin == "" { + return false, errors.New("DNST_SCANNER_SLIPSTREAM_PATH not set") + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + args := []string{ + "--resolver", resolver, + "--health", cfg.SlipstreamHealth, + } + + if cfg.SlipstreamFingerprint != "" { + args = append(args, "--fingerprint", cfg.SlipstreamFingerprint) + } + + cmd := exec.CommandContext(ctx, bin, args...) + err := cmd.Run() + + if ctx.Err() == context.DeadlineExceeded { + return false, errors.New("slipstream timeout") + } + + if err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/scanner/input.go b/internal/scanner/input.go new file mode 100644 index 0000000..49dbc57 --- /dev/null +++ b/internal/scanner/input.go @@ -0,0 +1,31 @@ +package scanner + +import ( + "bufio" + "os" + "strings" +) + +func LoadResolvers(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var resolvers []string + sc := bufio.NewScanner(f) + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + resolvers = append(resolvers, line) + } + + if err := sc.Err(); err != nil { + return nil, err + } + return resolvers, nil +} diff --git a/internal/scanner/model.go b/internal/scanner/model.go new file mode 100644 index 0000000..600f6f5 --- /dev/null +++ b/internal/scanner/model.go @@ -0,0 +1,29 @@ +package scanner + +type Classification string + +const ( + ClassClean Classification = "clean" + ClassCensored Classification = "censored" + ClassBroken Classification = "broken" +) + +type DomainResult struct { + Domain string `json:"domain"` + Resolved bool `json:"resolved"` + Hijacked bool `json:"hijacked"` +} + +type E2EResult struct { + SlipstreamOK bool `json:"slipstream_ok,omitempty"` + DNSTTOK bool `json:"dnstt_ok,omitempty"` + Error string `json:"error,omitempty"` +} + +type ScanResult struct { + IP string `json:"ip"` + PingOK bool `json:"ping_ok"` + Classification Classification `json:"classification"` + Domains []DomainResult `json:"domains"` + E2E *E2EResult `json:"e2e,omitempty"` +} diff --git a/internal/scanner/ping.go b/internal/scanner/ping.go new file mode 100644 index 0000000..159a553 --- /dev/null +++ b/internal/scanner/ping.go @@ -0,0 +1,15 @@ +package scanner + +import ( + "net" + "time" +) + +func PingCheck(ip string, timeout time.Duration) bool { + conn, err := net.DialTimeout("udp", ip+":53", timeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/internal/scanner/pool.go b/internal/scanner/pool.go new file mode 100644 index 0000000..ddc6b80 --- /dev/null +++ b/internal/scanner/pool.go @@ -0,0 +1,65 @@ +package scanner + +import ( + "sync" + "time" +) + +func RunBasicScanPool(ips []string, workers int, tunnelDomain string, timeout time.Duration) []ScanResult { + return runPool(ips, workers, timeout, func(ip string) ScanResult { + return BasicScan(ip, tunnelDomain, timeout) + }) +} + +func RunScanPoolWithE2E( + ips []string, + workers int, + tunnelDomain string, + timeout time.Duration, + e2eCfg E2EConfig, +) []ScanResult { + return runPool(ips, workers, timeout, func(ip string) ScanResult { + res := BasicScan(ip, tunnelDomain, timeout) + if e2eCfg.Enable { + res.E2E = RunE2E(ip, timeout, e2eCfg) + } + return res + }) +} + +func runPool( + ips []string, + workers int, + timeout time.Duration, + fn func(string) ScanResult, +) []ScanResult { + in := make(chan string) + out := make(chan ScanResult) + + var wg sync.WaitGroup + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for ip := range in { + out <- fn(ip) + } + }() + } + + go func() { + for _, ip := range ips { + in <- ip + } + close(in) + wg.Wait() + close(out) + }() + + results := make([]ScanResult, 0, len(ips)) + for r := range out { + results = append(results, r) + } + return results +} diff --git a/internal/scanner/retry.go b/internal/scanner/retry.go new file mode 100644 index 0000000..53596e1 --- /dev/null +++ b/internal/scanner/retry.go @@ -0,0 +1,28 @@ +package scanner + +import ( + "time" + + "github.com/miekg/dns" +) + +const ( + retryAttempts = 3 + retryBaseDelay = 200 * time.Millisecond +) + +func ResolveWithRetry(resolver, domain string, timeout time.Duration) (*dns.Msg, error) { + var lastErr error + + for attempt := 1; attempt <= retryAttempts; attempt++ { + msg, err := QueryAWithResponse(resolver, domain, timeout) + if err == nil && msg != nil && len(msg.Answer) > 0 { + return msg, nil + } + + lastErr = err + time.Sleep(time.Duration(attempt) * retryBaseDelay) + } + + return nil, lastErr +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a6ddcbb --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/net2share/dnst-scanner/cmd" + +func main() { + cmd.Execute() +} diff --git a/resolvers.txt b/resolvers.txt new file mode 100644 index 0000000..99c417e --- /dev/null +++ b/resolvers.txt @@ -0,0 +1,4 @@ +# public resolvers +8.8.8.8 +1.1.1.1 +9.9.9.9