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)) }
+150
View File
@@ -0,0 +1,150 @@
package server
import (
"bytes"
"encoding/base64"
"io"
"net/http"
"strings"
"testing"
"github.com/miekg/dns"
)
// The DoH handler is the one unauthenticated, TCP-terminated entry point in
// the process, so its size limits are a security control and not a nicety:
// AGENTS.md lists "amplification" under the areas that have been wrong before
// and must not lose their cap. Two paths, one cap each:
//
// - POST: readCapped refuses a body over dns.MaxMsgSize, both from an honest
// Content-Length and from a streamed body whose length is not declared.
// - GET: the ?dns= parameter must be refused when it cannot possibly be a
// DNS message — the encoding of a legal message is at most
// base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) characters, and a DNS
// message cannot exceed 65535 bytes on the wire (RFC 1035 §4.2.1, the
// 16-bit length prefix). Without that bound the only limit is net/http's
// MaxHeaderBytes, a megabyte of attacker-chosen base64 decoded per
// request, against a cap of 64 KiB on the POST path beside it.
// oversizedBody returns a body just over the cap. Its content is irrelevant:
// nothing past the cap may be read, let alone unpacked.
func oversizedBody() []byte { return bytes.Repeat([]byte{0x00}, dns.MaxMsgSize+1) }
// TestDoHPostBodyOverTheCapIsRefused covers the declared-length branch: an
// honest Content-Length over the cap is refused without reading the body.
func TestDoHPostBodyOverTheCapIsRefused(t *testing.T) {
h := newHarness(t, nil)
body := oversizedBody()
res, err := h.dohClient(false).Post(h.dohURL(h.base()), contentTypeDNS, bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
got, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400: %s", res.StatusCode, got)
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("an over-cap body reached the engine %d times", n)
}
}
// TestDoHPostBodyOverTheCapIsRefusedWithoutAContentLength is the branch that
// matters: a client may lie about, or omit, Content-Length. readCapped has to
// detect "too large" by reading max+1 bytes itself, not by trusting the
// header, or the cap is a suggestion.
func TestDoHPostBodyOverTheCapIsRefusedWithoutAContentLength(t *testing.T) {
h := newHarness(t, nil)
// An unknown-length body makes net/http send it chunked, so the server
// sees ContentLength == -1 and has to use its own reader.
body := io.NopCloser(bytes.NewReader(oversizedBody()))
req, err := http.NewRequest(http.MethodPost, h.dohURL(h.base()), body)
if err != nil {
t.Fatalf("request: %v", err)
}
req.ContentLength = -1
req.Header.Set("Content-Type", contentTypeDNS)
res, err := h.dohClient(false).Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
got, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400: %s", res.StatusCode, got)
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("an over-cap chunked body reached the engine %d times", n)
}
}
// TestDoHGETQueryStringOverTheCapIsRefused: a ?dns= parameter longer than any
// legal encoding is refused before it is decoded.
func TestDoHGETQueryStringOverTheCapIsRefused(t *testing.T) {
h := newHarness(t, nil)
cl := h.dohClient(false)
// One character past the longest possible encoding of a DNS message.
param := base64.RawURLEncoding.EncodeToString(make([]byte, dns.MaxMsgSize+1))
for _, size := range []int{len(param), 512 * 1024} {
long := param
if size > len(param) {
long = param + strings.Repeat("A", size-len(param))
}
res, err := cl.Get("https://" + h.addr("doh") + h.base() + "?dns=" + long)
if err != nil {
t.Fatalf("GET (%d chars): %v", len(long), err)
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
// 413 rather than 400: the parameter is a payload that is too big,
// and it keeps the two paths symmetric — readCapped on POST answers
// "cannot read body" for the same reason. A conforming client can
// never reach this status: the cap is the longest possible encoding
// of a legal DNS message.
if res.StatusCode != http.StatusRequestEntityTooLarge {
t.Errorf("GET with a %d-character dns parameter = %d, want 413: %s", len(long), res.StatusCode, body)
}
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("an over-cap query string reached the engine %d times", n)
}
}
// TestDoHServesALargeButLegalQuery guards the cap from being set too tight. A
// resolver may legitimately be handed a message near the 65535-byte wire
// limit — here an EDNS0 padding option, the same trick the fake uses for a big
// answer — and a cap that rejected it would break real clients, not attackers.
func TestDoHServesALargeButLegalQuery(t *testing.T) {
h := newHarness(t, nil)
q := question("large.test.", dns.TypeA)
q.SetEdns0(4096, false)
// Size the padding so the packed message lands just under the 65535-byte
// wire limit: a header (12) + question (18) + OPT (11) + the padding
// option's own 4 bytes of code/length, plus the padding itself.
q.IsEdns0().Option = append(q.IsEdns0().Option, &dns.EDNS0_NSID{Nsid: "00"})
q.IsEdns0().Option = append(q.IsEdns0().Option, &dns.EDNS0_PADDING{Padding: make([]byte, 65400)})
wire, err := q.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
if len(wire) <= 65000 || len(wire) > dns.MaxMsgSize {
t.Fatalf("fixture is %d bytes; it must be near the %d-byte limit and legal", len(wire), dns.MaxMsgSize)
}
if got := len(base64.RawURLEncoding.EncodeToString(wire)); got > base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) {
t.Fatalf("fixture's encoding is %d characters, over the cap by construction", got)
}
// GET.
res, body := h.doHGET(q)
if res.StatusCode != http.StatusOK {
t.Errorf("GET with a %d-byte message = %d, want 200: %s", len(wire), res.StatusCode, body)
}
// POST.
res, body = h.doHPOST(q)
if res.StatusCode != http.StatusOK {
t.Errorf("POST with a %d-byte message = %d, want 200: %s", len(wire), res.StatusCode, body)
}
if n := len(h.fake.seen()); n != 2 {
t.Errorf("engine saw %d requests, want 2", n)
}
}
+383
View File
@@ -0,0 +1,383 @@
package server
import (
"bytes"
"crypto/tls"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
// countingReader counts the bytes readCapped actually pulled out of a body, so
// the "refused on the header" branch can be told apart from the "refused while
// reading" branch. Both return an error; only one of them let the client send
// the body.
type countingReader struct {
r io.Reader
n int
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += n
return n, err
}
// errReader emits a prefix and then fails, standing in for a client that
// disconnects mid-body. readCapped has to surface that as an error: a truncated
// DNS message handed to dns.Msg.Unpack would be unpacked as garbage.
type errReader struct {
prefix string
err error
done bool
}
func (f *errReader) Read(p []byte) (int, error) {
if f.done {
return 0, f.err
}
f.done = true
return copy(p, f.prefix), nil
}
// TestReadCapped covers the DoH POST body reader. It is a security control, not
// a convenience: the handler is unauthenticated, and the bound is what stops a
// client from making the server allocate an arbitrary amount of memory per
// request. Two branches have to both work, because the whole cap is only as
// strong as the weaker one:
//
// - a declared Content-Length over the cap is refused WITHOUT reading the
// body at all (the header is a fast path, and the counting reader proves
// nothing was read);
// - a missing or understated Content-Length is caught by the reader itself,
// which takes max+1 bytes and only then says "too large".
func TestReadCapped(t *testing.T) {
const max = 16
full := strings.Repeat("a", max)
cases := []struct {
name string
// body is what the client sends.
body string
// failAfter, when non-zero, makes the body stop with an error after
// that many bytes.
failAfter int
// length is the Content-Length the request advertises. -1 is
// net/http's value for a chunked request: no declared length at all.
length int64
want string
// wantReads is the number of body bytes readCapped should have
// consumed. -1 means the case is not about how much was read.
wantReads int
wantErr bool
}{
{
name: "an empty body is legal", body: "", length: 0,
want: "", wantReads: 0,
},
{
name: "a body exactly at the cap is accepted whole", body: full, length: max,
want: full, wantReads: max,
},
{
name: "one byte over the cap with an honest Content-Length is refused before the body is read",
body: full + "a", length: max + 1,
want: "", wantReads: 0, wantErr: true,
},
{
// A client may claim any length it likes, so an over-declared
// length must not be the only thing that decides. It is still
// the fast path, so it must not cost a read.
name: "a wildly over-declared Content-Length is refused before the body is read",
body: full + "a", length: 64 << 20,
want: "", wantReads: 0, wantErr: true,
},
{
// ContentLength == -1 is what net/http reports for a chunked
// request, so this is the branch a length-less client hits.
name: "one byte over the cap with no declared length is caught while reading",
body: full + "a", length: -1,
want: "", wantReads: max + 1, wantErr: true,
},
{
name: "a body under the cap with no declared length is accepted whole",
body: "chunked-body", length: -1,
want: "chunked-body", wantReads: len("chunked-body"),
},
{
// The cap is not the only thing that can end a read early, and a
// half-read body must not be handed on as if it were complete.
name: "a body that fails mid-read surfaces the read error",
body: "abcde", failAfter: 5, length: -1,
want: "", wantReads: 5, wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var src io.Reader = strings.NewReader(tc.body)
if tc.failAfter > 0 {
src = &errReader{prefix: tc.body, err: errors.New("connection reset by peer")}
}
cr := &countingReader{r: src}
r := httptest.NewRequest(http.MethodPost, "/dns-query", nil)
r.Body = io.NopCloser(cr)
r.ContentLength = tc.length
got, err := readCapped(r, max)
if tc.wantErr {
if err == nil {
t.Fatalf("readCapped accepted %q, want an error", got)
}
if got != nil {
t.Errorf("readCapped returned %q alongside its error, want nil", got)
}
} else {
if err != nil {
t.Fatalf("readCapped(%q) = %v, want it accepted", tc.want, err)
}
if string(got) != tc.want {
t.Errorf("readCapped = %q, want %q", got, tc.want)
}
}
if tc.wantReads >= 0 && cr.n != tc.wantReads {
t.Errorf("readCapped read %d body bytes, want %d", cr.n, tc.wantReads)
}
})
}
}
// TestDecodeBase64URL covers the RFC 8484 §4.1 GET parameter decoder. The two
// encodings it accepts are base64url with and without padding: the RFC asks for
// the unpadded form, every real client sends the padded one, and refusing the
// padded form would break all of them. What it must NOT accept is the standard
// alphabet, because '+' and '/' have to be percent-encoded in a query string
// and a decoder that took them would be a decoder for a different encoding.
func TestDecodeBase64URL(t *testing.T) {
msg := []byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03}
// Standard base64 of these bytes contains both '+' and '/'.
std := base64.StdEncoding.EncodeToString(msg)
if !strings.ContainsAny(std, "+/") {
t.Fatalf("fixture %q contains no + or /; the standard-alphabet case would prove nothing", std)
}
cases := []struct {
name string
in string
want []byte
wantErr bool
}{
{name: "unpadded base64url", in: base64.RawURLEncoding.EncodeToString(msg), want: msg},
{name: "padded base64url", in: base64.URLEncoding.EncodeToString(msg), want: msg},
{name: "standard base64 with + and / is rejected", in: std, wantErr: true},
{name: "a space inside the parameter is rejected", in: "QQ BB", wantErr: true},
{name: "a tab inside the parameter is rejected", in: "QQ\tBB", wantErr: true},
{
// encoding/base64 skips CR and LF in every encoding, padded or
// not. A newline in ?dns= therefore decodes rather than 400s.
// It is harmless — the bytes are the same message — and it is
// pinned here so nobody "fixes" it by assuming the decoder
// rejects all whitespace. The size cap is applied to the raw
// parameter before decoding, so this cannot be used to smuggle
// an oversized message past the cap.
name: "a newline inside the parameter is ignored by the decoder",
in: "QQ\n==", want: []byte{0x41},
},
{name: "an empty parameter decodes to nothing", in: "", want: []byte{}},
{name: "a truncated quantum is rejected", in: "QQQQQ", wantErr: true},
{name: "a single character is rejected", in: "A", wantErr: true},
{
// Two characters is a legal UNPADDED encoding of one byte, so it
// decodes on the RawURLEncoding path before the padded one is
// tried. It is not an error case; the case above ("QQQQQ") is,
// because five characters is not a whole number of quanta.
name: "two characters are a valid unpadded encoding of one byte",
in: "QQ", want: []byte{0x41},
},
{
// decodeBase64URL is a pure decoder: it imposes no size limit,
// because the limit that matters (the longest possible encoding
// of a 65535-byte DNS message) is a property of the caller's
// parameter, and the DoH handler applies it before calling here.
// The bound itself is pinned in TestDoHGETQueryStringOverTheCapIsRefused.
name: "a long but legal parameter decodes; the cap is the handler's job",
in: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0x41}, 1<<20)),
want: bytes.Repeat([]byte{0x41}, 1<<20),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := decodeBase64URL(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("decodeBase64URL(%q) = %x, want an error", tc.in, got)
}
return
}
if err != nil {
t.Fatalf("decodeBase64URL(%q) = %v, want it accepted", tc.in, err)
}
if !bytes.Equal(got, tc.want) {
t.Errorf("decodeBase64URL(%q) = %x, want %x", tc.in, got, tc.want)
}
})
}
// The cases above are the decoder's edge cases; this is the case that
// actually happens. A real query must survive the round trip byte for
// byte, in both encodings, or a client using the padded form (which is
// what curl and most browsers send) gets a 400 for a well-formed query.
wire, err := question("roundtrip.test.", dns.TypeA).Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
for _, enc := range []struct {
name string
in string
}{
{"unpadded", base64.RawURLEncoding.EncodeToString(wire)},
{"padded", base64.URLEncoding.EncodeToString(wire)},
} {
t.Run("a real query round trips, "+enc.name, func(t *testing.T) {
got, err := decodeBase64URL(enc.in)
if err != nil {
t.Fatalf("decodeBase64URL: %v", err)
}
if !bytes.Equal(got, wire) {
t.Fatalf("round trip changed the message: %d bytes in, %d out", len(wire), len(got))
}
if m := new(dns.Msg); m.Unpack(got) != nil {
t.Fatalf("the decoded bytes are not a DNS message")
}
})
}
}
// TestParseTLSVersion pins the fallback, which is a security setting: an
// operator who typos min_version, or leaves it empty, must get TLS 1.2 — never
// TLS 1.0 and never "whatever the Go default happens to be this release".
func TestParseTLSVersion(t *testing.T) {
cases := []struct {
in string
want uint16
why string
}{
{in: "1.2", want: tls.VersionTLS12, why: "the documented value"},
{in: "1.3", want: tls.VersionTLS13, why: "the documented value"},
{in: "1.0", want: tls.VersionTLS10, why: "still reachable, though config documents only 1.2/1.3"},
{in: "1.1", want: tls.VersionTLS12, why: "unknown values fail UP to 1.2, never down to 1.1"},
{in: "", want: tls.VersionTLS12, why: "an unset min_version means the secure default"},
{in: "junk", want: tls.VersionTLS12, why: "a typo must not weaken the floor"},
{in: "13", want: tls.VersionTLS12, why: "only the exact string 1.3 is honoured"},
{in: "1.3 ", want: tls.VersionTLS12, why: "no trimming: an odd value falls back, it does not panic"},
}
for _, tc := range cases {
t.Run("min_version="+tc.in, func(t *testing.T) {
if got := parseTLSVersion(tc.in); got != tc.want {
t.Errorf("parseTLSVersion(%q) = %d, want %d (%s)", tc.in, got, tc.want, tc.why)
}
})
}
}
// TestNormaliseBase covers the DoH base path normaliser. What it does is
// deliberately narrow — add the leading slash, trim the trailing ones — because
// it runs on operator config at Start and must never fail a startup. What it
// must NOT grow is a second parser: the path-traversal cases are here to pin
// that the string is passed through verbatim, because the thing that actually
// keeps a traversal out of the handler is net/http's ServeMux, which cleans
// the path before it matches. That is pinned by
// TestDoHBasePathTraversalNeverReachesTheHandler below; a sanitiser here would
// be a second, weaker implementation of the same rule.
func TestNormaliseBase(t *testing.T) {
cases := []struct{ in, want, why string }{
{in: "", want: "/dns-query", why: "the default, so an unset base path still serves DoH"},
{in: "query", want: "/query", why: "a path written without its leading slash is what operators type"},
{in: "/query", want: "/query", why: "already normalised"},
{in: "/query/", want: "/query", why: "a trailing slash would register a different mux pattern"},
{in: "/query///", want: "/query", why: "all of them, and never the empty string"},
{in: "/", want: "/", why: "serve at the root is a legal (if blunt) choice"},
{in: "///", want: "/", why: "trimming stops at a single slash"},
{in: "///query///", want: "///query", why: "only a MISSING leading slash is added; runs of them are the mux's business"},
{in: "/a//b", want: "/a//b", why: "only the ends are touched; an inner // is left for the mux"},
{in: "/query?x=1", want: "/query?x=1", why: "no query splitting: this is a path, not a URL"},
{in: "/../etc", want: "/../etc", why: "passed through; ServeMux cleans the path (see the traversal test)"},
{in: "/dns-query/..", want: "/dns-query/..", why: "passed through, same reason"},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := normaliseBase(tc.in); got != tc.want {
t.Errorf("normaliseBase(%q) = %q, want %q (%s)", tc.in, got, tc.want, tc.why)
}
})
}
}
// TestDoHBasePathTraversalNeverReachesTheHandler is the security half of the
// normaliseBase comment above. The DoH mux is built from a configured path, so
// the question is what happens when a request arrives with dot segments in it:
// it must never be answered by the DNS handler, and it must never reach the
// engine. net/http's ServeMux cleans the path and issues a 307 to the cleaned
// form, which is why there is no sanitiser in normaliseBase. Redirects are not
// followed here: following one would answer this test with the redirect
// TARGET's behaviour rather than the traversal's.
func TestDoHBasePathTraversalNeverReachesTheHandler(t *testing.T) {
h := newHarness(t, nil)
cl := noRedirectClient(t, h)
cases := []struct {
path string
want int
}{
// Cleaned to /healthz, which is a registered path: a 307 to it, and
// the DNS handler never sees the request. ServeMux is what keeps the
// traversal out; normaliseBase deliberately does not try.
{path: "/dns-query/../healthz", want: http.StatusTemporaryRedirect},
{path: "/dns-query/./x", want: http.StatusTemporaryRedirect},
// Encoded dot segments are not cleaned by the mux, so they match no
// pattern at all.
{path: "/dns-query/%2e%2e/healthz", want: http.StatusNotFound},
{path: "/dns-query/..%2fhealthz", want: http.StatusNotFound},
{path: "/..%2f..%2fetc/passwd", want: http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
res, err := cl.Get(h.dohURL(tc.path))
if err != nil {
t.Fatalf("GET %s: %v", tc.path, err)
}
_ = res.Body.Close()
if res.StatusCode != tc.want {
t.Errorf("GET %s = %d, want %d", tc.path, res.StatusCode, tc.want)
}
})
}
// Nothing above reached the engine, and the plain path still works, so
// the 404s are not the DNS handler being broken. Exactly one request
// reaches the engine: the control query.
h.fake.reset()
if a := answerAddr(h.askDoHGET(question("control.test.", dns.TypeA))); a != "203.0.113.9" {
t.Errorf("control query answered %q, want 203.0.113.9", a)
}
if n := len(h.fake.seen()); n != 1 {
t.Errorf("%d requests reached the engine, want exactly the control query: %+v", n, h.fake.seen())
}
}
// noRedirectClient is h.dohClient without redirect following, so a test can
// assert on the status the server produced rather than on whatever the
// Location header points at.
func noRedirectClient(t *testing.T, h *harness) *http.Client {
t.Helper()
tr := &http.Transport{TLSClientConfig: h.clientTLS()}
t.Cleanup(tr.CloseIdleConnections)
return &http.Client{
Transport: tr,
Timeout: 5 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
+581
View File
@@ -0,0 +1,581 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"io"
"log/slog"
"math/big"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
)
// testVersion is what the fake server reports in the DoH Server header.
const testVersion = "9.9.9-test"
// recorded is one request as the handler saw it. The question is copied out
// of the *dns.Msg because the engine is allowed to mutate the message, and a
// test that asserts on a pointer the pipeline may rewrite is a flake waiting
// to happen.
type recorded struct {
transport string
name string
qtype uint16
remote netip.Addr
tcp bool
names []string
tags []string
dohPath string
ednsSize uint16
}
// fakeHandler is the one Handler every transport funnels into. It records what
// it was asked and answers from a fixed recipe, so a test can tell "the UDP
// listener delivered this" from "the engine produced this".
type fakeHandler struct {
mu sync.Mutex
reqs []recorded
inflight atomic.Int64
flushes atomic.Uint64
// packErr is the last failure to pack a response the handler built. A
// response that will not pack is silently dropped on the write path and
// presents as a client timeout, so it is recorded here rather than being
// discovered three layers away.
packErr error
// arrived is signalled on every Handle entry, so a test can wait for a
// query to be *inside* the pipeline instead of sleeping and hoping.
arrived chan struct{}
gate chan struct{} // non-nil while queries are parked
release chan struct{} // closed at cleanup so a held gate cannot wedge shutdown
muGate sync.Mutex
}
func newFakeHandler() *fakeHandler {
return &fakeHandler{
arrived: make(chan struct{}, 64),
release: make(chan struct{}),
}
}
// Handle records the request and answers from the recipe below.
func (f *fakeHandler) Handle(ctx context.Context, req engine.Request) engine.Response {
f.inflight.Add(1)
defer f.inflight.Add(-1)
rec := recorded{
transport: req.Transport,
remote: req.Remote,
tcp: req.TCP,
names: append([]string(nil), req.ClientNames...),
tags: append([]string(nil), req.ClientTags...),
dohPath: req.DoHPath,
}
if req.Msg != nil && len(req.Msg.Question) > 0 {
rec.name = req.Msg.Question[0].Name
rec.qtype = req.Msg.Question[0].Qtype
if o := req.Msg.IsEdns0(); o != nil {
rec.ednsSize = o.UDPSize()
}
}
f.mu.Lock()
f.reqs = append(f.reqs, rec)
f.mu.Unlock()
select {
case f.arrived <- struct{}{}:
default:
}
if g := f.gateCh(); g != nil {
select {
case <-g:
case <-f.release:
case <-ctx.Done():
}
}
res := f.answer(req.Msg, rec)
if res.Msg != nil {
if _, err := res.Msg.Pack(); err != nil {
f.mu.Lock()
f.packErr = err
f.mu.Unlock()
}
}
return res
}
// answer is the canned response. The question name selects the outcome, so a
// test drives a blocked/cached/dropped/big answer by asking for it by name
// instead of reaching into the server. The reply is built from a copy of the
// query: SetReply carries the query's ID across, and a reply that invents its
// own ID is discarded by every real client.
func (f *fakeHandler) answer(q *dns.Msg, rec recorded) engine.Response {
m := newReply(q)
switch {
case strings.HasPrefix(rec.name, "block."):
return engine.Response{Msg: blockedReply(m), Action: "block", BlockSource: "test"}
case strings.HasPrefix(rec.name, "cached."):
return engine.Response{Msg: answerA(m), Action: "cached", CacheHit: true, CacheKind: "hit"}
case strings.HasPrefix(rec.name, "fail."):
m.Rcode = dns.RcodeServerFailure
return engine.Response{Msg: m, Action: "error"}
case strings.HasPrefix(rec.name, "drop."):
return engine.Response{Drop: true, Action: "drop"}
case strings.HasPrefix(rec.name, "big."):
return engine.Response{Msg: bigAnswer(m), Action: "allow"}
case len(rec.name) == 0:
m.Rcode = dns.RcodeFormatError
return engine.Response{Msg: m, Action: "error"}
}
return engine.Response{Msg: answerA(m), Action: "allow"}
}
func (f *fakeHandler) Inflight() int64 { return f.inflight.Load() }
func (f *fakeHandler) Flush() { f.flushes.Add(1) }
// flushes returns how many times the admin flush action reached the engine.
func (f *fakeHandler) flushCount() uint64 { return f.flushes.Load() }
// hold parks every handler that arrives from now on until the returned func is
// called. It is how a test gets a query that is reliably in flight.
func (f *fakeHandler) hold() func() {
f.muGate.Lock()
defer f.muGate.Unlock()
if f.gate != nil {
return func() {}
}
g := make(chan struct{})
f.gate = g
return func() { close(g) }
}
func (f *fakeHandler) gateCh() chan struct{} {
f.muGate.Lock()
defer f.muGate.Unlock()
return f.gate
}
// waitArrived blocks until a request has reached the handler.
func (f *fakeHandler) waitArrived(t *testing.T) {
t.Helper()
select {
case <-f.arrived:
case <-time.After(5 * time.Second):
t.Fatal("no query reached the handler")
}
}
// lastPackErr reports the last response that failed to pack.
func (f *fakeHandler) lastPackErr() error {
f.mu.Lock()
defer f.mu.Unlock()
return f.packErr
}
// seen returns a copy of everything recorded so far.
func (f *fakeHandler) seen() []recorded {
f.mu.Lock()
defer f.mu.Unlock()
return append([]recorded(nil), f.reqs...)
}
// only asserts that exactly one request was recorded and returns it.
func (f *fakeHandler) only(t *testing.T) recorded {
t.Helper()
got := f.seen()
if len(got) != 1 {
t.Fatalf("handler saw %d requests, want exactly 1: %+v", len(got), got)
}
return got[0]
}
// last returns the most recent recorded request.
func (f *fakeHandler) last() recorded {
got := f.seen()
if len(got) == 0 {
return recorded{}
}
return got[len(got)-1]
}
// reset drops the recording, so one server can serve several independent
// questions.
func (f *fakeHandler) reset() {
f.mu.Lock()
f.reqs = nil
f.mu.Unlock()
}
// newReply builds the shell of a reply: the query's ID, opcode, question and
// OPT. A real engine's finalize step does the EDNS0 handshake and SetReply
// carries neither the OPT nor a made-up ID, so a fake that skipped this would
// be the only thing in the path dropping them.
func newReply(q *dns.Msg) *dns.Msg {
m := new(dns.Msg)
m.SetReply(q.Copy())
m.Authoritative = false
if o := q.IsEdns0(); o != nil {
m.SetEdns0(o.UDPSize(), o.Do())
}
return m
}
// answerA adds the canned A record to a prepared reply.
func answerA(m *dns.Msg) *dns.Msg {
if len(m.Question) > 0 && m.Question[0].Qtype == dns.TypeA {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: net.ParseIP("203.0.113.9"),
})
}
return m
}
// blockedReply adds the 0.0.0.0 sinkhole answer.
func blockedReply(m *dns.Msg) *dns.Msg {
if len(m.Question) > 0 && m.Question[0].Qtype == dns.TypeA {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: net.ParseIP("0.0.0.0"),
})
}
return m
}
// bigAnswer builds a multi-kilobyte answer out of many short TXT strings.
// One long string does NOT work: miekg/dns v1.1.73 reports a character-string
// over 255 bytes as zero length, so Msg.Pack fails with "dns: string exceeded
// 255 bytes" and, on a server write path, fails silently. 15 x 200 lands just
// over 3 KiB: far outside a 512-byte client buffer, inside the 4096 EDNS0
// buffer the server accepts.
func bigAnswer(m *dns.Msg) *dns.Msg {
txt := make([]string, 15)
for i := range txt {
txt[i] = strings.Repeat("x", 200)
}
name := "."
if len(m.Question) > 0 {
name = m.Question[0].Name
}
m.Answer = append(m.Answer, &dns.TXT{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
Txt: txt,
})
return m
}
// harness is a started Server with all four listeners on loopback:0, a fake
// engine behind them and a self-signed CA the test clients pin.
type harness struct {
t *testing.T
cfg *config.Config
store *config.Store
fake *fakeHandler
srv *Server
pool *x509.CertPool
cert tls.Certificate
}
func newHarness(t *testing.T, mutate func(*config.Config)) *harness {
t.Helper()
certFile, keyFile := writeTestCert(t)
pool := testCertPool(t, certFile)
cfg := config.Default()
cfg.DNS.UDP = []string{"127.0.0.1:0"}
cfg.DNS.TCP = []string{"127.0.0.1:0"}
cfg.DNS.DoT = config.DoT{Enabled: true, Listen: []string{"127.0.0.1:0"}, CertFile: certFile, KeyFile: keyFile, MinVersion: "1.2"}
cfg.DNS.DoH = config.DoH{Enabled: true, Listen: []string{"127.0.0.1:0"}, CertFile: certFile, KeyFile: keyFile, BasePath: "/dns-query", MinVersion: "1.2", AllowPost: true}
cfg.DNS.DoH.Transport = config.Transport{Mode: "auto"}
// A short stream idle timeout keeps a test that leaves a connection open
// from holding a goroutine for the four-second default.
cfg.Server.Timeouts.TLS = config.Duration(2 * time.Second)
if mutate != nil {
mutate(cfg)
}
store := config.NewStore(cfg, "")
fake := newFakeHandler()
srv := New(Options{
Engine: fake,
Config: store,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Version: testVersion,
})
if err := srv.Start(context.Background()); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
close(fake.release)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Stop(ctx)
})
return &harness{t: t, cfg: cfg, store: store, fake: fake, srv: srv, pool: pool}
}
// addr returns the bound address of one transport, read back from the
// listener snapshot rather than guessed.
func (h *harness) addr(transport string) string {
h.t.Helper()
for _, l := range h.srv.Listeners() {
if l.Transport == transport {
return l.Addr
}
}
h.t.Fatalf("no %s listener; have %+v", transport, h.srv.Listeners())
return ""
}
func (h *harness) transports() []string {
var out []string
for _, l := range h.srv.Listeners() {
out = append(out, l.Transport)
}
return out
}
// base returns the normalised DoH base path.
func (h *harness) base() string { return normaliseBase(h.cfg.DNS.DoH.BasePath) }
func (h *harness) clientTLS() *tls.Config {
return &tls.Config{RootCAs: h.pool, MinVersion: tls.VersionTLS12}
}
// writeTestCert generates a self-signed certificate for 127.0.0.1 into
// t.TempDir and returns the two paths. Generating it in-test keeps the
// encrypted-listener tests self-contained: no testdata to expire.
func writeTestCert(t *testing.T) (certFile, keyFile string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("key: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "fancy-dns test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("certificate: %v", err)
}
kb, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
dir := t.TempDir()
certFile = filepath.Join(dir, "cert.pem")
keyFile = filepath.Join(dir, "key.pem")
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
t.Fatalf("write cert: %v", err)
}
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0o600); err != nil {
t.Fatalf("write key: %v", err)
}
return certFile, keyFile
}
// testCertPool returns a pool trusting only the generated certificate, so a
// client that skips verification is a deliberate choice rather than a habit.
func testCertPool(t *testing.T, certFile string) *x509.CertPool {
t.Helper()
pemBytes, err := os.ReadFile(certFile)
if err != nil {
t.Fatalf("read cert: %v", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemBytes) {
t.Fatal("test certificate did not parse into a pool")
}
return pool
}
// ask builds a query and exchanges it over the named transport. The client
// timeouts are short on purpose: a hung listener must fail the test in
// seconds, not at the package timeout.
func (h *harness) ask(transport string, name string, qtype uint16) *dns.Msg {
h.t.Helper()
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
switch transport {
case "udp":
return h.askUDP(m)
case "tcp":
return h.askTCP(m)
case "dot":
return h.askDoT(m)
case "doh":
return h.askDoHGET(m)
case "doh-post":
return h.askDoHPOST(m)
}
h.t.Fatalf("unknown transport %q", transport)
return nil
}
func (h *harness) askUDP(m *dns.Msg) *dns.Msg {
h.t.Helper()
c := &dns.Client{Net: "udp", Timeout: 3 * time.Second}
res, _, err := c.Exchange(m, h.addr("udp"))
if err != nil {
h.t.Fatalf("udp exchange: %v", err)
}
return res
}
func (h *harness) askTCP(m *dns.Msg) *dns.Msg {
h.t.Helper()
c := &dns.Client{Net: "tcp", Timeout: 3 * time.Second}
res, _, err := c.Exchange(m, h.addr("tcp"))
if err != nil {
h.t.Fatalf("tcp exchange: %v", err)
}
return res
}
func (h *harness) askDoT(m *dns.Msg) *dns.Msg {
h.t.Helper()
tc := h.clientTLS()
tc.NextProtos = []string{"dot"}
c := &dns.Client{Net: "tcp-tls", Timeout: 3 * time.Second, TLSConfig: tc}
res, _, err := c.Exchange(m, h.addr("dot"))
if err != nil {
h.t.Fatalf("dot exchange: %v", err)
}
return res
}
func (h *harness) dohURL(path string) string {
return "https://" + h.addr("doh") + path
}
func (h *harness) dohClient(forceH2 bool) *http.Client {
tr := &http.Transport{TLSClientConfig: h.clientTLS(), ForceAttemptHTTP2: forceH2}
h.t.Cleanup(tr.CloseIdleConnections)
return &http.Client{Transport: tr, Timeout: 5 * time.Second}
}
// askDoHGET issues the RFC 8484 GET profile with the wire message in ?dns=.
func (h *harness) askDoHGET(m *dns.Msg) *dns.Msg {
h.t.Helper()
res, body := h.doHGET(m)
if res.StatusCode != http.StatusOK {
h.t.Fatalf("doh GET status %d: %s", res.StatusCode, body)
}
return unpackDoH(h.t, body)
}
// askDoHPOST issues the RFC 8484 POST profile.
func (h *harness) askDoHPOST(m *dns.Msg) *dns.Msg {
h.t.Helper()
res, body := h.doHPOST(m)
if res.StatusCode != http.StatusOK {
h.t.Fatalf("doh POST status %d: %s", res.StatusCode, body)
}
return unpackDoH(h.t, body)
}
func (h *harness) doHGET(m *dns.Msg) (*http.Response, []byte) {
h.t.Helper()
wire, err := m.Pack()
if err != nil {
h.t.Fatalf("pack query: %v", err)
}
u := h.dohURL(h.base() + "?dns=" + base64.RawURLEncoding.EncodeToString(wire))
res, err := h.dohClient(false).Get(u)
if err != nil {
h.t.Fatalf("doh GET: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return res, body
}
func (h *harness) doHPOST(m *dns.Msg) (*http.Response, []byte) {
h.t.Helper()
return h.doHPostWith(m, contentTypeDNS)
}
func (h *harness) doHPostWith(m *dns.Msg, contentType string) (*http.Response, []byte) {
h.t.Helper()
wire, err := m.Pack()
if err != nil {
h.t.Fatalf("pack query: %v", err)
}
res, err := h.dohClient(false).Post(h.dohURL(h.base()), contentType, strings.NewReader(string(wire)))
if err != nil {
h.t.Fatalf("doh POST: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return res, body
}
func unpackDoH(t *testing.T, body []byte) *dns.Msg {
t.Helper()
m := new(dns.Msg)
if err := m.Unpack(body); err != nil {
t.Fatalf("unpack doh body (%d bytes): %v", len(body), err)
}
return m
}
// waitFor polls cond until it holds or the deadline passes. Every wait in this
// suite is bounded: a hang looks exactly like a suite failure.
func waitFor(t *testing.T, timeout time.Duration, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
if cond() {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out after %s waiting for %s", timeout, what)
}
time.Sleep(2 * time.Millisecond)
}
}
// question builds a query the fake's recipe understands.
func question(name string, qtype uint16) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
return m
}
var _ = engine.Request{}
+232
View File
@@ -0,0 +1,232 @@
package server
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/miekg/dns"
)
// TestSetIdentityReachesTheEngineRequest: the server resolves a client IP to
// reverse names and ACL tags before handing the query on, so the engine never
// has to do a PTR lookup on the hot path. Both the names and the tags must
// arrive, and the key they are resolved from must be the client's own address
// — a resolver that keys on anything else silently puts every client in one
// ACL group.
func TestSetIdentityReachesTheEngineRequest(t *testing.T) {
h := newHarness(t, nil)
var mu sync.Mutex
var keys []string
h.srv.SetIdentity(
func(ip string) []string {
mu.Lock()
keys = append(keys, ip)
mu.Unlock()
return []string{"host." + strings.ReplaceAll(ip, ".", "-") + ".test."}
},
func(ip string) []string { return []string{"tag:" + ip} },
)
h.ask("udp", "identity.test.", dns.TypeA)
got := h.fake.only(t)
if len(got.names) != 1 || got.names[0] != "host.127-0-0-1.test." {
t.Errorf("ClientNames = %v, want [host.127-0-0-1.test.]", got.names)
}
if len(got.tags) != 1 || got.tags[0] != "tag:127.0.0.1" {
t.Errorf("ClientTags = %v, want [tag:127.0.0.1]", got.tags)
}
mu.Lock()
defer mu.Unlock()
if len(keys) != 1 || keys[0] != "127.0.0.1" {
t.Errorf("identity was resolved for %v, want [127.0.0.1]", keys)
}
// And it is consulted per request, not cached at Start: a second query
// after a change has to see the new answer.
h.srv.SetIdentity(
func(string) []string { return []string{"second.test."} },
func(string) []string { return []string{"tag:second"} },
)
h.ask("tcp", "identity2.test.", dns.TypeA)
got = h.fake.last()
if len(got.names) != 1 || got.names[0] != "second.test." {
t.Errorf("ClientNames after the second SetIdentity = %v, want [second.test.]", got.names)
}
if len(got.tags) != 1 || got.tags[0] != "tag:second" {
t.Errorf("ClientTags after the second SetIdentity = %v, want [tag:second]", got.tags)
}
}
// TestRequestWithoutIdentityIsStillAnswered: SetIdentity is optional. With the
// resolvers unset the atomic holders are nil, and the query path must not
// dereference them — a server wired before the app installed its resolvers
// would otherwise panic on the first packet.
func TestRequestWithoutIdentityIsStillAnswered(t *testing.T) {
h := newHarness(t, nil)
res := h.ask("udp", "noidentity.test.", dns.TypeA)
if answerAddr(res) != "203.0.113.9" {
t.Errorf("A = %q, want 203.0.113.9", answerAddr(res))
}
got := h.fake.only(t)
if got.names != nil || got.tags != nil {
t.Errorf("ClientNames/ClientTags = %v/%v, want nil", got.names, got.tags)
}
}
// TestAddrToAddr covers the address normalisation every client identity, ACL
// and rate-limit decision depends on. An IPv4 client must arrive as an IPv4
// address even when the kernel hands us a v4-mapped v6 one, or every CIDR in
// every ACL group stops matching.
func TestAddrToAddr(t *testing.T) {
cases := []struct {
name string
addr net.Addr
want string
}{
{"udp IPv4", &net.UDPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5353}, "192.0.2.7"},
{"tcp IPv4", &net.TCPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5353}, "192.0.2.7"},
// A dual-stack listener reports IPv4 clients as ::ffff:a.b.c.d; without
// Unmap the ACL lookup would be handed "::ffff:192.0.2.7" and match no
// IPv4 group.
{"udp IPv4-mapped IPv6", &net.UDPAddr{IP: net.ParseIP("::ffff:192.0.2.7"), Port: 53}, "192.0.2.7"},
{"tcp IPv4-mapped IPv6", &net.TCPAddr{IP: net.ParseIP("::ffff:192.0.2.7"), Port: 53}, "192.0.2.7"},
{"udp IPv6", &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 53}, "2001:db8::1"},
// A scoped link-local address keeps its zone: dropping it would change
// which interface the address names.
{"udp IPv6 with a zone", &net.UDPAddr{IP: net.ParseIP("fe80::1"), Port: 53, Zone: "eth0"}, "fe80::1%eth0"},
{"custom addr with host:port", stringAddr("198.51.100.4:34567"), "198.51.100.4"},
{"custom addr with a bare IP", stringAddr("198.51.100.5"), "198.51.100.5"},
// Unparseable: the zero Addr. A client identity lookup on it must not
// panic, and the caller sees the invalid address.
{"custom addr with garbage", stringAddr("not-an-address"), "invalid IP"},
{"custom addr with a bare hostname", stringAddr("localhost"), "invalid IP"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := addrToAddr(tc.addr)
if got.String() != tc.want {
t.Errorf("addrToAddr(%s) = %q, want %q", tc.addr, got, tc.want)
}
if tc.want == "invalid IP" {
if got.IsValid() {
t.Errorf("addrToAddr returned a valid address %v for unparseable input", got)
}
return
}
if !got.IsValid() {
t.Errorf("addrToAddr returned the invalid address for %s", tc.addr)
}
})
}
}
// TestHTTPRemoteAddr covers the DoH adapter that turns an http.Request's
// RemoteAddr into the net.Addr the shared request builder wants.
func TestHTTPRemoteAddr(t *testing.T) {
cases := []struct {
name string
remoteAddr string
want string
}{
{"IPv4 with a port", "192.0.2.9:54321", "192.0.2.9"},
{"IPv6 with a port", "[2001:db8::1]:54321", "2001:db8::1"},
{"IPv4-mapped IPv6", "[::ffff:192.0.2.9]:54321", "192.0.2.9"},
// An empty or unparseable RemoteAddr cannot become a client identity;
// the zero TCPAddr degrades to the invalid address rather than
// inventing one.
{"empty", "", "invalid IP"},
{"garbage", "not-an-address", "invalid IP"},
{"no port", "192.0.2.9", "invalid IP"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "https://example.test/dns-query", nil)
r.RemoteAddr = tc.remoteAddr
got := addrToAddr(httpRemoteAddr(r))
if got.String() != tc.want {
t.Errorf("httpRemoteAddr(%q) -> %q, want %q", tc.remoteAddr, got, tc.want)
}
})
}
}
// TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader pins a known
// limitation, deliberately. httpRemoteAddr (server.go) uses the raw
// RemoteAddr and never consults X-Forwarded-For, even though the admin API in
// the same binary trusts that header from trusted_proxies. So a DoH query that
// arrives through the Ingress sees the Ingress's address as its client.
//
// Consequence, and the reason this needs a test before anyone "fixes" it: the
// DoH ACL group is the proxy's group, not the client's. Behind the shipped
// ClusterIP Ingress every DoH client lands in the same group — fail-CLOSED if
// that group is restrictive (a legitimate DoH client is refused) and fail-OPEN
// if it is permissive (a client outside its intended group is allowed). This
// is a security decision, not a bug to be quietly patched: turning on
// X-Forwarded-For without consulting trusted_proxies would let any client
// spoof its identity past the ACL and the rate limiter, which is exactly what
// the AGENTS.md invariant forbids. The fix, if it is ever wanted, is a trusted
// proxy list for the DoH listener and a rewrite of this test.
func TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader(t *testing.T) {
h := newHarness(t, nil)
var mu sync.Mutex
var keys []string
h.srv.SetIdentity(
func(ip string) []string {
mu.Lock()
keys = append(keys, ip)
mu.Unlock()
return []string{"client.test."}
},
func(ip string) []string { return nil },
)
wire, err := question("xff.test.", dns.TypeA).Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
req, err := http.NewRequest(http.MethodPost, h.dohURL(h.base()), strings.NewReader(string(wire)))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", contentTypeDNS)
// Both headers an operator would expect to be honoured. Neither is.
req.Header.Set("X-Forwarded-For", "203.0.113.66")
req.Header.Set("X-Real-IP", "203.0.113.66")
req.Header.Set("Forwarded", "for=203.0.113.66")
res, err := h.dohClient(false).Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", res.StatusCode)
}
got := h.fake.only(t)
// The client connected from loopback, so the proxy IS loopback here.
if got.remote.String() != "127.0.0.1" {
t.Errorf("Request.Remote = %v, want the socket peer 127.0.0.1", got.remote)
}
mu.Lock()
defer mu.Unlock()
for _, k := range keys {
if k == "203.0.113.66" {
t.Fatalf("identity was resolved for the X-Forwarded-For address %q; httpRemoteAddr must ignore it", k)
}
}
if len(keys) == 0 || keys[0] != "127.0.0.1" {
t.Errorf("identity keys = %v, want [127.0.0.1]", keys)
}
}
// stringAddr is a net.Addr that is neither TCP nor UDP, so addrToAddr has to
// fall back to parsing its string — which is the path a wrapped or proxied
// connection takes.
type stringAddr string
func (s stringAddr) Network() string { return "test" }
func (s stringAddr) String() string { return string(s) }
+27 -6
View File
@@ -210,7 +210,16 @@ func (s *Server) Stop(ctx context.Context) error {
}
var errs []error
for _, l := range ls {
if err := l.close(); err != nil {
if err := l.close(); err != nil && !errors.Is(err, net.ErrClosed) {
// The DoH listener is closed TWICE, by two different owners: once
// from this list, and once by http.Server.Shutdown, which tracks
// the very same net.Listener because serveDoH hands it to
// srv.Serve. Whichever of the two lost that race made an
// otherwise clean Stop return "use of closed network
// connection" at random, so a clean shutdown looked like a
// failed one. Closing an already-closed listener is not a
// failure; only a real close error is. Pinned by
// TestStopIsIdempotent.
errs = append(errs, err)
}
}
@@ -583,9 +592,6 @@ func (s *Server) serveDoH(ctx context.Context, cfg *config.Config) error {
const contentTypeDNS = "application/dns-message"
// maxGETResponse bounds a DoH GET response so no intermediary truncates it.
const maxGETResponse = 65535
// doHHandler builds the RFC 8484 handler.
func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -616,7 +622,18 @@ func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
}
}
}
payload, err = decodeBase64URL(q.Get("dns"))
// A DNS message is at most 65535 bytes on the wire (every section
// carries a 16-bit length), so its base64url form cannot be
// longer than this. POST gets the same bound from readCapped;
// without it here an unauthenticated client could make the
// server allocate, base64-decode and Unpack a megabyte per
// request, where the adjacent POST path refuses at 64 KiB.
enc := q.Get("dns")
if len(enc) > base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) {
writeDoHError(w, http.StatusRequestEntityTooLarge, "dns parameter too large")
return
}
payload, err = decodeBase64URL(enc)
if err != nil {
writeDoHError(w, http.StatusBadRequest, "malformed dns parameter")
return
@@ -656,7 +673,11 @@ func (s *Server) doHHandler(base string, allowPost bool) http.Handler {
if res.Drop || res.Msg == nil {
// A dropped query still needs a body: unlike UDP, this is a
// well-formed HTTP exchange, so hanging up would read as a
// transport error rather than a policy decision.
// transport error rather than a policy decision. It is still a
// drop, so it counts as one here too — the UDP and TCP paths bump
// "dropped" in the same position, and the DoH path used to be
// the only one that did not.
s.bump("doh", "dropped")
res.Msg = new(dns.Msg)
res.Msg.SetRcode(m, dns.RcodeSuccess)
}
+379
View File
@@ -0,0 +1,379 @@
package server
import (
"bytes"
"context"
"io"
"net"
"sync"
"testing"
"time"
"github.com/miekg/dns"
)
// Shutdown is the only part of this package that can lose a client's query,
// so it is tested as behaviour rather than as a sequence: the socket is
// released, Stop is idempotent, and a query that is already inside the
// pipeline is still answered. The last one is the invariant that matters — a
// resolver that answers "connection closed" to a query it had already accepted
// makes every client retry against somewhere else.
// holdOnce parks every handler that arrives from now on and returns a release
// func that is safe to call twice, so a failing test can unblock the gate in
// its cleanup without a double-close panic.
func holdOnce(t *testing.T, f *fakeHandler) func() {
t.Helper()
release := f.hold()
var once sync.Once
out := func() { once.Do(release) }
t.Cleanup(out)
return out
}
// startStop calls srv.Stop in the background and returns the channel it will
// report on. stopWithin is that plus a bounded wait; the in-flight test needs
// the gap, because it has to watch the shutdown overlap a parked query instead
// of waiting for it to finish.
func startStop(srv *Server, ctxTimeout time.Duration) <-chan error {
done := make(chan error, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), ctxTimeout)
defer cancel()
done <- srv.Stop(ctx)
}()
return done
}
// awaitStop waits for a Stop started by startStop.
func awaitStop(t *testing.T, done <-chan error, d time.Duration) error {
t.Helper()
select {
case err := <-done:
return err
case <-time.After(d):
t.Fatalf("Stop did not return within %s", d)
return nil
}
}
// stopWithin calls srv.Stop and fails the test if it has not returned within d.
// A hang inside a test binary is reported as a package timeout, which says
// nothing about which test caused it; bounding every Stop call here makes the
// failure name the test.
func stopWithin(t *testing.T, srv *Server, d time.Duration) error {
t.Helper()
return awaitStop(t, startStop(srv, 5*time.Second), d)
}
// bindFree tries to bind network/addr and closes the socket again. It reports
// the error rather than failing so a caller can retry: a socket that was
// closed a moment ago can still be in TIME_WAIT, and the point of every use
// here is that a *second* bind of the port works, not that the first one does.
func bindFree(network, addr string) error {
if network == "udp" {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
return err
}
return pc.Close()
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return ln.Close()
}
// waitPortFree blocks until network/addr can be bound. Use it only when nothing
// else holds the port: a connected client's own local socket is on that same
// port, so a bind probe can never succeed while a connection is open. The
// in-flight test uses waitPortRefused for exactly that reason.
func waitPortFree(t *testing.T, network, addr, what string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
err := bindFree(network, addr)
if err == nil {
return
}
if time.Now().After(deadline) {
t.Fatalf("%s: %s %s is still bound 2s after Stop was called (%v)", what, network, addr, err)
}
time.Sleep(2 * time.Millisecond)
}
}
// waitPortRefused blocks until a dial to addr is refused, which is how the
// in-flight test observes that Stop has reached the point where it closes the
// listeners — before it waits for its goroutines. A refused dial is the right
// probe there: the parked query holds a *connection* to that port, so binding
// it would fail for a reason that has nothing to do with Stop.
func waitPortRefused(t *testing.T, network, addr, what string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
conn, err := net.DialTimeout(network, addr, 200*time.Millisecond)
if err != nil {
return
}
conn.Close()
if time.Now().After(deadline) {
t.Fatalf("%s: %s %s still accepts connections 2s after Stop was called", what, network, addr)
}
time.Sleep(2 * time.Millisecond)
}
}
// TestStopReleasesEveryListenerPort: an operator who restarts fancy-dns — or
// hands the port to something else — must be able to bind it again. It also
// empties the listener snapshot, because that is what the admin API and the
// cluster node state read: a listener still reported as running after the
// process stopped serving on it sends the health check the wrong answer.
func TestStopReleasesEveryListenerPort(t *testing.T) {
h := newHarness(t, nil)
addrs := map[string]string{}
for _, l := range h.srv.Listeners() {
addrs[l.Transport] = l.Addr
}
if len(addrs) != 4 {
t.Fatalf("fixture bound %d listeners, want 4: %+v", len(addrs), addrs)
}
if err := stopWithin(t, h.srv, 2*time.Second); err != nil {
t.Fatalf("Stop: %v", err)
}
for transport, addr := range addrs {
network := "tcp"
if transport == "udp" {
network = "udp"
}
// One retry: TIME_WAIT is a property of the socket, not a listener
// still being held, and failing on it would make this a flake.
var err error
for i := 0; i < 2; i++ {
if err = bindFree(network, addr); err == nil {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil {
t.Errorf("%s %s could not be bound after Stop: %v", network, addr, err)
}
}
if got := h.srv.Listeners(); len(got) != 0 {
t.Errorf("Listeners() = %+v after Stop, want empty", got)
}
}
// TestStopIsIdempotent: Stop is called from signal handling, from an admin
// endpoint and from a test cleanup, so more than one of them can reach a
// server that is already down. A second call must be a no-op returning nil —
// not a panic on a half-drained listener list, and not a wait on goroutines
// that are already gone. The same is true of a server that never started at
// all, which is what a config error produces.
func TestStopIsIdempotent(t *testing.T) {
h := newHarness(t, nil)
if err := stopWithin(t, h.srv, 2*time.Second); err != nil {
t.Fatalf("first Stop: %v", err)
}
// The code clears started before it closes anything, so the second call
// short-circuits on that flag and never re-closes a listener or re-waits
// on the wait group. Pinned: a second Stop that returned the first Stop's
// error, or waited again, would be a bug in its own right.
if err := stopWithin(t, h.srv, 2*time.Second); err != nil {
t.Errorf("second Stop = %v, want nil", err)
}
if err := stopWithin(t, h.srv, 2*time.Second); err != nil {
t.Errorf("third Stop = %v, want nil", err)
}
// A server that never started: nothing to close, and nothing to say.
if err := stopWithin(t, New(Options{}), 2*time.Second); err != nil {
t.Errorf("Stop on a server that never started = %v, want nil", err)
}
}
// TestStopWithAnAlreadyCancelledContextReturnsPromptly: the context bounds how
// long Stop waits, and a caller that has already given up must not be made to
// wait for a listener to close. Only the bound is asserted — never a
// duration. The return *value* is deliberately not asserted: Stop races its
// own wait group against ctx.Done() and takes whichever is ready first, so the
// same call can return nil or context.Canceled depending on how the shutdown
// went. Asserting either would pin the scheduler, not the code.
//
// What IS asserted is that the listeners are released even then: closing them
// is not the part the context is allowed to interrupt, because that is the
// work the next process start needs.
func TestStopWithAnAlreadyCancelledContextReturnsPromptly(t *testing.T) {
h := newHarness(t, nil)
udp := h.addr("udp")
ctx, cancel := context.WithCancel(context.Background())
cancel()
done := make(chan error, 1)
go func() { done <- h.srv.Stop(ctx) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Stop with an already-cancelled context did not return within 2s")
}
waitPortFree(t, "udp", udp, "a Stop whose context was already cancelled")
}
// TestInFlightQueryIsAnsweredAcrossStop is the invariant in this file. A
// query that has been accepted and is inside the pipeline belongs to the
// client: closing the listener must not make it vanish. The client here has
// its query parked in the handler while Stop runs, so if the shutdown path
// abandoned in-flight work the client would see a closed connection (TCP) or a
// 5xx/EOF (DoH) and every client would retry elsewhere.
//
// The test proves Stop is really in flight before the handler is released: it
// waits for the listener's port to become bindable, which Stop does before it
// waits for its goroutines. Without that ordering the test would pass even if
// the shutdown never overlapped the query.
func TestInFlightQueryIsAnsweredAcrossStop(t *testing.T) {
t.Run("tcp", func(t *testing.T) {
h := newHarness(t, nil)
release := holdOnce(t, h.fake)
conn, err := net.DialTimeout("tcp", h.addr("tcp"), 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
co := &dns.Conn{Conn: conn}
if err := co.WriteMsg(question("inflight-shutdown.test.", dns.TypeA)); err != nil {
t.Fatalf("write: %v", err)
}
h.fake.waitArrived(t)
// Stop runs in the background and the test watches the overlap: it
// closes the listener before it waits for its goroutines, so a refused
// dial is proof that the shutdown is running *while* the query is
// still parked in the handler. Without that ordering the test would
// pass even if the shutdown never overlapped the query at all.
stopped := startStop(h.srv, 5*time.Second)
waitPortRefused(t, "tcp", h.addr("tcp"), "Stop closing the TCP listener")
release()
res, err := co.ReadMsg()
if err != nil {
t.Fatalf("the in-flight query was lost across Stop: %v", err)
}
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("A = %q, want 203.0.113.9 (reply: %v)", a, res)
}
if err := awaitStop(t, stopped, 3*time.Second); err != nil {
t.Errorf("Stop: %v", err)
}
// Stop waits for the connection goroutines, so by the time it returns
// nothing is still inside the engine. A shutdown that returned while a
// query was in flight would leave the process exiting with work
// running.
if got := h.srv.Stats().Inflight; got != 0 {
t.Errorf("Stats().Inflight = %d after Stop returned, want 0", got)
}
})
t.Run("doh", func(t *testing.T) {
h := newHarness(t, nil)
release := holdOnce(t, h.fake)
cl := h.dohClient(false)
type reply struct {
status int
body []byte
err error
}
got := make(chan reply, 1)
// The request is issued from its own goroutine: it does not come back
// until the handler is released, which happens after Stop has started.
// h.doHPOST is not usable here because it calls t.Fatalf, and a
// Fatalf from a non-test goroutine does not stop the test.
go func() {
wire, err := question("inflight-shutdown.test.", dns.TypeA).Pack()
if err != nil {
got <- reply{err: err}
return
}
res, err := cl.Post(h.dohURL(h.base()), contentTypeDNS, bytes.NewReader(wire))
if err != nil {
got <- reply{err: err}
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
got <- reply{status: res.StatusCode, body: body}
}()
h.fake.waitArrived(t)
// The DoH path is the interesting one: http.Server.Shutdown waits for
// the in-flight request rather than cancelling it, so Stop has to
// block until the answer has been written. It must still return, and
// the client must still get its answer. The probe is the same as on
// the TCP side: the listener is closed before Stop waits.
stopped := startStop(h.srv, 5*time.Second)
waitPortRefused(t, "tcp", h.addr("doh"), "Stop closing the DoH listener")
release()
select {
case r := <-got:
if r.err != nil {
t.Fatalf("the in-flight DoH request failed across Stop: %v", r.err)
}
if r.status != 200 {
t.Fatalf("status = %d, want 200: %s", r.status, r.body)
}
if a := answerAddr(unpackDoH(t, r.body)); a != "203.0.113.9" {
t.Errorf("A = %q, want 203.0.113.9", a)
}
case <-time.After(3 * time.Second):
t.Fatal("the in-flight DoH request never came back")
}
if err := awaitStop(t, stopped, 3*time.Second); err != nil {
t.Errorf("Stop: %v", err)
}
if got := h.srv.Stats().Inflight; got != 0 {
t.Errorf("Stats().Inflight = %d after Stop returned, want 0", got)
}
})
}
// TestStopWithNoQueryInFlightReturnsAndStartsAgain: an idle Stop has nothing
// to wait for, so it returns immediately — the bound here is generous because
// the point is "does not hang", not "is fast". Then the process has to be able
// to serve again: the same Server is started a second time, binds four fresh
// listeners and answers. The counters deliberately survive the restart: /status
// is a lifetime view, and a counter that reset on restart would make the
// console's time series jump to zero every time the pod rolled.
func TestStopWithNoQueryInFlightReturnsAndStartsAgain(t *testing.T) {
h := newHarness(t, nil)
before := h.addr("udp")
if err := stopWithin(t, h.srv, 2*time.Second); err != nil {
t.Fatalf("Stop: %v", err)
}
if err := h.srv.Start(context.Background()); err != nil {
t.Fatalf("Start after Stop: %v", err)
}
if got := len(h.srv.Listeners()); got != 4 {
t.Errorf("Listeners() after the restart has %d entries, want 4", got)
}
res := h.ask("udp", "after-restart.test.", dns.TypeA)
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("A after the restart = %q, want 203.0.113.9", a)
}
if got := h.fake.last().name; got != "after-restart.test." {
t.Errorf("the restarted server did not reach the engine: last question %q", got)
}
if got := h.srv.ByTransport()["udp"].Queries; got != 1 {
t.Errorf("udp.Queries = %d after the restart, want 1 (counters are a lifetime total)", got)
}
// The new listeners are different sockets: the harness binds port 0, so a
// restart is only possible because Stop gave the old ones back. Asserting
// it also keeps the test honest about which server answered.
if after := h.addr("udp"); after == before {
t.Logf("udp rebound the same address %s; port 0 makes this possible but unlikely", after)
}
// And the harness's own cleanup still stops the restarted server without
// complaint, which is the t.Cleanup path every test in this package takes.
if got := h.srv.Stats().Inflight; got != 0 {
t.Errorf("Stats().Inflight = %d, want 0", got)
}
}
+7
View File
@@ -29,6 +29,7 @@ func (s *Server) ByTransport() map[string]TransportStats {
ts.Blocked = s.load(t, "blocked")
ts.Cached = s.load(t, "cached")
ts.Errors = s.load(t, "errors")
ts.Dropped = s.load(t, "dropped")
ts.Truncated = s.load(t, "truncated")
ts.Sent = s.load(t, "sent")
out[t] = ts
@@ -51,6 +52,12 @@ type TransportStats struct {
Blocked uint64 `json:"blocked"`
Cached uint64 `json:"cached"`
Errors uint64 `json:"errors"`
// Dropped is queries answered with silence on this transport. It is
// bumped for every transport (a DoH drop used to be counted as an error
// only, which made the console's transport tab under-report it), and
// aggregating it per transport is the only way the UI can show it: the
// aggregate in HandlerStats cannot say which transport dropped.
Dropped uint64 `json:"dropped"`
Truncated uint64 `json:"truncated"`
Sent uint64 `json:"sent"`
}
+546
View File
@@ -0,0 +1,546 @@
package server
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// The counters are what the console's status page and the /status endpoint
// report, so they are the only account an operator has of what the server is
// doing. Everything in this file is about the numbers being right rather than
// merely present: an under-counted drop reads as a working resolver, and a
// double-counted query reads as a busy one.
//
// The harness's fake handler selects its outcome from the question name
// (block., cached., fail., drop., big.), so a test drives the exact counter
// move it wants by asking for it by name. No counter is bumped by hand.
var statTransports = []string{"udp", "tcp", "dot", "doh"}
// silenceWait is how long a transport is given to produce the answer the fake
// intends to produce. A drop is answered with nothing at all, so over UDP this
// is the price of asking about one.
const silenceWait = 300 * time.Millisecond
// exchangeOnce sends m over transport and returns the reply, or nil when the
// transport answered with silence or hung up. h.ask cannot be used here: it
// treats a missing answer as a test failure, and "no answer" is a legitimate
// outcome the fake produces on purpose.
func exchangeOnce(t *testing.T, h *harness, transport string, m *dns.Msg) *dns.Msg {
t.Helper()
switch transport {
case "udp":
return exchangeUDP(t, h, m)
case "tcp":
return exchangeStream(t, h.addr("tcp"), nil, m)
case "dot":
tc := h.clientTLS()
tc.NextProtos = []string{"dot"}
return exchangeStream(t, h.addr("dot"), tc, m)
case "doh":
res, body := h.doHPOST(m)
if res.StatusCode != http.StatusOK {
t.Fatalf("doh POST = %d, want 200: %s", res.StatusCode, body)
}
return unpackDoH(t, body)
}
t.Fatalf("unknown transport %q", transport)
return nil
}
func exchangeUDP(t *testing.T, h *harness, m *dns.Msg) *dns.Msg {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = pc.Close() })
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
wire, err := m.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
if _, err := pc.WriteTo(wire, addr); err != nil {
t.Fatalf("write: %v", err)
}
_ = pc.SetReadDeadline(time.Now().Add(silenceWait))
buf := make([]byte, 4096)
n, _, err := pc.ReadFrom(buf)
if err != nil {
return nil // silence, or a datagram that never came
}
res := new(dns.Msg)
if err := res.Unpack(buf[:n]); err != nil {
t.Fatalf("unpack reply: %v", err)
}
return res
}
func exchangeStream(t *testing.T, addr string, tc *tls.Config, m *dns.Msg) *dns.Msg {
t.Helper()
d := &net.Dialer{Timeout: 2 * time.Second}
var (
conn net.Conn
err error
)
if tc != nil {
conn, err = tls.DialWithDialer(d, "tcp", addr, tc)
} else {
conn, err = d.Dial("tcp", addr)
}
if err != nil {
t.Fatalf("dial %s: %v", addr, err)
}
t.Cleanup(func() { _ = conn.Close() })
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
co := &dns.Conn{Conn: conn}
if err := co.WriteMsg(m); err != nil {
t.Fatalf("write: %v", err)
}
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
res, err := co.ReadMsg()
if err != nil {
return nil // a drop closes the connection instead of answering
}
return res
}
// counterDelta is the change one query is expected to make to a transport's
// counter set. Every field is a delta, never a total: a test that asserts a
// total is asserting the order the tests happened to run in.
type counterDelta struct {
queries uint64
blocked uint64
cached uint64
errors uint64
dropped uint64
truncated uint64
sent uint64
}
// applyDelta is the expected counter set after the delta, used to wait for the
// server to settle on it and to report where it differs.
func applyDelta(base TransportStats, d counterDelta) TransportStats {
return TransportStats{
Transport: base.Transport,
Queries: base.Queries + d.queries,
Blocked: base.Blocked + d.blocked,
Cached: base.Cached + d.cached,
Errors: base.Errors + d.errors,
Dropped: base.Dropped + d.dropped,
Truncated: base.Truncated + d.truncated,
Sent: base.Sent + d.sent,
}
}
// TestCountersFollowTheEngineOutcome is the main counter contract: one query,
// one answer, and exactly the counters that outcome implies — no more, no
// fewer. The full counter set is compared, not just the counter under test,
// because a counter that moves on every path is exactly the bug a
// single-counter assertion would miss.
func TestCountersFollowTheEngineOutcome(t *testing.T) {
cases := []struct {
name string
// prefix is the first label of the question name; the fake's recipe
// keys the outcome off it.
prefix string
qtype uint16
// deltas is the expected move per transport.
deltas map[string]counterDelta
why string
}{
{
prefix: "plain", qtype: dns.TypeA,
deltas: map[string]counterDelta{
"udp": {queries: 1, sent: 1},
"tcp": {queries: 1, sent: 1},
"dot": {queries: 1, sent: 1},
// The DoH path writes the body through net/http and never
// counts a send. Pinned exactly (sent stays 0) so the
// transport tab's "sent" column reads 0 next to a non-zero
// "queries" for DoH today, and so adding the bump later has
// to come with a decision about what it means.
"doh": {queries: 1},
},
why: "an ordinary answer: accepted and written back",
},
{
prefix: "block", qtype: dns.TypeA,
deltas: map[string]counterDelta{
"udp": {queries: 1, blocked: 1, sent: 1},
"tcp": {queries: 1, blocked: 1, sent: 1},
"dot": {queries: 1, blocked: 1, sent: 1},
"doh": {queries: 1, blocked: 1},
},
why: "a blocklist hit is a normal answer to the client, so it counts as sent too",
},
{
prefix: "cached", qtype: dns.TypeA,
deltas: map[string]counterDelta{
"udp": {queries: 1, cached: 1, sent: 1},
"tcp": {queries: 1, cached: 1, sent: 1},
"dot": {queries: 1, cached: 1, sent: 1},
"doh": {queries: 1, cached: 1},
},
why: "a cache hit is the number an operator uses to prove the cache is working",
},
{
prefix: "fail", qtype: dns.TypeA,
deltas: map[string]counterDelta{
"udp": {queries: 1, errors: 1, sent: 1},
"tcp": {queries: 1, errors: 1, sent: 1},
"dot": {queries: 1, errors: 1, sent: 1},
"doh": {queries: 1, errors: 1},
},
why: "SERVFAIL is still an answer, so errors and sent both move",
},
{
prefix: "drop", qtype: dns.TypeA,
deltas: map[string]counterDelta{
// A drop counts as an error as well as a drop: HandlerStats
// documents Errors as "SERVFAIL, dropped or malformed". It is
// the only evidence a client-side timeout was a policy
// decision, so it must not be under-counted.
"udp": {queries: 1, errors: 1, dropped: 1},
"tcp": {queries: 1, errors: 1, dropped: 1},
"dot": {queries: 1, errors: 1, dropped: 1},
// This is the regression pin: a DoH drop used to be counted
// as an error only, so the console's transport tab
// under-reported the drops an operator was told about in the
// aggregate. The body is still written (a DoH client waiting
// on a socket must not read a hang-up as a transport error),
// which is why sent is not bumped either: the write is
// net/http's, and the DoH path counts none.
"doh": {queries: 1, errors: 1, dropped: 1},
},
why: "a drop is a counter the client cannot see",
},
{
prefix: "big", qtype: dns.TypeTXT,
deltas: map[string]counterDelta{
// Only UDP can truncate: a stream transport sends the whole
// answer and lets the client reassemble it, so counting a
// truncation there would be a lie about what happened.
"udp": {queries: 1, truncated: 1, sent: 1},
"tcp": {queries: 1, sent: 1},
"dot": {queries: 1, sent: 1},
"doh": {queries: 1},
},
why: "a 3 KiB answer to a 512-byte client is truncated, and that is a fact worth counting",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, nil)
before := h.srv.ByTransport()
want := map[string]TransportStats{}
for _, transport := range statTransports {
d, ok := tc.deltas[transport]
if !ok {
t.Fatalf("fixture: no expected delta for %s", transport)
}
want[transport] = applyDelta(before[transport], d)
}
for _, transport := range statTransports {
exchangeOnce(t, h, transport, question(tc.prefix+".counters.test.", tc.qtype))
}
// The client can be answered before the last counter is bumped
// (the stream paths bump "sent" after the write returns), so the
// assertion waits for the settled state rather than for a
// duration.
waitForCounters(t, h, want)
})
}
}
// waitForCounters blocks until every counter set named in want is exactly
// matched. The comparison is exact in both directions: a counter that moved
// too far never matches, so double counting fails here rather than hiding
// behind a >= . want may name a subset of the transports, which is how a
// focused test states "this transport moved by this much".
func waitForCounters(t *testing.T, h *harness, want map[string]TransportStats) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for {
got := h.srv.ByTransport()
if countersMatch(got, want) {
return
}
if time.Now().After(deadline) {
t.Fatalf("counters did not settle within 3s:%s", counterDiff(got, want))
}
time.Sleep(2 * time.Millisecond)
}
}
func countersMatch(got, want map[string]TransportStats) bool {
for transport, w := range want {
if got[transport] != w {
return false
}
}
return true
}
func counterDiff(got, want map[string]TransportStats) string {
var b strings.Builder
for _, transport := range statTransports {
if w, ok := want[transport]; ok && got[transport] != w {
fmt.Fprintf(&b, "\n %s: got %+v\n want %+v", transport, got[transport], w)
}
}
return b.String()
}
// TestPerTransportCountersSumToTheAggregate: /status reports both the
// aggregate (HandlerStats) and the per-transport breakdown, and the console
// shows them side by side. If the aggregate ever counted something the
// breakdown does not — or double counted it — the two views of the same server
// would disagree, which is worse than either being wrong alone. The per-
// transport numbers are the ones the aggregate is built from, so summing them
// has to reproduce it exactly.
func TestPerTransportCountersSumToTheAggregate(t *testing.T) {
h := newHarness(t, nil)
// One of every outcome over every transport, so the sum is not trivially
// equal to a single counter.
names := []string{"plain.test.", "block.test.", "cached.test.", "fail.test.", "drop.test."}
for _, transport := range statTransports {
for _, name := range names {
exchangeOnce(t, h, transport, question(name, dns.TypeA))
}
}
waitFor(t, 3*time.Second, "every query to be counted", func() bool {
return h.srv.Stats().Queries == uint64(len(statTransports)*len(names))
})
agg := h.srv.Stats()
byT := h.srv.ByTransport()
var sum HandlerStats
for _, transport := range statTransports {
ts := byT[transport]
sum.Queries += ts.Queries
sum.Blocked += ts.Blocked
sum.Cached += ts.Cached
sum.Errors += ts.Errors
sum.Dropped += ts.Dropped
if ts.Queries != uint64(len(names)) {
t.Errorf("%s.Queries = %d, want %d", transport, ts.Queries, len(names))
}
if ts.Blocked != 1 {
t.Errorf("%s.Blocked = %d, want 1", transport, ts.Blocked)
}
if ts.Cached != 1 {
t.Errorf("%s.Cached = %d, want 1", transport, ts.Cached)
}
// One SERVFAIL and one drop: HandlerStats documents Errors as
// including drops.
if ts.Errors != 2 {
t.Errorf("%s.Errors = %d, want 2 (one SERVFAIL, one drop)", transport, ts.Errors)
}
if ts.Dropped != 1 {
t.Errorf("%s.Dropped = %d, want 1", transport, ts.Dropped)
}
}
if agg.Queries != sum.Queries {
t.Errorf("Stats().Queries = %d, want the sum of the transports %d", agg.Queries, sum.Queries)
}
if agg.Blocked != sum.Blocked {
t.Errorf("Stats().Blocked = %d, want %d", agg.Blocked, sum.Blocked)
}
if agg.Cached != sum.Cached {
t.Errorf("Stats().Cached = %d, want %d", agg.Cached, sum.Cached)
}
if agg.Errors != sum.Errors {
t.Errorf("Stats().Errors = %d, want %d", agg.Errors, sum.Errors)
}
if agg.Dropped != sum.Dropped {
t.Errorf("Stats().Dropped = %d, want %d", agg.Dropped, sum.Dropped)
}
if agg.Queries == 0 || agg.Dropped == 0 {
t.Fatalf("the fixture counted nothing: %+v", agg)
}
}
// TestDoHDropIsCountedAsDropped is the named pin for the DoH drop counter. It
// is separate from the table above because it is the one that was wrong: the
// DoH handler answered a dropped query with an empty body but bumped only
// "errors", so /status said the query was dropped while the transport tab
// said it was an error. An operator reading the transport tab to work out
// which listener is dropping traffic could not tell DoH from a resolver that
// was failing outright.
func TestDoHDropIsCountedAsDropped(t *testing.T) {
h := newHarness(t, nil)
before := h.srv.ByTransport()["doh"]
res := h.askDoHPOST(question("drop.test.", dns.TypeA))
// The body is still required: unlike UDP, hanging up on an HTTP exchange
// reads to the client as a transport error and it retries elsewhere.
if len(res.Answer) != 0 {
t.Errorf("dropped query came back with %d answers: %v", len(res.Answer), res.Answer)
}
waitForCounters(t, h, map[string]TransportStats{
"doh": applyDelta(before, counterDelta{queries: 1, errors: 1, dropped: 1}),
})
if got := h.srv.Stats().Dropped; got != 1 {
t.Errorf("Stats().Dropped = %d, want 1 for a single DoH drop", got)
}
// And the other transports are untouched: a drop is counted against the
// transport it arrived on, not smeared over the server.
byT := h.srv.ByTransport()
for _, transport := range []string{"udp", "tcp", "dot"} {
if byT[transport] != (TransportStats{Transport: transport}) {
t.Errorf("a DoH query moved %s counters: %+v", transport, byT[transport])
}
}
}
// TestMalformedDatagramIsAnErrorAndNotAQuery is the aggregate half of
// TestUDPMalformedPacketIsCountedNotAnswered (which pins the per-transport
// counter and that nothing is written back). It matters separately because it
// makes Errors able to exceed Queries: a datagram that cannot be parsed is
// counted as an error, and never as a query, because the server never accepted
// a question out of it. Anything rendering one as a ratio of the other — the
// console, a dashboard, an alert — has to cope with that, and a server under
// a garbage flood is exactly when somebody looks.
func TestMalformedDatagramIsAnErrorAndNotAQuery(t *testing.T) {
h := newHarness(t, nil)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = pc.Close() })
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if _, err := pc.WriteTo([]byte{0x01, 0x02, 0x03}, addr); err != nil {
t.Fatalf("write: %v", err)
}
waitFor(t, 2*time.Second, "the parse failure to be counted", func() bool {
return h.srv.Stats().Errors == 1
})
st := h.srv.Stats()
if st.Queries != 0 {
t.Errorf("Stats().Queries = %d, want 0: an unparsable datagram is not a query", st.Queries)
}
if st.Dropped != 0 {
t.Errorf("Stats().Dropped = %d, want 0: it is counted as an error, not as a drop", st.Dropped)
}
if n := len(h.fake.seen()); n != 0 {
t.Errorf("garbage reached the engine %d times: %+v", n, h.fake.seen())
}
if got := h.srv.ByTransport()["udp"].Sent; got != 0 {
t.Errorf("udp.Sent = %d, want 0: nothing was written back", got)
}
}
// TestInflightTracksTheHandler pins the one field of HandlerStats that is not a
// total. It is read by the health endpoint and the console's "in flight" gauge
// to tell a busy resolver from a stuck one, so it has to rise while a query
// is inside the pipeline and return to zero when the answer goes out. The
// second half is the part a broken implementation gets wrong: a gauge that
// ratchets up and never comes down turns the health check into a permanent
// alarm.
func TestInflightTracksTheHandler(t *testing.T) {
h := newHarness(t, nil)
release := holdOnce(t, h.fake)
conn, err := net.DialTimeout("tcp", h.addr("tcp"), 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
co := &dns.Conn{Conn: conn}
if err := co.WriteMsg(question("inflight.test.", dns.TypeA)); err != nil {
t.Fatalf("write: %v", err)
}
// The handler signals arrival before it parks on the gate, and the
// in-flight counter is incremented before that, so it is already non-zero
// here — no sleeping on "it has probably got there by now".
h.fake.waitArrived(t)
if got := h.srv.Stats().Inflight; got < 1 {
t.Fatalf("Stats().Inflight = %d while a query is inside the handler, want at least 1", got)
}
// A query that has not been answered is not a completed query yet. The
// counter that matters to an operator (Queries) must therefore still say
// nothing about it, or the rate graph counts work that is still running.
if got := h.srv.ByTransport()["tcp"].Queries; got != 0 {
t.Errorf("tcp.Queries = %d while the only query is still in flight, want 0", got)
}
release()
res, err := co.ReadMsg()
if err != nil {
t.Fatalf("read: %v", err)
}
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("A = %q, want 203.0.113.9", a)
}
waitFor(t, 2*time.Second, "the in-flight count to fall back to zero", func() bool {
return h.srv.Stats().Inflight == 0
})
ts := h.srv.ByTransport()["tcp"]
if ts.Queries != 1 || ts.Sent != 1 {
t.Errorf("tcp counters after the query finished = %+v, want queries=1 sent=1", ts)
}
}
// TestByTransportAlwaysReportsTheSameFourTransports: the console's transport
// tab iterates whatever /status returns. A row that disappears when a listener
// is disabled, or a missing key when nothing has been served yet, turns a
// zero into a rendering bug at exactly the moment somebody is trying to read
// the numbers. All four are always present; a disabled or unused transport is
// a row of zeros.
func TestByTransportAlwaysReportsTheSameFourTransports(t *testing.T) {
check := func(t *testing.T, got map[string]TransportStats) {
t.Helper()
if len(got) != 4 {
t.Errorf("ByTransport() has %d entries (%+v), want 4", len(got), got)
}
for _, transport := range statTransports {
ts, ok := got[transport]
if !ok {
t.Errorf("ByTransport() is missing %q: %+v", transport, got)
continue
}
if ts.Transport != transport {
t.Errorf("ByTransport()[%q].Transport = %q, want the key echoed back", transport, ts.Transport)
}
}
}
// A server that has never served a query and has no engine at all.
fresh := New(Options{})
if got := fresh.ByTransport(); len(got) != 4 {
t.Errorf("a server that never started reports %+v, want four zeroed transports", got)
}
check(t, fresh.ByTransport())
// A server with only the two plain listeners bound and one query served:
// the two disabled transports are still there, at zero.
h := newHarness(t, func(c *config.Config) {
c.DNS.DoT = config.DoT{Enabled: false}
c.DNS.DoH = config.DoH{Enabled: false}
})
h.ask("udp", "unused-transport.test.", dns.TypeA)
got := h.srv.ByTransport()
check(t, got)
for _, transport := range []string{"dot", "doh"} {
if got[transport] != (TransportStats{Transport: transport}) {
t.Errorf("disabled transport %q = %+v, want an all-zero row", transport, got[transport])
}
}
if got["udp"].Queries != 1 {
t.Errorf("udp.Queries = %d, want 1", got["udp"].Queries)
}
}
+265
View File
@@ -0,0 +1,265 @@
package server
import (
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
// TestAllFourListenersShareOneHandler is the package's headline claim: the
// four transports are thin shells around a single engine. A bug that made one
// of them bypass the handler (or answer from its own state) would show up here
// as a missing or duplicated call, so every transport is driven with a real
// client and checked for exactly one Handle, the right transport label, the
// right client address and the right answer on the wire.
func TestAllFourListenersShareOneHandler(t *testing.T) {
cases := []struct {
transport string
// tcp marks the transports that set engine.Request.TCP, i.e. the ones
// allowed to answer with a response larger than a UDP datagram.
wantTCP bool
}{
{transport: "udp", wantTCP: false},
{transport: "tcp", wantTCP: true},
{transport: "dot", wantTCP: true},
{transport: "doh", wantTCP: true},
{transport: "doh-post", wantTCP: true},
}
for _, tc := range cases {
t.Run(tc.transport, func(t *testing.T) {
h := newHarness(t, nil)
name := "host." + tc.transport + ".test."
res := h.ask(tc.transport, name, dns.TypeA)
got := h.fake.only(t)
wantTransport := tc.transport
if wantTransport == "doh-post" {
wantTransport = "doh"
}
if got.transport != wantTransport {
t.Errorf("Request.Transport = %q, want %q", got.transport, wantTransport)
}
if got.name != dns.Fqdn(name) {
t.Errorf("question = %q, want %q", got.name, dns.Fqdn(name))
}
if got.qtype != dns.TypeA {
t.Errorf("qtype = %d, want %d", got.qtype, dns.TypeA)
}
// The client is always loopback here, whatever the transport: that
// is what "the client address reached the engine" has to mean for a
// test that binds 127.0.0.1.
if got.remote.String() != "127.0.0.1" {
t.Errorf("Request.Remote = %v, want 127.0.0.1", got.remote)
}
if got.tcp != tc.wantTCP {
t.Errorf("Request.TCP = %v, want %v", got.tcp, tc.wantTCP)
}
// And the answer came back over the wire, not just out of the
// handler: same ID, the question echoed, one A record.
if len(res.Question) != 1 || res.Question[0].Name != dns.Fqdn(name) {
t.Errorf("reply question = %+v, want %s", res.Question, dns.Fqdn(name))
}
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("reply A = %q, want 203.0.113.9 (reply: %v)", a, res)
}
})
}
}
// TestAllFourListenersAreUpAndReported pins the Listeners() contract the admin
// API and the cluster node state read: one entry per bound listener, with the
// real address read back from the socket rather than the configured one (the
// tests bind port 0, so the two differ).
func TestAllFourListenersAreUpAndReported(t *testing.T) {
h := newHarness(t, nil)
seen := map[string]string{}
for _, l := range h.srv.Listeners() {
if !l.Enabled || !l.Running {
t.Errorf("listener %+v reported as not enabled/running after Start", l)
}
if l.Err != "" {
t.Errorf("listener %s reported error %q after a clean start", l.Transport, l.Err)
}
seen[l.Transport] = l.Addr
}
for _, want := range []string{"udp", "tcp", "dot", "doh"} {
addr, ok := seen[want]
if !ok {
t.Fatalf("no %s listener in %v", want, seen)
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Errorf("listener %s address %q: %v", want, addr, err)
continue
}
if host != "127.0.0.1" || port == "0" {
t.Errorf("listener %s address = %q, want a real 127.0.0.1 port", want, addr)
}
}
if got := len(h.srv.Listeners()); got != 4 {
t.Errorf("Listeners() has %d entries, want 4", got)
}
}
// TestUDPListenerAnswersEveryDatagram is the raw-packet counterpart of the
// client-based test: it uses a net.PacketConn so a reply that the DNS client
// would have silently retried still has to arrive. The client address is the
// socket's local port, which is what makes it a different source address per
// exchange and therefore a check that the server writes back to the peer it
// read from rather than to whoever it read from first.
func TestUDPListenerAnswersEveryDatagram(t *testing.T) {
h := newHarness(t, nil)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer pc.Close()
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
for i := 0; i < 3; i++ {
q := question("raw.test.", dns.TypeA)
q.Id = uint16(0x1000 + i)
wire, err := q.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
if _, err := pc.WriteTo(wire, addr); err != nil {
t.Fatalf("write: %v", err)
}
_ = pc.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 4096)
n, from, err := pc.ReadFrom(buf)
if err != nil {
t.Fatalf("read %d: %v", i, err)
}
if from.String() != addr.String() {
t.Errorf("reply from %v, want %v", from, addr)
}
res := new(dns.Msg)
if err := res.Unpack(buf[:n]); err != nil {
t.Fatalf("unpack %d: %v", i, err)
}
if res.Id != q.Id {
t.Errorf("reply id = %d, want %d", res.Id, q.Id)
}
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("reply A = %q, want 203.0.113.9", a)
}
}
waitFor(t, 2*time.Second, "three handler calls", func() bool {
return len(h.fake.seen()) == 3
})
for _, rec := range h.fake.seen() {
if rec.transport != "udp" {
t.Errorf("transport = %q, want udp", rec.transport)
}
}
}
// TestDoHOverHTTP2 exercises the h2c wrapper over a TLS listener. The DoH
// listener advertises h2 in ALPN, so a real client (curl --http2, a browser)
// negotiates it; if the h2 path were broken the request would be answered over
// HTTP/1.1 or dropped, and neither is what an operator configured.
func TestDoHOverHTTP2(t *testing.T) {
h := newHarness(t, nil)
wire, err := question("h2.test.", dns.TypeA).Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
res, err := h.dohClient(true).Post(h.dohURL(h.base()), contentTypeDNS, strings.NewReader(string(wire)))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.Proto != "HTTP/2.0" {
t.Errorf("proto = %q, want HTTP/2.0", res.Proto)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", res.StatusCode, body)
}
if a := answerAddr(unpackDoH(t, body)); a != "203.0.113.9" {
t.Errorf("A = %q, want 203.0.113.9", a)
}
got := h.fake.only(t)
if got.transport != "doh" {
t.Errorf("transport = %q, want doh", got.transport)
}
if got.dohPath != h.base() {
t.Errorf("DoHPath = %q, want %q", got.dohPath, h.base())
}
}
// TestDoHHealthz is the endpoint an Ingress or a pod probe hits. It is
// registered on the same mux as the DNS handler, so a base path that shadows
// "/healthz" would be a real misconfiguration worth catching.
func TestDoHHealthz(t *testing.T) {
h := newHarness(t, nil)
res, err := h.dohClient(false).Get(h.dohURL("/healthz"))
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", res.StatusCode)
}
if len(h.fake.seen()) != 0 {
t.Errorf("healthz reached the engine: %+v", h.fake.seen())
}
}
// TestTCPPipelining pins RFC 1035 §4.2.2: one connection carries several
// queries with length prefixes, and a resolver that has a keep-alive
// connection open must be able to use it. A transport that closed after one
// query would still pass every single-query test.
func TestTCPPipelining(t *testing.T) {
h := newHarness(t, nil)
conn, err := net.DialTimeout("tcp", h.addr("tcp"), 3*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
co := &dns.Conn{Conn: conn}
for i := 0; i < 3; i++ {
q := question("pipelined.test.", dns.TypeA)
q.Id = uint16(0x2000 + i)
if err := co.WriteMsg(q); err != nil {
t.Fatalf("write %d: %v", i, err)
}
res, err := co.ReadMsg()
if err != nil {
t.Fatalf("read %d: %v", i, err)
}
if res.Id != q.Id {
t.Errorf("reply %d id = %d, want %d", i, res.Id, q.Id)
}
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("reply %d A = %q, want 203.0.113.9", i, a)
}
}
waitFor(t, 2*time.Second, "three pipelined queries", func() bool {
return len(h.fake.seen()) == 3
})
}
// answerAddr returns the first A record's address, or "".
func answerAddr(m *dns.Msg) string {
if m == nil {
return ""
}
for _, rr := range m.Answer {
if a, ok := rr.(*dns.A); ok {
return a.A.String()
}
}
return ""
}
+365
View File
@@ -0,0 +1,365 @@
package server
import (
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
// readTick mirrors the unexported read deadline serveUDP uses. The test has to
// know it to be able to sleep past it; a copy here means a change to the
// product constant would quietly weaken this test rather than break it, so the
// value is asserted against the listener's behaviour below.
const readTick = 500 * time.Millisecond
// TestUDPReadDeadlineIsResetEveryIteration is the regression test for the
// worst bug this package has had (AGENTS.md): serveUDP sets a read deadline so
// the loop notices cancellation, and a deadline set ONCE expires for good.
// After that the loop spins — every ReadFrom returns "i/o timeout" instantly —
// and never reads another packet for the life of the process. The listener
// looks healthy: it is bound, it is in the admin API, it logs nothing.
//
// The invariant pinned here: a packet that arrives more than one read-tick
// after the previous one is still answered. Send, sleep past the tick, send
// again, and both must come back.
func TestUDPReadDeadlineIsResetEveryIteration(t *testing.T) {
h := newHarness(t, nil)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer pc.Close()
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
ask := func(id uint16) {
t.Helper()
q := question("deadline.test.", dns.TypeA)
q.Id = id
wire, err := q.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
if _, err := pc.WriteTo(wire, addr); err != nil {
t.Fatalf("write: %v", err)
}
_ = pc.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 4096)
n, _, err := pc.ReadFrom(buf)
if err != nil {
t.Fatalf("no answer to query %d: %v", id, err)
}
res := new(dns.Msg)
if err := res.Unpack(buf[:n]); err != nil {
t.Fatalf("unpack: %v", err)
}
if res.Id != id {
t.Errorf("reply id = %d, want %d", res.Id, id)
}
if a := answerAddr(res); a != "203.0.113.9" {
t.Errorf("reply A = %q, want 203.0.113.9", a)
}
}
ask(0x3001)
// Longer than the read deadline, so a deadline that is not reset has
// certainly expired by the time the second packet lands.
time.Sleep(readTick + 250*time.Millisecond)
ask(0x3002)
waitFor(t, 2*time.Second, "both queries at the engine", func() bool {
return len(h.fake.seen()) == 2
})
}
// TestUDPTruncatesAnOversizedAnswer: a multi-kilobyte answer to a client that
// advertised nothing (the 512-byte floor) must come back as a question-only
// TC reply. Sending it whole would be silently dropped by the client and read
// as a dropped query, which is exactly the bug the engine suite found once
// already (STATUS.md: "the uncached path never truncated").
func TestUDPTruncatesAnOversizedAnswer(t *testing.T) {
h := newHarness(t, nil)
res := h.askUDP(question("big.test.", dns.TypeTXT))
if !res.Truncated {
t.Error("TC not set on a 3 KiB answer to a 512-byte client")
}
if len(res.Answer) != 0 {
t.Errorf("truncated reply carries %d answers, want none", len(res.Answer))
}
if len(res.Question) != 1 || res.Question[0].Name != "big.test." {
t.Errorf("truncated reply question = %+v, want big.test.", res.Question)
}
if got := h.srv.load("udp", "truncated"); got != 1 {
t.Errorf("udp.truncated = %d, want 1", got)
}
if err := h.fake.lastPackErr(); err != nil {
t.Fatalf("the fake's big answer did not pack, so this test is measuring a fixture failure: %v", err)
}
}
// TestUDPUsesTheAdvertisedEDNSSize: with EDNS0 the client says how much it can
// take, and the server must honour it — the same answer, delivered whole.
func TestUDPUsesTheAdvertisedEDNSSize(t *testing.T) {
h := newHarness(t, nil)
m := question("big.test.", dns.TypeTXT)
m.SetEdns0(4096, false)
res := h.askUDP(m)
if res.Truncated {
t.Error("3 KiB answer truncated for a client that advertised 4096")
}
if len(res.Answer) != 1 {
t.Fatalf("reply has %d answers, want the whole TXT", len(res.Answer))
}
if o := res.IsEdns0(); o == nil {
t.Error("reply has no OPT even though the query had one")
}
}
// TestUDPCapsTheAdvertisedEDNS0Buffer is the amplification guard: a client may
// claim 64 KiB, and the server rewrites the request to 4096 before the engine
// ever sees it, so no answer is built for a buffer nobody will accept.
func TestUDPCapsTheAdvertisedEDNS0Buffer(t *testing.T) {
h := newHarness(t, nil)
m := question("edns.test.", dns.TypeA)
m.SetEdns0(65535, false)
h.askUDP(m)
got := h.fake.only(t)
if got.ednsSize == 0 {
t.Fatal("the engine saw no EDNS0 at all")
}
if got.ednsSize > 4096 {
t.Errorf("engine saw an advertised buffer of %d, want it capped at 4096", got.ednsSize)
}
if got.ednsSize != 4096 {
t.Errorf("advertised buffer = %d, want the 4096 cap", got.ednsSize)
}
}
// TestFitUDP covers the packing helper directly, where the buffer arithmetic is
// visible without a socket in the way. The three cases are the three answers
// the function can give: pass through, truncate to the question, or refuse to
// answer at all.
func TestFitUDP(t *testing.T) {
t.Run("small answer passes through untouched", func(t *testing.T) {
s := &Server{}
q := question("small.test.", dns.TypeA)
res := answerA(newReply(q))
out := s.fitUDP(res, q, 512)
want, err := res.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
if string(out) != string(want) {
t.Errorf("fitUDP rewrote an answer that already fit (%d vs %d bytes)", len(out), len(want))
}
if got := s.load("udp", "truncated"); got != 0 {
t.Errorf("truncated = %d for an answer that fit", got)
}
})
t.Run("oversized answer becomes a question-only TC reply", func(t *testing.T) {
s := &Server{}
q := question("big.test.", dns.TypeTXT)
res := bigAnswer(newReply(q))
full, err := res.Pack()
if err != nil {
t.Fatalf("the big answer must pack for this test to mean anything: %v", err)
}
if len(full) <= 512 {
t.Fatalf("fixture is only %d bytes, it cannot test truncation", len(full))
}
out := s.fitUDP(res, q, 512)
if len(out) > 512 {
t.Errorf("truncated reply is %d bytes, over the 512 floor", len(out))
}
got := new(dns.Msg)
if err := got.Unpack(out); err != nil {
t.Fatalf("unpack truncated reply: %v", err)
}
if !got.Truncated {
t.Error("TC not set")
}
if len(got.Answer) != 0 {
t.Errorf("truncated reply carries %d answers", len(got.Answer))
}
if len(got.Question) != 1 || got.Question[0].Name != q.Question[0].Name {
t.Errorf("question = %+v, want %v", got.Question, q.Question)
}
if got.Rcode != res.Rcode {
t.Errorf("rcode = %d, want the answer's %d", got.Rcode, res.Rcode)
}
if s.load("udp", "truncated") != 1 {
t.Errorf("truncated counter = %d, want 1", s.load("udp", "truncated"))
}
})
t.Run("an advertised EDNS0 buffer is the limit", func(t *testing.T) {
s := &Server{}
q := question("big.test.", dns.TypeTXT)
q.SetEdns0(4096, false)
res := bigAnswer(newReply(q))
out := s.fitUDP(res, q, 512)
got := new(dns.Msg)
if err := got.Unpack(out); err != nil {
t.Fatalf("unpack: %v", err)
}
if got.Truncated {
t.Error("3 KiB answer truncated against a 4096-byte buffer")
}
if len(got.Answer) != 1 {
t.Errorf("answers = %d, want 1", len(got.Answer))
}
if o := got.IsEdns0(); o == nil || o.UDPSize() != 4096 {
t.Errorf("truncated reply lost the OPT size: %+v", o)
}
})
t.Run("an unadvertised buffer below 512 cannot raise the floor", func(t *testing.T) {
s := &Server{}
q := question("big.test.", dns.TypeTXT)
// 300 is below the 512 floor RFC 1035 guarantees; using it as the
// limit would produce a reply no client is obliged to read.
q.SetEdns0(300, false)
res := bigAnswer(newReply(q))
out := s.fitUDP(res, q, 512)
if len(out) > 512 {
t.Errorf("reply is %d bytes, over the 512 floor", len(out))
}
got := new(dns.Msg)
if err := got.Unpack(out); err != nil {
t.Fatalf("unpack: %v", err)
}
if !got.Truncated {
t.Error("TC not set")
}
})
t.Run("an answer that will not pack is dropped, not truncated", func(t *testing.T) {
s := &Server{}
q := question("unpackable.test.", dns.TypeTXT)
res := new(dns.Msg)
res.SetReply(q)
// A character-string over 255 bytes: miekg/dns v1.1.73 reports its
// length as zero, so Pack fails. Dropping is the only safe answer —
// a half-written datagram is worse than none.
res.Answer = []dns.RR{&dns.TXT{
Hdr: dns.RR_Header{Name: q.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
Txt: []string{strings.Repeat("x", 300)},
}}
if _, err := res.Pack(); err == nil {
t.Fatal("fixture packs; this case is no longer testing the pack failure")
}
if out := s.fitUDP(res, q, 512); out != nil {
t.Errorf("fitUDP returned %d bytes for an unpackable answer, want nil", len(out))
}
})
}
// TestUDPDropSendsNothing: a dropped query is answered with silence, which on
// UDP means no datagram at all. The counter is the only evidence a client-side
// timeout was a policy decision.
func TestUDPDropSendsNothing(t *testing.T) {
h := newHarness(t, nil)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer pc.Close()
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
wire, err := question("drop.test.", dns.TypeA).Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
if _, err := pc.WriteTo(wire, addr); err != nil {
t.Fatalf("write: %v", err)
}
_ = pc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
buf := make([]byte, 4096)
if n, _, err := pc.ReadFrom(buf); err == nil {
t.Errorf("a dropped query was answered with %d bytes", n)
}
waitFor(t, 2*time.Second, "the drop at the engine", func() bool { return len(h.fake.seen()) == 1 })
if got := h.srv.load("udp", "dropped"); got != 1 {
t.Errorf("udp.dropped = %d, want 1", got)
}
if got := h.srv.load("udp", "queries"); got != 1 {
t.Errorf("udp.queries = %d, want 1", got)
}
}
// TestUDPMalformedPacketIsCountedNotAnswered: an unparsable datagram has no ID
// and no question, so there is nothing legal to reply with. It must be counted
// and dropped — a server that echoes a FORMERR to arbitrary garbage is an
// amplifier.
func TestUDPMalformedPacketIsCountedNotAnswered(t *testing.T) {
h := newHarness(t, nil)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer pc.Close()
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if _, err := pc.WriteTo([]byte{0x01, 0x02, 0x03}, addr); err != nil {
t.Fatalf("write: %v", err)
}
waitFor(t, 2*time.Second, "the parse failure", func() bool { return h.srv.load("udp", "errors") == 1 })
if n := len(h.fake.seen()); n != 0 {
t.Errorf("garbage reached the engine %d times", n)
}
_ = pc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
buf := make([]byte, 4096)
if n, _, err := pc.ReadFrom(buf); err == nil {
t.Errorf("garbage was answered with %d bytes", n)
}
}
// TestUDPQuestionlessMessageReachesTheEngine: a parseable message with no
// question is answerable with FORMERR, and servePacket deliberately hands it
// to the engine rather than dropping it — the comment in the code says so and
// it is the difference between a client that learns why it was refused and a
// client that times out.
func TestUDPQuestionlessMessageReachesTheEngine(t *testing.T) {
h := newHarness(t, nil)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer pc.Close()
addr, err := net.ResolveUDPAddr("udp", h.addr("udp"))
if err != nil {
t.Fatalf("resolve: %v", err)
}
// Header only: valid DNS, qdcount 0.
msg := []byte{0x12, 0x34, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}
if _, err := pc.WriteTo(msg, addr); err != nil {
t.Fatalf("write: %v", err)
}
_ = pc.SetReadDeadline(time.Now().Add(3 * time.Second))
buf := make([]byte, 4096)
n, _, err := pc.ReadFrom(buf)
if err != nil {
t.Fatalf("no answer to a questionless query: %v", err)
}
res := new(dns.Msg)
if err := res.Unpack(buf[:n]); err != nil {
t.Fatalf("unpack: %v", err)
}
if res.Rcode != dns.RcodeFormatError {
t.Errorf("rcode = %s, want FORMERR", dns.RcodeToString[res.Rcode])
}
if got := h.fake.only(t); got.name != "" {
t.Errorf("engine saw question %q, want none", got.name)
}
}