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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 09:53:04 +02:00
1 parent dd1117fd91
commit c6c5ca4ff7
31 files changed
+818 -3389

No files matched your search

-55
View File
@@ -53,8 +53,6 @@ type Hooks struct {
// StartedAt is the process start time.
StartedAt time.Time
// Dashboard returns the aggregated dashboard payload.
Dashboard func(ctx context.Context, rangeKey string) (any, error)
// BlocklistState returns the compiled blocklist detail.
BlocklistState func() any
// BlocklistStats returns the compiled rule counts.
@@ -107,18 +105,8 @@ type Hooks struct {
GetUpstream func() any
// PutUpstream validates and persists a new upstream config.
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
// SearchLog pages the query log.
SearchLog func(ctx context.Context, q LogQuery) (any, error)
// LogStats is the live stats event pushed over SSE.
LogStats func() any
// SubscribeLog streams new log entries; the cancel func must be called.
SubscribeLog func() (<-chan LogEntry, func())
// ClearLog empties the query log.
ClearLog func() error
// CacheStats returns the cache snapshot.
CacheStats func() any
// CacheTop returns the hottest cached names.
CacheTop func(limit int) any
// FlushCache empties the cache.
FlushCache func() int64
// Health returns the health payload for /healthz.
@@ -153,44 +141,6 @@ type ZoneFile struct {
Content string `json:"content"`
}
// LogQuery pages the query log.
type LogQuery struct {
Limit int
Offset int
Text string
Action string
Client string
RCode int
BlockedOnly bool
Since time.Time
}
// LogEntry is one query-log row as the admin API serves it.
//
// These fields are not omitempty, and that is deliberate: omitempty makes the
// key set depend on the row, so a query with no ACL group arrived without
// `client_group` and the console rendered "undefined" where the column should
// be empty. The contract test in internal/app pins the key set, and it can
// only pin one shape if the server emits one.
type LogEntry struct {
ID uint64 `json:"id"`
TS time.Time `json:"ts"`
Client string `json:"client"`
Group string `json:"client_group"`
Name string `json:"name"`
QType uint16 `json:"qtype"`
QTypeStr string `json:"qtype_name"`
RCode int `json:"rcode"`
RCodeStr string `json:"rcode_name"`
Action string `json:"action"`
Response string `json:"response"`
Duration int64 `json:"duration_ms"`
Upstream string `json:"upstream"`
List string `json:"list"`
Protocol string `json:"protocol"`
Blocked bool `json:"blocked"`
}
// Server is the admin HTTP server.
type Server struct {
hooks Hooks
@@ -284,7 +234,6 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
// Everything below requires a principal.
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
@@ -311,11 +260,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
-1
View File
@@ -237,7 +237,6 @@ func TestReadOnlyKeyCannotWrite(t *testing.T) {
hook string
}{
{"cache flush", http.MethodPost, "/api/cache/flush", "", "FlushCache"},
{"logs clear", http.MethodPost, "/api/logs/clear", "", "ClearLog"},
{"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"},
-3
View File
@@ -37,9 +37,7 @@ func TestHookErrorsAreReported(t *testing.T) {
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},
@@ -50,7 +48,6 @@ func TestHookErrorsAreReported(t *testing.T) {
{"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) {
+2 -144
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -56,20 +55,6 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
})
}
// handleDashboard serves the aggregated dashboard payload.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if s.hooks.Dashboard == nil {
writeError(w, http.StatusNotImplemented, "dashboard not available")
return
}
v, err := s.hooks.Dashboard(r.Context(), r.URL.Query().Get("range"))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleBlocklists serves the compiled blocklist state.
func (s *Server) handleBlocklists(w http.ResponseWriter, _ *http.Request) {
out := map[string]any{}
@@ -470,122 +455,6 @@ func (s *Server) handleUpstreamsPut(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version})
}
// handleLogs pages the query log.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
if s.hooks.SearchLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
q := LogQuery{
Limit: queryInt(r, "limit", 200),
Offset: queryInt(r, "offset", 0),
Text: r.URL.Query().Get("q"),
Action: r.URL.Query().Get("action"),
Client: r.URL.Query().Get("client"),
RCode: queryInt(r, "rcode", 0),
BlockedOnly: queryBool(r, "blocked"),
Since: queryTime(r, "since"),
}
v, err := s.hooks.SearchLog(r.Context(), q)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
// handleLogStream is the Server-Sent Events endpoint feeding the live view.
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// Without this, a reverse proxy buffers the stream and the UI shows
// nothing until the buffer fills.
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
send := func(event string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return true
}
// A single-line data field is what the SSE grammar requires, so the
// payload is compact JSON with no newlines (json.Marshal never emits
// a raw newline).
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
return false
}
flusher.Flush()
return true
}
// An initial event tells the client the stream is live, so the UI can show
// a "connected" state before any query happens.
if !send("ready", map[string]any{"at": time.Now(), "version": s.hooks.Version}) {
return
}
var (
entries <-chan LogEntry
cancel func()
)
if s.hooks.SubscribeLog != nil {
entries, cancel = s.hooks.SubscribeLog()
defer cancel()
}
stats := time.NewTicker(time.Second)
defer stats.Stop()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case e, ok := <-entries:
if !ok {
return
}
if !send("entry", e) {
return
}
case <-stats.C:
if s.hooks.LogStats == nil {
continue
}
if !send("stats", s.hooks.LogStats()) {
return
}
case <-ping.C:
// A comment frame keeps proxies from timing the stream out.
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// handleLogsClear empties the query log.
func (s *Server) handleLogsClear(w http.ResponseWriter, _ *http.Request) {
if s.hooks.ClearLog == nil {
writeError(w, http.StatusNotImplemented, "query log not available")
return
}
if err := s.hooks.ClearLog(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleCache returns the cache snapshot.
func (s *Server) handleCache(w http.ResponseWriter, _ *http.Request) {
if s.hooks.CacheStats == nil {
@@ -595,17 +464,6 @@ func (s *Server) handleCache(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.hooks.CacheStats())
}
// handleCacheTop returns the hottest cached names.
func (s *Server) handleCacheTop(w http.ResponseWriter, r *http.Request) {
if s.hooks.CacheTop == nil {
writeError(w, http.StatusNotImplemented, "cache not available")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": s.hooks.CacheTop(queryInt(r, "limit", 50)),
})
}
// handleCacheFlush empties the cache.
func (s *Server) handleCacheFlush(w http.ResponseWriter, _ *http.Request) {
if s.hooks.FlushCache == nil {
@@ -856,8 +714,8 @@ func configWarnings(cfg *config.Config) []string {
add("upstream.servers[%s]: insecure_skip_verify disables TLS validation", u.Addr)
}
}
if cfg.QueryLog.Enabled && cfg.QueryLog.SampleRate == 1 && cfg.QueryLog.LogCached {
add("qlog: logging every cached query on a busy instance is a lot of disk for little value; consider lowering sample_rate")
if cfg.AccessLog.Enabled && cfg.AccessLog.SampleRate == 1 {
add("access_log: sample_rate 1 logs every query; that is for short debugging sessions, the metrics already count ordinary traffic")
}
if cfg.Metrics.Enabled && !cfg.Metrics.Protect {
add("metrics.protect: /metrics is unauthenticated and exposes client and domain statistics")
-61
View File
@@ -48,19 +48,14 @@ type fakeHooks struct {
// arguments the API passed down, for the routes where the argument is
// the interesting part of the contract.
dashRange []string
domainQuery []DomainQuery
domainAdd []DomainInput
domainDel []domainDelete
aclPut []aclPut
upstreamPut []config.Upstream
logQuery []LogQuery
refresh [][]string
toggle []toggleCall
cacheTop []int
clearLog int
flushes int
logStats int
// fail maps a hook name to the error it should return, so the error
// branches of a handler can be reached without a real component.
@@ -68,12 +63,6 @@ type fakeHooks struct {
// panicOn names hooks that panic instead of returning, which is how the
// recoverer gets exercised.
panicOn map[string]bool
// stream plumbing for the SSE test.
stream chan LogEntry
unsub chan struct{}
unsubOnce sync.Once
subs int
}
type domainDelete struct {
@@ -96,8 +85,6 @@ func newFakeHooks() *fakeHooks {
return &fakeHooks{
fail: map[string]error{},
panicOn: map[string]bool{},
stream: make(chan LogEntry, 16),
unsub: make(chan struct{}),
}
}
@@ -174,10 +161,6 @@ func (f *fakeHooks) reset() {
func (f *fakeHooks) hooks() Hooks {
return Hooks{
Dashboard: func(_ context.Context, rangeKey string) (any, error) {
f.record("Dashboard", func() { f.dashRange = append(f.dashRange, rangeKey) })
return map[string]any{"range": rangeKey}, f.err("Dashboard")
},
BlocklistState: func() any {
f.record("BlocklistState", func() {})
return map[string]any{"lists": []any{}}
@@ -254,40 +237,10 @@ func (f *fakeHooks) hooks() Hooks {
f.record("PutUpstream", func() { f.upstreamPut = append(f.upstreamPut, up) })
return 43, f.err("PutUpstream")
},
SearchLog: func(_ context.Context, q LogQuery) (any, error) {
f.record("SearchLog", func() { f.logQuery = append(f.logQuery, q) })
return map[string]any{"entries": []any{}}, f.err("SearchLog")
},
LogStats: func() any {
f.mu.Lock()
f.logStats++
f.mu.Unlock()
return map[string]any{"qps": 1}
},
SubscribeLog: func() (<-chan LogEntry, func()) {
f.mu.Lock()
f.subs++
f.calls = append(f.calls, "SubscribeLog")
f.mu.Unlock()
return f.stream, func() {
f.mu.Lock()
f.subs--
f.mu.Unlock()
f.unsubOnce.Do(func() { close(f.unsub) })
}
},
ClearLog: func() error {
f.record("ClearLog", func() { f.clearLog++ })
return f.err("ClearLog")
},
CacheStats: func() any {
f.record("CacheStats", func() {})
return map[string]any{"entries": 0}
},
CacheTop: func(limit int) any {
f.record("CacheTop", func() { f.cacheTop = append(f.cacheTop, limit) })
return []any{}
},
FlushCache: func() int64 {
f.record("FlushCache", func() { f.flushes++ })
return 0
@@ -304,20 +257,6 @@ func (f *fakeHooks) hooks() Hooks {
}
}
// subscribers is the number of live SSE subscribers; it must be back to zero
// once a client has disconnected.
func (f *fakeHooks) subscribers() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.subs
}
func (f *fakeHooks) statsCalls() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.logStats
}
// harness is a wired Server behind an httptest listener, with a config file on
// disk so the settings and reload routes have something real to work on.
type harness struct {
+2 -10
View File
@@ -21,7 +21,6 @@ func TestRouteTableHappyPath(t *testing.T) {
want string // substring the response must contain
}{
{name: "healthz", method: http.MethodGet, path: "/api/healthz", hook: "Health", want: `"status":"ok"`},
{name: "dashboard", method: http.MethodGet, path: "/api/dashboard?range=1h", hook: "Dashboard", want: `"range":"1h"`},
{name: "status", method: http.MethodGet, path: "/api/status", hook: "Status", want: `"version":"test"`},
{name: "blocklists", method: http.MethodGet, path: "/api/blocklists", hook: "BlocklistState", want: `"lists"`},
{name: "blocklist refresh all", method: http.MethodPost, path: "/api/blocklists/refresh", hook: "RefreshBlocklists", want: `"started":true`},
@@ -41,10 +40,7 @@ func TestRouteTableHappyPath(t *testing.T) {
{name: "clients put", method: http.MethodPut, path: "/api/clients", body: `{"groups":[],"default_policy":"allow"}`, hook: "PutACL", want: `"version":42`},
{name: "upstreams get", method: http.MethodGet, path: "/api/upstreams", hook: "GetUpstream", want: `"policy":"parallel"`},
{name: "upstreams put", method: http.MethodPut, path: "/api/upstreams", body: `{"policy":"parallel","servers":[{"addr":"127.0.0.1:53","proto":"udp"}]}`, hook: "PutUpstream", want: `"version":43`},
{name: "logs search", method: http.MethodGet, path: "/api/logs?limit=5", hook: "SearchLog", want: `"entries"`},
{name: "logs clear", method: http.MethodPost, path: "/api/logs/clear", hook: "ClearLog", want: `"ok":true`},
{name: "cache stats", method: http.MethodGet, path: "/api/cache", hook: "CacheStats", want: `"entries":0`},
{name: "cache top", method: http.MethodGet, path: "/api/cache/top?limit=7", hook: "CacheTop", want: `"items"`},
{name: "cache flush", method: http.MethodPost, path: "/api/cache/flush", hook: "FlushCache", want: `"ok":true`},
{name: "settings get", method: http.MethodGet, path: "/api/settings", hook: "", want: `"config_yaml"`},
{name: "settings reload", method: http.MethodPost, path: "/api/settings/reload", hook: "", want: `"ok":true`},
@@ -109,7 +105,6 @@ func TestReadRoutesDoNotMutate(t *testing.T) {
path string
}{
{http.MethodGet, "/api/healthz"},
{http.MethodGet, "/api/dashboard?range=24h"},
{http.MethodGet, "/api/status"},
{http.MethodGet, "/api/blocklists"},
{http.MethodGet, "/api/domains"},
@@ -117,9 +112,7 @@ func TestReadRoutesDoNotMutate(t *testing.T) {
{http.MethodGet, "/api/zones"},
{http.MethodGet, "/api/clients"},
{http.MethodGet, "/api/upstreams"},
{http.MethodGet, "/api/logs"},
{http.MethodGet, "/api/cache"},
{http.MethodGet, "/api/cache/top"},
{http.MethodGet, "/api/settings"},
{http.MethodGet, "/api/whoami"},
}
@@ -131,7 +124,6 @@ func TestReadRoutesDoNotMutate(t *testing.T) {
}
mutations := []string{
"RefreshBlocklists", "ToggleBlocklist", "AddDomain", "DeleteDomain",
"PutACL", "PutUpstream", "ClearLog", "FlushCache",
"AddZoneRecord", "PutZoneRecord", "DeleteZoneRecord",
"AddZoneDelegation", "DeleteZoneDelegation", "DeleteZone",
}
@@ -154,10 +146,10 @@ func TestReadRoutesDoNotMutate(t *testing.T) {
func TestHandlerRecoversFromPanic(t *testing.T) {
h := newHarness(t, nil)
h.fake.mu.Lock()
h.fake.panicOn["Dashboard"] = true
h.fake.panicOn["CacheStats"] = true
h.fake.mu.Unlock()
res := h.get("/api/dashboard")
res := h.get("/api/cache")
if res.status != http.StatusInternalServerError {
t.Fatalf("panicking hook = %d, want 500: %s", res.status, res.body)
}
-243
View File
@@ -1,243 +0,0 @@
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")
}
}