Files
fancy-dns/internal/kubesource/merge.go
T
matsandClaude Sonnet 5.5 0fdc828a10 Keep list copies on disk, start from them, and stop logging address overrides
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>
2026-10-16 10:41:20 +02:00

199 lines
5.5 KiB
Go

package kubesource
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func signature(s *Snapshot) string {
b, _ := json.Marshal(struct {
G []config.Group
L []config.ListSource
Z map[string]string
O []Override
B []string
A []string
}{s.Groups, s.Lists, s.Zones, s.Overrides, s.BlockNames, s.AllowNames})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// Merge returns base with the cluster's groups and lists added; base is not
// modified. Precedence is deliberate: whatever the YAML declares is the
// operator's and wins. YAML groups are evaluated before cluster groups, and a
// cluster list that reuses a YAML list's name is dropped. The names of what was
// dropped are returned so the caller can say so.
func Merge(base *config.Config, snap *Snapshot) (*config.Config, []string) {
if snap == nil {
return base, nil
}
next := *base
next.ACL = base.ACL
next.ACL.Groups = append([]config.Group(nil), base.ACL.Groups...)
next.Block = base.Block
next.Block.Lists = append([]config.ListSource(nil), base.Block.Lists...)
// Inline rules from DomainRule objects join the config's own.
next.Block.BlockEntries = append(append([]string(nil), base.Block.BlockEntries...), snap.BlockNames...)
next.Block.AllowEntries = append(append([]string(nil), base.Block.AllowEntries...), snap.AllowNames...)
var dropped []string
groups := map[string]bool{}
for _, g := range base.ACL.Groups {
groups[g.Name] = true
}
for _, g := range snap.Groups {
if groups[g.Name] {
dropped = append(dropped, "ClientGroup "+g.Name+" (name used in the YAML)")
continue
}
next.ACL.Groups = append(next.ACL.Groups, g)
}
lists := map[string]bool{}
for _, l := range base.Block.Lists {
lists[l.Name] = true
}
for _, l := range snap.Lists {
if lists[l.Name] {
dropped = append(dropped, "BlockList "+l.Name+" (name used in the YAML)")
continue
}
// A cluster object cannot name a file, so the runtime picks one: a copy
// of the list under its own data directory. It is what lets a restart
// start with the list when the network (or the cluster DNS, which may
// be this very resolver) is not up yet.
if filepath.Base(l.Name) == l.Name && l.Name != "." && l.Name != ".." {
l.Path = filepath.Join("lists", l.Name)
l.Path = filepath.Join(filepath.Dir(base.CachePath()), l.Path)
}
// The same defaults config.applyDefaults gives a YAML list.
if l.Format == "" {
l.Format = "auto"
}
if l.Refresh == 0 {
l.Refresh = config.Duration(24 * time.Hour)
}
if l.Timeout <= 0 {
l.Timeout = base.Server.Timeouts.List
}
if l.MaxBytes <= 0 {
l.MaxBytes = 512 << 20
}
next.Block.Lists = append(next.Block.Lists, l)
}
return &next, dropped
}
// SyncZoneDir makes dir hold exactly one <apex>.zone file per zone, written
// atomically, and removes the files of zones that no longer exist. It reports
// whether anything on disk changed. Files it did not write (anything that is
// not *.zone) are left alone.
func SyncZoneDir(dir string, zones map[string]string) (bool, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return false, fmt.Errorf("zone directory %s: %w", dir, err)
}
changed := false
want := map[string]bool{}
for apex, text := range zones {
name := zoneFile(apex)
want[name] = true
path := filepath.Join(dir, name)
if old, err := os.ReadFile(path); err == nil && string(old) == text {
continue
}
tmp, err := os.CreateTemp(dir, ".kube-zone-*")
if err != nil {
return changed, err
}
_, werr := tmp.WriteString(text)
cerr := tmp.Close()
if werr != nil || cerr != nil {
_ = os.Remove(tmp.Name())
return changed, fmt.Errorf("write %s: %w", name, firstErr(werr, cerr))
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
_ = os.Remove(tmp.Name())
return changed, err
}
if err := os.Rename(tmp.Name(), path); err != nil {
_ = os.Remove(tmp.Name())
return changed, err
}
changed = true
}
entries, err := os.ReadDir(dir)
if err != nil {
return changed, err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".zone") || want[e.Name()] {
continue
}
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
return changed, err
}
changed = true
}
return changed, nil
}
// zoneFile is the file name for an apex. The apex has been normalised by
// NormalizeName, so it holds only DNS name characters; the wildcard label is
// the one that is not file-safe on every platform.
func zoneFile(apex string) string {
n := strings.TrimSuffix(apex, ".")
if n == "" {
n = "root"
}
return strings.ReplaceAll(n, "*", "_wild") + ".zone"
}
func firstErr(errs ...error) error {
for _, e := range errs {
if e != nil {
return e
}
}
return nil
}
// Save writes the snapshot so a restart that cannot reach the apiserver still
// has the last known policy. Problems are not saved.
func Save(path string, s *Snapshot) error {
b, err := json.Marshal(s)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return config.WriteFileAtomic(path, b, 0o644)
}
// Load reads a snapshot written by Save. A missing file is (nil, nil).
func Load(path string) (*Snapshot, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var s Snapshot
if err := json.Unmarshal(b, &s); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if s.Zones == nil {
s.Zones = map[string]string{}
}
return &s, nil
}