Files
fancy-dns/internal/app/harness_test.go
T
2026-09-27 11:20:34 +00:00

247 lines
6.9 KiB
Go

package app
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// testUpstream is a fake recursive resolver: it answers A queries for names
// under test. and NXDOMAIN for everything else, so a test can tell "resolved
// upstream" from "blocked locally" without touching the internet.
type testUpstream struct {
*dns.Server
addr string
mu sync.Mutex
hits map[string]int
}
func newTestUpstream(t *testing.T) *testUpstream {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
u := &testUpstream{addr: pc.LocalAddr().String(), hits: map[string]int{}}
u.Server = &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(u.handle)}
go func() { _ = u.ActivateAndServe() }()
t.Cleanup(func() { _ = u.Shutdown() })
return u
}
func (u *testUpstream) handle(w dns.ResponseWriter, q *dns.Msg) {
m := new(dns.Msg)
m.SetReply(q)
name := q.Question[0].Name
u.mu.Lock()
u.hits[name]++
u.mu.Unlock()
if strings.HasSuffix(name, ".test.") {
if q.Question[0].Qtype == dns.TypeA {
rr, _ := dns.NewRR(name + " 60 IN A 203.0.113.7")
m.Answer = append(m.Answer, rr)
}
soa, _ := dns.NewRR("test. 60 IN SOA ns.test. hostmaster.test. 1 3600 600 604800 60")
m.Ns = append(m.Ns, soa)
} else {
m.Rcode = dns.RcodeNameError
soa, _ := dns.NewRR("test. 60 IN SOA ns.test. hostmaster.test. 1 3600 600 604800 60")
m.Ns = append(m.Ns, soa)
}
_ = w.WriteMsg(m)
}
func (u *testUpstream) hitCount(name string) int {
u.mu.Lock()
defer u.mu.Unlock()
return u.hits[name]
}
// harness is a fully wired App on ephemeral ports, with a fake upstream.
type harness struct {
t *testing.T
app *App
addr string // host:port for plain DNS
api string // host:port for the admin API
key string
up *testUpstream
dir string
// config is the document the harness wrote to disk, so a test can make a
// realistic edit rather than inventing a minimal stub.
config string
}
// OriginalConfig returns the document the harness wrote to disk, so a test
// can make a realistic single-value edit instead of inventing a minimal stub.
func (h *harness) OriginalConfig() string { return h.config }
func newHarness(t *testing.T, mutate func(*config.Config)) *harness {
t.Helper()
dir := t.TempDir()
up := newTestUpstream(t)
hosts := filepath.Join(dir, "hosts")
if err := os.WriteFile(hosts, []byte(
"0.0.0.0 ads.example.com\n"+
"0.0.0.0 tracker.example.net\n"+
"0.0.0.0 shared.example.com\n"+
// Under .test. so the fake upstream resolves it: NOERROR then means
// "the blocklist let it through" and NXDOMAIN means "blocked", with
// no third possibility to confuse an assertion.
"0.0.0.0 blocked.example.test\n"+
"# comment\n"+
"127.0.0.1 localhost\n"), 0o644); err != nil {
t.Fatal(err)
}
zone := filepath.Join(dir, "local.zone")
if err := os.WriteFile(zone, []byte(
"$TTL 60\n"+
"local. SOA ns.local. hostmaster.local. 1 3600 600 604800 60\n"+
"local. NS ns.local.\n"+
"ns.local. A 192.168.1.2\n"+
"*.wild.local. A 192.168.1.50\n"+
"deep.svc.local. A 192.168.1.60\n"+
"*.deep.svc.local. A 192.168.1.61\n"), 0o644); err != nil {
t.Fatal(err)
}
// Pick a free port up front so the client can address the listeners and so
// a port clash fails the test rather than the app's start-up.
dnsPort := freePort(t)
dnsAddr := fmt.Sprintf("127.0.0.1:%d", dnsPort)
cfg := config.Default()
cfg.WorkingDirOrSet(dir)
cfg.DNS.UDP = []string{dnsAddr}
cfg.DNS.TCP = []string{dnsAddr}
cfg.DNS.DoT.Enabled = false
cfg.DNS.DoH.Enabled = false
cfg.API.Listen = []string{"127.0.0.1:0"}
cfg.API.Auth.Mode = "apikey"
cfg.API.Auth.APIKeys = []string{"test-key"}
cfg.API.Auth.TrustLoopback = false
cfg.API.UI = false
cfg.Upstream.Policy = "parallel"
cfg.Upstream.MaxAttempts = 1
cfg.Upstream.HealthCheck = 0
cfg.Upstream.Servers = []config.UpstreamServer{{Addr: up.addr, Proto: "udp", Weight: 1, HealthName: "test"}}
cfg.Cache.Persist.Path = filepath.Join(dir, "cache.db")
cfg.QueryLog.Path = filepath.Join(dir, "queries.db")
cfg.Block.Lists = []config.ListSource{{
Name: "test-hosts", Format: "hosts", Path: hosts, Enabled: true, Refresh: 0,
}}
cfg.Block.LocalZones = []string{zone}
cfg.Block.BlockEntries = []string{"inline-blocked.example.com", "*.inline-sub.example.com"}
cfg.Block.AllowEntries = []string{"allow.example.com"}
if mutate != nil {
mutate(cfg)
}
path := filepath.Join(dir, "config.yaml")
// Snapshot the document before the app starts: the server may rewrite this
// file (every API mutation does), so a test must not read it concurrently
// with a live instance's own writes.
doc := string(mustYAML(t, cfg))
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
a, err := New(Options{ConfigPath: path, Version: "test"})
if err != nil {
t.Fatalf("new app: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := a.Start(ctx); err != nil {
cancel()
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
sctx, scancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = a.Stop(sctx)
scancel()
cancel()
})
return &harness{t: t, app: a, addr: dnsAddr, api: a.Addr(), key: "test-key", up: up, dir: dir, config: doc}
}
// freePort returns a currently-free TCP port on the loopback interface.
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
return port
}
func (h *harness) query(name string, qtype uint16) *dns.Msg {
h.t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
c := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
res, _, err := c.Exchange(m, h.addr)
if err != nil {
h.t.Fatalf("query %s: %v", name, err)
}
return res
}
func (h *harness) get(path string) (int, []byte) {
h.t.Helper()
req, _ := http.NewRequest("GET", "http://"+h.api+path, nil)
req.Header.Set("X-API-Key", h.key)
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("GET %s: %v", path, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, b
}
func (h *harness) do(method, path, body string) (int, []byte) {
h.t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, _ := http.NewRequest(method, "http://"+h.api+path, rdr)
req.Header.Set("X-API-Key", h.key)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
h.t.Fatalf("%s %s: %v", method, path, err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return res.StatusCode, b
}
func mustYAML(t *testing.T, c *config.Config) []byte {
t.Helper()
b, err := config.Marshal(c)
if err != nil {
t.Fatal(err)
}
return b
}
var _ = base64.StdEncoding
var _ = tls.VersionTLS13