From fb37d34b508556b84a4ca20ce031546ffe53d8bf Mon Sep 17 00:00:00 2001 From: NimaAmiri Date: Mon, 26 Jan 2026 22:59:56 +0100 Subject: [PATCH] dnst-scanner: initial implementation of basic and e2e DNS scanning --- cmd/main.go | 98 ++++++++++++++++++++++++++++++++++++++ go.mod | 13 +++++ go.sum | 14 ++++++ internal/scanner/basic.go | 15 ++++++ internal/scanner/dns.go | 30 ++++++++++++ internal/scanner/input.go | 31 ++++++++++++ internal/scanner/worker.go | 35 ++++++++++++++ resolvers.txt | 4 ++ 8 files changed, 240 insertions(+) create mode 100644 cmd/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/scanner/basic.go create mode 100644 internal/scanner/dns.go create mode 100644 internal/scanner/input.go create mode 100644 internal/scanner/worker.go create mode 100644 resolvers.txt diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..7e76af0 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/net2share/dnst-scanner/internal/scanner" +) + +func main() { + input := flag.String("input", "resolvers.txt", "resolver IP list file") + workers := flag.Int("workers", 50, "number of concurrent workers") + timeoutSec := flag.Int("timeout", 3, "timeout per resolver (seconds)") + + step := flag.String("step", "basic", "scan step: basic|e2e") + + domain := flag.String("domain", "example.com", "domain to query (basic scan)") + hcSlip := flag.String("slipstream-health", "", "slipstream health check domain") + hcDNSTT := flag.String("dnstt-health", "", "dnstt health check domain") + + format := flag.String("format", "plain", "output format: plain|json") + only := flag.String("only", "", "filter results: ok|fail (optional)") + flag.Parse() + + if *workers <= 0 || *timeoutSec <= 0 { + fmt.Fprintln(os.Stderr, "invalid workers or timeout") + os.Exit(2) + } + + timeout := time.Duration(*timeoutSec) * time.Second + + ips, err := scanner.LoadResolvers(*input) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + // step handling + var domainToQuery string + + switch *step { + case "basic": + domainToQuery = *domain + case "e2e": + if *hcSlip != "" { + domainToQuery = *hcSlip + } else if *hcDNSTT != "" { + domainToQuery = *hcDNSTT + } else { + fmt.Fprintln(os.Stderr, "e2e step requires --slipstream-health or --dnstt-health") + os.Exit(2) + } + default: + fmt.Fprintln(os.Stderr, "invalid step:", *step) + os.Exit(2) + } + + results := scanner.RunPool(ips, *workers, timeout, domainToQuery) + + // filter + if *only == "ok" || *only == "fail" { + wantOK := *only == "ok" + filtered := make([]scanner.Result, 0) + for _, r := range results { + if r.OK == wantOK { + filtered = append(filtered, r) + } + } + results = filtered + } + + // output + if *format == "json" { + if err := json.NewEncoder(os.Stdout).Encode(results); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + } else { + for _, r := range results { + if r.OK { + fmt.Println("OK ", r.IP) + } else { + fmt.Println("FAIL", r.IP) + } + } + } + + // exit codes + for _, r := range results { + if r.OK { + os.Exit(0) + } + } + os.Exit(1) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b0e7b15 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/net2share/dnst-scanner + +go 1.25.6 + +require github.com/miekg/dns v1.1.72 + +require ( + 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..92364b3 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +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/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +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= diff --git a/internal/scanner/basic.go b/internal/scanner/basic.go new file mode 100644 index 0000000..123fb41 --- /dev/null +++ b/internal/scanner/basic.go @@ -0,0 +1,15 @@ +package scanner + +import ( + "net" + "time" +) + +func TestResolver(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/dns.go b/internal/scanner/dns.go new file mode 100644 index 0000000..87d294b --- /dev/null +++ b/internal/scanner/dns.go @@ -0,0 +1,30 @@ +package scanner + +import ( + "context" + "time" + + "github.com/miekg/dns" +) + +func QueryA(resolver, domain string, timeout time.Duration) bool { + 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 || r == nil { + return false + } + if r.Rcode != dns.RcodeSuccess { + return false + } + return len(r.Answer) > 0 +} 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/worker.go b/internal/scanner/worker.go new file mode 100644 index 0000000..723b00e --- /dev/null +++ b/internal/scanner/worker.go @@ -0,0 +1,35 @@ +package scanner + +import "time" + +type Result struct { + IP string `json:"ip"` + OK bool `json:"ok"` +} + +func RunPool(ips []string, workers int, timeout time.Duration, domain string) []Result { + jobs := make(chan string) + results := make(chan Result) + + for i := 0; i < workers; i++ { + go func() { + for ip := range jobs { + ok := QueryA(ip, domain, timeout) + results <- Result{IP: ip, OK: ok} + } + }() + } + + go func() { + for _, ip := range ips { + jobs <- ip + } + close(jobs) + }() + + out := make([]Result, 0, len(ips)) + for i := 0; i < len(ips); i++ { + out = append(out, <-results) + } + return out +} 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