Files
whatismyip/resolver/setup.go
T
dcarrillo 49ffcadb0a Fix data races, nil-record panics, scanner SSRF, log date layout, shutdown timeouts, and enable stricter linters (#54)
High (behavioral bugs):
- service.Geo: add RLock on lookups, handle Reload error, keep old DBs on failure
- models.GeoDB.Reload: open new DBs before closing old ones (no reader gap)
- router: guard nil geo records (404 instead of 500) in geo.go, generic.go, dns.go
- router/port_scanner: reject undetermined client IPs (nil->localhost dial)
- internal/httputils: fix access log date layout (02/Jan/2006, not literal Nov)
- server/quic: log.Printf instead of log.Fatal in request handler

Medium:
- server/*: 10s shutdown timeout; SIGTERM drains servers before closing geo DBs
- httputils: Clone() request headers instead of mutating the live map; strings.Builder
- setting/app: handle all os.Stat errors; fix "truster" typos; use errors.New;
  remove dead Token field
- cmd: errors.Is for sentinel checks; os.Stderr; log.Fatal instead of panic;
  pass http.Handler by value (not pointer-to-interface)
- resolver: validate config at startup (pre-parse RRs, validate IPs); check WriteMsg
  errors; Setup returns error

Low:
- .golangci.yaml: enable errcheck, govet, errorlint; fix all resulting findings
- server/tls: explicit TLSConfig{MinVersion: tls.VersionTLS12}
- router/dns: label-exact vhost suffix check, strip port; template.Must
- models: errors.Join instead of %s on []error
- service/geo_test: race-regression test (concurrent lookups during Reload)
- README: Go >= 1.25; fix latent test bug (store.Add->Set in dns_test.go)
2026-07-19 12:09:52 +02:00

187 lines
4.3 KiB
Go

package resolver
import (
"fmt"
"log"
"net"
"strings"
"github.com/dcarrillo/whatismyip/internal/metrics"
"github.com/dcarrillo/whatismyip/internal/setting"
"github.com/dcarrillo/whatismyip/internal/validator/uuid"
"github.com/miekg/dns"
"github.com/patrickmn/go-cache"
)
type Resolver struct {
handler *dns.ServeMux
store *cache.Cache
domain string
rr []dns.RR
ipv4 []net.IP
ipv6 []net.IP
}
func ensureDotSuffix(s string) string {
if !strings.HasSuffix(s, ".") {
return s + "."
}
return s
}
func Setup(store *cache.Cache) (*Resolver, error) {
domain := ensureDotSuffix(setting.App.Resolver.Domain)
rr := make([]dns.RR, 0, len(setting.App.Resolver.ResourceRecords))
for _, res := range setting.App.Resolver.ResourceRecords {
record, err := dns.NewRR(domain + " " + res)
if err != nil {
return nil, fmt.Errorf("parsing resource record %q: %w", res, err)
}
rr = append(rr, record)
}
ipv4, err := parseIPs(setting.App.Resolver.Ipv4)
if err != nil {
return nil, fmt.Errorf("parsing ipv4 addresses: %w", err)
}
ipv6, err := parseIPs(setting.App.Resolver.Ipv6)
if err != nil {
return nil, fmt.Errorf("parsing ipv6 addresses: %w", err)
}
resolver := &Resolver{
handler: dns.NewServeMux(),
store: store,
domain: domain,
rr: rr,
ipv4: ipv4,
ipv6: ipv6,
}
resolver.handler.HandleFunc(resolver.domain, resolver.resolve)
resolver.handler.HandleFunc(".", resolver.blackHole)
return resolver, nil
}
func parseIPs(addresses []string) ([]net.IP, error) {
ips := make([]net.IP, 0, len(addresses))
for _, address := range addresses {
ip := net.ParseIP(address)
if ip == nil {
return nil, fmt.Errorf("invalid IP address %q", address)
}
ips = append(ips, ip)
}
return ips, nil
}
func (rsv *Resolver) Handler() *dns.ServeMux {
return rsv.handler
}
func (rsv *Resolver) blackHole(w dns.ResponseWriter, r *dns.Msg) {
msg := startReply(r)
msg.SetRcode(r, dns.RcodeRefused)
writeMsg(w, msg)
logger(w, r.Question[0], msg.Rcode)
metrics.RecordDNSQuery(dns.TypeToString[r.Question[0].Qtype], dns.RcodeToString[msg.Rcode])
}
func (rsv *Resolver) resolve(w dns.ResponseWriter, r *dns.Msg) {
msg := startReply(r)
q := r.Question[0]
ip, _, _ := net.SplitHostPort(w.RemoteAddr().String())
for _, res := range rsv.rr {
if q.Qtype == res.Header().Rrtype {
msg.Answer = append(msg.Answer, dns.Copy(res))
writeMsg(w, msg)
logger(w, q, msg.Rcode)
metrics.RecordDNSQuery(dns.TypeToString[q.Qtype], dns.RcodeToString[msg.Rcode])
return
}
}
lowerName := strings.ToLower(q.Name) // lowercase because of dns-0x20
subDomain := strings.Split(lowerName, ".")[0]
switch {
case uuid.IsValid(subDomain):
msg.SetRcode(r, rsv.getIP(q, msg))
// Add fails when the uuid is already registered; keep the first seen
// resolver IP for the discovery window
_ = rsv.store.Add(subDomain, ip, cache.DefaultExpiration)
case lowerName == rsv.domain:
msg.SetRcode(r, rsv.getIP(q, msg))
default:
msg.SetRcode(r, dns.RcodeRefused)
}
writeMsg(w, msg)
logger(w, q, msg.Rcode)
metrics.RecordDNSQuery(dns.TypeToString[q.Qtype], dns.RcodeToString[msg.Rcode])
}
func (rsv *Resolver) getIP(question dns.Question, msg *dns.Msg) int {
if question.Qtype == dns.TypeA && len(rsv.ipv4) > 0 {
for _, ip := range rsv.ipv4 {
msg.Answer = append(msg.Answer, &dns.A{
Hdr: setHdr(question),
A: ip,
})
}
return dns.RcodeSuccess
}
if question.Qtype == dns.TypeAAAA && len(rsv.ipv6) > 0 {
for _, ip := range rsv.ipv6 {
msg.Answer = append(msg.Answer, &dns.AAAA{
Hdr: setHdr(question),
AAAA: ip,
})
}
return dns.RcodeSuccess
}
return dns.RcodeRefused
}
func writeMsg(w dns.ResponseWriter, msg *dns.Msg) {
if err := w.WriteMsg(msg); err != nil {
log.Printf("Failed to write DNS response: %s", err)
}
}
func setHdr(q dns.Question) dns.RR_Header {
return dns.RR_Header{
Name: q.Name,
Rrtype: q.Qtype,
Class: dns.ClassINET,
Ttl: 60,
}
}
func startReply(r *dns.Msg) *dns.Msg {
msg := new(dns.Msg)
msg.SetReply(r)
msg.Authoritative = true
return msg
}
func logger(w dns.ResponseWriter, q dns.Question, code int, err ...string) {
emsg := ""
if len(err) > 0 {
emsg = " - " + strings.Join(err, " ")
}
ip, _, _ := net.SplitHostPort(w.RemoteAddr().String())
log.Printf(
"DNS %s - %s - %s - %s%s",
ip,
dns.TypeToString[q.Qtype],
q.Name,
dns.RcodeToString[code],
emsg,
)
}