Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
This commit is contained in:
1 parent
2a7ac824f7
commit
32f940b763
18 files changed
+727
-36
No files matched your search
+12
-1
@@ -25,6 +25,7 @@ import (
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cluster"
|
||||
"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/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/qlog"
|
||||
@@ -61,6 +62,7 @@ type App struct {
|
||||
logger *qlog.Logger
|
||||
aclH *acl.Holder
|
||||
local *localzone.Holder
|
||||
zones *kubezones.Client
|
||||
pool *resolver.Pool
|
||||
reverse *engine.ReverseCache
|
||||
engine *engine.Engine
|
||||
@@ -75,6 +77,8 @@ type App struct {
|
||||
// reloadMu serialises reload work, since SIGHUP, the API and the cluster
|
||||
// can all trigger one at the same time.
|
||||
reloadMu sync.Mutex
|
||||
// zoneReloadMu serialises file-watcher and config-swap recompiles.
|
||||
zoneReloadMu sync.Mutex
|
||||
|
||||
startedAt time.Time
|
||||
// degraded is set when a background component failed; surfaced in health.
|
||||
@@ -96,7 +100,11 @@ func New(opt Options) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
store := config.NewStore(cfg, opt.ConfigPath)
|
||||
return &App{opt: opt, log: log, cfg: store, ver: opt.Version}, nil
|
||||
zones, err := kubezones.InCluster()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &App{opt: opt, log: log, cfg: store, ver: opt.Version, zones: zones}, nil
|
||||
}
|
||||
|
||||
// newLogger builds the structured logger from config plus CLI overrides.
|
||||
@@ -322,6 +330,8 @@ func (a *App) startLocalZones(cfg *config.Config) {
|
||||
// reloadLocalZones recompiles the local zone store. A failure keeps the
|
||||
// previous store: serving stale local data beats serving nothing.
|
||||
func (a *App) reloadLocalZones() {
|
||||
a.zoneReloadMu.Lock()
|
||||
defer a.zoneReloadMu.Unlock()
|
||||
cfg := a.cfg.Get()
|
||||
if len(cfg.Block.LocalZones) == 0 {
|
||||
a.local.Set(nil)
|
||||
@@ -550,6 +560,7 @@ 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)
|
||||
// Cache snapshots.
|
||||
if a.cstore != nil {
|
||||
interval := cfg.Cache.Persist.Interval.D()
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
||||
"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
|
||||
@@ -37,6 +38,9 @@ func (a *App) hooks() api.Hooks {
|
||||
ListDomains: a.listDomains,
|
||||
AddDomain: a.addDomain,
|
||||
DeleteDomain: a.deleteDomain,
|
||||
LocalZones: a.listLocalZones,
|
||||
SaveLocalZone: a.saveLocalZone,
|
||||
DeleteLocalZone: a.deleteLocalZone,
|
||||
GetACL: a.getACL,
|
||||
PutACL: a.putACL,
|
||||
GetUpstream: a.getUpstreams,
|
||||
@@ -52,6 +56,70 @@ func (a *App) hooks() api.Hooks {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user