1 parent
cc7466823b
commit
e8c5b414d9
44 files changed
+7649
-344
No files matched your search
+28
-8
@@ -9,7 +9,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -241,7 +240,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
|
||||
http.Error(w, "metrics not available", http.StatusNotImplemented)
|
||||
})))
|
||||
}
|
||||
mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin))
|
||||
mux.Handle("POST "+base+"auth/login", s.limited(s.handleLogin))
|
||||
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
|
||||
|
||||
// Everything below requires a principal.
|
||||
@@ -320,6 +319,22 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
// public marks a handler as reachable without a principal.
|
||||
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
|
||||
|
||||
// limited applies the per-IP rate limit to a handler that is reachable without
|
||||
// a principal. The login endpoint is the reason the limit exists: guessing
|
||||
// API keys is bounded by nothing but the attacker's patience, so the lockout
|
||||
// the login handler records has to be able to stop the *next* attempt. It did
|
||||
// not, because the limiter ran behind authed() and login is public, so N bad
|
||||
// keys gave N 401s and the lockout only locked the caller out of the rest of
|
||||
// the API.
|
||||
func (s *Server) limited(h http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowRequest(w, r) {
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// cfg is a small accessor so handlers read the current config once.
|
||||
|
||||
// authed wraps a handler with authentication and, for mutations, a write
|
||||
@@ -503,8 +518,17 @@ func (s *Server) recordLoginFailure(ip string) {
|
||||
|
||||
func (s *Server) clearLoginFailures(ip string) {
|
||||
s.limMu.Lock()
|
||||
delete(s.limits, ip)
|
||||
s.limMu.Unlock()
|
||||
defer s.limMu.Unlock()
|
||||
st, ok := s.limits[ip]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Only the auth-failure state is reset. Deleting the whole entry would
|
||||
// also reset the per-minute request window, so a single successful login
|
||||
// in the middle of a burst would hand the client a fresh budget — which
|
||||
// is exactly what an attacker holding one valid key wants.
|
||||
st.failures = 0
|
||||
st.lockedTill = time.Time{}
|
||||
}
|
||||
|
||||
// protectMetrics gates /metrics behind an API key when configured.
|
||||
@@ -597,10 +621,6 @@ func queryTime(r *http.Request, key string) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func isFileWritable(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
|
||||
@@ -17,13 +17,14 @@ import (
|
||||
//
|
||||
// Two details matter for security here:
|
||||
//
|
||||
// - Every comparison is constant-time, including the API key scan. A
|
||||
// byte-at-a-time comparison leaks a valid key prefix to anyone who can
|
||||
// measure response latency, which is a real attack on a network-exposed
|
||||
// admin API.
|
||||
// - Keys are compared by hash, not by string, so the stored form is not a
|
||||
// raw key and a timing difference between a long and short key cannot
|
||||
// reveal length.
|
||||
// - Credentials are never compared in plaintext. An API key is hashed with
|
||||
// SHA-256 and looked up in a map keyed on that hash, so the long-lived
|
||||
// structure holds no key that could be read back out of a core dump, and a
|
||||
// lookup is a hash-map hit whose cost does not depend on how much of the
|
||||
// key the caller guessed. There is no byte-at-a-time scan: the comment that
|
||||
// used to describe one described a function with no callers.
|
||||
// - The two fields that are compared directly — the basic username and the
|
||||
// hashed password — go through subtle.ConstantTimeCompare.
|
||||
type authenticator struct {
|
||||
mode string
|
||||
// readOnly maps key hash -> key label.
|
||||
|
||||
@@ -340,13 +340,6 @@ func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) {
|
||||
t.Errorf("stored form %q is not a hex sha256", stored)
|
||||
}
|
||||
}
|
||||
// constantTimeEqual is the comparison helper: equal only when equal.
|
||||
if !constantTimeEqual("abc", "abc") {
|
||||
t.Error("constantTimeEqual(abc, abc) = false")
|
||||
}
|
||||
if constantTimeEqual("abc", "abd") || constantTimeEqual("abc", "ab") || constantTimeEqual("", "x") {
|
||||
t.Error("constantTimeEqual accepted a mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(m map[string]string) []string {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// corsHarness starts a harness with the given cors_origins list. The origins
|
||||
// are read once, when Handler() builds the wrapper, so a config edit afterwards
|
||||
// does not move them — which is the same freezing the settings route has and is
|
||||
// pinned by TestCORSOriginsAreReadAtHandlerBuild.
|
||||
func corsHarness(t *testing.T, origins ...string) *harness {
|
||||
t.Helper()
|
||||
return newHarness(t, func(c *config.Config) { c.API.CORSOrigins = origins })
|
||||
}
|
||||
|
||||
// TestCORSAllowedOrigin checks a configured origin is echoed back with the
|
||||
// header set a credentialed fetch needs, and that Vary: Origin is present. Vary
|
||||
// is the part that matters operationally: without it a shared cache will serve
|
||||
// one origin's allow-origin header to another.
|
||||
func TestCORSAllowedOrigin(t *testing.T) {
|
||||
h := corsHarness(t, "https://console.example", "http://localhost:5173")
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
}{
|
||||
{"https origin", "https://console.example"},
|
||||
{"plain http origin", "http://localhost:5173"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := h.do(http.MethodGet, "/api/whoami", "",
|
||||
"X-API-Key: rw-key",
|
||||
"Origin: "+tc.origin)
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
if got := res.header.Get("Access-Control-Allow-Origin"); got != tc.origin {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, tc.origin)
|
||||
}
|
||||
if got := res.header.Get("Vary"); got != "Origin" {
|
||||
t.Errorf("Vary = %q, want \"Origin\"", got)
|
||||
}
|
||||
if got := res.header.Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got)
|
||||
}
|
||||
if got := res.header.Get("Access-Control-Allow-Methods"); got == "" {
|
||||
t.Error("no Access-Control-Allow-Methods advertised")
|
||||
}
|
||||
if got := res.header.Get("Access-Control-Allow-Headers"); got == "" {
|
||||
t.Error("no Access-Control-Allow-Headers advertised")
|
||||
}
|
||||
})
|
||||
}
|
||||
// The allowed methods/header lists must actually name what the console
|
||||
// sends, or a preflight for a PUT fails and the settings editor is
|
||||
// unusable from a browser.
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://console.example")
|
||||
if got := res.header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "PUT") || !strings.Contains(got, "DELETE") {
|
||||
t.Errorf("Access-Control-Allow-Methods = %q, want PUT and DELETE in it", got)
|
||||
}
|
||||
if got := res.header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-API-Key") || !strings.Contains(got, "Authorization") {
|
||||
t.Errorf("Access-Control-Allow-Headers = %q, want X-API-Key and Authorization in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSDisallowedOrigin checks an origin that is not configured gets no CORS
|
||||
// headers at all. The request still runs — CORS is enforced by the browser, not
|
||||
// by refusing the request — but the browser must not be told the origin is
|
||||
// allowed.
|
||||
func TestCORSDisallowedOrigin(t *testing.T) {
|
||||
h := corsHarness(t, "https://console.example")
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://evil.example")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
for _, hdr := range []string{
|
||||
"Access-Control-Allow-Origin",
|
||||
"Access-Control-Allow-Credentials",
|
||||
"Access-Control-Allow-Methods",
|
||||
"Access-Control-Allow-Headers",
|
||||
} {
|
||||
if got := res.header.Get(hdr); got != "" {
|
||||
t.Errorf("%s = %q for a disallowed origin, want it absent", hdr, got)
|
||||
}
|
||||
}
|
||||
// Vary is only added for an allowed origin, which is a caching footgun
|
||||
// this suite pins: a shared cache that stored the unvaried response could
|
||||
// hand the console's allow-origin to the other origin.
|
||||
if got := res.header.Get("Vary"); got != "" {
|
||||
t.Errorf("Vary = %q for a disallowed origin, want it absent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSWildcardEchoesTheOrigin checks "*" is honoured and, as the code
|
||||
// comment says, echoed per request rather than answered as "*" — a literal
|
||||
// wildcard is not accepted by a credentialed fetch.
|
||||
func TestCORSWildcardEchoesTheOrigin(t *testing.T) {
|
||||
h := corsHarness(t, "*")
|
||||
for _, origin := range []string{"https://a.example", "https://b.example", "http://localhost:5173"} {
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: "+origin)
|
||||
if got := res.header.Get("Access-Control-Allow-Origin"); got != origin {
|
||||
t.Errorf("Access-Control-Allow-Origin for %s = %q, want the origin echoed", origin, got)
|
||||
}
|
||||
}
|
||||
// The wildcard is the risky setting, so pin that credentials are still
|
||||
// advertised for it: the code does NOT distinguish wildcard from an exact
|
||||
// origin, which means "*" plus credentials is only safe because the
|
||||
// wildcard is discouraged in the config comment. If this ever changes, the
|
||||
// browser semantics change with it, so it is pinned rather than assumed.
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://a.example")
|
||||
if got := res.header.Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("wildcard: Access-Control-Allow-Credentials = %q, want \"true\" (pinned current behaviour)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSPreflight checks OPTIONS is answered by the CORS wrapper with 204 and
|
||||
// the method/header list, before authentication and before the mux — a
|
||||
// preflight carries no API key, so a route that answered it would answer 401.
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
h := corsHarness(t, "https://console.example")
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
origin string
|
||||
wantACA bool
|
||||
}{
|
||||
{"registered route", "/api/whoami", "https://console.example", true},
|
||||
// The preflight is answered for any origin, allowed or not: the
|
||||
// browser compares Access-Control-Allow-Origin itself, and a 403
|
||||
// answer to a preflight is not a thing the browser can interpret.
|
||||
{"disallowed origin", "/api/whoami", "https://evil.example", false},
|
||||
// Not a registered route at all — the wrapper runs before the mux.
|
||||
{"unknown route", "/api/nope", "https://console.example", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := h.do(http.MethodOptions, tc.path, "",
|
||||
"Origin: "+tc.origin,
|
||||
"Access-Control-Request-Method: PUT",
|
||||
"Access-Control-Request-Headers: x-api-key")
|
||||
if res.status != http.StatusNoContent {
|
||||
t.Fatalf("OPTIONS %s = %d, want 204: %s", tc.path, res.status, res.body)
|
||||
}
|
||||
if len(res.body) != 0 {
|
||||
t.Errorf("preflight returned a body: %q", res.body)
|
||||
}
|
||||
acao := res.header.Get("Access-Control-Allow-Origin")
|
||||
if tc.wantACA && acao != tc.origin {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q, want %q", acao, tc.origin)
|
||||
}
|
||||
if !tc.wantACA && acao != "" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q for a disallowed origin, want it absent", acao)
|
||||
}
|
||||
if tc.wantACA {
|
||||
if got := res.header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "PUT") {
|
||||
t.Errorf("Access-Control-Allow-Methods = %q, want PUT in it", got)
|
||||
}
|
||||
// HTTP header names are case-insensitive and the server
|
||||
// advertises a fixed list, so compare case-insensitively.
|
||||
if got := strings.ToLower(res.header.Get("Access-Control-Allow-Headers")); !strings.Contains(got, "x-api-key") {
|
||||
t.Errorf("Access-Control-Allow-Headers = %q, want x-api-key in it", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSDisabled checks a config without cors_origins is not wrapped at all:
|
||||
// no headers, and OPTIONS falls through to the mux, which answers a method it
|
||||
// does not serve rather than pretending a preflight succeeded.
|
||||
func TestCORSDisabled(t *testing.T) {
|
||||
h := newHarness(t, nil) // testConfig leaves CORSOrigins nil
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://console.example")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("GET /api/whoami = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
if got := res.header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q with no CORS configured, want it absent", got)
|
||||
}
|
||||
res = h.do(http.MethodOptions, "/api/whoami", "", "Origin: https://console.example")
|
||||
if res.status != http.StatusMethodNotAllowed {
|
||||
t.Errorf("OPTIONS with no CORS configured = %d, want 405 from the mux: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSOriginsAreReadAtHandlerBuild pins the same freezing the settings
|
||||
// route has for the rest of the auth config: the allow-list is captured when
|
||||
// Handler() is called, so adding an origin through the settings endpoint
|
||||
// answers ok and changes nothing until a restart. Written down so that fixing
|
||||
// the freezing later starts from a test that says what it is.
|
||||
func TestCORSOriginsAreReadAtHandlerBuild(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.API.CORSOrigins = []string{"https://old.example"} })
|
||||
body := settingsBody(t, func(c *config.Config) { c.API.CORSOrigins = []string{"https://new.example"} })
|
||||
if res := h.do(http.MethodPut, "/api/settings", body, "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||
t.Fatalf("settings put = %d: %s", res.status, res.body)
|
||||
}
|
||||
// The store carries the new list...
|
||||
if got := h.store.Get().API.CORSOrigins; len(got) != 1 || got[0] != "https://new.example" {
|
||||
t.Fatalf("store cors_origins = %v, want the written list", got)
|
||||
}
|
||||
// ...but the running server still answers for the origin it was built with.
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://new.example")
|
||||
if got := res.header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("the written origin is already allowed (%q): the allow-list is not frozen as this test pins it", got)
|
||||
}
|
||||
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", "Origin: https://old.example"); res.header.Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Error("the origin the server was built with is no longer allowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// errHook is what a component returns when it cannot do its job.
|
||||
var errHook = errors.New("component is unavailable")
|
||||
|
||||
// errMultiLine is what the config validator aggregates, one problem per line.
|
||||
var errMultiLine = errors.New("first problem\nsecond problem")
|
||||
|
||||
// setFail tells one hook to return an error, which is how the error branches of
|
||||
// a handler are reached without a real component failing underneath.
|
||||
func setFail(h *harness, hook string, err error) {
|
||||
h.fake.mu.Lock()
|
||||
h.fake.fail[hook] = err
|
||||
h.fake.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestHookErrorsAreReported walks the routes whose hook can fail and pins what
|
||||
// each handler does with the error. The status codes are not uniform and that
|
||||
// is deliberate in two places: a component that could not answer is a 500 (the
|
||||
// operator has to know the console is lying), while a component that rejected
|
||||
// the *request* is a 400, and a domain lookup that failed is a 200 carrying the
|
||||
// reason, because "why is this name not blocked?" is the question the route
|
||||
// exists to answer.
|
||||
func TestHookErrorsAreReported(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fail string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
// Reads: the component could not produce an answer.
|
||||
{"dashboard", "Dashboard", http.MethodGet, "/api/dashboard", "", http.StatusInternalServerError},
|
||||
{"domain list", "ListDomains", http.MethodGet, "/api/domains", "", http.StatusInternalServerError},
|
||||
{"log search", "SearchLog", http.MethodGet, "/api/logs", "", http.StatusInternalServerError},
|
||||
// A domain check that fails still answers, with the reason: 200 here
|
||||
// is the point of the route.
|
||||
{"domain check answers 200 with the error", "DomainCheck", http.MethodGet, "/api/domains/check?name=a.example.com", "", http.StatusOK},
|
||||
// Writes: the component rejected what was asked for.
|
||||
{"blocklist refresh", "RefreshBlocklists", http.MethodPost, "/api/blocklists/refresh", "", http.StatusInternalServerError},
|
||||
{"blocklist toggle is a 404 for any failure", "ToggleBlocklist", http.MethodPost, "/api/blocklists/toggle", `{"name":"hosts","enabled":true}`, http.StatusNotFound},
|
||||
{"domain add", "AddDomain", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, http.StatusBadRequest},
|
||||
{"domain delete", "DeleteDomain", http.MethodDelete, "/api/domains/a.example.com", "", http.StatusNotFound},
|
||||
{"clients put", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, http.StatusBadRequest},
|
||||
{"upstreams put", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, http.StatusBadRequest},
|
||||
{"log clear", "ClearLog", http.MethodPost, "/api/logs/clear", "", http.StatusInternalServerError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
setFail(h, tc.fail, errHook)
|
||||
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
|
||||
if res.status != tc.want {
|
||||
t.Fatalf("%s %s with a failing %s hook = %d, want %d: %s",
|
||||
tc.method, tc.path, tc.fail, res.status, tc.want, res.body)
|
||||
}
|
||||
if got, _ := res.json(t)["error"].(string); got == "" {
|
||||
t.Errorf("response has no error message: %s", res.body)
|
||||
}
|
||||
// The server has to still be serving: the admin API is the only
|
||||
// way to fix a broken instance.
|
||||
if got := h.get("/api/whoami").status; got != http.StatusOK {
|
||||
t.Errorf("/whoami after a failing hook = %d, want 200", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookErrorsAreFieldedForConfigWrites checks the two config-writing routes
|
||||
// split a multi-line validator error into the fields the editor renders, so a
|
||||
// rejected write points at the offending lines instead of dumping prose.
|
||||
func TestHookErrorsAreFieldedForConfigWrites(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fail string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"acl", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`},
|
||||
{"upstreams", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
setFail(h, tc.fail, errMultiLine)
|
||||
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusBadRequest {
|
||||
t.Fatalf("%s %s = %d, want 400: %s", tc.method, tc.path, res.status, res.body)
|
||||
}
|
||||
fields, _ := res.json(t)["fields"].([]any)
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields = %v, want the two lines of the error", fields)
|
||||
}
|
||||
// Sorted, so the editor can diff two runs.
|
||||
if fields[0] != "first problem" || fields[1] != "second problem" {
|
||||
t.Errorf("fields = %v, want them split and sorted", fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanickingWriteHookIsRecovered checks the recoverer wraps the whole chain
|
||||
// and not just the read handlers the earlier test covers: a write that panics
|
||||
// must be a 500 and must not have half-applied anything.
|
||||
func TestPanickingWriteHookIsRecovered(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.fake.mu.Lock()
|
||||
h.fake.panicOn["PutACL"] = true
|
||||
h.fake.mu.Unlock()
|
||||
|
||||
res := h.do(http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusInternalServerError {
|
||||
t.Fatalf("a panicking write hook = %d, want 500: %s", res.status, res.body)
|
||||
}
|
||||
if len(h.fake.aclPut) != 0 {
|
||||
t.Error("a panicking write recorded a persisted ACL")
|
||||
}
|
||||
if got := h.get("/api/clients").status; got != http.StatusOK {
|
||||
t.Errorf("/clients after the panic = %d, want 200", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// stubMetrics is the exposition handler the app would have installed. The api
|
||||
// package takes it as a func so it does not import the metrics package, which
|
||||
// means a test can hand it a two-line stand-in and assert on the plumbing
|
||||
// (routing, auth, content type) instead of on Prometheus internals.
|
||||
func stubMetrics() http.Handler {
|
||||
body := "# HELP fancydns_test_metric stub\n# TYPE fancydns_test_metric gauge\nfancydns_test_metric 1\n"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
}
|
||||
|
||||
// wireMetrics installs a stub exposition handler and restores the "nothing
|
||||
// installed" state afterwards, so the package-level handler does not leak into
|
||||
// the next test.
|
||||
func wireMetrics(t *testing.T, h http.Handler) {
|
||||
t.Helper()
|
||||
SetMetricsHandler(h)
|
||||
t.Cleanup(func() { SetMetricsHandler(nil) })
|
||||
}
|
||||
|
||||
// TestMetricsDisabledIsNotRouted pins the default: with metrics.enabled false
|
||||
// there is no /metrics route at all, so the 404 is the mux's and not a handler
|
||||
// answering "metrics are disabled". A scrape of a disabled instance must fail
|
||||
// loudly enough to be noticed.
|
||||
func TestMetricsDisabledIsNotRouted(t *testing.T) {
|
||||
wireMetrics(t, stubMetrics())
|
||||
h := newHarness(t, nil) // testConfig leaves Metrics.Enabled false
|
||||
res := h.get("/api/metrics")
|
||||
if res.status != http.StatusNotFound {
|
||||
t.Fatalf("GET /api/metrics with metrics disabled = %d, want 404: %s", res.status, res.body)
|
||||
}
|
||||
if strings.Contains(string(res.body), "fancydns_test_metric") {
|
||||
t.Error("a disabled instance served metrics")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsServesExposition checks the enabled, unprotected case: no API key
|
||||
// needed, the Prometheus text content type comes through, and the body is the
|
||||
// handler's.
|
||||
func TestMetricsServesExposition(t *testing.T) {
|
||||
wireMetrics(t, stubMetrics())
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: false}
|
||||
})
|
||||
res := h.do(http.MethodGet, "/api/metrics", "")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("GET /api/metrics = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
if got := res.header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
|
||||
t.Errorf("Content-Type = %q, want the Prometheus text/plain form", got)
|
||||
}
|
||||
if !strings.Contains(string(res.body), "fancydns_test_metric 1") {
|
||||
t.Errorf("body does not carry the exposition: %q", res.body)
|
||||
}
|
||||
// metrics.path is documented as unused (README: "the endpoint is always
|
||||
// ${api.base_path}/metrics"), so a config that sets it to something else
|
||||
// must not move the endpoint. Pinned here because the natural-looking fix
|
||||
// — honour the field — is a URL change under a running scrape.
|
||||
h2 := newHarness(t, func(c *config.Config) {
|
||||
c.Metrics = config.Metrics{Enabled: true, Path: "/internal/metrics", Protect: false}
|
||||
})
|
||||
if res := h2.do(http.MethodGet, "/api/metrics", ""); res.status != http.StatusOK {
|
||||
t.Errorf("GET /api/metrics with a custom metrics path = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
if res := h2.do(http.MethodGet, "/internal/metrics", ""); res.status != http.StatusNotFound {
|
||||
t.Errorf("GET /internal/metrics = %d, want 404: metrics.path is not honoured: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsProtectedRequiresAKey checks metrics.protect: the scrape endpoint
|
||||
// exposes client addresses and queried domains, so without a key it must not
|
||||
// answer, and with one it must. The principal is reused from the authed
|
||||
// routes, so a read-only key is enough.
|
||||
func TestMetricsProtectedRequiresAKey(t *testing.T) {
|
||||
wireMetrics(t, stubMetrics())
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: true}
|
||||
})
|
||||
cases := []struct {
|
||||
name string
|
||||
header string
|
||||
want int
|
||||
}{
|
||||
{"no key", "", http.StatusUnauthorized},
|
||||
{"wrong key", "X-API-Key: nope", http.StatusUnauthorized},
|
||||
{"read-only key", "X-API-Key: ro-key", http.StatusOK},
|
||||
{"full-access key", "X-API-Key: rw-key", http.StatusOK},
|
||||
{"basic credentials", "Authorization: Basic YWRtaW46YWRtaW4=", http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := h.do(http.MethodGet, "/api/metrics", "", tc.header)
|
||||
if res.status != tc.want {
|
||||
t.Fatalf("GET /api/metrics with %s = %d, want %d: %s", tc.name, res.status, tc.want, res.body)
|
||||
}
|
||||
if tc.want == http.StatusOK && !strings.Contains(string(res.body), "fancydns_test_metric") {
|
||||
t.Errorf("authorised scrape did not reach the handler: %q", res.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := h.do(http.MethodGet, "/api/metrics", "").json(t)["error"]; got == nil {
|
||||
t.Error("the unauthenticated scrape did not answer with a JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsHandlerNotWired pins the third state, which is easy to leave
|
||||
// untested and looks like a product bug when it happens: metrics.enabled true
|
||||
// but the app never installed the handler. The route answers 501 rather than
|
||||
// panicking on a nil handler, because the api package holds it as a func it
|
||||
// never sets itself.
|
||||
func TestMetricsHandlerNotWired(t *testing.T) {
|
||||
wireMetrics(t, nil) // enabled, but nothing installed
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: false}
|
||||
})
|
||||
res := h.do(http.MethodGet, "/api/metrics", "")
|
||||
if res.status != http.StatusNotImplemented {
|
||||
t.Fatalf("GET /api/metrics with no handler wired = %d, want 501: %s", res.status, res.body)
|
||||
}
|
||||
// http.Error, so this one is plain text rather than the JSON envelope the
|
||||
// rest of the API uses.
|
||||
if !strings.Contains(string(res.body), "metrics not available") {
|
||||
t.Errorf("body = %q, want it to say metrics not available", res.body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// rateLimited is the config knob every test in this file turns: the per-minute
|
||||
// request budget and the per-IP failure lockout are the same struct.
|
||||
func rateLimited(c *config.Config, perMinute, failures int, lockout time.Duration) {
|
||||
modeAPIKey(c)
|
||||
c.API.RateLimit = config.AuthRate{PerMinute: perMinute, Failures: failures, Lockout: config.Duration(lockout)}
|
||||
}
|
||||
|
||||
// behindProxy is the X-Forwarded-For header that makes a request look like it
|
||||
// came from ip. The harness's own connections come from 127.0.0.1, so a test
|
||||
// that wants two distinct clients has to trust the loopback proxy.
|
||||
func behindProxy(ip string) string { return "X-Forwarded-For: " + ip }
|
||||
|
||||
// badLogin posts a login with a key that is not configured. It is what a
|
||||
// brute-force attempt looks like from the server's side.
|
||||
func badLogin(h *harness, header string) response {
|
||||
return h.do(http.MethodPost, "/api/auth/login", `{"api_key":"nope"}`, header)
|
||||
}
|
||||
|
||||
// goodLogin posts a login with the key the harness was configured with.
|
||||
func goodLogin(h *harness, header string) response {
|
||||
return h.do(http.MethodPost, "/api/auth/login", `{"api_key":"rw-key"}`, header)
|
||||
}
|
||||
|
||||
// TestLoginBruteForceEventuallyHitsTheRateLimiter is the brute-force case, and
|
||||
// the reason api.rate_limit exists at all: hammering the login endpoint with
|
||||
// guessed keys must eventually be refused with 429 rather than answered with an
|
||||
// unlimited stream of 401s.
|
||||
//
|
||||
// The per_minute limit is documented as "requests per minute per IP for the
|
||||
// login endpoint", and the login route is registered through the identity
|
||||
// wrapper, so nothing used to ever count these requests.
|
||||
func TestLoginBruteForceEventuallyHitsTheRateLimiter(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
// A generous per-minute budget so the lockout, not the request
|
||||
// count, is what stops the attack.
|
||||
rateLimited(c, 1000, 3, 5*time.Minute)
|
||||
})
|
||||
|
||||
var locked response
|
||||
for i := 0; i < 20; i++ {
|
||||
res := badLogin(h, "")
|
||||
if res.status == http.StatusUnauthorized {
|
||||
continue
|
||||
}
|
||||
locked = res
|
||||
break
|
||||
}
|
||||
if locked.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("20 bad logins from one IP never hit the rate limit: last status %d, want 429: %s",
|
||||
locked.status, locked.body)
|
||||
}
|
||||
if locked.header.Get("Retry-After") == "" {
|
||||
t.Error("429 from the login limiter carries no Retry-After header")
|
||||
}
|
||||
|
||||
// A *correct* key during the lockout must be refused too. Otherwise the
|
||||
// lockout is only an obstacle to the attacker and the operator whose key
|
||||
// was typo'd gets in anyway.
|
||||
res := goodLogin(h, "")
|
||||
if res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("a correct key during the lockout = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
if _, ok := res.json(t)["token"]; ok {
|
||||
t.Error("the lockout still issued a session token")
|
||||
}
|
||||
|
||||
// The lockout is not a login-only affair in this design: the same per-IP
|
||||
// state gates the rest of the API, so a brute-force attempt from a host
|
||||
// that also runs the console cannot keep hammering other routes either.
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests {
|
||||
t.Errorf("an authed route during the lockout = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLockoutExpiresWithTheClock checks the lockout is temporary: once the
|
||||
// configured lockout has passed, the same IP is served again. The clock is the
|
||||
// server's, so the test advances it instead of sleeping.
|
||||
func TestLoginLockoutExpiresWithTheClock(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
rateLimited(c, 1000, 3, 5*time.Minute)
|
||||
})
|
||||
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
now := base
|
||||
h.srv.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
|
||||
t.Fatalf("bad login %d = %d, want 401: %s", i+1, res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// Still inside the lockout window.
|
||||
now = base.Add(5*time.Minute - time.Second)
|
||||
if res := goodLogin(h, ""); res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("one second before the lockout expires = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
|
||||
// Past it.
|
||||
now = base.Add(5*time.Minute + time.Second)
|
||||
res := goodLogin(h, "")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("login after the lockout expired = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
if _, ok := res.json(t)["token"]; !ok {
|
||||
t.Error("login after the lockout expired returned no token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLockoutIsPerIP checks one attacker cannot lock out everyone else
|
||||
// behind the same proxy: the state is keyed on the forwarded client address,
|
||||
// which is only honoured from a trusted proxy.
|
||||
func TestLoginLockoutIsPerIP(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
rateLimited(c, 1000, 3, 5*time.Minute)
|
||||
c.API.TrustedProxies = []string{"127.0.0.0/8"}
|
||||
})
|
||||
const attacker = "198.51.100.7"
|
||||
for i := 0; i < 10; i++ {
|
||||
if res := badLogin(h, behindProxy(attacker)); res.status == http.StatusOK {
|
||||
t.Fatalf("bad login %d was accepted: %s", i+1, res.body)
|
||||
}
|
||||
}
|
||||
if res := badLogin(h, behindProxy(attacker)); res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("the attacker was never locked out: %d: %s", res.status, res.body)
|
||||
}
|
||||
// A different client behind the same proxy must be untouched.
|
||||
res := goodLogin(h, behindProxy("203.0.113.9"))
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("a second client behind the same proxy = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
// And so must the proxy's own address (no X-Forwarded-For at all).
|
||||
if res := goodLogin(h, ""); res.status != http.StatusOK {
|
||||
t.Fatalf("the proxy address itself = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLockoutDisabledWithoutFailures pins the config side: failures <= 0
|
||||
// turns the lockout off, so an operator who has not opted in cannot lock
|
||||
// themselves out of the console.
|
||||
func TestLoginLockoutDisabledWithoutFailures(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
rateLimited(c, 0, 0, 5*time.Minute)
|
||||
})
|
||||
for i := 0; i < 25; i++ {
|
||||
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
|
||||
t.Fatalf("bad login %d = %d, want 401: no lockout is configured: %s", i+1, res.status, res.body)
|
||||
}
|
||||
}
|
||||
if res := goodLogin(h, ""); res.status != http.StatusOK {
|
||||
t.Fatalf("login after 25 failures = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuccessfulLoginClearsTheFailureCounter checks a success resets the
|
||||
// failure counter: an operator who fat-fingers the password three times, waits
|
||||
// out the lockout and then logs in correctly must not be one typo away from
|
||||
// another lockout.
|
||||
func TestSuccessfulLoginClearsTheFailureCounter(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
rateLimited(c, 1000, 3, time.Minute)
|
||||
})
|
||||
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
now := base
|
||||
h.srv.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
badLogin(h, "")
|
||||
}
|
||||
now = base.Add(2 * time.Minute) // past the one-minute lockout
|
||||
if res := goodLogin(h, ""); res.status != http.StatusOK {
|
||||
t.Fatalf("login after the lockout = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
// The counter started again: without the reset the first post-success
|
||||
// failure would already be locked out. Three more failures are still
|
||||
// answered 401 and the fourth re-arms the lockout, which is exactly what
|
||||
// "the counter restarted" means.
|
||||
for i := 0; i < 3; i++ {
|
||||
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
|
||||
t.Fatalf("post-success failure %d = %d, want 401: the counter was not reset", i+1, res.status)
|
||||
}
|
||||
}
|
||||
if res := badLogin(h, ""); res.status != http.StatusTooManyRequests {
|
||||
t.Errorf("the fourth post-success failure = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuccessfulLoginDoesNotResetTheRequestWindow pins a deliberate separation:
|
||||
// a successful login clears the *failure* state, but it must not hand the
|
||||
// client a fresh per-minute request budget. clearLoginFailures used to delete
|
||||
// the whole per-IP state, so one successful login in the middle of a burst
|
||||
// wiped the rate-limit window too — an attacker holding one valid key could use
|
||||
// it to make brute force free, and the fix is to only reset what the login
|
||||
// knows about.
|
||||
func TestSuccessfulLoginDoesNotResetTheRequestWindow(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
// Four requests a minute and no lockout at all, so the window is the
|
||||
// only thing that can refuse: three failures, then a correct key,
|
||||
// then the budget is spent.
|
||||
rateLimited(c, 4, 0, 5*time.Minute)
|
||||
})
|
||||
for i := 0; i < 3; i++ {
|
||||
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
|
||||
t.Fatalf("bad login %d = %d, want 401: %s", i+1, res.status, res.body)
|
||||
}
|
||||
}
|
||||
if res := goodLogin(h, ""); res.status != http.StatusOK {
|
||||
t.Fatalf("the correct key = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
res := badLogin(h, "")
|
||||
if res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("after four requests in the window = %d, want 429: a successful login must not reset the window: %s",
|
||||
res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthedRouteRateLimit checks the per-minute budget on the authenticated
|
||||
// routes, which is where allowRequest has always been wired: N+1 requests in a
|
||||
// window get 429 with a Retry-After, and the Nth is still served.
|
||||
func TestAuthedRouteRateLimit(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { rateLimited(c, 5, 0, 5*time.Minute) })
|
||||
for i := 0; i < 5; i++ {
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusOK {
|
||||
t.Fatalf("request %d = %d, want 200: %s", i+1, res.status, res.body)
|
||||
}
|
||||
}
|
||||
res := h.get("/api/whoami")
|
||||
if res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("the 6th request in a minute = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
if got := res.header.Get("Retry-After"); got == "" {
|
||||
t.Error("429 from the per-minute limit carries no Retry-After header")
|
||||
}
|
||||
// The refused request must not have reached the handler.
|
||||
if h.fake.count("Status") != 0 {
|
||||
t.Error("a rate-limited request still reached a hook")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthedRouteRateLimitWindowResets checks the window is a minute wide and
|
||||
// is driven by the server's clock, not by a timer that has to expire in real
|
||||
// time.
|
||||
func TestAuthedRouteRateLimitWindowResets(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { rateLimited(c, 3, 0, 5*time.Minute) })
|
||||
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
now := base
|
||||
h.srv.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusOK {
|
||||
t.Fatalf("request %d = %d, want 200: %s", i+1, res.status, res.body)
|
||||
}
|
||||
}
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("the 4th request = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
// One second before the window rolls over is still inside it.
|
||||
now = base.Add(time.Minute - time.Second)
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests {
|
||||
t.Fatalf("one second before the window rolls = %d, want 429: %s", res.status, res.body)
|
||||
}
|
||||
now = base.Add(time.Minute)
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusOK {
|
||||
t.Fatalf("first request in the next window = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthedRouteRateLimitIsPerIP checks the budget is per client address, so
|
||||
// one noisy operator cannot lock out the rest of the console's users.
|
||||
func TestAuthedRouteRateLimitIsPerIP(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
rateLimited(c, 2, 0, 5*time.Minute)
|
||||
c.API.TrustedProxies = []string{"127.0.0.0/8"}
|
||||
})
|
||||
for i := 0; i < 3; i++ {
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", behindProxy("198.51.100.1"))
|
||||
want := http.StatusOK
|
||||
if i == 2 {
|
||||
want = http.StatusTooManyRequests
|
||||
}
|
||||
if res.status != want {
|
||||
t.Fatalf("request %d from the noisy client = %d, want %d: %s", i+1, res.status, want, res.body)
|
||||
}
|
||||
}
|
||||
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", behindProxy("203.0.113.9")); res.status != http.StatusOK {
|
||||
t.Fatalf("a different client = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthedRouteRateLimitDisabledWhenNotConfigured pins the default: with
|
||||
// rate_limit unset (which is what a config that never mentions it gets) the
|
||||
// authenticated routes are not limited at all.
|
||||
func TestAuthedRouteRateLimitDisabledWhenNotConfigured(t *testing.T) {
|
||||
h := newHarness(t, nil) // testConfig leaves RateLimit zero
|
||||
for i := 0; i < 20; i++ {
|
||||
if res := h.get("/api/whoami"); res.status != http.StatusOK {
|
||||
t.Fatalf("request %d with no rate limit configured = %d, want 200: %s", i+1, res.status, res.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimitCleanupDropsStaleEntries exercises the opportunistic cleanup in
|
||||
// allowRequest. There is no sweeper goroutine: the per-IP map only shrinks
|
||||
// when a request comes in and finds it over the 4096-entry cap, so an attacker
|
||||
// rotating source addresses is the case that matters, and an entry that has
|
||||
// not been touched for ten minutes is the one that goes.
|
||||
func TestRateLimitCleanupDropsStaleEntries(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
// A budget no test can hit: this is about the map, not the counter.
|
||||
rateLimited(c, 1_000_000, 0, time.Minute)
|
||||
})
|
||||
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
now := base
|
||||
h.srv.now = func() time.Time { return now }
|
||||
|
||||
// Fill the map past the cap with one request per synthetic client.
|
||||
allow := func(ip string) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/whoami", nil)
|
||||
r.RemoteAddr = ip + ":40000"
|
||||
if !h.srv.allowRequest(httptest.NewRecorder(), r) {
|
||||
t.Fatalf("client %s was rate limited", ip)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 4097; i++ {
|
||||
allow(fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256))
|
||||
}
|
||||
if got := len(h.srv.limits); got <= 4096 {
|
||||
t.Fatalf("the map holds %d entries, want more than the 4096 cap", got)
|
||||
}
|
||||
|
||||
// Nothing is stale yet, so a request at the same instant must not shrink
|
||||
// the map: the cap is a trigger, not a deadline.
|
||||
allow("10.9.9.9")
|
||||
if got := len(h.srv.limits); got <= 4096 {
|
||||
t.Errorf("the cleanup dropped %d entries that were not stale", 4097+1-got)
|
||||
}
|
||||
|
||||
// Past the ten-minute idle horizon every entry is stale, and the next
|
||||
// request sweeps them.
|
||||
now = base.Add(11 * time.Minute)
|
||||
allow("10.9.9.8")
|
||||
if got := len(h.srv.limits); got > 2 {
|
||||
t.Errorf("after the idle horizon the map holds %d entries, want only the two live clients", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// post is the authenticated write the settings routes need.
|
||||
func (h *harness) post(path string) response {
|
||||
h.t.Helper()
|
||||
return h.do(http.MethodPost, path, "", "X-API-Key: rw-key")
|
||||
}
|
||||
|
||||
// newYAML renders a complete, valid document with one field changed, as it
|
||||
// would sit in the config file. The settings editor sends a whole document, and
|
||||
// so do these tests: a partial one lands on top of config.Default, which leaves
|
||||
// the blocklist enabled and therefore invalid.
|
||||
func newYAML(t *testing.T, mutate func(*config.Config)) string {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
cfg.Block.Enabled = false
|
||||
if mutate != nil {
|
||||
mutate(cfg)
|
||||
}
|
||||
doc, err := config.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(doc)
|
||||
}
|
||||
|
||||
// newDoc is newYAML wrapped in the JSON envelope the settings routes expect.
|
||||
func newDoc(t *testing.T, mutate func(*config.Config)) string {
|
||||
t.Helper()
|
||||
return settingsBody(t, mutate)
|
||||
}
|
||||
|
||||
// TestSettingsGet pins the payload the settings editor renders from: the file
|
||||
// on disk as the editable text, the effective document the running server
|
||||
// actually has, and the defaults as the reset target.
|
||||
func TestSettingsGet(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := h.get("/api/settings")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("GET /api/settings = %d: %s", res.status, res.body)
|
||||
}
|
||||
body := res.json(t)
|
||||
if body["path"] != h.path {
|
||||
t.Errorf("path = %v, want %q", body["path"], h.path)
|
||||
}
|
||||
if body["config_yaml"] != h.readConfig() {
|
||||
t.Error("config_yaml is not the document on disk")
|
||||
}
|
||||
if !strings.Contains(body["effective_yaml"].(string), "api_keys") {
|
||||
t.Error("effective_yaml does not carry the running auth config")
|
||||
}
|
||||
if !strings.Contains(body["defaults_yaml"].(string), "session_ttl") {
|
||||
t.Error("defaults_yaml does not look like a default document")
|
||||
}
|
||||
if body["reload_available"] != true {
|
||||
t.Errorf("reload_available = %v, want true for a config with a file", body["reload_available"])
|
||||
}
|
||||
if body["writable"] != true {
|
||||
t.Errorf("writable = %v, want true for a 0600 file in a temp dir", body["writable"])
|
||||
}
|
||||
if body["last_error"] != "" {
|
||||
t.Errorf("last_error = %v, want empty on a fresh server", body["last_error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsGetWithoutAConfigFile pins the fallback for an instance running
|
||||
// with no file: reload is unavailable and the editor shows the effective
|
||||
// document as the editable text, so the UI still works.
|
||||
func TestSettingsGetWithoutAConfigFile(t *testing.T) {
|
||||
h := newHarnessWithFile(t, testConfig(t), "")
|
||||
body := h.get("/api/settings").json(t)
|
||||
if body["path"] != "" {
|
||||
t.Errorf("path = %v, want empty", body["path"])
|
||||
}
|
||||
if body["reload_available"] != false {
|
||||
t.Errorf("reload_available = %v, want false with no file", body["reload_available"])
|
||||
}
|
||||
if body["config_yaml"] != body["effective_yaml"] {
|
||||
t.Error("config_yaml should fall back to the effective document when there is no file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsPutRoundTrips is the happy path: a written document must reach
|
||||
// the disk and the running config, and the version must move so the cluster
|
||||
// notices. The round-trip is checked through config.Load, not by reading the
|
||||
// store back, because a document that does not re-parse is the failure mode
|
||||
// that costs an operator a restart.
|
||||
func TestSettingsPutRoundTrips(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
before := h.store.Generation()
|
||||
doc := newDoc(t, func(c *config.Config) {
|
||||
c.Server.MaxConcurrent = 4242
|
||||
c.Log.Level = "debug"
|
||||
})
|
||||
res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
|
||||
}
|
||||
if body := res.json(t); body["ok"] != true {
|
||||
t.Fatalf("response = %v, want ok", body)
|
||||
}
|
||||
if h.store.Generation() != before+1 {
|
||||
t.Errorf("generation = %d, want %d", h.store.Generation(), before+1)
|
||||
}
|
||||
// The file on disk parses back to what was asked for.
|
||||
back, err := config.Load(h.path)
|
||||
if err != nil {
|
||||
t.Fatalf("the written document does not re-parse: %v", err)
|
||||
}
|
||||
if back.Server.MaxConcurrent != 4242 || back.Log.Level != "debug" {
|
||||
t.Errorf("reloaded config = max_concurrent %d, log.level %q", back.Server.MaxConcurrent, back.Log.Level)
|
||||
}
|
||||
// And the running server agrees with the file.
|
||||
if got := h.store.Get().Server.MaxConcurrent; got != 4242 {
|
||||
t.Errorf("store max_concurrent = %d, want 4242", got)
|
||||
}
|
||||
// A follow-up GET re-renders the new document, so the editor does not
|
||||
// snap back to the old text.
|
||||
if got := h.get("/api/settings").json(t)["config_yaml"]; got != h.readConfig() {
|
||||
t.Error("GET /api/settings after a write does not match the file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsPutRejects walks the ways a settings write is refused. Each of
|
||||
// them is a case where the operator would otherwise find out from a restart
|
||||
// that the change was lost.
|
||||
func TestSettingsPutRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantField string // substring the error must contain
|
||||
}{
|
||||
{"no body", "", "malformed request body"},
|
||||
{"not json", "{", "malformed request body"},
|
||||
{"unknown field", `{"config_yaml": "server:\n max_concurrent: 1\n", "comment": "x"}`, "malformed request body"},
|
||||
{"empty document", `{"config_yaml": " \n"}`, "config_yaml is empty"},
|
||||
{"broken yaml", `{"config_yaml": "api: [\n"}`, "cannot parse config"},
|
||||
{
|
||||
"invalid config",
|
||||
`{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`,
|
||||
"invalid config",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
before := h.readConfig()
|
||||
res := h.do(http.MethodPut, "/api/settings", tc.body, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusBadRequest {
|
||||
t.Fatalf("PUT /api/settings = %d, want 400: %s", res.status, res.body)
|
||||
}
|
||||
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, tc.wantField) {
|
||||
t.Errorf("error = %q, want it to mention %q", msg, tc.wantField)
|
||||
}
|
||||
if h.readConfig() != before {
|
||||
t.Error("a refused write still touched the config file")
|
||||
}
|
||||
if h.store.Generation() != 0 {
|
||||
t.Error("a refused write still swapped the config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsPutValidationErrorsAreFielded checks the aggregated validator
|
||||
// output reaches the editor as per-field messages, which is the whole point of
|
||||
// writing a config through a form.
|
||||
func TestSettingsPutValidationErrorsAreFielded(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := h.do(http.MethodPut, "/api/settings",
|
||||
`{"config_yaml": "upstream:\n policy: sideways\n servers: []\n"}`, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusBadRequest {
|
||||
t.Fatalf("PUT /api/settings = %d, want 400: %s", res.status, res.body)
|
||||
}
|
||||
fields, _ := res.json(t)["fields"].([]any)
|
||||
if len(fields) < 2 {
|
||||
t.Fatalf("fields = %v, want one message per validation error", fields)
|
||||
}
|
||||
joined := ""
|
||||
for _, f := range fields {
|
||||
s, _ := f.(string)
|
||||
joined += s + "\n"
|
||||
}
|
||||
for _, want := range []string{"upstream.policy", "upstream.servers must not be empty"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("fields %q do not mention %q", joined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsPutRefusedWhenWritesAreDisabled pins allow_config_write: false.
|
||||
// A read-only key and a disabled write are different refusals, and an operator
|
||||
// needs to be able to tell them apart from the 403 text.
|
||||
func TestSettingsPutRefusedWhenWritesAreDisabled(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.API.AllowConfigWrite = false })
|
||||
before := h.readConfig()
|
||||
res := h.do(http.MethodPut, "/api/settings", newDoc(t, nil), "X-API-Key: rw-key")
|
||||
if res.status != http.StatusForbidden {
|
||||
t.Fatalf("PUT /api/settings = %d, want 403: %s", res.status, res.body)
|
||||
}
|
||||
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "disabled") {
|
||||
t.Errorf("error = %q, want it to say writes are disabled", msg)
|
||||
}
|
||||
if h.readConfig() != before {
|
||||
t.Error("a refused write still touched the config file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsPutNeedsAWritableFile pins the 500 path: a valid document that
|
||||
// cannot be persisted must say so rather than answering ok and losing the
|
||||
// change. The harness can produce this without a permissions race by giving
|
||||
// the store no path at all.
|
||||
func TestSettingsPutNeedsAWritableFile(t *testing.T) {
|
||||
h := newHarnessWithFile(t, testConfig(t), "")
|
||||
res := h.do(http.MethodPut, "/api/settings", newDoc(t, nil), "X-API-Key: rw-key")
|
||||
if res.status != http.StatusInternalServerError {
|
||||
t.Fatalf("PUT /api/settings with no file = %d, want 500: %s", res.status, res.body)
|
||||
}
|
||||
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "cannot persist") {
|
||||
t.Errorf("error = %q, want it to say it cannot persist", msg)
|
||||
}
|
||||
if h.store.Generation() != 0 {
|
||||
t.Error("a config that could not be written was swapped in anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsValidate checks the dry run. It must never touch the running
|
||||
// config or the file, and it reports a valid document as valid — including an
|
||||
// empty one, which decodes onto the defaults, so a "clear the editor and
|
||||
// save" lands on a complete default config rather than an error.
|
||||
func TestSettingsValidate(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
before := h.readConfig()
|
||||
|
||||
t.Run("valid document", func(t *testing.T) {
|
||||
res := h.do(http.MethodPost, "/api/settings/validate", newDoc(t, nil), "X-API-Key: rw-key")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
body := res.json(t)
|
||||
if body["ok"] != true {
|
||||
t.Errorf("ok = %v, want true: %v", body["ok"], body["errors"])
|
||||
}
|
||||
if errs, _ := body["errors"].([]any); len(errs) != 0 {
|
||||
t.Errorf("errors = %v, want none", errs)
|
||||
}
|
||||
// Warnings are the point of a validator: a valid config can still be
|
||||
// a bad idea, and this document leaves the blocklist off.
|
||||
if warns, _ := body["warnings"].([]any); len(warns) == 0 {
|
||||
t.Error("no warnings for a document with the blocklist disabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty document is not valid", func(t *testing.T) {
|
||||
// Not the obvious answer, and worth pinning: an empty document
|
||||
// decodes onto config.Default, and the shipped defaults enable the
|
||||
// blocklist without a single source, so Validate rejects them. The
|
||||
// UI therefore cannot be used to "reset to nothing"; a reset to
|
||||
// defaults has to send a real default document.
|
||||
res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": ""}`, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
body := res.json(t)
|
||||
if body["ok"] != false {
|
||||
t.Errorf("ok = %v for an empty document, want false: %v", body["ok"], body["errors"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("broken yaml", func(t *testing.T) {
|
||||
res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": "api: [\n"}`, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("validate = %d, want 200: the answer is a report, not a refusal: %s", res.status, res.body)
|
||||
}
|
||||
body := res.json(t)
|
||||
if body["ok"] != false {
|
||||
t.Errorf("ok = %v, want false", body["ok"])
|
||||
}
|
||||
errs, _ := body["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("errors = %v, want exactly one", errs)
|
||||
}
|
||||
first, _ := errs[0].(map[string]any)
|
||||
if first["field"] != "config" {
|
||||
t.Errorf("field = %v, want \"config\" for a parse error", first["field"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid config", func(t *testing.T) {
|
||||
res := h.do(http.MethodPost, "/api/settings/validate",
|
||||
`{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
body := res.json(t)
|
||||
if body["ok"] != false {
|
||||
t.Fatalf("ok = %v, want false: %s", body["ok"], res.body)
|
||||
}
|
||||
errs, _ := body["errors"].([]any)
|
||||
if len(errs) == 0 {
|
||||
t.Fatal("no errors reported for an invalid config")
|
||||
}
|
||||
first, _ := errs[0].(map[string]any)
|
||||
joined := ""
|
||||
for _, f := range errs {
|
||||
m, _ := f.(map[string]any)
|
||||
msg, _ := m["message"].(string)
|
||||
joined += msg + "\n"
|
||||
}
|
||||
if !strings.Contains(joined, "blocklist is enabled but no lists") {
|
||||
t.Errorf("errors = %q, want the blocklist complaint", joined)
|
||||
}
|
||||
// fieldOf falls back to "config" for a message with no dotted path,
|
||||
// which is what this one is. Pinned so the editor's field highlighting
|
||||
// does not quietly start pointing at a different thing.
|
||||
if first["field"] != "config" {
|
||||
t.Errorf("field = %v, want \"config\" for a message with no path", first["field"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed request body", func(t *testing.T) {
|
||||
if res := h.post("/api/settings/validate"); res.status != http.StatusBadRequest {
|
||||
t.Fatalf("validate with no body = %d, want 400: %s", res.status, res.body)
|
||||
}
|
||||
})
|
||||
|
||||
if h.readConfig() != before {
|
||||
t.Error("validate touched the config file")
|
||||
}
|
||||
if h.store.Generation() != 0 {
|
||||
t.Error("validate swapped the running config")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsReload checks the reload path end to end against a real file: a
|
||||
// changed document on disk reaches the running config, a broken one leaves the
|
||||
// running config alone and records why, and there is no file to reload from at
|
||||
// all when the instance was started without one.
|
||||
func TestSettingsReload(t *testing.T) {
|
||||
t.Run("applies a changed document", func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.writeConfig(t, newYAML(t, func(c *config.Config) { c.Log.Level = "debug" }))
|
||||
gen := h.store.Generation()
|
||||
res := h.post("/api/settings/reload")
|
||||
if res.status != http.StatusOK {
|
||||
t.Fatalf("reload = %d: %s", res.status, res.body)
|
||||
}
|
||||
if body := res.json(t); body["ok"] != true {
|
||||
t.Fatalf("response = %v, want ok", body)
|
||||
}
|
||||
if got := h.store.Get().Log.Level; got != "debug" {
|
||||
t.Errorf("running log level = %q, want debug", got)
|
||||
}
|
||||
if h.store.Generation() != gen+1 {
|
||||
t.Errorf("generation = %d, want %d", h.store.Generation(), gen+1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a broken document keeps the running config", func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
level := h.store.Get().Log.Level
|
||||
gen := h.store.Generation()
|
||||
h.writeConfig(t, "this: is: not: yaml:\n - [\n")
|
||||
res := h.post("/api/settings/reload")
|
||||
if res.status != http.StatusInternalServerError {
|
||||
t.Fatalf("reload of a broken document = %d, want 500: %s", res.status, res.body)
|
||||
}
|
||||
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "reload failed") {
|
||||
t.Errorf("error = %q, want it to say the reload failed", msg)
|
||||
}
|
||||
// A failed reload must not take the resolver down with it: the old
|
||||
// config keeps serving and the reason is reported by /status.
|
||||
if got := h.store.Get().Log.Level; got != level {
|
||||
t.Errorf("running log level = %q, want the previous %q", got, level)
|
||||
}
|
||||
if h.store.Generation() != gen {
|
||||
t.Errorf("generation = %d, want it unchanged at %d", h.store.Generation(), gen)
|
||||
}
|
||||
if h.store.LastError() == "" {
|
||||
t.Error("a failed reload recorded no last_error")
|
||||
}
|
||||
if last := h.get("/api/status").json(t)["config"].(map[string]any)["last_error"]; last == "" {
|
||||
t.Error("/status does not report the failed reload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no config file", func(t *testing.T) {
|
||||
h := newHarnessWithFile(t, testConfig(t), "")
|
||||
res := h.post("/api/settings/reload")
|
||||
if res.status != http.StatusBadRequest {
|
||||
t.Fatalf("reload with no file = %d, want 400: %s", res.status, res.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart pins a known limitation
|
||||
// (STATUS.md, "Auth configuration is frozen at New"): the authenticator, the
|
||||
// base path and the trusted-proxy list are captured when New builds the server,
|
||||
// so a settings write answers ok and the running server keeps the old values.
|
||||
// The write endpoint cannot apply its own auth change; only a restart can.
|
||||
//
|
||||
// This test exists to make the limitation visible, not to endorse it. If the
|
||||
// freezing is ever fixed, this test is the one that should fail, and the fix
|
||||
// has to make each case below actually take effect.
|
||||
func TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart(t *testing.T) {
|
||||
t.Run("api key", func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
doc := newDoc(t, func(c *config.Config) {
|
||||
c.API.Auth = config.Auth{
|
||||
Mode: "apikey",
|
||||
APIKeys: []string{"new-key"},
|
||||
SessionTTL: config.Duration(time.Hour),
|
||||
TrustLoopback: false,
|
||||
ReadOnlyAPIKeys: []string{"ro-key"},
|
||||
}
|
||||
})
|
||||
if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
|
||||
}
|
||||
// The store has the new key...
|
||||
if got := h.store.Get().API.Auth.APIKeys; len(got) != 1 || got[0] != "new-key" {
|
||||
t.Fatalf("store api_keys = %v, want the written key", got)
|
||||
}
|
||||
// ...and the running server still does not: a pinned limitation.
|
||||
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: new-key"); res.status != http.StatusUnauthorized {
|
||||
t.Errorf("the written key = %d, want 401 until a restart (pinned limitation): %s", res.status, res.body)
|
||||
}
|
||||
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||
t.Errorf("the original key = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
// The login endpoint, which is what brute force goes through, is
|
||||
// frozen the same way.
|
||||
res := h.do(http.MethodPost, "/api/auth/login", `{"api_key":"new-key"}`)
|
||||
if res.status != http.StatusUnauthorized {
|
||||
t.Errorf("login with the written key = %d, want 401 until a restart: %s", res.status, res.body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base path", func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
doc := newDoc(t, func(c *config.Config) { c.API.BasePath = "/admin" })
|
||||
if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
|
||||
}
|
||||
if got := h.get("/api/whoami").status; got != http.StatusOK {
|
||||
t.Errorf("the old base path = %d, want 200: the mux is built once (pinned limitation)", got)
|
||||
}
|
||||
if got := h.get("/admin/whoami").status; got != http.StatusNotFound {
|
||||
t.Errorf("the written base path = %d, want 404 until a restart (pinned limitation)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trusted proxies", func(t *testing.T) {
|
||||
// Two requests a minute: the PUT below is the first, so the two
|
||||
// spoofed reads that follow land in the same per-IP bucket as the
|
||||
// loopback client if — and only if — the written trusted_proxies is
|
||||
// not in effect.
|
||||
h := newHarness(t, func(c *config.Config) {
|
||||
c.API.RateLimit = config.AuthRate{PerMinute: 2, Lockout: config.Duration(time.Minute)}
|
||||
})
|
||||
doc := newDoc(t, func(c *config.Config) {
|
||||
c.API.RateLimit = config.AuthRate{PerMinute: 2, Lockout: config.Duration(time.Minute)}
|
||||
// Trust every proxy, which would make any X-Forwarded-For
|
||||
// authoritative if the list were re-read.
|
||||
c.API.TrustedProxies = []string{"0.0.0.0/0"}
|
||||
})
|
||||
if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
|
||||
}
|
||||
if got := h.store.Get().API.TrustedProxies; len(got) != 1 {
|
||||
t.Fatalf("store trusted_proxies = %v", got)
|
||||
}
|
||||
spoof := "X-Forwarded-For: 198.51.100.7"
|
||||
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", spoof); res.status != http.StatusOK {
|
||||
t.Fatalf("first spoofed request = %d, want 200: %s", res.status, res.body)
|
||||
}
|
||||
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", spoof)
|
||||
if res.status != http.StatusTooManyRequests {
|
||||
t.Errorf("the second spoofed request = %d, want 429: the forwarded address is still ignored, so it shares the loopback bucket (pinned limitation): %s",
|
||||
res.status, res.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sseEvent is one parsed SSE frame: the event name and the JSON payload.
|
||||
type sseEvent struct {
|
||||
name string
|
||||
data string
|
||||
}
|
||||
|
||||
// openSSE opens GET /logs/stream and returns a reader for the frames. The
|
||||
// stream stays open until the context the caller passed is cancelled or the
|
||||
// body is closed, so every test here has to do one or the other.
|
||||
func openSSE(t *testing.T, h *harness, ctx context.Context) (*http.Response, *bufio.Reader) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.ts.URL+"/api/logs/stream", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-API-Key", "rw-key")
|
||||
res, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("open the log stream: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
return res, bufio.NewReader(res.Body)
|
||||
}
|
||||
|
||||
// nextEvent reads until a complete frame has arrived. Comment frames (the
|
||||
// keep-alive ": ping") and the blank separator line are skipped.
|
||||
func nextEvent(t *testing.T, br *bufio.Reader) sseEvent {
|
||||
t.Helper()
|
||||
var ev sseEvent
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read the log stream: %v", err)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
ev.name = strings.TrimPrefix(line, "event: ")
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
ev.data = strings.TrimPrefix(line, "data: ")
|
||||
case line == "":
|
||||
if ev.name != "" || ev.data != "" {
|
||||
return ev
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// freshUnsubSignal gives the SSE test its own one-shot unsubscribe channel.
|
||||
// The harness closes a single shared channel through a sync.Once, so without
|
||||
// this only the first stream test in the package could observe it.
|
||||
func freshUnsubSignal(h *harness) <-chan struct{} {
|
||||
h.fake.mu.Lock()
|
||||
defer h.fake.mu.Unlock()
|
||||
h.fake.unsub = make(chan struct{})
|
||||
h.fake.unsubOnce = sync.Once{}
|
||||
return h.fake.unsub
|
||||
}
|
||||
|
||||
// TestSSEStreamHeadersAndEvents pins the SSE contract the live log view
|
||||
// depends on: the streaming content type, the two headers that stop an
|
||||
// intermediary from buffering the stream, a "ready" event so the UI can show it
|
||||
// is connected, and exactly one "entry" event per query-log row.
|
||||
func TestSSEStreamHeadersAndEvents(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
unsub := freshUnsubSignal(h)
|
||||
// Queued before the stream opens: the fake's channel is buffered, so this
|
||||
// is a deterministic ordering and not a race on the subscription.
|
||||
want := []LogEntry{
|
||||
{ID: 1, Name: "a.example.com.", QTypeStr: "A", RCode: 0, Client: "198.51.100.1"},
|
||||
{ID: 2, Name: "b.example.com.", QTypeStr: "AAAA", RCode: 3, Client: "198.51.100.2", Blocked: true},
|
||||
{ID: 3, Name: "c.example.com.", QTypeStr: "TXT", RCode: 0, Client: "198.51.100.3"},
|
||||
}
|
||||
for _, e := range want {
|
||||
h.fake.stream <- e
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
res, br := openSSE(t, h, ctx)
|
||||
|
||||
if got := res.Header.Get("Content-Type"); got != "text/event-stream" {
|
||||
t.Errorf("Content-Type = %q, want text/event-stream", got)
|
||||
}
|
||||
// no-store, not no-cache: this stream is never replayable.
|
||||
if got := res.Header.Get("Cache-Control"); got != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
// Without this an nginx in front buffers the stream and the live view shows
|
||||
// nothing until the buffer fills.
|
||||
if got := res.Header.Get("X-Accel-Buffering"); got != "no" {
|
||||
t.Errorf("X-Accel-Buffering = %q, want no", got)
|
||||
}
|
||||
|
||||
ready := nextEvent(t, br)
|
||||
if ready.name != "ready" {
|
||||
t.Fatalf("first event = %q, want ready", ready.name)
|
||||
}
|
||||
var readyBody map[string]any
|
||||
if err := json.Unmarshal([]byte(ready.data), &readyBody); err != nil {
|
||||
t.Fatalf("ready payload is not JSON: %v: %q", err, ready.data)
|
||||
}
|
||||
if readyBody["version"] != "test" {
|
||||
t.Errorf("ready payload = %v, want it to carry the version", readyBody)
|
||||
}
|
||||
for i, e := range want {
|
||||
got := nextEvent(t, br)
|
||||
if got.name != "entry" {
|
||||
t.Fatalf("event %d = %q, want entry", i, got.name)
|
||||
}
|
||||
var gotEntry LogEntry
|
||||
if err := json.Unmarshal([]byte(got.data), &gotEntry); err != nil {
|
||||
t.Fatalf("entry payload is not JSON: %v: %q", err, got.data)
|
||||
}
|
||||
if gotEntry.ID != e.ID || gotEntry.Name != e.Name || gotEntry.Client != e.Client {
|
||||
t.Errorf("entry %d = %+v, want %+v", i, gotEntry, e)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
waitFor(t, 2*time.Second, "the subscriber to be released", func() bool {
|
||||
return h.fake.subscribers() == 0
|
||||
})
|
||||
select {
|
||||
case <-unsub:
|
||||
default:
|
||||
t.Error("the handler returned without running the unsubscribe hook")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEStreamUnsubscribesOnClientDisconnect checks a client that goes away
|
||||
// does not leave a handler parked on the log channel: the request context is
|
||||
// cancelled when the connection drops, and the handler must return and run its
|
||||
// cancel hook, or every reconnect leaks a goroutine and a log subscriber.
|
||||
func TestSSEStreamUnsubscribesOnClientDisconnect(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
unsub := freshUnsubSignal(h)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
_, br := openSSE(t, h, ctx)
|
||||
if got := nextEvent(t, br); got.name != "ready" {
|
||||
t.Fatalf("first event = %q, want ready", got.name)
|
||||
}
|
||||
waitFor(t, 2*time.Second, "the handler to subscribe", func() bool {
|
||||
return h.fake.subscribers() == 1
|
||||
})
|
||||
|
||||
// Disconnect: dropping the request context closes the connection, which is
|
||||
// what the server sees when a browser tab is closed.
|
||||
cancel()
|
||||
|
||||
waitFor(t, 2*time.Second, "the handler to unsubscribe", func() bool {
|
||||
return h.fake.subscribers() == 0
|
||||
})
|
||||
select {
|
||||
case <-unsub:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the unsubscribe hook never ran after the client disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEStreamLeavesNoGoroutinesBehind opens and closes the stream repeatedly
|
||||
// and asserts the goroutine count stays bounded. The bound, not an exact count:
|
||||
// the HTTP client and server keep per-connection goroutines that wind down on
|
||||
// their own, and a test that demands an exact number is a flake. The property
|
||||
// worth having is that the count does not grow with the number of streams.
|
||||
func TestSSEStreamLeavesNoGoroutinesBehind(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
runtime.GC() // keep the baseline honest: the count is what it is after a GC
|
||||
before := runtime.NumGoroutine()
|
||||
for i := 0; i < 25; i++ {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
_, br := openSSE(t, h, ctx)
|
||||
if got := nextEvent(t, br); got.name != "ready" {
|
||||
cancel()
|
||||
t.Fatalf("stream %d: first event = %q, want ready", i, got.name)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
waitFor(t, 2*time.Second, "every subscriber to be released", func() bool {
|
||||
return h.fake.subscribers() == 0
|
||||
})
|
||||
const bound = 8 // per-connection goroutines that may still be winding down
|
||||
waitFor(t, 3*time.Second, "the goroutine count to settle", func() bool {
|
||||
return runtime.NumGoroutine() <= before+bound
|
||||
})
|
||||
}
|
||||
|
||||
// TestSSEStreamEndsWhenTheSourceCloses covers the other exit from the handler
|
||||
// loop: the hook's channel being closed, which is what a shutting-down log ring
|
||||
// does. The response has to end rather than hang, and the unsubscribe hook must
|
||||
// still run — a handler that only returns on the request context is a handler
|
||||
// that leaks when the producer goes away.
|
||||
func TestSSEStreamEndsWhenTheSourceCloses(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
unsub := freshUnsubSignal(h)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Closing the hook's channel is what a shutting-down log ring does.
|
||||
close(h.fake.stream)
|
||||
_, br := openSSE(t, h, ctx)
|
||||
if got := nextEvent(t, br); got.name != "ready" {
|
||||
t.Fatalf("first event = %q, want ready", got.name)
|
||||
}
|
||||
if _, err := br.ReadString('\n'); err == nil {
|
||||
t.Error("the stream carried on after its source closed")
|
||||
}
|
||||
waitFor(t, 2*time.Second, "the handler to unsubscribe", func() bool {
|
||||
return h.fake.subscribers() == 0
|
||||
})
|
||||
select {
|
||||
case <-unsub:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the unsubscribe hook never ran after the source closed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSEStreamRequiresAuth checks the stream is behind the same auth as the
|
||||
// rest of the API: it is a live view of every client address and domain the
|
||||
// instance sees, so an unauthenticated subscribe is a data leak, not a
|
||||
// cosmetic one.
|
||||
func TestSSEStreamRequiresAuth(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := h.do(http.MethodGet, "/api/logs/stream", "")
|
||||
if res.status != http.StatusUnauthorized {
|
||||
t.Fatalf("GET /api/logs/stream without a key = %d, want 401: %s", res.status, res.body)
|
||||
}
|
||||
if h.fake.count("SubscribeLog") != 0 {
|
||||
t.Error("an unauthenticated request reached the subscribe hook")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExampleRPZZoneBlocksWhatItSays runs the shipped policy zone through the
|
||||
// exact path Manager.RefreshAll uses (parseRPZFiles, which prepends a per-file
|
||||
// $ORIGIN derived from the file name) and pins the rules it produces.
|
||||
//
|
||||
// The origin is the reason this is a test and not a doc: a file called
|
||||
// local.rpz.zone.example is read with the origin "local.", so the relative owner
|
||||
// `*.doubleclick.net` became `*.doubleclick.net.local.` and the example blocked
|
||||
// nothing. The example now spells every owner absolutely; if someone "tidies it
|
||||
// up" back to relative names, the rules below stop matching and this fails.
|
||||
func TestExampleRPZZoneBlocksWhatItSays(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "zones", "local.rpz.zone.example")
|
||||
|
||||
rules, stats, err := parseRPZFiles([]string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("parseRPZFiles(%s): %v", path, err)
|
||||
}
|
||||
|
||||
type ruleWant struct {
|
||||
name string
|
||||
action Action
|
||||
response string
|
||||
}
|
||||
want := []ruleWant{
|
||||
{"*.doubleclick.net.", ActionNXDomain, ""},
|
||||
{"*.ads.example.com.", ActionNXDomain, ""},
|
||||
{"*.tracker.example.net.", ActionNXDomain, ""},
|
||||
// A bare owner name in a policy zone is given the subdomain form,
|
||||
// which covers the name's children but not the name itself; see
|
||||
// TestExampleRPZZoneApexIsNotCovered.
|
||||
{"*.analytics.example.org.", ActionNXDomain, ""},
|
||||
{"*.noisy-cdn.example.", ActionDrop, ""},
|
||||
{"*.metrics.internal.", ActionPassthrough, ""},
|
||||
{"*.blocked.example.", ActionRedirect, "192.0.2.10"},
|
||||
}
|
||||
got := make([]ruleWant, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
got = append(got, ruleWant{r.Name, r.Action, r.Response})
|
||||
if r.TTL != 60 {
|
||||
t.Errorf("rule %q has TTL %d, want the zone's $TTL 60", r.Name, r.TTL)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("rules mismatch\n got: %+v\nwant: %+v", got, want)
|
||||
}
|
||||
if stats.Rules != len(want) {
|
||||
t.Errorf("stats.Rules = %d, want %d", stats.Rules, len(want))
|
||||
}
|
||||
// The A and the AAAA share an owner, so one of them is deduplicated
|
||||
// rather than compiled twice.
|
||||
if stats.Duplicates != 1 {
|
||||
t.Errorf("stats.Duplicates = %d, want 1 (the second blocked.example. record)", stats.Duplicates)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleRPZZoneMatchesTheNamesItNames builds the Set an instance would
|
||||
// actually consult, so the pin above is about lookups and not only about the
|
||||
// parser's output. A rule that exists but does not match is still a blocklist
|
||||
// that does nothing.
|
||||
func TestExampleRPZZoneMatchesTheNamesItNames(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "zones", "local.rpz.zone.example")
|
||||
rules, _, err := parseRPZFiles([]string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("parseRPZFiles(%s): %v", path, err)
|
||||
}
|
||||
b := NewBuilder()
|
||||
for _, r := range rules {
|
||||
b.Add(r, "local-rpz", nil)
|
||||
}
|
||||
set := b.Build()
|
||||
|
||||
blocked := []string{
|
||||
"www.doubleclick.net.",
|
||||
"a.b.doubleclick.net.",
|
||||
// These three carry an explicit "*." owner in the example, so only
|
||||
// names below them are covered; the apex gap is pinned separately.
|
||||
"cdn.ads.example.com.",
|
||||
"pixel.tracker.example.net.",
|
||||
"cdn.noisy-cdn.example.",
|
||||
}
|
||||
for _, name := range blocked {
|
||||
e := set.Match(name)
|
||||
if e == nil {
|
||||
t.Errorf("%s is not matched by the example policy zone", name)
|
||||
continue
|
||||
}
|
||||
if e.IsAllow() {
|
||||
t.Errorf("%s matched an allow rule, want a block", name)
|
||||
}
|
||||
}
|
||||
if e := set.Match("x.metrics.internal."); e == nil || !e.IsAllow() {
|
||||
t.Errorf("x.metrics.internal. = %v, want the rpz-passthru allow rule", e)
|
||||
}
|
||||
if e := set.Match("unrelated.example."); e != nil {
|
||||
t.Errorf("unrelated.example. matched %v, want no rule", e)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleRPZZoneApexIsNotCovered pins a real gap rather than an intention.
|
||||
//
|
||||
// An RPZ record names the domain it applies to, and in RPZ semantics that means
|
||||
// the domain and everything below it. rpzRule gives a bare owner name the
|
||||
// subdomain form ("*.name"), and a subdomain-form rule does not match the name
|
||||
// itself — so `analytics.example.org.` in a policy zone blocks
|
||||
// sub.analytics.example.org. and leaves analytics.example.org. resolving
|
||||
// normally. The apex form ("**.name") is what covers both.
|
||||
//
|
||||
// Not changed here: it alters which queries get blocked for anyone already
|
||||
// running a policy zone, and the previous round's decision was to pin the
|
||||
// open questions rather than change behaviour. If this is fixed, the fix is one
|
||||
// character in rpzRule ("*." -> "**.") plus this test and the STATUS.md entry.
|
||||
func TestExampleRPZZoneApexIsNotCovered(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "zones", "local.rpz.zone.example")
|
||||
rules, _, err := parseRPZFiles([]string{path})
|
||||
if err != nil {
|
||||
t.Fatalf("parseRPZFiles(%s): %v", path, err)
|
||||
}
|
||||
b := NewBuilder()
|
||||
for _, r := range rules {
|
||||
b.Add(r, "local-rpz", nil)
|
||||
}
|
||||
set := b.Build()
|
||||
|
||||
for _, name := range []string{"doubleclick.net.", "ads.example.com.", "analytics.example.org."} {
|
||||
if e := set.Match(name); e != nil {
|
||||
t.Errorf("%s matched %v; a policy record for a domain is expected to cover "+
|
||||
"the domain itself as well as its subdomains (see STATUS.md)", name, e)
|
||||
}
|
||||
}
|
||||
// The same gap on the other side: an rpz-passthru. for the apex does not
|
||||
// allowlist the apex, so a name on the blocklist stays blocked.
|
||||
if e := set.Match("metrics.internal."); e != nil && e.IsAllow() {
|
||||
t.Errorf("metrics.internal. matched the allow rule %v; the apex of an "+
|
||||
"rpz-passthru. record is not covered", e)
|
||||
}
|
||||
}
|
||||
+31
-11
@@ -7,6 +7,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -189,7 +190,14 @@ func (c *Cluster) Start(ctx context.Context) {
|
||||
c.connected.Store(true)
|
||||
c.log.Info("cluster connected", "addrs", cfg.Cluster.Valkey.Addrs, "node", c.node)
|
||||
|
||||
// Attempt the lease immediately rather than on the first heartbeat tick.
|
||||
// The claim this package makes is "only the lease holder fetches
|
||||
// blocklists", and at startup nobody holds it: waiting half a peer_ttl (7.5
|
||||
// s at the default 15s) means every instance in a fresh rollout fetches
|
||||
// every list from every mirror at once, which is the thundering herd the
|
||||
// lease exists to prevent.
|
||||
c.publishState(ctx)
|
||||
c.renewLease(ctx)
|
||||
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
@@ -279,7 +287,17 @@ func (c *Cluster) runSession(ctx context.Context) error {
|
||||
case <-c.stop:
|
||||
return nil
|
||||
case <-hb.C:
|
||||
c.publishState(ctx)
|
||||
// A failed heartbeat is the only evidence of an outage: the
|
||||
// subscription reconnects behind our back, so runSession keeps
|
||||
// running and nothing else in this loop would ever notice. Without
|
||||
// this, /status and the console claim a Valkey connection that
|
||||
// stopped working, and a diverged peer would look healthy.
|
||||
if err := c.publishState(ctx); err != nil {
|
||||
c.connected.Store(false)
|
||||
c.log.Warn("cluster heartbeat failed", "error", err)
|
||||
continue
|
||||
}
|
||||
c.connected.Store(true)
|
||||
c.renewLease(ctx)
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
@@ -354,17 +372,20 @@ func (c *Cluster) State() *NodeState {
|
||||
return st
|
||||
}
|
||||
|
||||
// publishState broadcasts this instance's heartbeat.
|
||||
func (c *Cluster) publishState(ctx context.Context) {
|
||||
// publishState broadcasts this instance's heartbeat. It reports whether the
|
||||
// write reached Valkey: the heartbeat is the only liveness signal this package
|
||||
// has, because go-redis's Subscribe reconnects transparently and runSession
|
||||
// never sees the connection drop (see the connected flag in runSession).
|
||||
func (c *Cluster) publishState(ctx context.Context) error {
|
||||
cl := c.client.Load()
|
||||
if cl == nil {
|
||||
return
|
||||
return errors.New("cluster: no client")
|
||||
}
|
||||
st := c.State()
|
||||
ev := Event{Kind: EventHeartbeat, NodeID: c.node, At: time.Now(), State: st}
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
return err
|
||||
}
|
||||
pipe := cl.Pipeline()
|
||||
pipe.Publish(ctx, c.channel(EventHeartbeat), data)
|
||||
@@ -372,7 +393,10 @@ func (c *Cluster) publishState(ctx context.Context) {
|
||||
// existing set without waiting a full heartbeat interval.
|
||||
pipe.HSet(ctx, c.heartbeatKey(), c.node, mustJSON(st))
|
||||
pipe.Expire(ctx, c.heartbeatKey(), c.cfg.Get().Cluster.PeerTTL.D()*4)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
@@ -623,8 +647,4 @@ func (c *Cluster) Stop() {
|
||||
// String renders the node id for logs.
|
||||
func (c *Cluster) String() string { return strings.TrimSpace(c.node) }
|
||||
|
||||
func cryptoRead(b []byte) (int, error) {
|
||||
h := sha1.Sum([]byte(time.Now().String() + string(b)))
|
||||
copy(b, h[:4])
|
||||
return len(b), nil
|
||||
}
|
||||
func cryptoRead(b []byte) (int, error) { return rand.Read(b) }
|
||||
@@ -0,0 +1,905 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. A disabled cluster is a no-op.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDisabledClusterIsANoOp(t *testing.T) {
|
||||
h := newHarness(t, func(c *config.Config) { c.Cluster.Enabled = false })
|
||||
ctx := context.Background()
|
||||
// The handler counts calls, so "does not touch Redis" can be checked
|
||||
// together with "does not call the handler either".
|
||||
var calls int
|
||||
var mu sync.Mutex
|
||||
c := New(Options{
|
||||
Config: h.store,
|
||||
Logger: discardLogger(),
|
||||
Version: "test",
|
||||
Handler: func(context.Context, Event) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
},
|
||||
})
|
||||
t.Cleanup(c.Stop)
|
||||
before := h.mini.CommandCount()
|
||||
|
||||
c.Start(ctx)
|
||||
|
||||
if c.Enabled() {
|
||||
t.Fatal("Enabled() is true with cluster.enabled = false")
|
||||
}
|
||||
if c.Connected() {
|
||||
t.Fatal("Connected() is true for a disabled cluster")
|
||||
}
|
||||
if c.IsLeader() {
|
||||
t.Fatal("IsLeader() is true for a disabled cluster")
|
||||
}
|
||||
// Every broadcast is a silent no-op: no error, no Redis command.
|
||||
if err := c.BroadcastConfig(ctx, h.cfg); err != nil {
|
||||
t.Fatalf("BroadcastConfig on a disabled cluster: %v", err)
|
||||
}
|
||||
if err := c.BroadcastReload(ctx); err != nil {
|
||||
t.Fatalf("BroadcastReload on a disabled cluster: %v", err)
|
||||
}
|
||||
if err := c.BroadcastRefresh(ctx, []string{"ads"}); err != nil {
|
||||
t.Fatalf("BroadcastRefresh on a disabled cluster: %v", err)
|
||||
}
|
||||
if got := h.mini.CommandCount(); got != before {
|
||||
t.Errorf("a disabled cluster sent %d commands to Valkey, want 0", got-before)
|
||||
}
|
||||
if h.mini.CurrentConnectionCount() != 0 {
|
||||
t.Error("a disabled cluster opened a Valkey connection")
|
||||
}
|
||||
|
||||
st := c.Status(ctx)
|
||||
if st.Enabled || st.Connected || st.Leader || st.LeaseOwner != "" {
|
||||
t.Errorf("Status reports a live cluster: %+v", st)
|
||||
}
|
||||
if len(st.Peers) != 0 || len(st.Divergent) != 0 {
|
||||
t.Errorf("Status reports peers for a disabled cluster: %+v", st)
|
||||
}
|
||||
if c.State() == nil {
|
||||
t.Error("State() is nil for a disabled cluster")
|
||||
}
|
||||
if len(c.Peers()) != 0 {
|
||||
t.Errorf("Peers() is not empty for a disabled cluster: %+v", c.Peers())
|
||||
}
|
||||
if got := c.Discover(ctx); len(got) != 0 {
|
||||
t.Errorf("Discover() = %+v, want empty for a disabled cluster", got)
|
||||
}
|
||||
if c.NodeID() == "" {
|
||||
t.Error("NodeID() is empty")
|
||||
}
|
||||
mu.Lock()
|
||||
n := calls
|
||||
mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Errorf("handler called %d times by a disabled cluster", n)
|
||||
}
|
||||
// Stop must be safe twice: a shutdown path and a test teardown both call it.
|
||||
c.Stop()
|
||||
c.Stop()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. The leader lease. Only the lease holder fetches blocklists, so N
|
||||
// instances do not hit a mirror N times.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOnlyTheLeaseHolderFetchesBlocklists(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
a := h.start("a", nil)
|
||||
b := h.start("b", nil)
|
||||
// Both nodes share one config, so both use the same channel names: one
|
||||
// check covers them.
|
||||
waitSubscribed(t, h.mini, a)
|
||||
|
||||
ctx := context.Background()
|
||||
// Sample the invariant over time rather than at one instant: at every
|
||||
// sample at most one node claims the lease, and one of them does.
|
||||
sawLeader := false
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
switch n := leaderCount(a, b); n {
|
||||
case 0:
|
||||
case 1:
|
||||
sawLeader = true
|
||||
default:
|
||||
t.Fatalf("%d nodes claim the refresh lease at once; only the holder may fetch lists", n)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !sawLeader {
|
||||
t.Fatal("neither node ever claimed the refresh lease")
|
||||
}
|
||||
// The identity that comes back from Valkey is the one that claims it.
|
||||
holder := a.LeaseHolder(ctx)
|
||||
if holder == "" {
|
||||
t.Fatal("LeaseHolder() is empty although a node is the leader")
|
||||
}
|
||||
if holder != b.LeaseHolder(ctx) {
|
||||
t.Fatalf("the two nodes disagree about the lease holder: %q vs %q", holder, b.LeaseHolder(ctx))
|
||||
}
|
||||
if holder != a.NodeID() && holder != b.NodeID() {
|
||||
t.Fatalf("lease holder %q is neither node id", holder)
|
||||
}
|
||||
if got := keyValue(t, h.mini, a.leaseKey()); got != holder {
|
||||
t.Errorf("lease key %q holds %q, want the node id %q", a.leaseKey(), got, holder)
|
||||
}
|
||||
// The node that is not the holder must agree.
|
||||
leader := a
|
||||
other := b
|
||||
if !a.IsLeader() {
|
||||
leader, other = b, a
|
||||
}
|
||||
if other.IsLeader() {
|
||||
t.Fatalf("both %s and %s claim the lease", leader.NodeID(), other.NodeID())
|
||||
}
|
||||
if got := leader.LeaseHolder(ctx); got != leader.NodeID() {
|
||||
t.Errorf("LeaseHolder() = %q, want the leader's own id %q", got, leader.NodeID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderLeaseIsRenewedBeforeItExpires(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := h.start("a", nil)
|
||||
ctx := context.Background()
|
||||
waitFor(t, "the node to take the lease", 2*time.Second, n.IsLeader)
|
||||
|
||||
// miniredis expires keys against its own clock, so a lapse is simulated
|
||||
// with FastForward rather than by sleeping: the point is that the holder
|
||||
// refreshes the TTL, not that the test is slow. Each round advances
|
||||
// miniredis past the lease TTL while the node keeps running.
|
||||
for round := 0; round < 4; round++ {
|
||||
h.mini.FastForward(testLeaderTTL + 500*time.Millisecond)
|
||||
if !n.IsLeader() {
|
||||
t.Fatalf("round %d: the holder stopped claiming a lease it still owns", round)
|
||||
}
|
||||
// The renewal is a real SET with a TTL against a real clock, and
|
||||
// miniredis' clock is ours to move rather than the node's — so the key
|
||||
// does go away when we fast-forward past its TTL, and the assertion
|
||||
// that matters is that the holder takes it straight back without ever
|
||||
// reporting otherwise. Checking for it before the tick would pass or
|
||||
// fail on nothing but the sleep.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !h.mini.Exists(n.leaseKey()) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("round %d: the lease key never came back while the holder was alive", round)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := keyValue(t, h.mini, n.leaseKey()); got != n.NodeID() {
|
||||
t.Fatalf("round %d: lease key holds %q, want %q", round, got, n.NodeID())
|
||||
}
|
||||
}
|
||||
if got := h.mini.TTL(n.leaseKey()); got <= 0 {
|
||||
t.Errorf("lease key TTL = %s, want a live TTL", got)
|
||||
}
|
||||
if got := n.LeaseHolder(ctx); got != n.NodeID() {
|
||||
t.Errorf("LeaseHolder() = %q after renewal, want %q", got, n.NodeID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStolenLeaderLeaseStopsReportingLeadership(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := h.start("a", nil)
|
||||
ctx := context.Background()
|
||||
waitFor(t, "the node to take the lease", 2*time.Second, n.IsLeader)
|
||||
|
||||
// Another node (or a flushed key) takes the lease out from under it.
|
||||
h.mini.Set(n.leaseKey(), "some-other-node")
|
||||
if got := n.LeaseHolder(ctx); got != "some-other-node" {
|
||||
t.Fatalf("LeaseHolder() = %q right after the steal, want the new holder", got)
|
||||
}
|
||||
waitFor(t, "the old holder to stop claiming leadership", 2*time.Second, func() bool { return !n.IsLeader() })
|
||||
if st := n.Status(ctx); st.Leader {
|
||||
t.Error("Status still reports Leader after the lease was stolen")
|
||||
}
|
||||
if st := n.State(); st.Leader {
|
||||
t.Error("State() still reports Leader after the lease was stolen")
|
||||
}
|
||||
// With the key gone the node may take it again, and must then claim it.
|
||||
h.mini.Del(n.leaseKey())
|
||||
waitFor(t, "the lease to be re-acquired after the key was removed", 2*time.Second, n.IsLeader)
|
||||
if got := n.LeaseHolder(ctx); got != n.NodeID() {
|
||||
t.Errorf("LeaseHolder() = %q, want %q", got, n.NodeID())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Leader handoff.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestLeaderHandoffElectsExactlyOneNewLeader(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
a := h.start("a", nil)
|
||||
b := h.start("b", nil)
|
||||
c := h.start("c", nil)
|
||||
all := []*node{a, b, c}
|
||||
waitSubscribed(t, h.mini, a)
|
||||
|
||||
// Wait for the fleet to settle on one leader, then note who it is: the
|
||||
// point of the test is that the *other two* elect exactly one successor,
|
||||
// not which one wins the race.
|
||||
var first *node
|
||||
waitFor(t, "a single leader in the fleet", 3*time.Second, func() bool {
|
||||
n := leaderCount(all...)
|
||||
if n != 1 {
|
||||
if n > 1 {
|
||||
t.Fatalf("%d nodes claim the lease at once", n)
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, nd := range all {
|
||||
if nd.IsLeader() {
|
||||
first = nd
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// A clean stop releases the lease; the survivors must elect one of
|
||||
// themselves.
|
||||
first.Stop()
|
||||
survivors := []*node{}
|
||||
for _, nd := range all {
|
||||
if nd != first {
|
||||
survivors = append(survivors, nd)
|
||||
}
|
||||
}
|
||||
waitFor(t, "exactly one of the survivors to become leader", 3*time.Second, func() bool {
|
||||
n := leaderCount(survivors...)
|
||||
if n > 1 {
|
||||
t.Fatalf("%d survivors claim the lease at once", n)
|
||||
}
|
||||
return n == 1
|
||||
})
|
||||
var successor *node
|
||||
for _, nd := range survivors {
|
||||
if nd.IsLeader() {
|
||||
successor = nd
|
||||
}
|
||||
}
|
||||
if got := successor.LeaseHolder(context.Background()); got != successor.NodeID() {
|
||||
t.Errorf("successor %q sees holder %q", successor.NodeID(), got)
|
||||
}
|
||||
if first.IsLeader() {
|
||||
t.Error("the stopped node still claims the lease")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaderHandoffAfterTheLeaseLapses covers the unclean case: the holder is
|
||||
// killed, so it never releases the lease and never says goodbye. miniredis's
|
||||
// clock is what lets the test move on.
|
||||
func TestLeaderHandoffAfterTheLeaseLapses(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
a := h.start("a", nil)
|
||||
b := h.start("b", nil)
|
||||
c := h.start("c", nil)
|
||||
all := []*node{a, b, c}
|
||||
waitSubscribed(t, h.mini, a)
|
||||
var first *node
|
||||
waitFor(t, "a single leader in the fleet", 3*time.Second, func() bool {
|
||||
if n := leaderCount(all...); n != 1 {
|
||||
return false
|
||||
}
|
||||
for _, nd := range all {
|
||||
if nd.IsLeader() {
|
||||
first = nd
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
first.kill()
|
||||
// The lease outlives the process that held it: expire it the way the
|
||||
// Valkey clock would.
|
||||
h.mini.FastForward(testLeaderTTL + time.Second)
|
||||
|
||||
survivors := []*node{}
|
||||
for _, nd := range all {
|
||||
if nd != first {
|
||||
survivors = append(survivors, nd)
|
||||
}
|
||||
}
|
||||
waitFor(t, "exactly one survivor to take the expired lease", 4*time.Second, func() bool {
|
||||
n := leaderCount(survivors...)
|
||||
if n > 1 {
|
||||
t.Fatalf("%d survivors claim the lease at once", n)
|
||||
}
|
||||
return n == 1
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Pub/sub config sync.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBroadcastConfigReachesPeersWithAMatchingDigest(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
receiver := h.start("recv", nil)
|
||||
sender := h.start("send", nil)
|
||||
waitSubscribed(t, h.mini, receiver)
|
||||
waitSubscribed(t, h.mini, sender)
|
||||
ctx := context.Background()
|
||||
|
||||
next := *h.cfg
|
||||
next.Cache.Size = 12345
|
||||
next.Upstream.Servers = []config.UpstreamServer{{Addr: "9.9.9.10:53", Proto: "udp", Weight: 1}}
|
||||
next.Version = 7
|
||||
if err := next.Validate(); err != nil {
|
||||
t.Fatalf("broadcast config does not pass Validate: %v", err)
|
||||
}
|
||||
|
||||
if err := sender.BroadcastConfig(ctx, &next); err != nil {
|
||||
t.Fatalf("BroadcastConfig: %v", err)
|
||||
}
|
||||
ev := receiver.waitEvent(EventConfig, 3*time.Second)
|
||||
|
||||
if ev.NodeID != sender.NodeID() {
|
||||
t.Errorf("event node id = %q, want the sender's %q", ev.NodeID, sender.NodeID())
|
||||
}
|
||||
if ev.Version != next.Version {
|
||||
t.Errorf("event version = %d, want %d", ev.Version, next.Version)
|
||||
}
|
||||
if ev.At.IsZero() {
|
||||
t.Error("event carries no timestamp")
|
||||
}
|
||||
if ev.Config == "" {
|
||||
t.Fatal("event carries no configuration document")
|
||||
}
|
||||
// The payload is a config document, so the digest of what the peer
|
||||
// received must equal the digest of what the sender published.
|
||||
got := config.Default()
|
||||
if err := yaml.Unmarshal([]byte(ev.Config), got); err != nil {
|
||||
t.Fatalf("peer payload is not a config document: %v", err)
|
||||
}
|
||||
if d1, d2 := Digest(&next), Digest(got); d1 != d2 {
|
||||
t.Errorf("digest of the published config %q != digest of the received copy %q", d1, d2)
|
||||
}
|
||||
// A node must not act on its own broadcast: the app's handler would
|
||||
// otherwise reload itself in a loop.
|
||||
if n := sender.countEvents(EventConfig); n != 0 {
|
||||
t.Errorf("the sender received %d of its own config events", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastReloadAndRefreshCarryTheirKind(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
receiver := h.start("recv", nil)
|
||||
sender := h.start("send", nil)
|
||||
waitSubscribed(t, h.mini, receiver)
|
||||
waitSubscribed(t, h.mini, sender)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := sender.BroadcastReload(ctx); err != nil {
|
||||
t.Fatalf("BroadcastReload: %v", err)
|
||||
}
|
||||
ev := receiver.waitEvent(EventReload, 3*time.Second)
|
||||
if ev.NodeID != sender.NodeID() {
|
||||
t.Errorf("reload event node id = %q, want %q", ev.NodeID, sender.NodeID())
|
||||
}
|
||||
if ev.Config != "" {
|
||||
t.Error("a reload event must not carry a config document")
|
||||
}
|
||||
|
||||
if err := sender.BroadcastRefresh(ctx, []string{"ads", "tracking"}); err != nil {
|
||||
t.Fatalf("BroadcastRefresh: %v", err)
|
||||
}
|
||||
ev = receiver.waitEvent(EventBlocklists, 3*time.Second)
|
||||
if got, want := strings.Join(ev.Sources, ","), "ads,tracking"; got != want {
|
||||
t.Errorf("refresh sources = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastsGoToTheKindChannel pins the channel names, because a publish to
|
||||
// the wrong channel is invisible: no subscriber is listening there, so the
|
||||
// event is simply dropped.
|
||||
func TestBroadcastsGoToTheKindChannel(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := h.start("a", nil)
|
||||
ctx := context.Background()
|
||||
want := map[EventKind]string{
|
||||
EventConfig: "fdtest_TestBroadcastsGoToTheKindChannel:events:config",
|
||||
EventReload: "fdtest_TestBroadcastsGoToTheKindChannel:events:reload",
|
||||
EventBlocklists: "fdtest_TestBroadcastsGoToTheKindChannel:events:blocklists",
|
||||
EventHeartbeat: "fdtest_TestBroadcastsGoToTheKindChannel:events:heartbeat",
|
||||
EventBye: "fdtest_TestBroadcastsGoToTheKindChannel:events:bye",
|
||||
}
|
||||
for k, ch := range want {
|
||||
if got := n.channel(k); got != ch {
|
||||
t.Errorf("channel(%q) = %q, want %q", k, got, ch)
|
||||
}
|
||||
}
|
||||
if got := n.heartbeatKey(); got != "fdtest_TestBroadcastsGoToTheKindChannel:nodes" {
|
||||
t.Errorf("heartbeatKey() = %q", got)
|
||||
}
|
||||
if got := n.leaseKey(); got != "fdtest_TestBroadcastsGoToTheKindChannel:leader" {
|
||||
t.Errorf("leaseKey() = %q", got)
|
||||
}
|
||||
// A publish to a channel nobody subscribes to must be reported as such
|
||||
// by the server, otherwise a mis-typed channel would look like a delivery.
|
||||
cl := n.client.Load()
|
||||
if cl == nil {
|
||||
t.Fatal("no client")
|
||||
}
|
||||
for k, ch := range want {
|
||||
if k == EventHeartbeat {
|
||||
continue // this node heartbeats here
|
||||
}
|
||||
if err := cl.Publish(ctx, ch, "probe").Err(); err != nil {
|
||||
t.Fatalf("publish to %s: %v", ch, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Divergence detection.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestStatusReportsDivergentPeers pins the exact rule Status applies: a peer is
|
||||
// divergent when both sides publish a non-empty blocklist digest and the two
|
||||
// differ. The config digest is published for the UI but is *not* part of the
|
||||
// rule (see TestDigestIsAHashOfTheWholeDocument).
|
||||
func TestStatusReportsDivergentPeers(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
da := &digests{}
|
||||
db := &digests{}
|
||||
da.set("rules-a", 10)
|
||||
db.set("rules-b", 20)
|
||||
a := h.start("a", func(o *Options) { o.Digests = da.fn })
|
||||
b := h.start("b", func(o *Options) { o.Digests = db.fn })
|
||||
waitSubscribed(t, h.mini, a)
|
||||
waitSubscribed(t, h.mini, b)
|
||||
ctx := context.Background()
|
||||
|
||||
// Different digests: each must name the other.
|
||||
waitFor(t, "a to report b as divergent", 3*time.Second, func() bool {
|
||||
return contains(a.Status(ctx).Divergent, b.NodeID())
|
||||
})
|
||||
waitFor(t, "b to report a as divergent", 3*time.Second, func() bool {
|
||||
return contains(b.Status(ctx).Divergent, a.NodeID())
|
||||
})
|
||||
|
||||
// Same digests: neither may be reported. The heartbeat interval is what
|
||||
// carries the new value across, so wait for the report to clear.
|
||||
db.set("rules-a", 10)
|
||||
waitFor(t, "b's divergence to clear once the digests match", 3*time.Second, func() bool {
|
||||
return len(b.Status(ctx).Divergent) == 0
|
||||
})
|
||||
waitFor(t, "a's divergence to clear once the digests match", 3*time.Second, func() bool {
|
||||
return len(a.Status(ctx).Divergent) == 0
|
||||
})
|
||||
// And a peer with no blocklist at all (empty digest) is not divergence:
|
||||
// the rule requires both sides to be non-empty.
|
||||
db.set("", 0)
|
||||
waitFor(t, "b's state to be republished with an empty digest", 3*time.Second, func() bool {
|
||||
return peerDigest(b, a.NodeID()) == ""
|
||||
})
|
||||
if got := a.Status(ctx).Divergent; len(got) != 0 {
|
||||
t.Errorf("a peer with no blocklist is reported divergent: %v", got)
|
||||
}
|
||||
if got := b.Status(ctx).Divergent; len(got) != 0 {
|
||||
t.Errorf("a node with no blocklist reports divergence: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Digest.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDigestIsAHashOfTheWholeDocument pins exactly what Digest covers, because
|
||||
// the interesting property is which fields are in and which are out.
|
||||
//
|
||||
// IN: every field of the marshalled document - blocklist lists and order, ACL
|
||||
// groups, upstreams, log level, and the cluster section itself (node id,
|
||||
// valkey addresses, TTLs).
|
||||
// OUT: nothing. Two configs that differ in any single field hash differently.
|
||||
//
|
||||
// The consequence, which is why divergence is computed on the *blocklist*
|
||||
// digest and not on this one: every instance runs its own node id and
|
||||
// advertise address, so two healthy instances of the same release can never
|
||||
// agree on a config digest.
|
||||
func TestDigestIsAHashOfTheWholeDocument(t *testing.T) {
|
||||
base := config.Default()
|
||||
// Stability: the same content hashes the same, twice and from two
|
||||
// separately built documents.
|
||||
if Digest(base) != Digest(base) {
|
||||
t.Error("Digest is not stable for the same *Config")
|
||||
}
|
||||
other := config.Default()
|
||||
if Digest(base) != Digest(other) {
|
||||
t.Error("two identically populated configs hash differently")
|
||||
}
|
||||
// A meaningful change moves the digest.
|
||||
changed := *base
|
||||
changed.Cache.Size = base.Cache.Size + 1
|
||||
if Digest(base) == Digest(&changed) {
|
||||
t.Error("changing cache.size does not change the digest")
|
||||
}
|
||||
// A field that only matters to the operator moves it too: the digest has
|
||||
// no allow-list.
|
||||
loud := *base
|
||||
loud.Log.Level = "debug"
|
||||
if loud.Log.Level != "info" && Digest(base) == Digest(&loud) {
|
||||
t.Error("changing log.level does not change the digest")
|
||||
}
|
||||
// Including the per-instance identity, which is why this digest cannot be
|
||||
// used to compare two instances.
|
||||
renamed := *base
|
||||
renamed.Cluster.NodeID = "pod-2"
|
||||
if Digest(base) == Digest(&renamed) {
|
||||
t.Error("changing cluster.node_id does not change the digest")
|
||||
}
|
||||
// A nil config must not panic and must not return a digest of nothing.
|
||||
if got := Digest(nil); got == "" {
|
||||
t.Error("Digest(nil) is empty")
|
||||
}
|
||||
if strings.ContainsAny(Digest(nil), "ghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("Digest(nil) = %q is not hex", Digest(nil))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Heartbeat, peers and discovery.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHeartbeatAppearsInPeersWithItsIdentity(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
d := &digests{}
|
||||
d.set("rules-a", 42)
|
||||
a := h.start("a", func(o *Options) { o.Digests = d.fn })
|
||||
waitSubscribed(t, h.mini, a)
|
||||
started := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||
_ = started
|
||||
|
||||
// A second node joins; the first must learn about it from heartbeats
|
||||
// alone, without any API call.
|
||||
b := h.start("b", nil)
|
||||
waitSubscribed(t, h.mini, a)
|
||||
waitSubscribed(t, h.mini, b)
|
||||
|
||||
waitFor(t, "a to see b in Peers()", 3*time.Second, func() bool {
|
||||
return hasPeer(a.Peers(), b.NodeID())
|
||||
})
|
||||
var got NodeState
|
||||
for _, p := range a.Peers() {
|
||||
if p.NodeID == b.NodeID() {
|
||||
got = p
|
||||
}
|
||||
}
|
||||
if got.Version != "test" {
|
||||
t.Errorf("peer version = %q, want %q", got.Version, "test")
|
||||
}
|
||||
if got.Addr != b.opt.Addr {
|
||||
t.Errorf("peer addr = %q, want %q", got.Addr, b.opt.Addr)
|
||||
}
|
||||
if got.StartedAt.IsZero() {
|
||||
t.Error("peer carries no start timestamp")
|
||||
}
|
||||
if !got.StartedAt.Equal(b.State().StartedAt) {
|
||||
t.Errorf("peer started_at = %s, want %s", got.StartedAt, b.State().StartedAt)
|
||||
}
|
||||
// A node never lists itself.
|
||||
if hasPeer(a.Peers(), a.NodeID()) {
|
||||
t.Error("a node appears in its own peer list")
|
||||
}
|
||||
if !hasPeer(b.Peers(), a.NodeID()) {
|
||||
t.Error("b does not see a in Peers()")
|
||||
}
|
||||
// The blocklist digest travels with the heartbeat: that is what the
|
||||
// divergence rule compares.
|
||||
waitFor(t, "a to learn b's blocklist digest", 3*time.Second, func() bool {
|
||||
return peerDigest(a, b.NodeID()) == "rules-a" || peerDigest(a, b.NodeID()) != ""
|
||||
})
|
||||
if got := peerDigest(a, b.NodeID()); got != "" {
|
||||
t.Errorf("unexpected digest %q from a node with no digests configured", got)
|
||||
}
|
||||
if got := peerDigest(b, a.NodeID()); got != "rules-a" {
|
||||
t.Errorf("b sees a's blocklist digest as %q, want %q", got, "rules-a")
|
||||
}
|
||||
if got := a.State().BlocklistRules; got != 42 {
|
||||
t.Errorf("a reports %d blocklist rules, want 42", got)
|
||||
}
|
||||
if a.State().NodeID != a.NodeID() {
|
||||
t.Error("State().NodeID does not match NodeID()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadPeerAgesOutOfPeers(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
a := h.start("a", nil)
|
||||
waitSubscribed(t, h.mini, a)
|
||||
b := h.start("b", nil)
|
||||
waitSubscribed(t, h.mini, b)
|
||||
waitFor(t, "a to see b in Peers()", 3*time.Second, func() bool {
|
||||
return hasPeer(a.Peers(), b.NodeID())
|
||||
})
|
||||
|
||||
// b is killed without a farewell, so the only signal left is silence.
|
||||
b.kill()
|
||||
waitFor(t, "the dead peer to age out of a's peer list", 4*time.Second, func() bool {
|
||||
return !hasPeer(a.Peers(), b.NodeID())
|
||||
})
|
||||
// It must not reappear: nothing is left to publish it.
|
||||
time.Sleep(2 * (testPeerTTL / 2))
|
||||
if hasPeer(a.Peers(), b.NodeID()) {
|
||||
t.Error("the dead peer came back")
|
||||
}
|
||||
// A clean departure is immediate, and must not leave the node behind
|
||||
// either.
|
||||
c := h.start("c", nil)
|
||||
waitSubscribed(t, h.mini, c)
|
||||
waitFor(t, "a to see c in Peers()", 3*time.Second, func() bool {
|
||||
return hasPeer(a.Peers(), c.NodeID())
|
||||
})
|
||||
c.Stop()
|
||||
waitFor(t, "a to drop c after its farewell", 3*time.Second, func() bool {
|
||||
return !hasPeer(a.Peers(), c.NodeID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoverListsPeersButNotTheLocalNodeTwice(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
a := h.start("a", nil)
|
||||
waitSubscribed(t, h.mini, a)
|
||||
b := h.start("b", nil)
|
||||
waitSubscribed(t, h.mini, b)
|
||||
waitFor(t, "a to see b in Peers()", 3*time.Second, func() bool {
|
||||
return hasPeer(a.Peers(), b.NodeID())
|
||||
})
|
||||
ctx := context.Background()
|
||||
// The keyed record the heartbeat writes is what a fresh instance reads,
|
||||
// so the local node must appear in it (under its own id) but must not be
|
||||
// reported back to itself.
|
||||
waitFor(t, "both nodes in the heartbeat record", 3*time.Second, func() bool {
|
||||
found := a.Discover(ctx)
|
||||
return hasPeer(found, b.NodeID()) && !hasPeer(found, a.NodeID())
|
||||
})
|
||||
found := a.Discover(ctx)
|
||||
seen := map[string]bool{}
|
||||
for _, s := range found {
|
||||
if seen[s.NodeID] {
|
||||
t.Errorf("Discover() returned %q twice", s.NodeID)
|
||||
}
|
||||
seen[s.NodeID] = true
|
||||
}
|
||||
// Reading a snapshot while the cluster keeps running must be safe: the
|
||||
// maintain goroutine writes to seen while this reads. Run under -race.
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = a.Discover(ctx)
|
||||
_ = a.Peers()
|
||||
_ = a.Status(ctx)
|
||||
}
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
if hasPeer(a.Discover(ctx), a.NodeID()) {
|
||||
t.Error("Discover() returned the local node")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Resilience.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRedisOutageDoesNotPanicAndRecovers(t *testing.T) {
|
||||
m := miniredisNew(t)
|
||||
h := newHarnessOn(t, m)
|
||||
a := h.start("a", nil)
|
||||
ctx := context.Background()
|
||||
waitFor(t, "the node to connect", 3*time.Second, a.Connected)
|
||||
waitFor(t, "the node to take the lease", 3*time.Second, a.IsLeader)
|
||||
addr := m.Addr()
|
||||
|
||||
m.Close()
|
||||
// The client is closed underneath the running session: nothing may panic
|
||||
// and the health flag must report the outage.
|
||||
waitFor(t, "Connected() to go false while Valkey is away", 5*time.Second, func() bool {
|
||||
return !a.Connected()
|
||||
})
|
||||
// The status and state calls must keep working (they read local state)
|
||||
// while the peer is unreachable.
|
||||
_ = a.Status(ctx)
|
||||
_ = a.State()
|
||||
_ = a.Peers()
|
||||
if err := a.BroadcastReload(ctx); err == nil {
|
||||
t.Log("BroadcastReload reported success while Valkey was down")
|
||||
}
|
||||
|
||||
if err := m.StartAddr(addr); err != nil {
|
||||
t.Fatalf("restart miniredis: %v", err)
|
||||
}
|
||||
waitFor(t, "the cluster to reconnect", 10*time.Second, a.Connected)
|
||||
waitFor(t, "the node to reclaim the lease", 5*time.Second, a.IsLeader)
|
||||
// And it is live again: a peer can still be discovered.
|
||||
b := h.start("b", nil)
|
||||
waitSubscribed(t, h.mini, b)
|
||||
waitFor(t, "a to see b after the outage", 5*time.Second, func() bool {
|
||||
return hasPeer(a.Peers(), b.NodeID())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopDuringAnActiveSessionIsClean(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
// Let any one-off runtime goroutines settle before the baseline.
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
before := runtime.NumGoroutine()
|
||||
|
||||
nodes := make([]*node, 0, 3)
|
||||
for i, name := range []string{"a", "b", "c"} {
|
||||
nodes = append(nodes, h.start(name, func(o *Options) {
|
||||
o.Version = "test"
|
||||
o.Addr = name
|
||||
_ = i
|
||||
}))
|
||||
}
|
||||
for _, n := range nodes {
|
||||
waitFor(t, "a node to be connected", 3*time.Second, n.Connected)
|
||||
}
|
||||
// Stop in the middle of live sessions (subscriptions, heartbeats, lease
|
||||
// renewals in flight). Stop twice: shutdown paths do that.
|
||||
for _, n := range nodes {
|
||||
n.Stop()
|
||||
n.Stop()
|
||||
}
|
||||
// A bounded comparison, not an exact count: the runtime owns goroutines we
|
||||
// do not.
|
||||
waitFor(t, "the maintain goroutines to exit", 5*time.Second, func() bool {
|
||||
return runtime.NumGoroutine() <= before+2
|
||||
})
|
||||
for _, n := range nodes {
|
||||
if n.Connected() {
|
||||
t.Errorf("%s still reports Connected after Stop", n.NodeID())
|
||||
}
|
||||
if n.IsLeader() {
|
||||
t.Errorf("%s still claims the lease after Stop", n.NodeID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Pure helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMustJSONFallsBackToAnObject(t *testing.T) {
|
||||
if got := mustJSON(NodeState{NodeID: "n1", Version: "v"}); !strings.Contains(got, `"n1"`) {
|
||||
t.Errorf("mustJSON = %q, want the node id", got)
|
||||
}
|
||||
// A value that cannot be marshalled must not take the caller down.
|
||||
if got := mustJSON(make(chan int)); got != "{}" {
|
||||
t.Errorf("mustJSON(chan) = %q, want %q", got, "{}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoReadFillsTheBuffer(t *testing.T) {
|
||||
b := make([]byte, 4)
|
||||
n, err := cryptoRead(b)
|
||||
if err != nil {
|
||||
t.Fatalf("cryptoRead: %v", err)
|
||||
}
|
||||
if n != len(b) {
|
||||
t.Errorf("cryptoRead returned %d, want %d", n, len(b))
|
||||
}
|
||||
// Two reads must not be identical often enough to collide a node id in
|
||||
// practice; a single pair colliding is possible, so only a large batch is
|
||||
// asserted here.
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 256; i++ {
|
||||
x := make([]byte, 4)
|
||||
if _, err := cryptoRead(x); err != nil {
|
||||
t.Fatalf("cryptoRead: %v", err)
|
||||
}
|
||||
k := string(x)
|
||||
if seen[k] {
|
||||
t.Fatalf("cryptoRead repeated %q after %d draws", k, i)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortIDIsHexAndUniquelyNamesNodes(t *testing.T) {
|
||||
for i := 0; i < 32; i++ {
|
||||
s := shortID()
|
||||
if len(s) != 8 {
|
||||
t.Fatalf("shortID() = %q, want 8 hex characters", s)
|
||||
}
|
||||
if strings.Trim(s, "0123456789abcdef") != "" {
|
||||
t.Fatalf("shortID() = %q is not lowercase hex", s)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 64; i++ {
|
||||
s := shortID()
|
||||
if seen[s] {
|
||||
t.Fatalf("shortID() repeated %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIdentityAndString(t *testing.T) {
|
||||
// The configured node id is the base of the identity, so two instances
|
||||
// sharing a config file still get distinct ids.
|
||||
h := newHarness(t, func(c *config.Config) { c.Cluster.NodeID = "pod-0" })
|
||||
a := New(Options{Config: h.store, Logger: discardLogger(), Version: "v1.2.3"})
|
||||
b := New(Options{Config: h.store, Logger: discardLogger(), Version: "v1.2.3"})
|
||||
if a.NodeID() == b.NodeID() {
|
||||
t.Errorf("two clusters share the node id %q", a.NodeID())
|
||||
}
|
||||
for _, c := range []*Cluster{a, b} {
|
||||
if !strings.HasPrefix(c.NodeID(), "pod-0-") {
|
||||
t.Errorf("node id %q does not start with the configured node id", c.NodeID())
|
||||
}
|
||||
if c.String() != c.NodeID() {
|
||||
t.Errorf("String() = %q, want %q", c.String(), c.NodeID())
|
||||
}
|
||||
}
|
||||
// The base is the version when there is one, and the hostname when there
|
||||
// is not. The random suffix is the part that matters: it is the only
|
||||
// thing keeping two instances of the same release apart, and a shared id
|
||||
// means each discards the other's events as its own echo.
|
||||
h2 := newHarness(t, func(c *config.Config) { c.Cluster.NodeID = "" })
|
||||
ver := New(Options{Config: h2.store, Logger: discardLogger(), Version: "v1"})
|
||||
if !strings.HasPrefix(ver.NodeID(), "v1-") {
|
||||
t.Errorf("node id %q does not start with the version", ver.NodeID())
|
||||
}
|
||||
noVer := New(Options{Config: h2.store, Logger: discardLogger()})
|
||||
host, _ := os.Hostname()
|
||||
if host != "" && !strings.HasPrefix(noVer.NodeID(), host+"-") {
|
||||
t.Errorf("node id %q does not start with the hostname %q", noVer.NodeID(), host)
|
||||
}
|
||||
if ver.NodeID() == noVer.NodeID() {
|
||||
t.Error("two clusters share a node id")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// peerDigest returns the blocklist digest node has on record for id.
|
||||
func peerDigest(n *node, id string) string {
|
||||
for _, p := range n.Peers() {
|
||||
if p.NodeID == id {
|
||||
return p.BlocklistDigest
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// testPeerTTL is short enough that a heartbeat lands every 100ms (the
|
||||
// maintain loop ticks at peerTTL/2) and a dead peer is reaped in
|
||||
// peerTTL*4, and long enough that a loaded machine does not make a live
|
||||
// peer look dead.
|
||||
testPeerTTL = 200 * time.Millisecond
|
||||
// testLeaderTTL is the shortest lease go-redis will accept: it truncates
|
||||
// a sub-second expiration to 1s, so anything shorter would silently
|
||||
// become this anyway. It is comfortably longer than the renewal interval
|
||||
// (peerTTL/2), which is the invariant the lease tests check.
|
||||
testLeaderTTL = time.Second
|
||||
)
|
||||
|
||||
// harness owns one miniredis and one validated *config.Config for a test.
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
mini *miniredis.Miniredis
|
||||
cfg *config.Config
|
||||
store *config.Store
|
||||
}
|
||||
|
||||
// newHarness starts a miniredis for this test alone, so tests cannot interfere
|
||||
// with each other, and builds a configuration that passes Validate.
|
||||
func newHarness(t *testing.T, tweak ...func(*config.Config)) *harness {
|
||||
t.Helper()
|
||||
m := miniredis.RunT(t)
|
||||
return newHarnessOn(t, m, tweak...)
|
||||
}
|
||||
|
||||
// newHarnessOn is newHarness for a miniredis the test started itself, which
|
||||
// the outage test needs because it closes and restarts it on the same address.
|
||||
func newHarnessOn(t *testing.T, m *miniredis.Miniredis, tweak ...func(*config.Config)) *harness {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
// Validate rejects an enabled blocklist with no sources; these tests are
|
||||
// about coordination, not rules, so the blocklist is off.
|
||||
cfg.Block.Enabled = false
|
||||
cfg.Cluster.Enabled = true
|
||||
cfg.Cluster.PeerTTL = config.Duration(testPeerTTL)
|
||||
cfg.Cluster.Valkey = config.Valkey{
|
||||
Addrs: []string{m.Addr()},
|
||||
Prefix: testPrefix(t),
|
||||
PoolSize: 2,
|
||||
DialTimeout: config.Duration(500 * time.Millisecond),
|
||||
ReadTimeout: config.Duration(time.Second),
|
||||
LeaderTTL: config.Duration(testLeaderTTL),
|
||||
}
|
||||
for _, fn := range tweak {
|
||||
fn(cfg)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("test config does not pass Validate: %v", err)
|
||||
}
|
||||
return &harness{t: t, mini: m, cfg: cfg, store: config.NewStore(cfg, "")}
|
||||
}
|
||||
|
||||
// testPrefix is the Valkey key prefix for a test, so a failure names the test
|
||||
// whose keys are in the dump.
|
||||
func testPrefix(t *testing.T) string {
|
||||
r := strings.NewReplacer("/", "_", " ", "_", "#", "_")
|
||||
return "fdtest_" + r.Replace(t.Name())
|
||||
}
|
||||
|
||||
// miniredisNew is miniredis.RunT for the tests that close and restart the
|
||||
// server themselves: the address has to be captured before Close, because
|
||||
// miniredis.Addr() panics once the server is gone.
|
||||
func miniredisNew(t *testing.T) *miniredis.Miniredis {
|
||||
t.Helper()
|
||||
m := miniredis.NewMiniRedis()
|
||||
if err := m.Start(); err != nil {
|
||||
t.Fatalf("start miniredis: %v", err)
|
||||
}
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// keyValue reads a Valkey key through miniredis rather than through a client,
|
||||
// so a test can assert what the server actually stored.
|
||||
func keyValue(t *testing.T, m *miniredis.Miniredis, key string) string {
|
||||
t.Helper()
|
||||
v, err := m.Get(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type node struct {
|
||||
*Cluster
|
||||
t *testing.T
|
||||
events chan Event
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// start builds and starts a Cluster against the harness' miniredis. The node
|
||||
// is stopped and its context cancelled at the end of the test, so no test can
|
||||
// leak a maintain goroutine into the next one.
|
||||
func (h *harness) start(name string, opt func(*Options)) *node {
|
||||
h.t.Helper()
|
||||
o := Options{
|
||||
Config: h.store,
|
||||
Logger: discardLogger(),
|
||||
// The version is deliberately the same for every node: the identity
|
||||
// that matters is NodeID, and two nodes must not collide on it.
|
||||
Version: "test",
|
||||
StartedAt: time.Now().Add(-time.Hour).Truncate(time.Second),
|
||||
Addr: name + ".test:8080",
|
||||
}
|
||||
if opt != nil {
|
||||
opt(&o)
|
||||
}
|
||||
c := New(o)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
n := &node{Cluster: c, t: h.t, events: make(chan Event, 64), cancel: cancel}
|
||||
// The handler must be in place before Start: the maintain goroutine
|
||||
// reads c.opt from another goroutine.
|
||||
if c.opt.Handler == nil {
|
||||
c.opt.Handler = func(_ context.Context, ev Event) {
|
||||
select {
|
||||
case n.events <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
h.t.Cleanup(c.Stop)
|
||||
h.t.Cleanup(cancel)
|
||||
c.Start(ctx)
|
||||
return n
|
||||
}
|
||||
|
||||
// kill ends this node's maintain session the way a crash does: the context is
|
||||
// cancelled, so the node publishes no "bye" and its peers must notice through
|
||||
// the heartbeat going stale instead of through a farewell.
|
||||
func (n *node) kill() { n.cancel() }
|
||||
|
||||
// waitEvent returns the first captured event of the given kind, discarding the
|
||||
// others (heartbeats from the peer, mostly).
|
||||
func (n *node) waitEvent(kind EventKind, timeout time.Duration) Event {
|
||||
n.t.Helper()
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case ev := <-n.events:
|
||||
if ev.Kind == kind {
|
||||
return ev
|
||||
}
|
||||
case <-deadline.C:
|
||||
n.t.Fatalf("no %q event within %s", kind, timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countEvents drains the captured events and counts those of one kind.
|
||||
func (n *node) countEvents(kind EventKind) int {
|
||||
c := 0
|
||||
for {
|
||||
select {
|
||||
case ev := <-n.events:
|
||||
if ev.Kind == kind {
|
||||
c++
|
||||
}
|
||||
default:
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasPeer reports whether states contains id, without asserting anything
|
||||
// about which other nodes are in the list.
|
||||
func hasPeer(states []NodeState, id string) bool {
|
||||
for _, s := range states {
|
||||
if s.NodeID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// leaderCount counts the nodes that currently claim the refresh lease.
|
||||
func leaderCount(nodes ...*node) int {
|
||||
n := 0
|
||||
for _, nd := range nodes {
|
||||
if nd.IsLeader() {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 1}))
|
||||
}
|
||||
|
||||
// waitFor polls cond until it holds, failing with what it waited for. The
|
||||
// condition is an invariant of the cluster, never an incidental observation
|
||||
// about what a goroutine happened to see.
|
||||
func waitFor(t *testing.T, what string, timeout time.Duration, 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(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// waitSubscribed blocks until miniredis counts a subscriber on every channel.
|
||||
// This is the only race-free way to know a peer will hear a publish: pub/sub
|
||||
// delivery to a channel with no subscriber is fire-and-forget.
|
||||
func waitSubscribed(t *testing.T, m *miniredis.Miniredis, n *node) {
|
||||
t.Helper()
|
||||
chans := []string{
|
||||
n.channel(EventHeartbeat),
|
||||
n.channel(EventConfig),
|
||||
n.channel(EventReload),
|
||||
n.channel(EventBlocklists),
|
||||
n.channel(EventBye),
|
||||
}
|
||||
waitFor(t, "a live subscription on every cluster channel", 5*time.Second, func() bool {
|
||||
counts := m.PubSubNumSub(chans...)
|
||||
for _, ch := range chans {
|
||||
if counts[ch] < 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// digests is a race-free source of blocklist digests for one node, so a test
|
||||
// can change what a node publishes and observe the effect.
|
||||
type digests struct {
|
||||
mu sync.Mutex
|
||||
blocklist string
|
||||
rules int
|
||||
cfgDigest string
|
||||
}
|
||||
|
||||
func (d *digests) set(blocklist string, rules int) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.blocklist, d.rules = blocklist, rules
|
||||
}
|
||||
|
||||
func (d *digests) fn() (string, int, time.Time, string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.blocklist, d.rules, time.Unix(0, 0), d.cfgDigest
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// configMapBody extracts the `config.yaml: |` block from a k8s manifest. The
|
||||
// ConfigMap is the thing an operator actually applies, and nothing in the test
|
||||
// suite loads it, so a typo in it is invisible until a pod crash-loops.
|
||||
func configMapBody(t *testing.T, manifest, key string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
header := " " + key + ": |"
|
||||
start := -1
|
||||
for i, line := range lines {
|
||||
if strings.TrimRight(line, " \r") == header {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
t.Fatalf("%s has no %q block", manifest, key)
|
||||
}
|
||||
var body []string
|
||||
for _, line := range lines[start:] {
|
||||
// The block scalar's content is indented past the key; the first
|
||||
// line that is not ends the block.
|
||||
if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, " ") {
|
||||
break
|
||||
}
|
||||
body = append(body, strings.TrimPrefix(line, " "))
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Fatalf("%s: the %q block is empty", manifest, key)
|
||||
}
|
||||
return strings.Join(body, "\n")
|
||||
}
|
||||
|
||||
// writeTemp writes body to a temp config file and returns its path. Load
|
||||
// resolves relative paths against the file's directory, so the fixture has to
|
||||
// be a real file in a real directory rather than an in-memory string.
|
||||
func writeTemp(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestExampleConfigsValidate loads the two configuration files we ship and
|
||||
// requires both to survive Load, which ends in Validate. Everything else in
|
||||
// this file is free to be wrong; a shipped example that cannot start is not.
|
||||
func TestExampleConfigsValidate(t *testing.T) {
|
||||
t.Run("deploy/config.example.yaml", func(t *testing.T) {
|
||||
cfg, err := Load(filepath.Join("..", "..", "deploy", "config.example.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("the shipped example config does not load: %v", err)
|
||||
}
|
||||
// Spot-check that the example is not merely valid but is saying what
|
||||
// its comments say: the API is open for writes here, metrics are on,
|
||||
// and the three blocklist sources are the three the file lists.
|
||||
if !cfg.API.AllowConfigWrite {
|
||||
t.Error("config.example.yaml documents config writes but sets allow_config_write: false")
|
||||
}
|
||||
if !cfg.Metrics.Enabled || !cfg.Metrics.Protect {
|
||||
t.Error("config.example.yaml should ship metrics enabled and protected")
|
||||
}
|
||||
if got := len(cfg.Block.Lists); got != 3 {
|
||||
t.Errorf("blocklist lists = %d, want 3 (haas, easylist, local-rpz)", got)
|
||||
}
|
||||
if got := cfg.Memory.Reserve; got != 256*1024*1024 {
|
||||
t.Errorf("memory.reserve = %d, want 256MiB as documented", int64(got))
|
||||
}
|
||||
// The example must not carry a key that looks like a real one. This
|
||||
// is the file people copy, and a working key in it is a published
|
||||
// credential.
|
||||
for _, k := range cfg.API.Auth.APIKeys {
|
||||
if len(k) > 12 && !strings.Contains(k, "CHANGE-ME") {
|
||||
t.Errorf("config.example.yaml ships an API key that is not a "+
|
||||
"placeholder: %q", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deploy/k8s/00-config.yaml ConfigMap", func(t *testing.T) {
|
||||
manifest := filepath.Join("..", "..", "deploy", "k8s", "00-config.yaml")
|
||||
body := configMapBody(t, manifest, "config.yaml")
|
||||
// The key comes from a Secret, so the manifest's ${FANCYDNS_API_KEY}
|
||||
// has to be set for the config to load at all — which is the point:
|
||||
// a missing Secret is a crash loop, not an open console.
|
||||
t.Setenv("FANCYDNS_API_KEY", "k8s-manifest-fixture-key")
|
||||
|
||||
cfg, err := Load(writeTemp(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("the k8s ConfigMap does not load: %v", err)
|
||||
}
|
||||
if got := cfg.API.Auth.APIKeys; len(got) != 1 || got[0] != "k8s-manifest-fixture-key" {
|
||||
t.Errorf("api keys = %q, want the expanded ${FANCYDNS_API_KEY}", got)
|
||||
}
|
||||
if cfg.API.Auth.TrustLoopback {
|
||||
t.Error("the k8s manifest exposes the API on 0.0.0.0, so trust_loopback must be false")
|
||||
}
|
||||
if cfg.API.AllowConfigWrite {
|
||||
t.Error("the k8s manifest sets allow_config_write: false; a ConfigMap " +
|
||||
"is not writable from the pod")
|
||||
}
|
||||
// The memory envelope is matched to the StatefulSet's 1Gi limit, and a
|
||||
// limit above the cgroup's is the cgroup kill AGENTS.md warns about.
|
||||
if got := int64(cfg.Memory.Limit); got != 1024*1024*1024 {
|
||||
t.Errorf("memory.limit = %d, want 1GiB", got)
|
||||
}
|
||||
if got := int64(cfg.Memory.CacheMaxBytes); got != 384*1024*1024 {
|
||||
t.Errorf("memory.cache_max_bytes = %d, want 384MiB", got)
|
||||
}
|
||||
if got := cfg.Cache.Size; got != 500000 {
|
||||
t.Errorf("cache.size = %d, want the 500000 the manifest documents", got)
|
||||
}
|
||||
if !cfg.DNS.DoT.Enabled || !cfg.DNS.DoH.Enabled {
|
||||
t.Error("the k8s manifest enables both DoT and DoH; both are required for the Ingress")
|
||||
}
|
||||
if !cfg.Metrics.Protect {
|
||||
t.Error("metrics.protect must stay true in the cluster")
|
||||
}
|
||||
if got := cfg.Log.Format; got != "json" {
|
||||
t.Errorf("log.format = %q, want json in a cluster", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unset API key variable is still fatal", func(t *testing.T) {
|
||||
// The control for the case above: the expansion does not fall back to
|
||||
// something harmless when the Secret is missing.
|
||||
manifest := filepath.Join("..", "..", "deploy", "k8s", "00-config.yaml")
|
||||
t.Setenv("FANCYDNS_API_KEY", "")
|
||||
os.Unsetenv("FANCYDNS_API_KEY")
|
||||
if _, err := Load(writeTemp(t, configMapBody(t, manifest, "config.yaml"))); err == nil {
|
||||
t.Fatal("the k8s ConfigMap loaded with no API key in the environment; " +
|
||||
"a missing Secret must be a startup failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadYAML writes body to a temp file and loads it, failing the test if Load
|
||||
// errors. Every test here wants "Load accepted this", so the helper asserts it
|
||||
// once instead of repeating the check.
|
||||
func loadYAML(t *testing.T, body string) *Config {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load rejected the config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestLoadIgnoresUnknownKeys pins a decision STATUS.md lists as open: Load
|
||||
// unmarshals leniently, so a key the struct does not know is silently dropped
|
||||
// instead of rejected. yaml.KnownFields(true) would catch it, and was
|
||||
// deliberately NOT adopted: it is a behaviour change for anyone carrying extra
|
||||
// keys in their config.
|
||||
//
|
||||
// The reason this is pinned rather than left alone is that leniency has already
|
||||
// cost this project twice (two test fixtures were caught by a stray key, not by
|
||||
// what they were testing). If the decision ever flips, the failure below is the
|
||||
// reminder to update STATUS.md, AGENTS.md and the README together.
|
||||
func TestLoadIgnoresUnknownKeys(t *testing.T) {
|
||||
t.Run("a misspelled top-level block is dropped", func(t *testing.T) {
|
||||
// The real trap from the field: the struct tag is "blocklist", so
|
||||
// writing "block:" produces a plausible-looking server that has
|
||||
// quietly kept every blocklist default. The correctly spelled
|
||||
// block below disables the blocklist so the config validates at all
|
||||
// — which is itself the sharper half of the pin: if the typo were
|
||||
// honoured, block: {enabled: true} with no sources would fail
|
||||
// Validate and the server would refuse to start.
|
||||
cfg := loadYAML(t, `
|
||||
blocklist:
|
||||
enabled: false
|
||||
block:
|
||||
enabled: true
|
||||
response_ip: 10.0.0.1
|
||||
response_ttl: 5s
|
||||
`)
|
||||
if cfg.Block.Enabled {
|
||||
t.Fatal("the misspelled block: block was applied; Load is no longer lenient " +
|
||||
"and this pin is stale")
|
||||
}
|
||||
if cfg.Block.ResponseIP != Default().Block.ResponseIP {
|
||||
t.Fatalf("blocklist.response_ip = %q, want the default %q",
|
||||
cfg.Block.ResponseIP, Default().Block.ResponseIP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the same block spelled correctly is applied", func(t *testing.T) {
|
||||
// The control for the case above: the lenient path is the
|
||||
// difference between these two keys, not Load ignoring the file.
|
||||
cfg := loadYAML(t, `
|
||||
blocklist:
|
||||
enabled: false
|
||||
response_ip: 10.0.0.1
|
||||
response_ttl: 5s
|
||||
`)
|
||||
if cfg.Block.Enabled {
|
||||
t.Fatal("blocklist.enabled: false was not applied")
|
||||
}
|
||||
if cfg.Block.ResponseIP != "10.0.0.1" {
|
||||
t.Fatalf("blocklist.response_ip = %q, want 10.0.0.1", cfg.Block.ResponseIP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a misspelled nested key is dropped", func(t *testing.T) {
|
||||
cfg := loadYAML(t, `
|
||||
blocklist:
|
||||
enabled: false
|
||||
api:
|
||||
basepath: /console
|
||||
`)
|
||||
if cfg.API.BasePath != Default().API.BasePath {
|
||||
t.Fatalf("api.base_path = %q, want the default %q — leniency changed",
|
||||
cfg.API.BasePath, Default().API.BasePath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unknown top-level key is dropped", func(t *testing.T) {
|
||||
cfg := loadYAML(t, `
|
||||
blocklist:
|
||||
enabled: false
|
||||
telemetry:
|
||||
endpoint: https://example.invalid
|
||||
`)
|
||||
if cfg.API.Enabled != Default().API.Enabled {
|
||||
t.Fatalf("an unknown key changed the config: api.enabled = %v", cfg.API.Enabled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a known key with a bad TYPE is still fatal", func(t *testing.T) {
|
||||
// The asymmetry is the point: a typo is invisible, a wrong value type
|
||||
// is a startup failure. Leniency covers unknown keys only.
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("blocklist:\n enabled: false\nserver:\n max_concurrent: many\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("Load accepted a string where a number belongs; the strictness " +
|
||||
"contrast this pin documents is gone")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -32,6 +33,12 @@ import (
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
|
||||
)
|
||||
|
||||
// upstreamNSID is the nameserver identifier we advertise to upstreams when a
|
||||
// client asks for one. It is hex-encoded on the way into the EDNS0 option:
|
||||
// dns.EDNS0_NSID.Nsid is a hex string in this miekg/dns version, not the
|
||||
// opaque bytes RFC 5001 describes.
|
||||
const upstreamNSID = "fancy-dns"
|
||||
|
||||
// Request describes one inbound query.
|
||||
type Request struct {
|
||||
// Msg is the parsed query. The engine may modify it (to attach an OPT or
|
||||
@@ -696,10 +703,14 @@ func (e *Engine) prepareUpstream(q *dns.Msg, req Request) *dns.Msg {
|
||||
if opt := o.Option; opt != nil {
|
||||
for _, oo := range opt {
|
||||
if oo.Option() == dns.EDNS0NSID {
|
||||
// Our own NSID, not the client's.
|
||||
// Our own NSID, not the client's — but only when the
|
||||
// operator has not asked us to hide. dns.EDNS0_NSID.Nsid
|
||||
// is hex on the wire (the library hex-decodes it in pack
|
||||
// and hex-encodes it in unpack), so a plain string here
|
||||
// does not pack and every NSID query gets SERVFAIL.
|
||||
if e.cfgStore == nil || !e.cfgStore.Get().DNS.HideIdentity {
|
||||
nsid := new(dns.EDNS0_NSID)
|
||||
nsid.Nsid = "fancy-dns"
|
||||
nsid.Nsid = hex.EncodeToString([]byte(upstreamNSID))
|
||||
if m.IsEdns0() == nil {
|
||||
m.SetEdns0(maxSize, o.Do())
|
||||
}
|
||||
|
||||
@@ -718,11 +718,10 @@ func TestForwardedQueryShape(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// The NSID branch of prepareUpstream is deliberately not pinned here:
|
||||
// miekg/dns v1.1.73 reports an EDNS0_NSID option as zero length, so any
|
||||
// message carrying one fails to pack ("dns: overflow packing opt") and a
|
||||
// client that asks for NSID gets SERVFAIL. That is a library defect, not
|
||||
// a decision this package owns; see STATUS.md.
|
||||
// The NSID branch of prepareUpstream is covered in pins_test.go: the
|
||||
// option is hex on the wire (dns.EDNS0_NSID.Nsid is a hex string), a
|
||||
// plain string there does not pack, and a client that asks for NSID
|
||||
// used to get SERVFAIL for exactly that reason.
|
||||
}
|
||||
|
||||
func findOption(m *dns.Msg, code uint16) dns.EDNS0 {
|
||||
|
||||
@@ -241,6 +241,7 @@ type hopt struct {
|
||||
blockCfg func(*config.Block)
|
||||
server func(*config.Server)
|
||||
cacheCfg func(*config.Cache)
|
||||
dnsCfg func(*config.DNS)
|
||||
// rules are compiled into the blocklist set the engine holds.
|
||||
rules []blocklist.Rule
|
||||
// localZone is a master file; empty means no local zone at all.
|
||||
@@ -302,6 +303,9 @@ func newHarness(t *testing.T, o hopt) *harness {
|
||||
if o.cacheCfg != nil {
|
||||
o.cacheCfg(&cfg.Cache)
|
||||
}
|
||||
if o.dnsCfg != nil {
|
||||
o.dnsCfg(&cfg.DNS)
|
||||
}
|
||||
|
||||
// The default pool is the fake upstream; a dead address is what the
|
||||
// upstream-failure test needs, and noPool needs no pool at all.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// The tests in this file PIN behaviour we know is wrong or known to be
|
||||
// incomplete. They are not assertions of intent — they are tripwires, so that
|
||||
// when the behaviour changes (a library bump, a metrics change) the suite says
|
||||
// so out loud instead of the drift reaching the dashboards or the clients.
|
||||
// Each one names in its failure message what to do when it goes off.
|
||||
|
||||
// outcomeCount sums the _count of fancydns_query_duration_seconds over every
|
||||
// transport for one outcome label. Zero is a legitimate answer: a histogram
|
||||
// with no observations publishes no series at all.
|
||||
func outcomeCount(t *testing.T, outcome string) uint64 {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
prefix := metrics.Namespace + "_query_duration_seconds_count{"
|
||||
want := `outcome="` + outcome + `"`
|
||||
var total uint64
|
||||
for _, line := range strings.Split(rec.Body.String(), "\n") {
|
||||
if !strings.HasPrefix(line, prefix) || !strings.Contains(line, want) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
n, err := strconv.ParseUint(fields[len(fields)-1], 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("unparseable exposition line %q: %v", line, err)
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// TestPinnedLocalAnswersAreCountedAsUpstream pins the gap STATUS.md calls
|
||||
// "metrics.OutcomeLocal has no producer": a query answered from the local
|
||||
// zone reports qlog.ActionAllow, and outcomeFor maps ActionAllow to
|
||||
// OutcomeUpstream, so every locally answered query is counted as an upstream
|
||||
// one. The outcome="local" series is therefore never published and the
|
||||
// dashboard's local column is permanently empty.
|
||||
//
|
||||
// If this test fails, something started producing OutcomeLocal (or the
|
||||
// mapping changed): that moves published counters, so it is a deliberate
|
||||
// change — update the Grafana panels and the ServiceMonitor queries, and
|
||||
// remove this pin. Do not "fix" it silently.
|
||||
func TestPinnedLocalAnswersAreCountedAsUpstream(t *testing.T) {
|
||||
h := newHarness(t, hopt{localZone: testZone})
|
||||
|
||||
before := outcomeCount(t, metrics.OutcomeUpstream)
|
||||
res := h.ask("host.local.", dns.TypeA)
|
||||
requireAction(t, res, qlog.ActionAllow)
|
||||
if got := answerA(res.Msg); got != "192.168.1.5" {
|
||||
t.Fatalf("answer = %q, want the local zone's 192.168.1.5", got)
|
||||
}
|
||||
requireNoUpstreamHit(t, h, "host.local.")
|
||||
|
||||
if got := outcomeCount(t, metrics.OutcomeUpstream) - before; got != 1 {
|
||||
t.Fatalf("a locally answered query moved the upstream outcome by %d, want 1", got)
|
||||
}
|
||||
if got := outcomeCount(t, metrics.OutcomeLocal); got != 0 {
|
||||
t.Fatalf("metrics.OutcomeLocal now has %d observations; it is no longer "+
|
||||
"dead. Publishing published counters is a decision: update the "+
|
||||
"dashboards and STATUS.md, then delete this pin.", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinnedEDNS0NSIDIsHex pins a contract of the pinned miekg/dns version
|
||||
// (v1.1.73) that is easy to get wrong and fails silently: EDNS0_NSID.Nsid is
|
||||
// a HEX STRING, not the opaque bytes RFC 5001 describes. pack() hex-decodes
|
||||
// it and unpack() hex-encodes it, so:
|
||||
//
|
||||
// - a hex string packs, and the bytes on the wire are the decoded value;
|
||||
// - a plain string such as "fancy-dns" does NOT pack — hex.DecodeString
|
||||
// rejects the '-' — and the caller gets an error instead of a message.
|
||||
//
|
||||
// This is the trap behind the SERVFAIL bug: the engine used to put the literal
|
||||
// "fancy-dns" in the option, so every query from a client that asked for NSID
|
||||
// failed to build. If a future miekg/dns changes Nsid to raw bytes, this pin
|
||||
// fails and the hex.EncodeToString in prepareUpstream has to go with it.
|
||||
func TestPinnedEDNS0NSIDIsHex(t *testing.T) {
|
||||
pack := func(nsid string) error {
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion("nsid.example.test.", dns.TypeA)
|
||||
m.SetEdns0(1232, false)
|
||||
m.IsEdns0().Option = append(m.IsEdns0().Option, &dns.EDNS0_NSID{Nsid: nsid})
|
||||
_, err := m.Pack()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pack(hex.EncodeToString([]byte(upstreamNSID))); err != nil {
|
||||
t.Fatalf("a hex-encoded NSID must pack: %v", err)
|
||||
}
|
||||
if err := pack(upstreamNSID); err == nil {
|
||||
t.Fatalf("%q packed as an NSID; dns.EDNS0_NSID.Nsid is no longer hex-encoded, "+
|
||||
"so drop the hex.EncodeToString in prepareUpstream and update this pin", upstreamNSID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientNSIDIsAnswered pins the user-visible half of that fix: a client
|
||||
// that asks for NSID gets a real answer, whatever the size of its NSID, and
|
||||
// the option we forward upstream is our own identifier rather than the
|
||||
// client's. With dns.hide_identity the option is dropped entirely, which is
|
||||
// the whole point of the setting.
|
||||
func TestClientNSIDIsAnswered(t *testing.T) {
|
||||
t.Run("short NSID", func(t *testing.T) {
|
||||
h := newHarness(t, hopt{})
|
||||
req := h.request("nsid.example.test.", dns.TypeA)
|
||||
req.Msg.SetEdns0(1232, false)
|
||||
req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option,
|
||||
&dns.EDNS0_NSID{Nsid: "abc"})
|
||||
res := h.askWith(req)
|
||||
requireRcode(t, res, dns.RcodeSuccess)
|
||||
|
||||
got := h.up.received()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("upstream saw %d queries, want 1", len(got))
|
||||
}
|
||||
opt, ok := findOption(got[0], dns.EDNS0NSID).(*dns.EDNS0_NSID)
|
||||
if !ok {
|
||||
t.Fatal("the forwarded query carries no NSID; we do not advertise ourselves upstream")
|
||||
}
|
||||
raw, err := hex.DecodeString(opt.Nsid)
|
||||
if err != nil {
|
||||
t.Fatalf("forwarded NSID %q is not hex: %v", opt.Nsid, err)
|
||||
}
|
||||
if string(raw) != upstreamNSID {
|
||||
t.Fatalf("forwarded NSID = %q, want our own %q (we forwarded the client's)",
|
||||
raw, upstreamNSID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NSID larger than 255 bytes", func(t *testing.T) {
|
||||
// A character-string over 255 bytes does not pack for TXT, but NSID
|
||||
// is hex-decoded into a byte slice, so a long NSID is fine here.
|
||||
// Pinning it keeps the distinction between the two documented.
|
||||
h := newHarness(t, hopt{})
|
||||
req := h.request("big-nsid.example.test.", dns.TypeA)
|
||||
req.Msg.SetEdns0(1232, false)
|
||||
req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option,
|
||||
&dns.EDNS0_NSID{Nsid: hex.EncodeToString([]byte(strings.Repeat("a", 300)))})
|
||||
requireRcode(t, h.askWith(req), dns.RcodeSuccess)
|
||||
})
|
||||
|
||||
t.Run("hide_identity drops the option", func(t *testing.T) {
|
||||
h := newHarness(t, hopt{
|
||||
dnsCfg: func(d *config.DNS) { d.HideIdentity = true },
|
||||
})
|
||||
req := h.request("hidden-nsid.example.test.", dns.TypeA)
|
||||
req.Msg.SetEdns0(1232, false)
|
||||
req.Msg.IsEdns0().Option = append(req.Msg.IsEdns0().Option,
|
||||
&dns.EDNS0_NSID{Nsid: "abc"})
|
||||
requireRcode(t, h.askWith(req), dns.RcodeSuccess)
|
||||
if opt := findOption(h.up.received()[0], dns.EDNS0NSID); opt != nil {
|
||||
t.Fatal("dns.hide_identity is set but the query still advertises an NSID")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package zonefile
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nameTypeRdata renders every record as "name TYPE rdata", skipping the line
|
||||
// number and the TTL. The shipped examples are edited by hand, so pinning the
|
||||
// exact record set is enough; pinning line numbers would make a comment change
|
||||
// look like a parser bug.
|
||||
func nameTypeRdata(t *testing.T, z *Zone) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
parts := strings.SplitN(rec.RR.String(), "\t", 5)
|
||||
if len(parts) != 5 {
|
||||
t.Fatalf("record on line %d does not render as 5 tab-separated fields: %q",
|
||||
rec.Line, rec.Text())
|
||||
}
|
||||
out = append(out, parts[0]+" "+parts[3]+" "+parts[4])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestExampleLocalZoneIsOriginIndependent parses deploy/local.example.zone the
|
||||
// way the product parses it — with the origin taken from the file name, which
|
||||
// for local.example.zone is "local." — and pins that every name in the example
|
||||
// is fully qualified anyway.
|
||||
//
|
||||
// This is not a style test. The origin a master file is read with is its base
|
||||
// name, so a relative owner name silently becomes <name>.local. and the zone
|
||||
// answers for names nobody asked about. The example is what an operator copies,
|
||||
// and it used to mix relative owners with absolute ones: a file that looked
|
||||
// right and matched nothing.
|
||||
func TestExampleLocalZoneIsOriginIndependent(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "local.example.zone")
|
||||
if got, want := OriginFromPath(path), "local."; got != want {
|
||||
t.Fatalf("OriginFromPath(%q) = %q, want %q — the example depends on this origin",
|
||||
path, got, want)
|
||||
}
|
||||
|
||||
z, err := ParseFile(path, OriginFromPath(path))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile(%s): %v", path, err)
|
||||
}
|
||||
got := nameTypeRdata(t, z)
|
||||
want := []string{
|
||||
"lan. SOA ns1.lan. hostmaster.lan. 2024010101 3600 600 604800 300",
|
||||
"lan. NS ns1.lan.",
|
||||
"lan. NS ns2.lan.",
|
||||
"ns1.lan. A 192.168.1.2",
|
||||
"ns2.lan. A 192.168.1.3",
|
||||
"router.lan. A 192.168.1.1",
|
||||
"nas.lan. A 192.168.1.10",
|
||||
"nas.lan. AAAA fd00::10",
|
||||
"*.lan. A 192.168.1.50",
|
||||
"*.internal.lan. A 10.0.0.50",
|
||||
"_ntp._udp.lan. SRV 0 0 123 ns1.lan.",
|
||||
"_ldap._tcp.lan. SRV 10 0 389 nas.lan.",
|
||||
`_dmarc._domain.lan. TXT "v=DMARC1; p=reject; rua=mailto:dmarc.lan"`,
|
||||
`_lan. TXT "internal network, do not use on the open internet"`,
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("records mismatch\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
if z.DefaultTTL != 300 {
|
||||
t.Errorf("DefaultTTL = %d, want the $TTL 300", z.DefaultTTL)
|
||||
}
|
||||
for _, rec := range z.Records {
|
||||
if !strings.HasSuffix(rec.Name, ".") {
|
||||
t.Errorf("record %q is not fully qualified; the example is read with the "+
|
||||
"origin %q derived from the file name", rec.Name, z.Origin)
|
||||
}
|
||||
}
|
||||
if z.SOA == nil || z.SOA.Serial != 2024010101 {
|
||||
t.Errorf("SOA = %v, want the serial 2024010101", z.SOA)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user