302 lines
8.0 KiB
Go
302 lines
8.0 KiB
Go
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)
|
|
}
|