half broken
build / build-push (push) Failing after 23s

This commit is contained in:
developer committed 2026-09-27 17:17:53 +00:00
1 parent cc7466823b
commit e8c5b414d9
44 files changed
+7649 -344

No files matched your search

+386
View File
@@ -0,0 +1,386 @@
package server
import (
"context"
"encoding/base64"
"io"
"log/slog"
"net"
"net/http"
"strings"
"testing"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// freeTCPAddr returns a loopback address that was free a moment ago, so a
// test can name the port a listener must NOT have taken.
func freeTCPAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("probe listen: %v", err)
}
addr := ln.Addr().String()
if err := ln.Close(); err != nil {
t.Fatalf("probe close: %v", err)
}
return addr
}
// TestDisabledListenerIsNotStartedAndBindsNothing is the "configured off" half
// of the per-listener contract. A disabled transport must not appear in the
// snapshot the admin API serves, and — the part Listeners() alone cannot show —
// it must not have taken its port. An operator who turned DoT off because the
// certificate was broken must be able to bind that port themselves.
func TestDisabledListenerIsNotStartedAndBindsNothing(t *testing.T) {
doTAddr := freeTCPAddr(t)
doHAddr := freeTCPAddr(t)
h := newHarness(t, func(c *config.Config) {
c.DNS.DoT = config.DoT{Enabled: false, Listen: []string{doTAddr}}
c.DNS.DoH = config.DoH{Enabled: false, Listen: []string{doHAddr}, BasePath: "/dns-query", AllowPost: true}
})
for _, tr := range h.transports() {
if tr == "dot" || tr == "doh" {
t.Errorf("disabled listener %q appears in %v", tr, h.srv.Listeners())
}
}
if got := h.transports(); len(got) != 2 {
t.Errorf("transports = %v, want just udp and tcp", got)
}
for _, addr := range []string{doTAddr, doHAddr} {
ln, err := net.Listen("tcp", addr)
if err != nil {
t.Errorf("port %s is not free after Start with the listener disabled: %v", addr, err)
continue
}
_ = ln.Close()
}
}
// TestEnabledDoTWithoutCertIsAFailure covers the misconfiguration the disabled
// case hides: DoT switched on with no certificate cannot serve TLS, and the
// server says so instead of binding a port that answers nothing.
func TestEnabledDoTWithoutCertIsAFailure(t *testing.T) {
cfg := config.Default()
cfg.DNS.UDP = nil
cfg.DNS.TCP = nil
cfg.DNS.DoT = config.DoT{Enabled: true, Listen: []string{"127.0.0.1:0"}}
cfg.DNS.DoH = config.DoH{Enabled: false}
srv := New(Options{Engine: newFakeHandler(), Config: config.NewStore(cfg, ""), Logger: discardLogger()})
err := srv.Start(context.Background())
if err == nil {
_ = srv.Stop(context.Background())
t.Fatal("Start succeeded with DoT enabled and no certificate")
}
t.Cleanup(func() { _ = srv.Stop(context.Background()) })
if got := srv.Listeners(); len(got) != 0 {
t.Errorf("Listeners() = %v, want empty", got)
}
}
// TestStartReportsAPartialFailureButServes pins the two halves of the Start
// contract at once: a listener that cannot bind is an error worth returning,
// but it must not take the whole process down when another listener is fine.
// The admin API treats an empty listener list as degraded (app/hooks.go), so
// serving on the survivors is the difference between one broken transport and
// an outage.
func TestStartReportsAPartialFailureButServes(t *testing.T) {
// Port 1 is privileged: an unprivileged test process cannot bind it, which
// is a deterministic bind failure without a port race.
cfg := config.Default()
cfg.DNS.UDP = []string{"127.0.0.1:0"}
cfg.DNS.TCP = []string{"127.0.0.1:1"}
cfg.DNS.DoT = config.DoT{Enabled: false}
cfg.DNS.DoH = config.DoH{Enabled: false}
srv := New(Options{Engine: newFakeHandler(), Config: config.NewStore(cfg, ""), Logger: discardLogger()})
if err := srv.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = srv.Stop(context.Background()) })
got := []string{}
for _, l := range srv.Listeners() {
got = append(got, l.Transport)
}
if len(got) != 1 || got[0] != "udp" {
t.Errorf("transports = %v, want just udp", got)
}
// The failing transport is not in the snapshot at all: ListenerInfo has an
// Err field, but a listener that never bound is never added, so the API
// cannot show an operator WHICH address failed. Pinned as current
// behaviour — see the report.
for _, l := range srv.Listeners() {
if l.Err != "" {
t.Errorf("listener %s carries Err %q; a failed bind is expected to be absent, not reported", l.Transport, l.Err)
}
}
}
// TestStartTwiceIsRejected: a second Start must not re-bind and double every
// counter, and must say so rather than silently doing nothing useful.
func TestStartTwiceIsRejected(t *testing.T) {
h := newHarness(t, nil)
if err := h.srv.Start(context.Background()); err == nil {
t.Fatal("second Start succeeded, want an error")
}
}
// TestDoHBasePathIsServedAtTheConfiguredPath: the DoH path is operator
// configuration, and a request to any other path must 404 rather than being
// answered — an Ingress path or a stale client that keeps using /dns-query
// needs to fail loudly.
func TestDoHBasePathIsServedAtTheConfiguredPath(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.DNS.DoH.BasePath = "/query" })
cl := h.dohClient(false)
// Configured path answers.
if res := h.askDoHGET(question("base.test.", dns.TypeA)); answerAddr(res) != "203.0.113.9" {
t.Errorf("A over the configured base path = %q", answerAddr(res))
}
// RFC 8484 §4.1 alias still works when the base path is not the default.
res, _ := dohGetAt(cl, h, h.addr("doh"), "/.well-known/dns-query", question("alias.test.", dns.TypeA))
if res != http.StatusOK {
t.Errorf("GET /.well-known/dns-query = %d, want 200", res)
}
// Anything else 404s and never reaches the engine.
for _, path := range []string{"/dns-query", "/query/extra", "/"} {
res, _ := dohGetAt(cl, h, h.addr("doh"), path, question("nope.test.", dns.TypeA))
if res != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, res)
}
}
if n := len(h.fake.seen()); n != 2 {
t.Errorf("engine saw %d requests, want 2 (the configured path and the alias)", n)
}
}
// TestDoHRejectsPostWhenNotAllowed: allow_post exists because some networks
// block POST. Turning it off must reject POST with the documented status and
// leave GET working, or the operator has turned DoH off entirely.
func TestDoHRejectsPostWhenNotAllowed(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.DNS.DoH.AllowPost = false })
res, body := h.doHPOST(question("post.test.", dns.TypeA))
if res.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST with allow_post=false = %d, want 405 (%s)", res.StatusCode, body)
}
if allow := res.Header.Get("Allow"); allow != "GET" {
t.Errorf("Allow = %q, want GET", allow)
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("rejected POST reached the engine %d times", n)
}
if res := h.askDoHGET(question("post.test.", dns.TypeA)); answerAddr(res) != "203.0.113.9" {
t.Errorf("GET stopped working when POST was disabled: %q", answerAddr(res))
}
}
// TestDoHPostRequiresTheRFC8484ContentType: a POST whose body is a DNS message
// but whose media type is something else is not RFC 8484, and answering it
// would let a browser (which can post anything) reach the resolver.
func TestDoHPostRequiresTheRFC8484ContentType(t *testing.T) {
h := newHarness(t, nil)
res, body := h.doHPostWith(question("ct.test.", dns.TypeA), "application/octet-stream")
if res.StatusCode != http.StatusUnsupportedMediaType {
t.Errorf("POST with the wrong content type = %d, want 415 (%s)", res.StatusCode, body)
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("wrong-content-type POST reached the engine %d times", n)
}
}
// TestDoHRejectsNonQueryMethods: the handler is not a general HTTP endpoint.
func TestDoHRejectsNonQueryMethods(t *testing.T) {
h := newHarness(t, nil)
for _, method := range []string{http.MethodPut, http.MethodDelete, http.MethodHead} {
req, err := http.NewRequest(method, h.dohURL(h.base()), nil)
if err != nil {
t.Fatalf("%s request: %v", method, err)
}
res, err := h.dohClient(false).Do(req)
if err != nil {
t.Fatalf("%s: %v", method, err)
}
_ = res.Body.Close()
if res.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("%s = %d, want 405", method, res.StatusCode)
}
if allow := res.Header.Get("Allow"); allow != "GET, POST" {
t.Errorf("%s Allow = %q, want \"GET, POST\"", method, allow)
}
}
}
// TestDoHHidesVersionUnlessAsked pins cfgHideIdentity. The Server header is the
// only thing this switches off; the version string is an attacker's first
// question, and an operator who set hide_identity expects it gone from the
// encrypted listener too.
func TestDoHHidesVersionUnlessAsked(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.DNS.HideIdentity = true })
res, _ := h.doHGET(question("hide.test.", dns.TypeA))
if got := res.Header.Get("Server"); got != "" {
t.Errorf("Server header = %q, want it hidden", got)
}
if res := h.askDoHPOST(question("hide.test.", dns.TypeA)); answerAddr(res) != "203.0.113.9" {
t.Errorf("hide_identity broke the POST path: %q", answerAddr(res))
}
h2 := newHarness(t, nil)
res2, _ := h2.doHGET(question("show.test.", dns.TypeA))
if got, want := res2.Header.Get("Server"), "fancy-dns/"+testVersion; got != want {
t.Errorf("Server header = %q, want %q", got, want)
}
}
// TestDoHHideIdentityIsReadPerRequest: the flag lives in the hot-reloadable
// store, so turning it on must take effect without a restart. It is read on
// every request (cfgHideIdentity calls cfg.Get()), which is what makes the
// reload meaningful — and it is worth pinning, because freezing it at New
// would be an invisible regression.
func TestDoHHideIdentityIsReadPerRequest(t *testing.T) {
h := newHarness(t, nil)
res, _ := h.doHGET(question("reload.test.", dns.TypeA))
if res.Header.Get("Server") == "" {
t.Fatal("version header missing before the reload")
}
next := *h.cfg
next.DNS.HideIdentity = true
if err := h.store.Swap(&next); err != nil {
t.Fatalf("swap: %v", err)
}
res, _ = h.doHGET(question("reload.test.", dns.TypeA))
if got := res.Header.Get("Server"); got != "" {
t.Errorf("Server header = %q after hide_identity, want hidden", got)
}
}
// TestDoHDropStillGetsABody: a dropped query must not hang the HTTP exchange.
// Unlike UDP, where silence is the answer, a DoH client waiting on a socket
// reads a missing body as a transport error and retries against a resolver
// that is working exactly as configured.
func TestDoHDropStillGetsABody(t *testing.T) {
h := newHarness(t, nil)
res, body := h.doHPOST(question("drop.test.", dns.TypeA))
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", res.StatusCode, body)
}
m := unpackDoH(t, body)
if len(m.Answer) != 0 {
t.Errorf("dropped query came back with %d answers: %v", len(m.Answer), m.Answer)
}
if got := h.fake.only(t); got.name != "drop.test." {
t.Errorf("name = %q, want drop.test.", got.name)
}
}
// TestDoHMalformedRequestsAreRejected: every one of these is a 400/404 that
// must not reach the engine, because a request that cannot be understood is
// also a request the rate limiter and the query log should not see.
func TestDoHMalformedRequestsAreRejected(t *testing.T) {
h := newHarness(t, nil)
cl := h.dohClient(false)
addr := h.addr("doh")
base := h.base()
post := func(body string, ct string) int {
res, err := cl.Post("https://"+addr+base, ct, stringReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
_ = res.Body.Close()
return res.StatusCode
}
cases := []struct {
name string
run func() int
want int
}{
{"no dns parameter", func() int {
res, _ := dohRawGet(cl, "https://"+addr+base+"?name=example.com")
return res
}, http.StatusBadRequest},
{"unknown parameter", func() int {
res, _ := dohRawGet(cl, "https://"+addr+base+"?dns=AAAB&extra=1")
return res
}, http.StatusBadRequest},
{"malformed base64", func() int {
res, _ := dohRawGet(cl, "https://"+addr+base+"?dns="+"not base64!!")
return res
}, http.StatusBadRequest},
{"not a DNS message", func() int {
res, _ := dohRawGet(cl, "https://"+addr+base+"?dns=YWJjZGVmZ2g")
return res
}, http.StatusBadRequest},
{"empty body", func() int { return post("", contentTypeDNS) }, http.StatusBadRequest},
{"garbage body", func() int { return post("this is not a DNS message", contentTypeDNS) }, http.StatusBadRequest},
{"header with no question", func() int {
// A well-formed DNS header with qdcount 0: parseable, unanswerable.
return post(string([]byte{0x12, 0x34, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}), contentTypeDNS)
}, http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.run(); got != tc.want {
t.Errorf("status = %d, want %d", got, tc.want)
}
})
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("a malformed request reached the engine: %+v", h.fake.seen()[:n])
}
}
// TestDoHBasePathCannotBeUsedForOtherPaths: a base path of "/" is what an
// operator writes to "serve DoH at the root". It answers every path, which is
// why healthz is registered explicitly and why this is worth knowing.
func TestDoHBasePathCannotBeUsedForOtherPaths(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.DNS.DoH.BasePath = "/" })
if h.base() != "/" {
t.Fatalf("base = %q, want /", h.base())
}
res, body := h.doHGET(question("root.test.", dns.TypeA))
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", res.StatusCode, body)
}
// /healthz is registered ahead of the catch-all, so it still works.
hres, err := h.dohClient(false).Get(h.dohURL("/healthz"))
if err != nil {
t.Fatalf("healthz: %v", err)
}
_ = hres.Body.Close()
if hres.StatusCode != http.StatusOK {
t.Errorf("healthz = %d, want 200", hres.StatusCode)
}
}
// dohGetAt performs a GET against an arbitrary path on the DoH listener, so a
// test can check a path the server is not supposed to serve.
func dohGetAt(cl *http.Client, _ *harness, addr, path string, m *dns.Msg) (int, []byte) {
wire, err := m.Pack()
if err != nil {
return 0, nil
}
res, body := dohRawGet(cl, "https://"+addr+path+"?dns="+base64.RawURLEncoding.EncodeToString(wire))
return res, body
}
// dohRawGet issues a GET and returns the status and body.
func dohRawGet(cl *http.Client, url string) (int, []byte) {
res, err := cl.Get(url)
if err != nil {
return 0, nil
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return res.StatusCode, body
}
// stringReader keeps the POST bodies in the malformed-request table readable.
func stringReader(s string) io.Reader { return strings.NewReader(s) }
// discardLogger keeps the package's log output out of the test run.
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }