feat(qol): add gol.go

This commit is contained in:
2025-09-08 19:10:47 +01:00
parent c6e2b19a84
commit 07317afe4f
3 changed files with 317 additions and 1 deletions

3
go.mod
View File

@@ -4,8 +4,10 @@ go 1.24.0
require ( require (
github.com/alecthomas/kong v1.8.1 github.com/alecthomas/kong v1.8.1
github.com/google/gopacket v1.1.19
github.com/miekg/dns v1.1.63 github.com/miekg/dns v1.1.63
github.com/quic-go/quic-go v0.50.0 github.com/quic-go/quic-go v0.50.0
golang.org/x/net v0.35.0
) )
require ( require (
@@ -17,7 +19,6 @@ require (
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.33.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.18.0 // 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/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect

14
go.sum
View File

@@ -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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 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= 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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 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 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 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 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 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 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

301
qol.go Normal file
View File

@@ -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)
}