Found by rolling the resolver out to the real cluster. BlockList objects have no path, deliberately, so the first rollout downloaded the list at every start. The cluster DNS that the download needs is this very resolver, which was not up yet, so the first compile failed after 14 seconds, held the DNS listeners closed for that long, and left the pod with no ad blocking at all. Three changes: * Lists from cluster objects get a copy under the runtime's own data directory (data/lists/<name>), chosen by the runtime from the object's name, never by the object. * The copy is used as the answer only while it is younger than the list's refresh interval, and otherwise the list is downloaded again, with the old copy as the fallback if that fails. Before, any copy on disk was treated as current for ever, so a list with a path was frozen at its first download. A source now ages from its file, an operator's "refresh now" forces a download, and a failed download keeps the stale copy. * Startup loads only what is on disk (RefreshOffline), opens the listeners, and fetches stale or missing lists in the background. The resolver answers at once and the network is never on its critical path. An address override answered as a redirect and so counted as a policy action: with the access log logging redirects, every lookup of an internal name wrote a line. Overrides from the managed source are local answers now; a redirect a list imposes is still logged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
210 lines
7.0 KiB
Go
210 lines
7.0 KiB
Go
package blocklist
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
type listFixture struct {
|
|
t *testing.T
|
|
srv *httptest.Server
|
|
hits atomic.Int32
|
|
fail atomic.Bool
|
|
body atomic.Value // string
|
|
path string
|
|
mgr *Manager
|
|
cfgSrc *config.Store
|
|
}
|
|
|
|
func newListFixture(t *testing.T, refresh time.Duration) *listFixture {
|
|
t.Helper()
|
|
f := &listFixture{t: t, path: filepath.Join(t.TempDir(), "lists", "ads")}
|
|
f.body.Store("0.0.0.0 first.example\n")
|
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.hits.Add(1)
|
|
if f.fail.Load() {
|
|
http.Error(w, "down", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
fmt.Fprint(w, f.body.Load().(string))
|
|
}))
|
|
t.Cleanup(f.srv.Close)
|
|
|
|
cfg := config.Default()
|
|
cfg.Block.Enabled = true
|
|
cfg.Block.Lists = []config.ListSource{{
|
|
Name: "ads", Format: "hosts", URL: f.srv.URL, Path: f.path, Enabled: true,
|
|
Refresh: config.Duration(refresh), MaxBytes: 1 << 20, Timeout: config.Duration(2 * time.Second),
|
|
}}
|
|
f.cfgSrc = config.NewStore(cfg, "")
|
|
f.mgr = NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))})
|
|
return f
|
|
}
|
|
|
|
func (f *listFixture) blocks(name string) bool {
|
|
set := f.mgr.Holder().Get()
|
|
return set != nil && set.Match(name) != nil
|
|
}
|
|
|
|
func (f *listFixture) age(d time.Duration) {
|
|
f.t.Helper()
|
|
old := time.Now().Add(-d)
|
|
if err := os.Chtimes(f.path, old, old); err != nil {
|
|
f.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// A restart should come up with the list from disk, not wait for the network:
|
|
// the network may be this resolver's own cluster DNS, which is not up yet.
|
|
func TestFreshCopyStartsWithoutTheNetwork(t *testing.T) {
|
|
f := newListFixture(t, 24*time.Hour)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil { // first start: downloads and keeps a copy
|
|
t.Fatal(err)
|
|
}
|
|
if f.hits.Load() != 1 || !f.blocks("first.example") {
|
|
t.Fatalf("setup: hits=%d", f.hits.Load())
|
|
}
|
|
f.fail.Store(true)
|
|
again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()})
|
|
if _, err := again.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.hits.Load() != 1 {
|
|
t.Errorf("a fresh copy still went to the network (%d requests)", f.hits.Load())
|
|
}
|
|
if set := again.Holder().Get(); set == nil || set.Match("first.example") == nil {
|
|
t.Error("the restarted manager did not load the copy from disk")
|
|
}
|
|
}
|
|
|
|
// A copy past its refresh interval must be replaced; treating any copy as
|
|
// current froze a list at its first download for good.
|
|
func TestStaleCopyIsDownloadedAgain(t *testing.T) {
|
|
f := newListFixture(t, time.Hour)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.body.Store("0.0.0.0 second.example\n")
|
|
f.age(2 * time.Hour)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.hits.Load() != 2 || !f.blocks("second.example") || f.blocks("first.example") {
|
|
t.Errorf("hits=%d second=%v first=%v: the stale copy was not replaced", f.hits.Load(), f.blocks("second.example"), f.blocks("first.example"))
|
|
}
|
|
if b, _ := os.ReadFile(f.path); string(b) != "0.0.0.0 second.example\n" {
|
|
t.Errorf("the copy on disk was not updated: %q", b)
|
|
}
|
|
}
|
|
|
|
// And when the download fails, the stale copy still stands in: a stale list
|
|
// beats no list.
|
|
func TestStaleCopyStandsInWhenTheDownloadFails(t *testing.T) {
|
|
f := newListFixture(t, time.Hour)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.age(2 * time.Hour)
|
|
f.fail.Store(true)
|
|
again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()})
|
|
if _, err := again.RefreshAll(context.Background()); err != nil {
|
|
t.Fatalf("refresh with a stale copy and a dead server: %v", err)
|
|
}
|
|
if set := again.Holder().Get(); set == nil || set.Match("first.example") == nil {
|
|
t.Error("the stale copy was not used")
|
|
}
|
|
}
|
|
|
|
func TestForceRefreshDownloadsAFreshCopy(t *testing.T) {
|
|
f := newListFixture(t, 24*time.Hour)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.body.Store("0.0.0.0 second.example\n")
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil || f.hits.Load() != 1 {
|
|
t.Fatalf("a plain refresh of a fresh copy should not download: hits=%d err=%v", f.hits.Load(), err)
|
|
}
|
|
if _, err := f.mgr.ForceRefresh(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.hits.Load() != 2 || !f.blocks("second.example") {
|
|
t.Errorf("a forced refresh did not download: hits=%d", f.hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestNoCopyAndNoNetworkIsAnError(t *testing.T) {
|
|
f := newListFixture(t, time.Hour)
|
|
f.fail.Store(true)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err == nil && f.blocks("first.example") {
|
|
t.Fatal("blocked a name with no list data")
|
|
}
|
|
}
|
|
|
|
// The source ages from its file, so the scheduled refresh is due when the copy
|
|
// is, not a whole interval after a restart.
|
|
func TestSourceAgeFollowsTheCopy(t *testing.T) {
|
|
f := newListFixture(t, 10*time.Hour)
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.age(9 * time.Hour)
|
|
again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()})
|
|
if _, err := again.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src, ok := again.previous("ads")
|
|
if !ok {
|
|
t.Fatal("no source")
|
|
}
|
|
if age := time.Since(src.Meta.UpdatedAt); age < 8*time.Hour || age > 10*time.Hour {
|
|
t.Errorf("source age = %v, want about 9h (the copy's)", age)
|
|
}
|
|
}
|
|
|
|
// Startup must not wait for the network: the offline pass serves what is on
|
|
// disk, however old, and skips what was never downloaded; a normal pass then
|
|
// updates it.
|
|
func TestOfflinePassNeverTouchesTheNetwork(t *testing.T) {
|
|
f := newListFixture(t, time.Hour)
|
|
st, _ := f.mgr.RefreshOffline(context.Background())
|
|
if st.Errors["ads"] == "" {
|
|
t.Errorf("a list with no copy should be reported as not downloaded yet: %+v", st.Errors)
|
|
}
|
|
if f.hits.Load() != 0 || f.blocks("first.example") {
|
|
t.Fatalf("hits=%d: an offline pass reached for the network", f.hits.Load())
|
|
}
|
|
|
|
if _, err := f.mgr.RefreshAll(context.Background()); err != nil { // the background pass
|
|
t.Fatal(err)
|
|
}
|
|
f.body.Store("0.0.0.0 second.example\n")
|
|
f.age(5 * time.Hour) // stale
|
|
again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()})
|
|
if _, err := again.RefreshOffline(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.hits.Load() != 1 {
|
|
t.Errorf("hits=%d: the offline pass downloaded a stale copy", f.hits.Load())
|
|
}
|
|
if set := again.Holder().Get(); set == nil || set.Match("first.example") == nil {
|
|
t.Error("the stale copy was not served while offline")
|
|
}
|
|
// ...and the pass that follows brings it up to date.
|
|
if _, err := again.RefreshAll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.hits.Load() != 2 || again.Holder().Get().Match("second.example") == nil {
|
|
t.Errorf("hits=%d: the follow-up pass did not refresh the stale copy", f.hits.Load())
|
|
}
|
|
}
|