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 .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 "" }