diff --git a/go.mod b/go.mod index 40e95d0..4184108 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ go 1.24.0 require ( github.com/alecthomas/kong v1.8.1 + github.com/google/gopacket v1.1.19 github.com/miekg/dns v1.1.63 github.com/quic-go/quic-go v0.50.0 + golang.org/x/net v0.35.0 ) require ( @@ -17,7 +19,6 @@ require ( golang.org/x/crypto v0.33.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.18.0 // indirect - golang.org/x/net v0.35.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum index 224a424..69ae768 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 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/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -41,25 +43,37 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/qol.go b/qol.go new file mode 100644 index 0000000..a704710 --- /dev/null +++ b/qol.go @@ -0,0 +1,301 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/afonsofrancof/sdns-proxy/client" + "github.com/alecthomas/kong" + "github.com/google/gopacket" + "github.com/google/gopacket/pcap" + "github.com/google/gopacket/pcapgo" + "github.com/miekg/dns" +) + +type CLI struct { + Run RunCmd `cmd:"" help:"Run measurements for given servers and domains"` +} + +type RunCmd struct { + DomainsFile string `arg:"" help:"File with domains (one per line)"` + OutputDir string `short:"o" long:"output" default:"results" help:"Output directory"` + QueryType string `short:"t" long:"type" default:"A" help:"DNS query type"` + Repeat int `short:"r" long:"repeat" default:"5" help:"Queries per domain (sequential)"` + Timeout time.Duration `long:"timeout" default:"5s" help:"Query timeout (informational)"` + DNSSEC bool `long:"dnssec" help:"Enable DNSSEC"` + Interface string `long:"iface" default:"any" help:"Capture interface (e.g., eth0, any)"` + + Servers []string `short:"s" long:"server" help:"Upstream servers (udp://..., tls://..., https://..., doq://...)"` +} + +type DNSMetric struct { + Domain string `json:"domain"` + QueryType string `json:"query_type"` + Protocol string `json:"protocol"` + DNSSEC bool `json:"dnssec"` + DNSServer string `json:"dns_server"` + Timestamp time.Time `json:"timestamp"` + Duration int64 `json:"duration_ns"` + DurationMs float64 `json:"duration_ms"` + RequestSize int `json:"request_size_bytes"` + ResponseSize int `json:"response_size_bytes"` + ResponseCode string `json:"response_code"` + Error string `json:"error,omitempty"` +} + +func (r *RunCmd) Run() error { + // Check if running with sufficient privileges for packet capture + if err := checkCapturePermissions(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + fmt.Fprintf(os.Stderr, "Packet capture may fail. Consider running as root/administrator.\n") + } + + domains, err := readDomainsFile(r.DomainsFile) + if err != nil { + return fmt.Errorf("failed reading domains: %w", err) + } + if len(r.Servers) == 0 { + return fmt.Errorf("at least one --server must be provided") + } + if err := os.MkdirAll(r.OutputDir, 0755); err != nil { + return fmt.Errorf("mkdir output: %w", err) + } + + qType, ok := dns.StringToType[strings.ToUpper(r.QueryType)] + if !ok { + return fmt.Errorf("invalid qtype: %s", r.QueryType) + } + + for _, upstream := range r.Servers { + if err := r.runOne(upstream, domains, qType); err != nil { + fmt.Fprintf(os.Stderr, "error on server %s: %v\n", upstream, err) + } + } + return nil +} + +func (r *RunCmd) runOne(upstream string, domains []string, qType uint16) error { + opts := client.Options{DNSSEC: r.DNSSEC} + dnsClient, err := client.New(upstream, opts) + if err != nil { + return fmt.Errorf("failed creating client: %w", err) + } + defer dnsClient.Close() + + // file naming + proto := detectProtocol(upstream) + ts := time.Now().Format("20060102_1504") + dnssecStr := "off" + if r.DNSSEC { + dnssecStr = "on" + } + base := fmt.Sprintf("%s_%s_dnssec_%s_%s", + proto, sanitize(upstream), dnssecStr, ts) + jsonPath := filepath.Join(r.OutputDir, base+".jsonl") + pcapPath := filepath.Join(r.OutputDir, base+".pcap") + + fmt.Printf(">>> Measuring %s (dnssec=%v) → %s\n", upstream, r.DNSSEC, base) + + // setup pcap capture + handle, err := pcap.OpenLive(r.Interface, 65535, true, pcap.BlockForever) + if err != nil { + return fmt.Errorf("pcap open (try running as root): %w", err) + } + defer handle.Close() + + pcapFile, err := os.Create(pcapPath) + if err != nil { + return fmt.Errorf("create pcap file: %w", err) + } + defer pcapFile.Close() + + writer := pcapgo.NewWriter(pcapFile) + if err := writer.WriteFileHeader(65535, handle.LinkType()); err != nil { + return fmt.Errorf("pcap header: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + psrc := gopacket.NewPacketSource(handle, handle.LinkType()) + pktCh := psrc.Packets() + + var wg sync.WaitGroup + var captureErr error + captureMutex := sync.Mutex{} + + // Start packet capture goroutine + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case pkt, ok := <-pktCh: + if !ok { + return + } + ci := pkt.Metadata().CaptureInfo + if err := writer.WritePacket(ci, pkt.Data()); err != nil { + captureMutex.Lock() + if captureErr == nil { + captureErr = fmt.Errorf("pcap write error: %w", err) + } + captureMutex.Unlock() + fmt.Fprintf(os.Stderr, "pcap write error: %v\n", err) + } + case <-ctx.Done(): + return + } + } + }() + + // open JSONL output + out, err := os.Create(jsonPath) + if err != nil { + cancel() + wg.Wait() + return fmt.Errorf("create json out: %w", err) + } + defer out.Close() + enc := json.NewEncoder(out) + + // sequential measurement + for _, domain := range domains { + for rep := 0; rep < r.Repeat; rep++ { + metric := performQuery(dnsClient, domain, upstream, proto, qType, r.QueryType, r.DNSSEC) + if err := enc.Encode(metric); err != nil { + fmt.Fprintf(os.Stderr, "encode error: %v\n", err) + } + fmt.Printf("✓ %s [%s] %s %.2fms\n", + metric.Domain, metric.Protocol, metric.ResponseCode, metric.DurationMs) + + // Small delay to allow packet capture to catch up + time.Sleep(10 * time.Millisecond) + } + } + + // Allow some time for final packets to be captured + time.Sleep(100 * time.Millisecond) + cancel() + wg.Wait() + + // Check if there were capture errors + captureMutex.Lock() + defer captureMutex.Unlock() + if captureErr != nil { + fmt.Fprintf(os.Stderr, "Warning: packet capture errors occurred: %v\n", captureErr) + } + + return nil +} + +func performQuery(dnsClient client.DNSClient, domain, upstream, proto string, + qType uint16, qTypeStr string, dnssec bool) DNSMetric { + + metric := DNSMetric{ + Domain: domain, + QueryType: qTypeStr, + Protocol: proto, + DNSSEC: dnssec, + DNSServer: upstream, + Timestamp: time.Now(), + } + + msg := new(dns.Msg) + msg.Id = dns.Id() + msg.RecursionDesired = true + msg.SetQuestion(dns.Fqdn(domain), qType) + + packed, err := msg.Pack() + if err != nil { + metric.ResponseCode = "ERROR" + metric.Error = fmt.Sprintf("pack request: %v", err) + return metric + } + metric.RequestSize = len(packed) + + start := time.Now() + resp, err := dnsClient.Query(msg) + metric.Duration = time.Since(start).Nanoseconds() + metric.DurationMs = float64(metric.Duration) / 1e6 + + if err != nil { + metric.ResponseCode = "ERROR" + metric.Error = err.Error() + return metric + } + + respBytes, err := resp.Pack() + if err != nil { + metric.ResponseCode = "ERROR" + metric.Error = fmt.Sprintf("pack response: %v", err) + return metric + } + + metric.ResponseSize = len(respBytes) + metric.ResponseCode = dns.RcodeToString[resp.Rcode] + return metric +} + +func readDomainsFile(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var out []string + sc := bufio.NewScanner(f) + for sc.Scan() { + l := strings.TrimSpace(sc.Text()) + if l != "" && !strings.HasPrefix(l, "#") { + out = append(out, l) + } + } + return out, sc.Err() +} + +func sanitize(s string) string { + return strings.NewReplacer(":", "_", "/", "_").Replace(s) +} + +func detectProtocol(upstream string) string { + if strings.Contains(upstream, "://") { + u, err := url.Parse(upstream) + if err == nil && u.Scheme != "" { + return strings.ToLower(u.Scheme) + } + } + return "do53" +} + +func checkCapturePermissions() error { + // Try to open a test interface to check permissions + handle, err := pcap.OpenLive("any", 65535, false, time.Millisecond*100) + if err != nil { + if strings.Contains(err.Error(), "permission") || + strings.Contains(err.Error(), "Operation not permitted") { + return fmt.Errorf("insufficient permissions for packet capture") + } + // Other errors might be due to interface availability, which is acceptable + return nil + } + handle.Close() + return nil +} + +func main() { + ctx := kong.Parse(&CLI{}, + kong.Name("dns-measurer"), + kong.Description("DNS secure protocols measurer with metrics + full pcap capture"), + kong.UsageOnError(), + ) + err := ctx.Run() + ctx.FatalIfErrorf(err) +}