Remove the ConfigMap, RPZ and managed-directory editors the cluster objects replace

Zones, address redirects, inline rules, lists and groups are cluster objects now
(see the previous commits and deploy/MIGRATION.md), so the mechanisms that held
them are gone:

* internal/kubezones and the resolver's service-account access to a ConfigMap,
  the address-overrides read/write path and its poller, the zone-file poller and
  the Local zones editor with its /api/local-zones routes.
* localzone.Managed, the directory of console-written master files, and the
  blocklist.managed_zones key. The resolver now writes no zone data at all; its
  zone hooks only list. The shared half of that file (the record validator, the
  renderer and the round-trip check) stays as zonestate.go, because DNSZone
  rendering is built on it. Its tests are ported to RenderZone, which keeps the
  coverage of every editable record type, wildcards, relative owners, the
  SOA/NS rule and the delegation rule.
* Tests of the removed write paths: the contract test now builds its zone from a
  DNSZone, and a new test pins that every zone write route on the resolver is
  refused and changes nothing.

Standalone installs keep editing master files on disk and SIGHUP, and the
YAML-backed domain, client, upstream and settings editors, which have not moved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:17:17 +02:00
1 parent 2f25463347
commit 467733a7f9
21 files changed
+505 -2312

No files matched your search

+18 -55
View File
@@ -29,7 +29,6 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubezones"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
@@ -63,29 +62,22 @@ type App struct {
cfg *config.Store
ver string
blocks *blocklist.Manager
overrideMu sync.Mutex
cache *cache.Manager
cstore *cache.Store
logger *accesslog.Logger
aclH *acl.Holder
local *localzone.Holder
// zones is the Kubernetes-sourced zone client.
zones *kubezones.Client
blocks *blocklist.Manager
cache *cache.Manager
cstore *cache.Store
logger *accesslog.Logger
aclH *acl.Holder
local *localzone.Holder
// kube watches the cluster's fancy-dns objects when enabled; kubeSnap is
// what the config overlay merges in.
kube *kubesource.Source
kubeSnap atomic.Pointer[kubesource.Snapshot]
// managedZones is the console-managed zone directory: the only zone data
// the admin API may rewrite. An empty directory is what makes the
// console's Zones page read-only.
managedZones *localzone.Managed
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
dns *server.Server
api *api.Server
cl *cluster.Cluster
pool *resolver.Pool
reverse *engine.ReverseCache
engine *engine.Engine
dns *server.Server
api *api.Server
cl *cluster.Cluster
apiServers []*http.Server
apiLn []net.Listener
@@ -116,11 +108,7 @@ func New(opt Options) (*App, error) {
return nil, err
}
store := config.NewStore(cfg, opt.ConfigPath)
zones, err := kubezones.InCluster()
if err != nil {
return nil, err
}
return &App{opt: opt, log: log, cfg: store, ver: opt.Version, zones: zones}, nil
return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil
}
// newLogger builds the structured logger from config plus CLI overrides.
@@ -322,22 +310,9 @@ func (a *App) startBlocklist() {
Config: a.cfg,
Logger: a.log,
})
if a.zones != nil {
ctx := context.Background()
content, exists, err := a.zones.Data(ctx, "address-overrides.rpz")
if err == nil && !exists {
if seed, readErr := os.ReadFile("/etc/fancy-dns/rpz/address-overrides.rpz"); readErr == nil {
content = string(seed)
if saveErr := a.zones.SaveData(ctx, "address-overrides.rpz", content); saveErr != nil {
a.log.Warn("seed address overrides failed", "error", saveErr)
}
}
}
if err == nil {
a.blocks.SetManagedRPZ(content)
} else {
a.log.Warn("load address overrides failed", "error", err)
}
// The cluster's address overrides, if the Kubernetes source has any yet.
if snap := a.kubeSnap.Load(); snap != nil {
a.blocks.SetManagedRPZ(kubesource.RenderRPZ(snap.Overrides))
}
// Compile once before serving so the first queries are not unprotected.
if _, err := a.blocks.RefreshAll(context.Background()); err != nil {
@@ -349,14 +324,9 @@ func (a *App) startBlocklist() {
// startLocalZones compiles the local master files.
func (a *App) startLocalZones(cfg *config.Config) {
a.local = localzone.NewHolder(nil)
a.managedZones = localzone.NewManaged(cfg.AbsPath(cfg.Block.ManagedZones))
a.reloadLocalZones()
}
// managedZoneDir is the directory the console may write zone files into, or
// "" when the console is read-only.
func (a *App) managedZoneDir() string { return a.managedZones.Dir() }
// reloadLocalZones recompiles the local zone store. A failure keeps the
// previous store: serving stale local data beats serving nothing.
func (a *App) reloadLocalZones() {
@@ -367,13 +337,8 @@ func (a *App) reloadLocalZones() {
for _, p := range cfg.Block.LocalZones {
paths = append(paths, cfg.AbsPath(p))
}
// The managed directory is a zone source like any other, so a zone the
// console created is served by the same store. It is only added when it
// exists: the loader stats every path it is given, and a directory the
// console has not written to yet is not a broken configuration.
if a.managedZones.Exists() {
paths = append(paths, a.managedZones.Dir())
}
// DNSZone objects are rendered into a directory of their own, which is a
// zone source like any other.
if dir := a.kubeZoneDir(); dir != "" {
paths = append(paths, dir)
}
@@ -600,8 +565,6 @@ func (a *App) startCluster(ctx context.Context, cfg *config.Config) {
// startBackground runs the periodic jobs: cache snapshots, upstream health
// probes and the metrics gauge.
func (a *App) startBackground(ctx context.Context, cfg *config.Config) {
go a.watchLocalZones(ctx)
go a.watchAddressOverrides(ctx)
// Cache snapshots.
if a.cstore != nil {
interval := cfg.Cache.Persist.Interval.D()
+23 -16
View File
@@ -2,16 +2,19 @@ package app
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
// The contract tests in this file exist because the console's TypeScript types
@@ -469,21 +472,25 @@ func TestRemainingEndpointKeySets(t *testing.T) {
// record is the thing the console renders row by row. So this builds a zone
// through the write endpoint, delegates a name in it, and pins every level.
func TestZoneEndpointKeySets(t *testing.T) {
managed := filepath.Join(t.TempDir(), "zones")
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
write := func(t *testing.T, method, path, body string) {
t.Helper()
code, out := h.do(method, path, body)
if code != http.StatusOK {
t.Fatalf("%s %s = %d: %s", method, path, code, out)
}
client := kubeClient()
h := newHarness(t, nil, func(o *Options) {
o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: client, SyncTimeout: 5 * time.Second}
})
// A zone with a record and a delegation, from the cluster.
if _, err := client.Resource(kubesource.ZoneGVR).Namespace("dns").Create(context.Background(), kubeObj("DNSZone", "console", map[string]any{
"zone": "console.test",
"records": []any{
map[string]any{"name": "@", "type": "NS", "value": "ns1.console.test."},
map[string]any{"name": "www", "type": "A", "ttl": int64(60), "value": "10.9.9.9"},
map[string]any{"name": "sub", "type": "NS", "value": "ns1.sub.console.test."},
},
}), metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
// A zone needs a server list before it can hold anything, and a
// delegation is the other way to start one.
write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
write(t, http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
write(t, http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
eventually(t, "the zone to be listed", func() bool {
_, body := h.get("/api/zones")
return strings.Contains(string(body), "console.test.")
})
t.Run("zones", func(t *testing.T) {
code, body := h.get("/api/zones")
+24 -187
View File
@@ -17,49 +17,36 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
)
// hooks builds the api.Hooks that connect the admin API to the live
// components. Everything the UI can read or change goes through here.
func (a *App) hooks() api.Hooks {
return api.Hooks{
Store: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
Resolve: a.resolveTest,
ResolveUpstream: a.resolveTestUpstream,
AddressOverrides: a.listAddressOverrides,
SaveAddressOverride: a.saveAddressOverride,
DeleteAddressOverride: a.deleteAddressOverride,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
ListZones: a.listZones,
AddZoneRecord: a.addZoneRecord,
PutZoneRecord: a.putZoneRecord,
DeleteZoneRecord: a.deleteZoneRecord,
DeleteZone: a.deleteZone,
AddZoneDelegation: a.addZoneDelegation,
DeleteZoneDelegation: a.deleteZoneDelegation,
LocalZones: a.listLocalZones,
SaveLocalZone: a.saveLocalZone,
DeleteLocalZone: a.deleteLocalZone,
GetACL: a.getACL,
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
CacheStats: a.cacheStats,
FlushCache: a.flushCache,
Flush: a.flush,
Store: a.cfg,
Logger: a.log,
Version: a.ver,
StartedAt: a.startedAt,
Status: a.status,
Health: a.health,
BlocklistState: a.blocklistState,
BlocklistStats: a.blocklistStats,
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
Resolve: a.resolveTest,
ResolveUpstream: a.resolveTestUpstream,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
ListZones: a.listZones,
GetACL: a.getACL,
PutACL: a.putACL,
GetUpstream: a.getUpstreams,
PutUpstream: a.putUpstream,
CacheStats: a.cacheStats,
FlushCache: a.flushCache,
Flush: a.flush,
}
}
@@ -99,156 +86,6 @@ func (a *App) resolveTestUpstream(ctx context.Context, name, qtype, upstream str
return out, nil
}
type addressOverride struct {
Domain string `json:"domain"`
Address string `json:"address"`
}
func (a *App) listAddressOverrides(ctx context.Context) (any, error) {
if a.zones == nil {
return map[string]any{"items": []addressOverride{}, "writable": false}, nil
}
raw, ok, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil {
return nil, err
}
items := parseAddressOverrides(raw)
return map[string]any{"items": items, "writable": true, "initialized": ok}, nil
}
func parseAddressOverrides(raw string) []addressOverride {
out := []addressOverride{}
for _, line := range strings.Split(raw, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && !strings.HasPrefix(f[0], "$") && (strings.EqualFold(f[1], "A") || strings.EqualFold(f[1], "AAAA")) {
out = append(out, addressOverride{Domain: strings.TrimSuffix(f[0], "."), Address: f[2]})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
return out
}
func (a *App) saveAddressOverride(ctx context.Context, domain, address string) error {
return a.mutateAddressOverride(ctx, domain, address, false)
}
func (a *App) deleteAddressOverride(ctx context.Context, domain string) error {
return a.mutateAddressOverride(ctx, domain, "", true)
}
func (a *App) mutateAddressOverride(ctx context.Context, domain, address string, remove bool) error {
a.overrideMu.Lock()
defer a.overrideMu.Unlock()
if a.zones == nil {
return errors.New("address overrides are writable only in Kubernetes")
}
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if _, ok := dns.IsDomainName(dns.Fqdn(domain)); !ok || domain == "" {
return errors.New("enter a valid domain name")
}
ip, err := netip.ParseAddr(strings.TrimSpace(address))
if !remove && err != nil {
return errors.New("enter a valid IPv4 or IPv6 address")
}
raw, _, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil {
return err
}
items := parseAddressOverrides(raw)
next := make([]addressOverride, 0, len(items)+1)
for _, it := range items {
if it.Domain != domain {
next = append(next, it)
}
}
if !remove {
next = append(next, addressOverride{Domain: domain, Address: ip.String()})
}
sort.Slice(next, func(i, j int) bool { return next[i].Domain < next[j].Domain })
var b strings.Builder
b.WriteString("$TTL 60\n")
for _, it := range next {
typ := "A"
if strings.Contains(it.Address, ":") {
typ = "AAAA"
}
fmt.Fprintf(&b, "%s %s %s\n", it.Domain, typ, it.Address)
}
if err := a.zones.SaveData(ctx, "address-overrides.rpz", b.String()); err != nil {
return err
}
if a.blocks != nil {
a.blocks.SetManagedRPZ(b.String())
if _, err := a.blocks.RefreshAll(ctx); err != nil {
return err
}
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
func (a *App) listLocalZones(ctx context.Context) ([]api.ZoneFile, error) {
if a.zones == nil {
return nil, fmt.Errorf("local zone editor is available only in Kubernetes")
}
zones, err := a.zones.List(ctx)
if err != nil {
return nil, err
}
out := make([]api.ZoneFile, 0, len(zones))
for _, z := range zones {
out = append(out, api.ZoneFile{Name: z.Name, Content: z.Content})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (a *App) saveLocalZone(ctx context.Context, name, content string) error {
if a.zones == nil {
return fmt.Errorf("local zone editor is available only in Kubernetes")
}
apex := strings.ToLower(dns.Fqdn(strings.TrimSuffix(strings.TrimSpace(name), ".")))
_, valid := dns.IsDomainName(apex)
if !valid || apex == "." || len(apex) > 243 {
return fmt.Errorf("invalid zone name %q", name)
}
if strings.TrimSpace(content) == "" {
return fmt.Errorf("zone file cannot be empty")
}
parsed, err := zonefile.Parse(strings.NewReader(content), zonefile.Options{Origin: apex, File: name + ".zone"})
if err != nil {
return fmt.Errorf("invalid zone file: %w", err)
}
if len(parsed.Records) == 0 {
return fmt.Errorf("zone file must contain at least one record")
}
for _, record := range parsed.Records {
owner := strings.ToLower(dns.Fqdn(record.Name))
if owner != strings.ToLower(apex) && !strings.HasSuffix(owner, "."+strings.ToLower(apex)) {
return fmt.Errorf("record %q is outside zone %s", owner, apex)
}
}
if err := a.zones.Save(ctx, strings.TrimSuffix(apex, "."), content); err != nil {
return err
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
func (a *App) deleteLocalZone(ctx context.Context, name string) error {
if a.zones == nil {
return fmt.Errorf("local zone editor is available only in Kubernetes")
}
name = strings.TrimSuffix(strings.TrimSpace(name), ".")
if err := a.zones.Delete(ctx, name); err != nil {
return err
}
if a.cache != nil {
a.cache.Flush()
}
return nil
}
// health is the unauthenticated probe payload.
func (a *App) health() any {
status := "ok"
+20 -199
View File
@@ -4,8 +4,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -15,170 +13,6 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// TestZoneConsoleRoundTrip is the console's zone page, end to end: a record
// added through the admin API is answered by this resolver on the next query,
// with no reload and no restart, because a zone the console created is
// compiled into the same store the operator's master files go into.
func TestZoneConsoleRoundTrip(t *testing.T) {
managed := filepath.Join(t.TempDir(), "zones")
h := newHarness(t, func(c *config.Config) { c.Block.ManagedZones = managed })
do := func(method, path, body string) (int, string) {
t.Helper()
code, out := h.do(method, path, body)
return code, string(out)
}
mustWrite := func(method, path, body string) string {
t.Helper()
code, out := do(method, path, body)
if code != http.StatusOK {
t.Fatalf("%s %s = %d: %s", method, path, code, out)
}
return out
}
// Before anything is written the page is empty but honest: no zones, the
// managed directory named, and the types the API accepts so the console
// builds its picker from the server.
_, body := h.get("/api/zones")
var empty zonesPayload
if err := json.Unmarshal(body, &empty); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
// The operator's own zone is listed before the console has written
// anything, and it is not the console's to edit.
if len(empty.Zones) != 1 || empty.Zones[0].Origin != "local." {
t.Fatalf("zones = %+v, want just the operator's local. zone", empty.Zones)
}
if empty.Zones[0].Managed {
t.Error("the operator's zone is reported as managed")
}
if empty.ManagedDir != managed {
t.Errorf("managed_dir = %q, want %q", empty.ManagedDir, managed)
}
if !empty.Writable {
t.Error("writable = false, but the harness's temp dir is writable")
}
if len(empty.Types) == 0 {
t.Error("types is empty, so the console has no record types to offer")
}
// A zone starts with a server list: without one it cannot say NXDOMAIN,
// and the writer refuses to invent it.
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`)
mustWrite(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"www.console.test.","type":"A","ttl":60,"value":"10.9.9.9"}`)
m := h.query("www.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query www.console.test = %v, want one answer", m)
}
a, ok := m.Answer[0].(*dns.A)
if !ok || a.A.String() != "10.9.9.9" {
t.Errorf("www.console.test A = %v, want the console's record, not the upstream's", m.Answer[0])
}
if m.Authoritative != true {
t.Error("the local answer is not authoritative")
}
// The file is on disk where the operator can read it, and it is a zone
// file rather than a JSON dump.
raw, err := os.ReadFile(filepath.Join(managed, "console.test.zone"))
if err != nil {
t.Fatalf("read the managed zone file: %v", err)
}
for _, want := range []string{"$ORIGIN console.test.", "IN SOA", "IN NS", "10.9.9.9"} {
if !strings.Contains(string(raw), want) {
t.Errorf("the zone file is missing %q:\n%s", want, raw)
}
}
// A delegation is a cut: the parent stops answering below it and the
// query goes upstream, which is the whole point of making one.
mustWrite(http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub.console.test.","targets":["ns1.sub.console.test."]}`)
m = h.query("www.sub.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query below the cut = %v, want the upstream answer", m)
}
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
t.Errorf("below the cut = %v, want the upstream's 203.0.113.7", m.Answer[0])
}
// And the cut itself is answerable, or no resolver could ever learn it.
m = h.query("sub.console.test", dns.TypeNS)
if len(m.Answer) != 1 {
t.Errorf("NS at the cut = %v, want the delegation itself", m)
}
// A record below a cut is dead data, so it is refused rather than
// written and silently never answered.
if code, _ := do(http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"host.sub.console.test.","type":"A","value":"10.9.9.10"}`); code != http.StatusBadRequest {
t.Errorf("a record below a delegation = %d, want 400", code)
}
// Deleting the record leaves the zone authoritative, and a zone that
// exists says NXDOMAIN for the names it does not hold. That is what
// creating a zone means, and it is why deleting a zone is a separate
// action below rather than a side effect of deleting a record.
mustWrite(http.MethodDelete, "/api/zones/records?zone=console.test.&name=www.console.test.&type=A", "")
m = h.query("www.console.test", dns.TypeA)
if m.Rcode != dns.RcodeNameError || len(m.Answer) != 0 {
t.Errorf("query after the record was deleted = %v, want NXDOMAIN from the zone", m)
}
if m.Authoritative != true {
t.Error("the NXDOMAIN is not authoritative, so the zone is not serving")
}
// Deleting something that is not there is a 404, not a 500.
if code, _ := do(http.MethodDelete, "/api/zones/records?zone=console.test.&name=nope.console.test.&type=A", ""); code != http.StatusNotFound {
t.Errorf("deleting an absent record = %d, want 404", code)
}
// Removing the zone is what hands the name back to upstream, and the
// cached NXDOMAIN goes with it.
mustWrite(http.MethodDelete, "/api/zones?zone=console.test.", "")
if _, err := os.Stat(filepath.Join(managed, "console.test.zone")); !os.IsNotExist(err) {
t.Errorf("the managed zone file is still there (stat err = %v)", err)
}
m = h.query("www.console.test", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("query after the zone was deleted = %v, want the upstream answer", m)
}
if a, ok := m.Answer[0].(*dns.A); !ok || a.A.String() != "203.0.113.7" {
t.Errorf("after the zone was deleted = %v, want the upstream's 203.0.113.7", m.Answer[0])
}
// And the zone is gone from the page.
_, body = h.get("/api/zones")
var gone zonesPayload
if err := json.Unmarshal(body, &gone); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
for _, zone := range gone.Zones {
if zone.Origin == "console.test." {
t.Errorf("the deleted zone is still listed: %+v", zone)
}
}
// An operator's zone is not the console's to delete.
if code, _ := do(http.MethodDelete, "/api/zones?zone=local.", ""); code != http.StatusNotFound {
t.Errorf("deleting the operator's zone = %d, want 404", code)
}
// The operator's master file is still theirs: it is listed, and it is not
// reported as console-managed.
var sawLocal bool
for _, zone := range gone.Zones {
if zone.Origin != "local." {
continue
}
sawLocal = true
if zone.Managed {
t.Error("the operator's zone is reported as managed, so the console would offer to rewrite it")
}
}
if !sawLocal {
t.Errorf("the operator's zone is missing from the list: %+v", gone.Zones)
}
}
// zonesPayload is the slice of GET /api/zones this test reads. It is declared
// here rather than imported so the assertions name the fields the console
// depends on; the contract test in this package pins the whole key set.
@@ -202,10 +36,11 @@ type zonesPayload struct {
Records int `json:"records"`
}
// TestZoneWritesNeedAManagedDirectory pins the read-only default: a
// deployment that never sets blocklist.managed_zones can browse its local zones
// and cannot have one rewritten behind its back.
func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
// TestZonesAreReadOnlyOnTheResolver: the resolver lists the zones it serves and
// writes none of them. Editing a zone is the console's job and happens on the
// DNSZone objects (see console_e2e_test.go); every write route on the
// resolver answers "not available" and changes nothing.
func TestZonesAreReadOnlyOnTheResolver(t *testing.T) {
h := newHarness(t, nil)
code, body := h.get("/api/zones")
if code != http.StatusOK {
@@ -215,43 +50,29 @@ func TestZoneWritesNeedAManagedDirectory(t *testing.T) {
if err := json.Unmarshal(body, &zones); err != nil {
t.Fatalf("zones: %v: %s", err, body)
}
if zones.Writable {
t.Error("writable = true with no managed zone directory configured")
if zones.Writable || zones.ManagedDir != "" {
t.Errorf("writable=%v managed_dir=%q on a resolver with no Kubernetes source", zones.Writable, zones.ManagedDir)
}
if zones.ManagedDir != "" {
t.Errorf("managed_dir = %q, want empty", zones.ManagedDir)
if len(zones.Zones) != 1 || zones.Zones[0].Origin != "local." || zones.Zones[0].Managed {
t.Errorf("zones = %+v, want only the operator's master file, not managed", zones.Zones)
}
if len(zones.Zones) == 0 {
t.Error("the operator's local zone is missing, so the page would be blank rather than read-only")
}
for _, zone := range zones.Zones {
if zone.Managed {
t.Errorf("zone %s is reported as managed without a managed directory", zone.Origin)
}
}
// Every write route refuses, and says what to configure.
writes := []struct {
method string
path string
body string
}{
for _, w := range []struct{ method, path, body string }{
{http.MethodPost, "/api/zones/records", `{"zone":"console.test.","name":"console.test.","type":"NS","value":"ns1.console.test."}`},
{http.MethodPut, "/api/zones/records", `{"zone":"console.test.","name":"www","type":"A","value":"10.0.0.1"}`},
{http.MethodDelete, "/api/zones/records?zone=console.test.&name=www&type=A", ""},
{http.MethodDelete, "/api/zones/records?zone=local.&name=ns.local.&type=A", ""},
{http.MethodPost, "/api/zones/delegations", `{"zone":"console.test.","name":"sub","targets":["ns1.sub.console.test."]}`},
{http.MethodDelete, "/api/zones/delegations?zone=console.test.&name=sub.console.test.", ""},
{http.MethodDelete, "/api/zones?zone=console.test.", ""},
}
for _, w := range writes {
{http.MethodDelete, "/api/zones/delegations?zone=local.&name=sub.local.", ""},
{http.MethodDelete, "/api/zones?zone=local.", ""},
{http.MethodGet, "/api/local-zones", ""},
} {
code, out := h.do(w.method, w.path, w.body)
if code != http.StatusBadRequest {
t.Errorf("%s %s = %d, want 400: %s", w.method, w.path, code, out)
continue
}
if !strings.Contains(string(out), "managed_zones") {
t.Errorf("%s %s did not say what to configure: %s", w.method, w.path, out)
if code != http.StatusNotImplemented && code != http.StatusNotFound && code != http.StatusMethodNotAllowed {
t.Errorf("%s %s = %d, want it refused: %s", w.method, w.path, code, out)
}
}
if m := h.query("ns.local", dns.TypeA); len(m.Answer) != 1 {
t.Errorf("the operator's zone stopped answering after the refused writes: %v", m)
}
}
// TestResolveUpstream covers the happy path: a name nobody blocks is forwarded
-76
View File
@@ -1,76 +0,0 @@
package app
import (
"context"
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"sort"
"time"
)
// watchLocalZones reloads projected ConfigMap files on every replica. A
// Kubernetes ConfigMap volume swaps its ..data symlink atomically when the UI
// edits a zone; polling the contents keeps both pods in sync without routing
// DNS changes through a single pod.
func (a *App) watchLocalZones(ctx context.Context) {
if a.zones == nil {
return
}
last, _ := a.localZoneFingerprint()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
next, err := a.localZoneFingerprint()
if err != nil {
a.log.Warn("local zone change check failed", "error", err)
continue
}
if next == last {
continue
}
a.reloadLocalZones()
if a.cache != nil {
a.cache.Flush()
}
last = next
}
}
}
func (a *App) localZoneFingerprint() (string, error) {
paths := make([]string, 0)
for _, configured := range a.cfg.Get().Block.LocalZones {
path := a.cfg.Get().AbsPath(configured)
if matches, err := filepath.Glob(path); err == nil && len(matches) > 0 {
paths = append(paths, matches...)
continue
}
if info, err := os.Stat(path); err == nil && info.IsDir() {
matches, err := filepath.Glob(filepath.Join(path, "*.zone"))
if err != nil {
return "", err
}
paths = append(paths, matches...)
continue
}
}
sort.Strings(paths)
h := sha256.New()
for _, path := range paths {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
_, _ = h.Write([]byte(path))
_, _ = h.Write([]byte{0})
_, _ = h.Write(b)
_, _ = h.Write([]byte{0})
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
-35
View File
@@ -1,35 +0,0 @@
package app
import (
"context"
"time"
)
func (a *App) watchAddressOverrides(ctx context.Context) {
if a.zones == nil || a.blocks == nil {
return
}
t := time.NewTicker(2 * time.Second)
defer t.Stop()
var last string
for {
select {
case <-ctx.Done():
return
case <-t.C:
content, ok, err := a.zones.Data(ctx, "address-overrides.rpz")
if err != nil || !ok || content == last {
continue
}
a.overrideMu.Lock()
a.blocks.SetManagedRPZ(content)
if _, err := a.blocks.RefreshAll(ctx); err == nil {
if a.cache != nil {
a.cache.Flush()
}
last = content
}
a.overrideMu.Unlock()
}
}
}
+8 -211
View File
@@ -1,38 +1,22 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
)
// The local-zone write path.
//
// Everything the console changes lives in the managed zone directory
// (blocklist.managed_zones), one <apex>.zone per zone. The operator's own master
// files in blocklist.local_zones are read and reported, never rewritten: a
// deployment that never configures a managed directory gets a read-only zone
// browser, and one that does gets a console whose edits land in a file it
// names and owns.
//
// Every change follows the same four steps: write the file, recompile the
// whole store, drop the cached answers, report. A change that does not
// recompile is a change the query path is not serving, so the store is only
// swapped after the new file parsed, and a failure keeps the previous store.
// The resolver only reads zones. Master files from blocklist.local_zones and the
// rendered DNSZone objects are compiled into one store; editing is the
// console's job and happens on the objects.
// listZones renders the compiled store for the console.
func (a *App) listZones() (any, error) {
dir, writable := a.managedZoneDir(), a.managedZones.Writable()
if a.kube != nil {
// With the Kubernetes source the zones that can be edited are the
// DNSZone objects, so those are the "managed" ones. The runtime itself
// cannot write them; the console, which can, reports writable.
dir, writable = a.kubeZoneDir(), false
}
// The zones that can be edited are the DNSZone objects, which the Kubernetes
// source renders into a directory of its own. The resolver itself writes
// nothing; the console, which can, reports the zones writable.
dir := a.kubeZoneDir()
store := a.local.Get()
// The slices are never nil: the console reads lengths and maps without
// a null check, and an empty list is a list.
@@ -40,7 +24,7 @@ func (a *App) listZones() (any, error) {
Zones: []api.Zone{},
Files: []string{},
ManagedDir: dir,
Writable: writable,
Writable: false,
Types: localzone.EditableTypes(),
}
if store != nil {
@@ -55,193 +39,6 @@ func (a *App) listZones() (any, error) {
return res, nil
}
// addZoneRecord adds one record to a zone.
func (a *App) addZoneRecord(_ context.Context, in api.ZoneRecordInput) (any, error) {
origin, err := a.zoneOrigin(in.Zone)
if err != nil {
return nil, err
}
serial, err := a.managedZones.Add(origin, localzone.Record{
Name: in.Name,
Type: in.Type,
TTL: in.TTL,
Value: in.Value,
}, a.zoneDefaults(origin))
if err != nil {
return nil, err
}
a.afterZoneWrite(origin, "add", serial)
return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil
}
// putZoneRecord replaces the whole RRset of one name and type.
func (a *App) putZoneRecord(_ context.Context, in api.ZoneRecordInput) (any, error) {
origin, err := a.zoneOrigin(in.Zone)
if err != nil {
return nil, err
}
serial, err := a.managedZones.Put(origin, localzone.Record{
Name: in.Name,
Type: in.Type,
TTL: in.TTL,
Value: in.Value,
}, a.zoneDefaults(origin))
if err != nil {
return nil, err
}
a.afterZoneWrite(origin, "replace", serial)
return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil
}
// deleteZoneRecord removes a record, or its whole RRset when value is empty.
func (a *App) deleteZoneRecord(_ context.Context, zone, name, rrtype, value string) error {
origin, err := a.zoneOrigin(zone)
if err != nil {
return err
}
serial, removed, err := a.managedZones.Delete(origin, name, rrtype, value)
if err != nil {
return err
}
a.afterZoneWrite(origin, "delete", serial)
if !removed {
return fmt.Errorf("no managed record matches %s %s in %s", name, rrtype, origin)
}
return nil
}
// addZoneDelegation hands a name below an apex to other servers.
func (a *App) addZoneDelegation(_ context.Context, in api.ZoneDelegationInput) (any, error) {
origin, err := a.zoneOrigin(in.Zone)
if err != nil {
return nil, err
}
targets := make([]string, 0, len(in.Targets))
for _, t := range in.Targets {
if t = strings.TrimSpace(t); t != "" {
targets = append(targets, t)
}
}
if len(targets) == 0 {
return nil, errors.New("a delegation needs at least one NS target")
}
// A brand new zone has no NS set of its own, so the delegation's targets
// are also the zone's. An existing file ignores this.
def := a.zoneDefaults(origin)
def.NS = targets
if def.TTL == 0 {
def.TTL = in.TTL
}
serial, err := a.managedZones.SetDelegation(origin, in.Name, targets, in.TTL, def)
if err != nil {
return nil, err
}
a.afterZoneWrite(origin, "delegate", serial)
return api.ZoneWriteResponse{OK: true, Zone: origin, Serial: serial}, nil
}
// deleteZoneDelegation removes a cut.
func (a *App) deleteZoneDelegation(_ context.Context, zone, name string) error {
origin, err := a.zoneOrigin(zone)
if err != nil {
return err
}
serial, removed, err := a.managedZones.RemoveDelegation(origin, name)
if err != nil {
return err
}
a.afterZoneWrite(origin, "undelegate", serial)
if !removed {
return fmt.Errorf("no delegation for %s in %s", name, origin)
}
return nil
}
// zoneOrigin canonicalises the apex a write names and refuses to go near the
// disk when the console has no managed directory.
func (a *App) zoneOrigin(zone string) (string, error) {
origin, err := localzone.NormalizeOrigin(zone)
if err != nil {
return "", err
}
if err := a.managedZones.Check(); err != nil {
return "", err
}
return origin, nil
}
// deleteZone removes a zone the console created, which is the only way a name
// goes back to upstream once the console has made a zone authoritative for
// it: a zone that exists answers NXDOMAIN for everything it does not hold.
func (a *App) deleteZone(_ context.Context, zone string) error {
origin, err := a.zoneOrigin(zone)
if err != nil {
return err
}
// The zone has to be wholly the console's: deleting a managed file that
// shares its apex with an operator's master file would remove none of
// the zone, only half of it.
whole := false
if store := a.local.Get(); store != nil {
if view := store.Snapshot(origin, a.managedZoneDir()); view != nil {
whole = view.Managed
} else {
return fmt.Errorf("no zone %s is loaded", origin)
}
} else {
return fmt.Errorf("no zone %s is loaded", origin)
}
removed, err := a.managedZones.Remove(origin, whole)
if err != nil {
return err
}
if !removed {
return fmt.Errorf("no managed zone file for %s", origin)
}
a.afterZoneWrite(origin, "delete-zone", 0)
return nil
}
// zoneDefaults seeds a managed file for an apex that has none from the zone
// as it is compiled today, so the console never has to restate a zone's
// server list and never invents one.
func (a *App) zoneDefaults(origin string) localzone.ZoneDefaults {
store := a.local.Get()
if store == nil {
return localzone.ZoneDefaults{}
}
zone := store.Snapshot(origin, a.managedZoneDir())
if zone == nil {
return localzone.ZoneDefaults{}
}
def := localzone.ZoneDefaults{TTL: zone.DefaultTTL, Soa: zone.Soa}
// The apex NS set follows the zone's existing one, from the operator's
// file if that is where it lives. A managed file that already has its own
// is not asked for defaults at all, so this only ever happens once.
for _, rec := range zone.Records {
if rec.Name == origin && rec.Type == "NS" {
def.NS = append(def.NS, rec.Value)
}
}
return def
}
// afterZoneWrite is the tail every change shares: recompile, drop the cached
// answers, log. The flush is the blunt half and it is deliberate — a name the
// zone now answers authoritatively may have an upstream answer cached, and
// which names changed is not knowable from here.
func (a *App) afterZoneWrite(origin, action string, serial uint32) {
a.reloadLocalZones()
if a.cache != nil {
a.cache.Flush()
}
records := 0
if store := a.local.Get(); store != nil {
records = store.Count()
}
a.log.Info("local zone changed", "zone", origin, "action", action, "serial", serial, "records", records)
}
// localZoneError is the reason the store is stale, or "" when it is current.
// It comes from the degraded list, so the console and /healthz agree.
func (a *App) localZoneError() string {