387 lines
14 KiB
Go
387 lines
14 KiB
Go
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)) }
|