diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30f3f28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +**/tls-key-log.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..31d7daf --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# DNS Resolver + +A DNS resolver supporting multiple protocols including DoH, DoT, DoQ, DNSSEC, ODoH, and DNSCrypt. diff --git a/cmd/resolver/main.go b/cmd/resolver/main.go new file mode 100644 index 0000000..c770c7d --- /dev/null +++ b/cmd/resolver/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/afonsofrancof/sdns-perf/internal/client" + + "github.com/alecthomas/kong" + "github.com/miekg/dns" +) + +var cli struct { + // Global flags + Verbose bool `help:"Enable verbose logging." short:"v"` + + Query QueryCmd `cmd:"" help:"Perform a DNS query (client mode)."` + Listen ListenCmd `cmd:"" help:"Run as a DNS listener/resolver (server mode)."` +} + +type QueryCmd struct { + DomainName string `help:"Domain name to resolve." arg:"" required:""` + Server string `help:"Upstream server address (e.g., https://1.1.1.1/dns-query, tls://1.1.1.1, 8.8.8.8)." short:"s" required:""` + QueryType string `help:"Query type (A, AAAA, MX, TXT, etc.)." short:"t" enum:"A,AAAA,MX,TXT,NS,CNAME,SOA,PTR" default:"A"` + DNSSEC bool `help:"Enable DNSSEC (DO bit)." short:"d"` + Timeout time.Duration `help:"Timeout for the query operation." default:"10s"` // Default might be higher now + KeyLogFile string `help:"Path to TLS key log file (for DoT/DoH/DoQ)." env:"SSLKEYLOGFILE"` +} + +func (q *QueryCmd) Run() error { + log.Printf("Querying %s for %s type %s (DNSSEC: %v, Timeout: %v)\n", + q.Server, q.DomainName, q.QueryType, q.DNSSEC, q.Timeout) + + opts := client.Options{ + Timeout: q.Timeout, + DNSSEC: q.DNSSEC, + KeyLogPath: q.KeyLogFile, + } + + dnsClient, err := client.New(q.Server, opts) + if err != nil { + return err + } + defer dnsClient.Close() + + qTypeUint, ok := dns.StringToType[strings.ToUpper(q.QueryType)] + if !ok { + return fmt.Errorf("invalid query type: %s", q.QueryType) + } + + dnsMsg, err := dnsClient.Query(q.DomainName, qTypeUint) + if err != nil { + return fmt.Errorf("query failed: %w ", err) + } + + printResponse(q.DomainName, q.QueryType, dnsMsg) + + return nil +} + +type ListenCmd struct { + Address string `help:"Address to listen on (e.g., :53, :8053)." default:":53"` + // Add other server-specific flags: default upstream, TLS cert/key paths etc. +} + +func (l *ListenCmd) Run() error { + return fmt.Errorf("server/listen mode not yet implemented") +} + +func printResponse(domain, qtype string, msg *dns.Msg) { + fmt.Println(";; QUESTION SECTION:") + + fmt.Printf(";%s.\tIN\t%s\n", dns.Fqdn(domain), strings.ToUpper(qtype)) + + fmt.Println("\n;; ANSWER SECTION:") + if len(msg.Answer) > 0 { + for _, rr := range msg.Answer { + fmt.Println(rr.String()) + } + } else { + fmt.Println(";; No records found in answer section.") + } + + if len(msg.Ns) > 0 { + fmt.Println("\n;; AUTHORITY SECTION:") + for _, rr := range msg.Ns { + fmt.Println(rr.String()) + } + } + if len(msg.Extra) > 0 { + hasRealExtra := false + for _, rr := range msg.Extra { + if rr.Header().Rrtype != dns.TypeOPT { + hasRealExtra = true + break + } + } + if hasRealExtra { + fmt.Println("\n;; ADDITIONAL SECTION:") + for _, rr := range msg.Extra { + if rr.Header().Rrtype != dns.TypeOPT { + fmt.Println(rr.String()) + } + } + } + } + + fmt.Printf("\n;; RCODE: %s, ID: %d", dns.RcodeToString[msg.Rcode], msg.Id) + opt := msg.IsEdns0() + if opt != nil { + fmt.Printf(", EDNS: version: %d; flags:", opt.Version()) + if opt.Do() { + fmt.Printf(" do;") + } else { + fmt.Printf(";") + } + fmt.Printf(" udp: %d", opt.UDPSize()) + } + fmt.Println() +} + +func main() { + log.SetOutput(os.Stderr) + log.SetFlags(log.Ltime | log.Lshortfile) + + kongCtx := kong.Parse(&cli, + kong.Name("sdns-perf"), + kong.Description("A DNS client/server tool supporting multiple protocols."), + kong.UsageOnError(), + kong.ConfigureHelp(kong.HelpOptions{Compact: true, Summary: true}), + ) + + err := kongCtx.Run() + kongCtx.FatalIfErrorf(err) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..307936c --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/afonsofrancof/sdns-perf + +go 1.24.0 + +require ( + github.com/alecthomas/kong v1.8.1 + github.com/miekg/dns v1.1.63 + github.com/quic-go/quic-go v0.50.0 +) + +require ( + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect + github.com/onsi/ginkgo/v2 v2.9.5 // indirect + go.uber.org/mock v0.5.0 // indirect + 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.8.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/tools v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25e4870 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.8.1 h1:6aamvWBE/REnR/BCq10EcozmcpUPc5aGI1lPAWdB0EE= +github.com/alecthomas/kong v1.8.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +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/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= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= +github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q= +github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/quic-go v0.50.0 h1:3H/ld1pa3CYhkcc20TPIyG1bNsdhn9qZBGN3b9/UyUo= +github.com/quic-go/quic-go v0.50.0/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +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.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/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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.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.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +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= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..0a81128 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,195 @@ +// internal/client/client.go +package client + +import ( + "fmt" + "io" + "net" + "net/url" + "strconv" + "strings" + "time" + + "github.com/afonsofrancof/sdns-perf/internal/protocols/do53" + "github.com/afonsofrancof/sdns-perf/internal/protocols/doh" + // "github.com/afonsofrancof/sdns-perf/internal/protocols/doq" + // "github.com/afonsofrancof/sdns-perf/internal/protocols/dot" + + "github.com/miekg/dns" +) + +// DNSClient defines the interface that all specific protocol clients must implement. +type DNSClient interface { + Query(domain string, queryType uint16) (*dns.Msg, error) + Close() +} + +// Options holds common configuration options for creating any DNS client. +type Options struct { + Timeout time.Duration + DNSSEC bool + KeyLogPath string // Path for TLS key logging +} + +type protocolType int + +const ( + protoUnknown protocolType = iota + protoDo53 + protoDoT + protoDoH + protoDoH3 + protoDoQ +) + +// config holds the parsed details of an upstream server string. +// This is internal to the client package. +type config struct { + original string + protocol protocolType + host string + port string + path string +} + +// parseUpstream takes a user-provided upstream string and attempts to determine +// the protocol, host, port, and path. (Internal helper) +func parseUpstream(upstreamStr string) (config, error) { + cfg := config{original: upstreamStr, protocol: protoUnknown} + + // Try parsing as a full URL first + parsedURL, err := url.Parse(upstreamStr) + if err == nil && parsedURL.Scheme != "" && parsedURL.Host != "" { + cfg.host = parsedURL.Hostname() + cfg.port = parsedURL.Port() + cfg.path = parsedURL.Path + if cfg.path == "" { + cfg.path = "/" // Default path + } + + switch strings.ToLower(parsedURL.Scheme) { + case "https", "doh": + cfg.protocol = protoDoH + if cfg.port == "" { + cfg.port = "443" + } + case "h3", "doh3": + cfg.protocol = protoDoH3 + if cfg.port == "" { + cfg.port = "443" + } + case "tls", "dot": + cfg.protocol = protoDoT + if cfg.port == "" { + cfg.port = "853" + } + case "quic", "doq": + cfg.protocol = protoDoQ + if cfg.port == "" { + cfg.port = "853" + } + case "udp", "do53": + cfg.protocol = protoDo53 + if cfg.port == "" { + cfg.port = "53" + } + default: + return cfg, fmt.Errorf("unsupported URL scheme: %q", parsedURL.Scheme) + } + return cfg, nil + } + + // If not a valid URL or no scheme, assume plain DNS (Do53 UDP) + cfg.protocol = protoDo53 + host, port, err := net.SplitHostPort(upstreamStr) + if err == nil { + cfg.host = host + cfg.port = port + if _, pErr := strconv.Atoi(port); pErr != nil { + return cfg, fmt.Errorf("invalid port %q in upstream %q: %w", port, upstreamStr, pErr) + } + } else { + cfg.host = upstreamStr + cfg.port = "53" + // Basic check for likely IPv6 without brackets and port + if strings.Contains(cfg.host, ":") && !strings.Contains(cfg.host, "[") { + _, resolveErr := net.ResolveUDPAddr("udp", net.JoinHostPort(cfg.host, cfg.port)) + if resolveErr != nil { + return cfg, fmt.Errorf("invalid upstream format; could not parse %q as host:port or resolve as host with default port 53: %w", upstreamStr, err) + } + } + } + + if cfg.host == "" { + return cfg, fmt.Errorf("could not extract host from upstream: %q", upstreamStr) + } + + return cfg, nil +} + +// New creates the appropriate DNS client based on the upstream string format. +// It returns an uninitialized client (connections are lazy). +func New(upstreamStr string, opts Options) (DNSClient, error) { + cfg, err := parseUpstream(upstreamStr) + if err != nil { + return nil, fmt.Errorf("client: failed to parse upstream %q: %w", upstreamStr, err) + } + + var client DNSClient + var clientErr error + + switch cfg.protocol { + case protoDo53: + // Ensure do53.New matches this signature + config := do53.Config{HostAndPort: net.JoinHostPort(cfg.host, cfg.port), DNSSEC: false} + client, clientErr = do53.New(config) + + case protoDoH: + // Ensure doh.New matches this signature + config := doh.Config{Host: cfg.host, Port: cfg.port, Path: cfg.path, DNSSEC: false} + client, clientErr = doh.New(config) + + case protoDoT: + // Ensure dot.New matches this signature + // client, clientErr = dot.New(cfg.hostPort(), opts.Timeout, opts.DNSSEC, opts.KeyLogPath) + // if clientErr == nil && client == nil { + // clientErr = fmt.Errorf("client: DoT package returned nil client without error") + // } + + case protoDoQ: + // Ensure doq.New matches this signature + // client, clientErr = doq.New(cfg.hostPort(), cfg.path, opts.Timeout, opts.DNSSEC, opts.KeyLogPath) + // if clientErr == nil && client == nil { + // clientErr = fmt.Errorf("client: DoQ package returned nil client without error") + // } + + case protoDoH3: + // Decide on DoH3 handling (fallback or error) + // Fallback example: + // fmt.Fprintf(os.Stderr, "Warning: DoH3 protocol (h3://) detected for %s. Attempting connection using standard DoH (HTTPS).\n", cfg.original) + // client, clientErr = doh.New(cfg.hostPort(), cfg.path, opts.Timeout, opts.DNSSEC, opts.KeyLogPath) + // Error example: + // clientErr = fmt.Errorf("client: DoH3 protocol (h3://) is not yet supported") + + default: + clientErr = fmt.Errorf("client: unknown or unsupported protocol detected for upstream: %s", upstreamStr) + } + + if clientErr != nil { + return nil, fmt.Errorf("client: failed to create client for %s: %w", upstreamStr, clientErr) + } + if client == nil { + // Should be caught by clientErr checks above, but as a safeguard + return nil, fmt.Errorf("client: internal error - nil client returned for %s", upstreamStr) + } + + return client, nil +} + +// Helper function to close key log writer if needed (can be used by specific clients) +func CloseKeyLogWriter(w io.WriteCloser) error { + if w != nil { + return w.Close() + } + return nil +} diff --git a/internal/protocols/dnscrypt/dnscrypt.go b/internal/protocols/dnscrypt/dnscrypt.go new file mode 100644 index 0000000..9939b27 --- /dev/null +++ b/internal/protocols/dnscrypt/dnscrypt.go @@ -0,0 +1,3 @@ +package dnscrypt + +// DNSCrypt resolver implementation diff --git a/internal/protocols/dnssec/dnssec.go b/internal/protocols/dnssec/dnssec.go new file mode 100644 index 0000000..fb852f6 --- /dev/null +++ b/internal/protocols/dnssec/dnssec.go @@ -0,0 +1,3 @@ +package dnssec + +// DNSSEC resolver implementation diff --git a/internal/protocols/do53/do53.go b/internal/protocols/do53/do53.go new file mode 100644 index 0000000..ee659f9 --- /dev/null +++ b/internal/protocols/do53/do53.go @@ -0,0 +1,129 @@ +package do53 + +import ( + "fmt" + "log" + "net" + "sync" + + "github.com/miekg/dns" +) + +type Config struct { + HostAndPort string + DNSSEC bool +} + +type Client struct { + udpAddr *net.UDPAddr + conn *net.UDPConn + + responseChannels map[uint16]chan *dns.Msg + responseMutex *sync.Mutex + + config Config +} + +func New(config Config) (*Client, error) { + udpAddr, err := net.ResolveUDPAddr("udp", config.HostAndPort) + if err != nil { + return nil, fmt.Errorf("do53: failed to resolve UDP address %q: %w", config.HostAndPort, err) + } + + conn, err := net.DialUDP("udp", nil, udpAddr) + if err != nil { + return nil, fmt.Errorf("do53: failed to dial UDP connection to %s: %w", config.HostAndPort, err) + } + + responseChannels := map[uint16]chan *dns.Msg{} + rcMutex := new(sync.Mutex) + + client := &Client{ + udpAddr: udpAddr, + conn: conn, + responseChannels: responseChannels, + responseMutex: rcMutex, + config: config, + } + + go client.receiveLoop() + + return client, nil +} + +func (c *Client) Close() { + if c.conn != nil { + c.conn.Close() + c.conn = nil + } +} + +func (c *Client) receiveLoop() { + + buffer := make([]byte, dns.MaxMsgSize) + + for { + // Reads one UDP Datagram + n, err := c.conn.Read(buffer) + if err != nil { + log.Printf("do53: failed to read DNS response: %s", err.Error()) + } + + recvMsg := new(dns.Msg) + err = recvMsg.Unpack(buffer[:n]) + if err != nil { + log.Printf("do53: failed to unpack DNS response: %s", err.Error()) + continue + } + + c.responseMutex.Lock() + respChan, ok := c.responseChannels[recvMsg.Id] + delete(c.responseChannels, recvMsg.Id) + c.responseMutex.Unlock() + + if ok { + respChan <- recvMsg + } else { + log.Printf("Receiver: Received DNS response for unknown or already processed msg ID: %v\n", recvMsg.Id) + } + } + +} + +func (c *Client) Query(domain string, queryType uint16) (*dns.Msg, error) { + + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(domain), queryType) + msg.Id = dns.Id() + msg.RecursionDesired = true + + if c.config.DNSSEC { + msg.SetEdns0(4096, true) + } + + respChan := make(chan *dns.Msg) + + c.responseMutex.Lock() + c.responseChannels[msg.Id] = respChan + c.responseMutex.Unlock() + + packedMsg, err := msg.Pack() + if err != nil { + c.responseMutex.Lock() + delete(c.responseChannels, msg.Id) + c.responseMutex.Unlock() + return nil, fmt.Errorf("do53: failed to pack DNS message: %w", err) + } + + _, err = c.conn.Write(packedMsg) + if err != nil { + c.responseMutex.Lock() + delete(c.responseChannels, msg.Id) + c.responseMutex.Unlock() + return nil, fmt.Errorf("do53: failed to send DNS query: %w", err) + } + + recvMsg := <-respChan + + return recvMsg, nil +} diff --git a/internal/protocols/do53/packet.go b/internal/protocols/do53/packet.go new file mode 100644 index 0000000..29f6a55 --- /dev/null +++ b/internal/protocols/do53/packet.go @@ -0,0 +1,40 @@ +package do53 + +import ( + "github.com/miekg/dns" +) + +func NewDNSMessage(domain string, queryType string) ([]byte, error) { + + // TODO: Move this somewhere else and receive the type already parsed + var queryTypeValue uint16 + switch queryType { + case "A": + queryTypeValue = dns.TypeA + case "AAAA": + queryTypeValue = dns.TypeAAAA + case "MX": + queryTypeValue = dns.TypeMX + case "CNAME": + queryTypeValue = dns.TypeCNAME + case "TXT": + queryTypeValue = dns.TypeTXT + default: + queryTypeValue = dns.TypeA + } + + message := new(dns.Msg) + + message.Id = dns.Id() + message.Response = false + message.Opcode = dns.OpcodeQuery + message.Question = make([]dns.Question, 1) + message.Question[0] = dns.Question{Name: domain, Qtype: uint16(queryTypeValue), Qclass: dns.ClassINET} + message.Compress = true + wireMsg, err := message.Pack() + if err != nil { + return nil, err + } + + return wireMsg, nil +} diff --git a/internal/protocols/doh/doh.go b/internal/protocols/doh/doh.go new file mode 100644 index 0000000..fa17cdf --- /dev/null +++ b/internal/protocols/doh/doh.go @@ -0,0 +1,125 @@ +package doh + +import ( + "bytes" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + + "github.com/miekg/dns" +) + +const dnsMessageContentType = "application/dns-message" + +type Config struct { + Host string + Port string + Path string + DNSSEC bool +} + +type Client struct { + httpClient *http.Client + upstreamURL *url.URL + config Config +} + +func New(config Config) (*Client, error) { + if config.Host == "" || config.Port == "" || config.Path == "" { + fmt.Printf("%v,%v,%v", config.Host,config.Port,config.Path) + return nil, errors.New("doh: host, port, and path must not be empty") + } + + if !strings.HasPrefix(config.Path, "/") { + config.Path = "/" + config.Path + } + rawURL := "https://" + net.JoinHostPort(config.Host, config.Port) + config.Path + + parsedURL, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("doh: failed to parse constructed URL %q: %w", rawURL, err) + } + + tlsConfig := &tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, + } + + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + ForceAttemptHTTP2: true, + } + + httpClient := &http.Client{ + Transport: transport, + } + + return &Client{ + httpClient: httpClient, + upstreamURL: parsedURL, + config: config, + }, nil +} + +// Close cleans up idle connections held by the underlying HTTP transport. +func (c *Client) Close() { + c.httpClient.CloseIdleConnections() +} + +func (c *Client) Query(domain string, queryType uint16) (*dns.Msg, error) { + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(domain), queryType) + msg.Id = dns.Id() + msg.RecursionDesired = true + + if c.config.DNSSEC { + msg.SetEdns0(4096, true) + } + + packedMsg, err := msg.Pack() + if err != nil { + return nil, fmt.Errorf("doh: failed to pack DNS message: %w", err) + } + + httpReq, err := http.NewRequest("POST", c.upstreamURL.String(), bytes.NewReader(packedMsg)) + if err != nil { + return nil, fmt.Errorf("doh: failed to create HTTP request object: %w", err) + } + + httpReq.Header.Set("User-Agent", "sdns-perf") + httpReq.Header.Set("Content-Type", dnsMessageContentType) + httpReq.Header.Set("Accept", dnsMessageContentType) + + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("doh: failed executing HTTP request to %s: %w", c.upstreamURL.Host, err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("doh: received non-200 HTTP status from %s: %s", c.upstreamURL.Host, httpResp.Status) + } + + if ct := httpResp.Header.Get("Content-Type"); ct != dnsMessageContentType { + return nil, fmt.Errorf("doh: unexpected Content-Type from %s: got %q, want %q", c.upstreamURL.Host, ct, dnsMessageContentType) + } + + responseBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("doh: failed reading response body from %s: %w", c.upstreamURL.Host, err) + } + + // Unpack the DNS message + recvMsg := new(dns.Msg) + err = recvMsg.Unpack(responseBody) + if err != nil { + return nil, fmt.Errorf("doh: failed to unpack DNS response from %s: %w", c.upstreamURL.Host, err) + } + + return recvMsg, nil +} diff --git a/internal/protocols/doq/doq.go b/internal/protocols/doq/doq.go new file mode 100644 index 0000000..d99b95b --- /dev/null +++ b/internal/protocols/doq/doq.go @@ -0,0 +1,175 @@ +package doq + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/binary" + "fmt" + "io" + "net" + "os" + "time" + + "github.com/afonsofrancof/sdns-perf/internal/protocols/do53" + "github.com/miekg/dns" + "github.com/quic-go/quic-go" +) + +type Client struct { + targetAddr *net.UDPAddr + keyLogFile *os.File + tlsConfig *tls.Config + udpConn *net.UDPConn + quicConn quic.Connection + quicTransport *quic.Transport + quicConfig *quic.Config +} + +func New(target string) (*Client, error) { + keyLogFile, err := os.OpenFile( + "tls-key-log.txt", + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + return nil, fmt.Errorf("failed opening key log file: %w", err) + } + + tlsConfig := &tls.Config{ + // FIX: Actually check the domain name + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + ClientSessionCache: tls.NewLRUClientSessionCache(100), + KeyLogWriter: keyLogFile, + NextProtos: []string{"doq"}, + } + + udpAddr, err := net.ResolveUDPAddr("udp", "0.0.0.0:6000") + if err != nil { + return nil, fmt.Errorf("failed to resolve target address: %w", err) + } + targetAddr, err := net.ResolveUDPAddr("udp", target) + if err != nil { + return nil, err + } + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + return nil, fmt.Errorf("failed to connect to target address: %w", err) + } + + quicTransport := quic.Transport{ + Conn: udpConn, + } + + quicConfig := quic.Config{ + // Use the default value of 30 seconds + MaxIdleTimeout: 30 * time.Second, + } + + return &Client{ + targetAddr: targetAddr, + keyLogFile: keyLogFile, + tlsConfig: tlsConfig, + udpConn: udpConn, + quicConn: nil, + quicTransport: &quicTransport, + quicConfig: &quicConfig, + }, nil +} + +func (c *Client) Close() { + if c.keyLogFile != nil { + c.keyLogFile.Close() + } + if c.udpConn != nil { + c.udpConn.Close() + } +} + +func (c *Client) OpenConnection() error { + quicConn, err := c.quicTransport.DialEarly(context.Background(), c.targetAddr, c.tlsConfig, c.quicConfig) + if err != nil { + return err + } + + c.quicConn = quicConn + return nil +} + +func (c *Client) Query(domain, queryType string, dnssec bool) error { + + if c.quicConn == nil { + err := c.OpenConnection() + if err != nil { + return err + } + } + + DNSMessage, err := do53.NewDNSMessage(domain, queryType) + if err != nil { + return err + } + + var quicStream quic.Stream + quicStream, err = c.quicConn.OpenStream() + if err != nil { + err = c.OpenConnection() + if err != nil { + return err + } + quicStream, err = c.quicConn.OpenStream() + if err != nil { + return err + } + } + + var lengthPrefixedMessage bytes.Buffer + err = binary.Write(&lengthPrefixedMessage, binary.BigEndian, uint16(len(DNSMessage))) + if err != nil { + return fmt.Errorf("failed to write message length: %w", err) + } + _, err = lengthPrefixedMessage.Write(DNSMessage) + if err != nil { + return fmt.Errorf("failed to write DNS message: %w", err) + } + + _, err = quicStream.Write(lengthPrefixedMessage.Bytes()) + if err != nil { + return fmt.Errorf("failed writing to QUIC stream: %w", err) + } + // Indicate that no further data will be written from this side + quicStream.Close() + + lengthBuf := make([]byte, 2) + _, err = io.ReadFull(quicStream, lengthBuf) + if err != nil { + return fmt.Errorf("failed reading response length: %w", err) + } + + messageLength := binary.BigEndian.Uint16(lengthBuf) + if messageLength == 0 { + return fmt.Errorf("received zero-length message") + } + + responseBuf := make([]byte, messageLength) + _, err = io.ReadFull(quicStream, responseBuf) + if err != nil { + return fmt.Errorf("failed reading response data: %w", err) + } + + recvMsg := new(dns.Msg) + err = recvMsg.Unpack(responseBuf) + if err != nil { + return fmt.Errorf("failed to parse DNS response: %w", err) + } + + // TODO: Check if the response had no errors or TD bit set + + fmt.Println(c.quicConn.ConnectionState().Used0RTT) + for _, answer := range recvMsg.Answer { + fmt.Println(answer.String()) + } + + return nil +} diff --git a/internal/protocols/dot/dot.go b/internal/protocols/dot/dot.go new file mode 100644 index 0000000..d314281 --- /dev/null +++ b/internal/protocols/dot/dot.go @@ -0,0 +1,161 @@ +package dot + +import ( + "context" + "crypto/tls" + "encoding/binary" + "fmt" + "io" + "log" + "net" + "os" + "sync" + "time" + + "github.com/miekg/dns" +) + +type Config struct { + Host string + Port string + DNSSEC bool + Debug bool +} + +type Client struct { + config Config + + serverAddr *net.TCPAddr + + tcpConn *net.TCPConn + tlsConn *tls.Conn + tlsConfig *tls.Config + keyLogFile *os.File + + sendChannel chan *dns.Msg + + responseChannels map[uint16]chan *dns.Msg + responseMutex *sync.Mutex +} + +func New(config Config) (*Client, error) { + serverAddr, err := net.ResolveTCPAddr("tcp", net.JoinHostPort(config.Host, config.Port)) + if err != nil { + return nil, fmt.Errorf("dot: failed to resolve TCP address %q: %w", config.Host, err) + } + + var keyLogFile *os.File + if config.Debug { + keyLogFile, err = os.OpenFile( + "tls-key-log.txt", + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0600, + ) + if err != nil { + log.Printf("dot: failed opening TLS key log file: %v", err) + keyLogFile = nil + } + } + + tlsConfig := &tls.Config{ + ServerName: serverAddr.IP.String(), + MinVersion: tls.VersionTLS12, + KeyLogWriter: keyLogFile, + ClientSessionCache: tls.NewLRUClientSessionCache(100), + } + + client := &Client{ + config: config, + serverAddr: serverAddr, + tlsConfig: tlsConfig, + keyLogFile: keyLogFile, + } + + go client.receiveLoop() + + return client, nil +} + +func (c *Client) Close() { + if c.tlsConn != nil { + c.tlsConn.Close() + c.tlsConn = nil + } + + if c.tcpConn != nil { + c.tcpConn.Close() + c.tcpConn = nil + } + + if c.keyLogFile != nil { + c.keyLogFile.Close() + c.keyLogFile = nil + } +} + +func (c *Client) receiveLoop() { + + lengthBuffer := make([]byte, 2) + buffer := make([]byte, dns.MaxMsgSize) + + for { + msgSize, err := io.ReadFull(c.tlsConn, lengthBuffer) + if err != nil { + log.Printf("doh: failed to read the DNS message's size: %s", err.Error()) + // FIX: HANDLE RECONNECTION + } + n, err := io.ReadFull(c.tlsConn, buffer[:msgSize]) + if err != nil { + log.Printf("doh: failed to read the DNS message: %s", err.Error()) + // FIX: HANDLE RECONNECTION + } + + recvMsg := new(dns.Msg) + err = recvMsg.Unpack(buffer[:n]) + if err != nil { + log.Printf("do53: failed to unpack DNS response: %s", err.Error()) + continue + } + + c.responseMutex.Lock() + respChan, ok := c.responseChannels[recvMsg.Id] + delete(c.responseChannels, recvMsg.Id) + c.responseMutex.Unlock() + + if ok { + respChan <- recvMsg + } else { + log.Printf("Receiver: Received DNS response for unknown or already processed msg ID: %v\n", recvMsg.Id) + } + + } + +} + +func (c *Client) connect(ctx context.Context) error { + tcpConn, err := net.DialTCP("tcp", nil, c.serverAddr) + if err != nil { + return fmt.Errorf("dot: failed to establish TCP connection: %w", err) + } + + c.tcpConn.SetKeepAlive(true) + c.tcpConn.SetKeepAlivePeriod(1 * time.Minute) + + tlsConn := tls.Client(c.tcpConn, c.tlsConfig) + err = tlsConn.HandshakeContext(ctx) + if err != nil { + c.tcpConn.Close() + c.tcpConn = nil + return fmt.Errorf("dot: failed to execute the TLS handshake: %w", err) + } + + c.tlsConn = tlsConn + + log.Println("dot: TCP/TLS connection established successfully.") + + return nil +} + +func (c *Client) Query(domain string, queryType uint16) (*dns.Msg, error) { + //TODO +}