Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 31 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
85 changes: 85 additions & 0 deletions cmd/resolvers.go
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 18 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions cmd/scan.go
Original file line number Diff line number Diff line change
@@ -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)
}
Binary file added dnst-scanner.exe
Binary file not shown.
18 changes: 18 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
24 changes: 24 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
49 changes: 49 additions & 0 deletions internal/scanner/analyze.go
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 41 additions & 0 deletions internal/scanner/analyze_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
35 changes: 35 additions & 0 deletions internal/scanner/basic.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading