fancy-dns-console is a second binary holding the web UI, authentication and every edit. It writes ClientGroup, BlockList and DNSZone objects through the new kubeedit package and forwards all other /api routes to a running resolver with its own key, dropping the browser's credentials, so it can run in the cluster or on a workstation with a kubeconfig and nothing about the resolver's placement matters. The resolver never imports kubeedit and the console is not on the DNS path. kubeedit validates every spec with the runtime's own converters (kubesource.CheckGroup/CheckList/CheckZone) before writing, so the console cannot store an object the resolver would reject. Zone edits keep the file-based writer's behaviour: duplicate and below-a-delegation records are refused, a delegation in a new zone also gives the zone its servers, and a zone with no apex NS is never stored. Each object records the editing user in an annotation; updates retry on conflict. The resolver reports groups from ClientGroup objects as source "kubernetes" and counter-only rows (the "(default)" bucket, or a group removed since) as "history", and lists DNSZone zones as the editable ones. The console uses that to leave the YAML's groups alone and to drop history rows, which the UI would otherwise send back on save. Its GET /api/clients overlays the cluster's current objects on the resolver's list, because the resolver only sees an edit after its watch fires and a save made just after another would delete the first. An end-to-end test runs both halves against one fake apiserver: a group saved through the console refuses queries, a zone built record by record answers authoritatively, and a request with the resolver's own key is rejected by the console. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
255 lines
7.9 KiB
Go
255 lines
7.9 KiB
Go
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.
|
|
|
|
// 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
|
|
}
|
|
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.
|
|
res := api.ZonesResponse{
|
|
Zones: []api.Zone{},
|
|
Files: []string{},
|
|
ManagedDir: dir,
|
|
Writable: writable,
|
|
Types: localzone.EditableTypes(),
|
|
}
|
|
if store != nil {
|
|
res.Files = store.Files()
|
|
res.BuiltAt = store.BuiltAt()
|
|
for _, zone := range store.SnapshotAll(dir) {
|
|
res.Zones = append(res.Zones, *zone)
|
|
res.Records += zone.RecordCount
|
|
}
|
|
}
|
|
res.Error = a.localZoneError()
|
|
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 {
|
|
for _, d := range a.Degraded() {
|
|
if rest, ok := strings.CutPrefix(d, "local zones: "); ok {
|
|
return rest
|
|
}
|
|
}
|
|
return ""
|
|
}
|