Files
matsandClaude Sonnet 5.5 c6c5ca4ff7 Make the resolver pure DNS: JSON access log on stdout instead of a log store
The resolver kept its own query-log store (a bbolt ring, a live ring with SSE
fanout, rolling per-domain counters and dashboard time-series) for the console.
In a multi-node deployment each pod held a different slice of that, so every
view disagreed with itself, and the cost sat on the query path. Charts belong
in Grafana and log search in the log collector, so the store is gone.

internal/accesslog writes one JSON line per notable query to stdout. The default
is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped,
refused); log_cached and sample_rate add ordinary answers for a debugging
session. Lines go through a bounded buffer to a single writer goroutine and are
dropped, and counted in fancydns_access_log_dropped_total, when the stream
cannot keep up, so a stalled log pipe can never stall a query. The config key is
access_log (the old qlog block is ignored, like any unknown key).

The metrics gain what the dashboard used to compute: fancydns_group_queries_total
by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the
store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear,
/api/cache/top, the series rings, and the cache's per-name hit table, which took
a mutex and built a string on every cache hit.

Tests that read the old log now read the access-log lines; the contract test
drops the removed endpoints. The web console still calls them and is changed in
the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:53:04 +02:00

431 lines
17 KiB
Go

package api
import (
"encoding/base64"
"net/http"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func basicHeader(user, pass string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
}
func modeAPIKey(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "apikey",
APIKeys: []string{"rw-key"},
ReadOnlyAPIKeys: []string{"ro-key"},
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
}
}
func modeBasic(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "basic",
Username: "admin",
Password: "s3cret",
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
}
}
func modeOIDCWithAPIKey(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "oidc", APIKeys: []string{"machine-key"},
OIDCIssuer: "https://users.example.test", OIDCAudience: "https://dns.example.test", OIDCClientID: "fancy-dns",
TrustLoopback: false, SessionTTL: config.Duration(time.Hour),
}
}
func TestOIDCModeKeepsAPIKeyLoginSessionsUsable(t *testing.T) {
h := newHarness(t, modeOIDCWithAPIKey)
res := h.do(http.MethodPost, "/api/auth/login", `{"api_key":"machine-key"}`)
if res.status != http.StatusOK {
t.Fatalf("API key login = %d: %s", res.status, res.body)
}
token, _ := res.json(t)["token"].(string)
if token == "" {
t.Fatal("login returned an empty bearer token")
}
res = h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token)
if res.status != http.StatusOK {
t.Fatalf("session bearer in OIDC mode = %d: %s", res.status, res.body)
}
}
// TestAuthModeAPIKey covers every way a request can present a key in apikey
// mode, and the invariant that matters: the right key always works and every
// other credential never does.
func TestAuthModeAPIKey(t *testing.T) {
cases := []struct {
name string
header string
want int
wantName string
wantWrite bool
wantAuthMd string
}{
{name: "no credentials", header: "", want: http.StatusUnauthorized},
{name: "correct key", header: "X-API-Key: rw-key", want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
{name: "read-only key", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
{name: "wrong key", header: "X-API-Key: nope", want: http.StatusUnauthorized},
{name: "key prefix", header: "X-API-Key: rw-ke", want: http.StatusUnauthorized},
{name: "key with suffix", header: "X-API-Key: rw-keyx", want: http.StatusUnauthorized},
{name: "key case folded", header: "X-API-Key: RW-KEY", want: http.StatusUnauthorized},
{name: "read-only key keeps its label", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
// curl -u any:KEY is a common client convention and is supported.
{name: "key in the basic password field", header: "Authorization: " + basicHeader("any", "rw-key"), want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
{name: "wrong basic password", header: "Authorization: " + basicHeader("any", "nope"), want: http.StatusUnauthorized},
{name: "unsupported scheme", header: "Authorization: Digest whatever", want: http.StatusUnauthorized},
{name: "malformed authorization", header: "Authorization: Bearer", want: http.StatusUnauthorized},
{name: "unknown bearer token", header: "Authorization: Bearer deadbeef", want: http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, modeAPIKey)
res := h.do(http.MethodGet, "/api/whoami", "", tc.header)
if res.status != tc.want {
t.Fatalf("GET /api/whoami with %q = %d, want %d: %s", tc.header, res.status, tc.want, res.body)
}
if tc.want != http.StatusOK {
return
}
who := res.json(t)
if who["name"] != tc.wantName {
t.Errorf("name = %v, want %v", who["name"], tc.wantName)
}
if who["can_write"] != tc.wantWrite {
t.Errorf("can_write = %v, want %v", who["can_write"], tc.wantWrite)
}
if who["auth_mode"] != tc.wantAuthMd {
t.Errorf("auth_mode = %v, want %v", who["auth_mode"], tc.wantAuthMd)
}
})
}
}
// TestAuthModeBasic covers the password login, the bearer session it issues and
// logout, plus the per-request basic form.
func TestAuthModeBasic(t *testing.T) {
h := newHarness(t, modeBasic)
// Wrong password is refused and does not issue a token.
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"wrong"}`)
if res.status != http.StatusUnauthorized {
t.Fatalf("bad password login = %d, want 401: %s", res.status, res.body)
}
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"nobody","password":"s3cret"}`)
if res.status != http.StatusUnauthorized {
t.Fatalf("bad username login = %d, want 401: %s", res.status, res.body)
}
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
if res.status != http.StatusOK {
t.Fatalf("login = %d: %s", res.status, res.body)
}
body := res.json(t)
token, _ := body["token"].(string)
if token == "" {
t.Fatalf("login returned no token: %s", res.body)
}
if body["mode"] != "basic" || body["read_only"] != false {
t.Errorf("login payload mode/read_only = %v/%v", body["mode"], body["read_only"])
}
// The token is a bearer credential for the whole API.
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Errorf("bearer whoami = %d: %s", res.status, res.body)
}
// Basic auth also works per request, without a session.
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: "+basicHeader("admin", "s3cret")); res.status != http.StatusOK {
t.Errorf("basic whoami = %d: %s", res.status, res.body)
}
// An API key is not a credential in basic mode.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusUnauthorized {
t.Errorf("api key in basic mode = %d, want 401", res.status)
}
// Logout revokes the session; the token must stop working.
if res := h.do(http.MethodPost, "/api/auth/logout", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Fatalf("logout = %d: %s", res.status, res.body)
}
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusUnauthorized {
t.Errorf("revoked token = %d, want 401", res.status)
}
}
// TestSessionExpiry checks a session stops being accepted once it is past its
// TTL, so a stolen token is not valid forever.
func TestSessionExpiry(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeBasic(c)
c.API.Auth.SessionTTL = config.Duration(10 * time.Millisecond)
})
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
if res.status != http.StatusOK {
t.Fatalf("login = %d: %s", res.status, res.body)
}
token, _ := res.json(t)["token"].(string)
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Fatalf("fresh session = %d: %s", res.status, res.body)
}
waitFor(t, time.Second, "the session to expire", func() bool {
return h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token).status == http.StatusUnauthorized
})
}
// TestAuthModeNone pins mode "none": nothing is required, and the caller runs
// as a local principal that may write.
func TestAuthModeNone(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
})
res := h.do(http.MethodGet, "/api/whoami", "")
if res.status != http.StatusOK {
t.Fatalf("unauthenticated whoami in mode none = %d: %s", res.status, res.body)
}
who := res.json(t)
if who["mode"] != "loopback" || who["can_write"] != true {
t.Errorf("whoami = %+v, want a writable loopback principal", who)
}
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: totally-wrong"); res.status != http.StatusOK {
t.Errorf("a rejected key in mode none = %d, want 200: the mode wins", res.status)
}
// Basic auth is not a configured scheme in this mode.
if res := h.do(http.MethodPost, "/api/auth/login", `{"username":"a","password":"b"}`); res.status != http.StatusUnauthorized {
t.Errorf("login in mode none = %d, want 401", res.status)
}
}
// TestLoginRequestValidation covers the login endpoint's own input checks.
func TestLoginRequestValidation(t *testing.T) {
h := newHarness(t, modeAPIKey)
cases := []struct {
name string
body string
want int
}{
{"empty body", ``, http.StatusBadRequest},
{"no credentials", `{}`, http.StatusBadRequest},
{"malformed json", `{`, http.StatusBadRequest},
{"unknown field", `{"api_key":"rw-key","nope":1}`, http.StatusBadRequest},
{"wrong key", `{"api_key":"nope"}`, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := h.do(http.MethodPost, "/api/auth/login", tc.body)
if res.status != tc.want {
t.Errorf("login %q = %d, want %d: %s", tc.body, res.status, tc.want, res.body)
}
})
}
}
// TestReadOnlyKeyCannotWrite walks every mutating route with a read-only key:
// each one must be refused and none of them may reach its hook.
func TestReadOnlyKeyCannotWrite(t *testing.T) {
writes := []struct {
name string
method string
path string
body string
hook string
}{
{"cache flush", http.MethodPost, "/api/cache/flush", "", "FlushCache"},
{"domain add", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, "AddDomain"},
{"domain delete", http.MethodDelete, "/api/domains/a.example.com", "", "DeleteDomain"},
{"blocklist refresh", http.MethodPost, "/api/blocklists/refresh", "", "RefreshBlocklists"},
{"blocklist toggle", http.MethodPost, "/api/blocklists/toggle", `{"name":"a","enabled":true}`, "ToggleBlocklist"},
{"clients put", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "PutACL"},
{"upstreams put", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, "PutUpstream"},
{"settings put", http.MethodPut, "/api/settings", settingsBody(t, nil), ""},
{"settings reload", http.MethodPost, "/api/settings/reload", "", ""},
}
for _, tc := range writes {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, modeAPIKey)
before := h.readConfig()
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: ro-key")
if res.status != http.StatusForbidden {
t.Fatalf("%s %s with a read-only key = %d, want 403: %s", tc.method, tc.path, res.status, res.body)
}
if got := res.json(t)["error"]; got != "this key is read-only" {
t.Errorf("error = %v, want \"this key is read-only\"", got)
}
if tc.hook != "" && h.fake.count(tc.hook) != 0 {
t.Errorf("refused request still reached hook %s", tc.hook)
}
if h.readConfig() != before {
t.Errorf("%s %s rewrote the config file", tc.method, tc.path)
}
// The same request with a read-write key is allowed, so the 403
// above is about the key and not about the payload.
if res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("%s %s with a read-write key = %d: %s", tc.method, tc.path, res.status, res.body)
}
})
}
}
// TestConfigWriteDisabled checks allow_config_write: false refuses the settings
// editor with a valid key, while the other mutations still work.
func TestConfigWriteDisabled(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.AllowConfigWrite = false
})
before := h.readConfig()
res := h.do(http.MethodPut, "/api/settings", settingsBody(t, nil), "X-API-Key: rw-key")
if res.status != http.StatusForbidden {
t.Fatalf("settings put = %d, want 403: %s", res.status, res.body)
}
if got := res.json(t)["error"]; got != "config writes are disabled" {
t.Errorf("error = %v, want \"config writes are disabled\"", got)
}
if h.readConfig() != before {
t.Error("a refused settings write still rewrote the config file")
}
// The gate is specific to the config document.
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("cache flush with config writes disabled = %d, want 200", res.status)
}
}
// TestTrustLoopback checks trust_loopback lets a local client through with no
// credentials at all, and that a *wrong* credential is still refused: the
// loopback exemption is a fallback, not an override.
func TestTrustLoopback(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.Auth.TrustLoopback = true
})
res := h.do(http.MethodGet, "/api/whoami", "")
if res.status != http.StatusOK {
t.Fatalf("loopback whoami without a key = %d, want 200: %s", res.status, res.body)
}
// A key that does not match is not accepted as a key: the loopback
// exemption is the fallback for a request with no usable credential, so a
// local client lands on the loopback principal rather than a named one.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusOK {
t.Fatalf("loopback with a wrong key = %d, want 200: %s", res.status, res.body)
} else if name := res.json(t)["name"]; name != "loopback" {
t.Errorf("a wrong key on loopback ran as %v, want the loopback principal", name)
}
// Without trust_loopback the same request is refused.
hNoTrust := newHarness(t, modeAPIKey)
if res := hNoTrust.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusUnauthorized {
t.Errorf("wrong key without trust_loopback = %d, want 401", res.status)
}
// A trusted proxy in front of a loopback client does not extend the
// exemption to the forwarded address, which is not loopback.
h2 := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.Auth.TrustLoopback = true
c.API.TrustedProxies = []string{"127.0.0.0/8"}
})
res = h2.do(http.MethodGet, "/api/whoami", "", "X-Forwarded-For: 198.51.100.7")
if res.status != http.StatusUnauthorized {
t.Errorf("forwarded non-loopback client = %d, want 401", res.status)
}
}
// TestAPIKeysAreNotStoredInPlaintext pins the hashing: the authenticator keys
// its maps on the SHA-256 of the credential, so the configured key never sits
// in the long-lived structure as itself.
func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) {
a := newAuthenticator(config.Auth{
Mode: "apikey",
APIKeys: []string{"rw-key"},
ReadOnlyAPIKeys: []string{"ro-key"},
Username: "admin",
Password: "s3cret",
SessionTTL: config.Duration(time.Hour),
})
if label, ok := a.readWrite[hashKey("rw-key")]; !ok || label != "rw-1" {
t.Errorf("readWrite is not keyed on the key hash: %v", a.readWrite)
}
if label, ok := a.readOnly[hashKey("ro-key")]; !ok || label != "ro-1" {
t.Errorf("readOnly is not keyed on the key hash: %v", a.readOnly)
}
if a.passwordHash == "s3cret" || a.passwordHash != hashKey("s3cret") {
t.Errorf("passwordHash = %q, want the sha256 of the password", a.passwordHash)
}
for _, stored := range append(mapKeys(a.readWrite), mapKeys(a.readOnly)...) {
if stored == "rw-key" || stored == "ro-key" || stored == "s3cret" {
t.Errorf("a credential is stored in plaintext: %q", stored)
}
if len(stored) != 64 {
t.Errorf("stored form %q is not a hex sha256", stored)
}
}
}
func mapKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestAPIModeReported checks AuthMode/whoami report the configured mode, which
// is what the console uses to decide whether to offer a login form.
func TestAPIModeReported(t *testing.T) {
cases := []struct {
mode string
want string
}{
{"apikey", "apikey"},
{"basic", "basic"},
{"none", "none"},
}
for _, tc := range cases {
t.Run(tc.mode, func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
if tc.mode == "basic" {
modeBasic(c)
} else if tc.mode == "none" {
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
}
})
if got := h.srv.AuthMode(); got != tc.want {
t.Errorf("AuthMode() = %q, want %q", got, tc.want)
}
auth := "X-API-Key: rw-key"
if tc.mode == "basic" {
auth = "Authorization: " + basicHeader("admin", "s3cret")
} else if tc.mode == "none" {
auth = ""
}
res := h.do(http.MethodGet, "/api/whoami", "", auth)
if res.status != http.StatusOK {
t.Fatalf("whoami = %d: %s", res.status, res.body)
}
if got := res.json(t)["auth_mode"]; got != tc.want {
t.Errorf("whoami auth_mode = %v, want %q", got, tc.want)
}
})
}
}
// TestBasicAuthDisabledWithoutPassword checks basic mode with no configured
// password refuses rather than accepting an empty one.
func TestBasicAuthDisabledWithoutPassword(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth = config.Auth{Mode: "basic", Username: "admin", SessionTTL: config.Duration(time.Hour)}
})
if err := h.cfg.Validate(); err != nil && !strings.Contains(err.Error(), "username") {
t.Fatalf("unexpected validation error: %v", err)
}
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":""}`)
if res.status != http.StatusUnauthorized {
t.Errorf("login with no password configured = %d, want 401: %s", res.status, res.body)
}
}