The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
249 lines
7.6 KiB
Go
249 lines
7.6 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 := a.managedZoneDir()
|
|
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: a.managedZones.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 ""
|
|
}
|