Files
dnstest/backend/internal/resolver/resolver.go
robertas_stauskas a70f3262e0 Initial commit: DNS Test - DNS health checking tool
Go backend (miekg/dns) + Nuxt 3 frontend (Tailwind CSS v4).

8 check categories, 52 checks total:
- Overview: @ record, WWW, MX with ASN/provider lookup
- Domain Registration: expiry, registrar (RDAP + whois fallback)
- Parent Delegation: NS records, glue, consistency
- Nameservers: 17 checks (reachability, auth, recursion, TCP/UDP, AXFR, etc.)
- SOA: serial consistency, timing values
- Mail (MX): 11 checks (CNAME, PTR, public IPs, consistency)
- Mail Auth: SPF, DKIM, DMARC
- WWW: A record, CNAME

Features:
- SSE streaming (results appear as each category completes)
- SQLite history (modernc.org/sqlite)
- Rate limiting, CORS, request logging
- Dark mode, responsive design
2026-03-20 13:39:57 +02:00

235 lines
5.2 KiB
Go

package resolver
import (
"fmt"
"strings"
"time"
"github.com/miekg/dns"
)
// Resolver wraps miekg/dns with timeout and retry logic.
type Resolver struct {
Timeout time.Duration
Retries int
}
// NewResolver creates a Resolver with sensible defaults.
func NewResolver() *Resolver {
return &Resolver{
Timeout: 3 * time.Second,
Retries: 1,
}
}
// Query sends a UDP DNS query. If the response is truncated it automatically
// retries over TCP.
func (r *Resolver) Query(name string, server string, qtype uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("udp query %s @%s: %w", dns.TypeToString[qtype], server, err)
}
// Fall back to TCP on truncation.
if resp.Truncated {
return r.QueryTCP(name, server, qtype)
}
return resp, nil
}
// QueryTCP sends a DNS query over TCP.
func (r *Resolver) QueryTCP(name string, server string, qtype uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "tcp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("tcp query %s @%s: %w", dns.TypeToString[qtype], server, err)
}
return resp, nil
}
// QueryNoRecurse sends a UDP query with RD=0 (non-recursive). Falls back to
// TCP on truncation.
func (r *Resolver) QueryNoRecurse(name string, server string, qtype uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = false
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("udp query (no recurse) %s @%s: %w", dns.TypeToString[qtype], server, err)
}
if resp.Truncated {
m.RecursionDesired = false
c2 := new(dns.Client)
c2.Timeout = r.Timeout
c2.Net = "tcp"
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c2.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("tcp query (no recurse) %s @%s: %w", dns.TypeToString[qtype], server, err)
}
}
return resp, nil
}
// QueryEDNS sends a UDP query with EDNS0 buffer size set.
func (r *Resolver) QueryEDNS(name string, server string, qtype uint16, bufsize uint16) (*dns.Msg, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = false
m.SetEdns0(bufsize, false)
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return nil, fmt.Errorf("edns query %s @%s: %w", dns.TypeToString[qtype], server, err)
}
return resp, nil
}
// QueryVersionBind asks for version.bind TXT in the CH class.
func (r *Resolver) QueryVersionBind(server string) (string, error) {
m := new(dns.Msg)
m.SetQuestion("version.bind.", dns.TypeTXT)
m.Question[0].Qclass = dns.ClassCHAOS
m.RecursionDesired = false
c := new(dns.Client)
c.Timeout = r.Timeout
c.Net = "udp"
var resp *dns.Msg
var err error
for attempt := 0; attempt <= r.Retries; attempt++ {
resp, _, err = c.Exchange(m, ensurePort(server))
if err == nil {
break
}
}
if err != nil {
return "", err
}
for _, rr := range resp.Answer {
if txt, ok := rr.(*dns.TXT); ok {
if len(txt.Txt) > 0 {
return txt.Txt[0], nil
}
}
}
return "", nil
}
// QueryAXFR attempts a zone transfer. Returns true if the server allows it.
// Uses a short timeout to avoid blocking on unresponsive servers.
func (r *Resolver) QueryAXFR(name string, server string) (bool, error) {
type axfrResult struct {
allowed bool
err error
}
ch := make(chan axfrResult, 1)
go func() {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), dns.TypeAXFR)
tr := new(dns.Transfer)
tr.DialTimeout = 3 * time.Second
tr.ReadTimeout = 3 * time.Second
env, err := tr.In(m, ensurePort(server))
if err != nil {
ch <- axfrResult{false, nil}
return
}
for e := range env {
if e.Error != nil {
ch <- axfrResult{false, nil}
return
}
if len(e.RR) > 0 {
ch <- axfrResult{true, nil}
return
}
}
ch <- axfrResult{false, nil}
}()
select {
case res := <-ch:
return res.allowed, res.err
case <-time.After(5 * time.Second):
return false, nil
}
}
func ensurePort(server string) string {
// Already has port (IPv4:port or [IPv6]:port).
if strings.Contains(server, "]:") || (!strings.Contains(server, "[") && strings.Count(server, ":") == 1) {
return server
}
// IPv6 without brackets/port.
if strings.Contains(server, ":") && !strings.Contains(server, "[") {
return "[" + server + "]:53"
}
return server + ":53"
}