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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user