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

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

No files matched your search

+28 -8
View File
@@ -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
+8 -7
View File
@@ -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.
-7
View File
@@ -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 {
+213
View File
@@ -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")
}
}
+129
View File
@@ -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)
}
}
+136
View File
@@ -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)
}
}
+356
View File
@@ -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)
}
}
+492
View File
@@ -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)
}
})
}
+243
View File
@@ -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")
}
}