Files
matsandClaude Sonnet 5.5 1a536e7d0e Split the console from the resolver: fancy-dns-console writes the cluster
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>
2026-10-16 08:43:41 +02:00

346 lines
10 KiB
Go

package kubeedit
import (
"context"
"errors"
"fmt"
"sort"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/util/retry"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
)
// ZoneObject is a stored DNSZone.
type ZoneObject struct {
Name string
Apex string // canonical, fully qualified
Spec kubesource.DNSZoneSpec
}
// Zones lists the DNSZone objects.
func (e *Editor) Zones(ctx context.Context) ([]ZoneObject, error) {
items, err := e.list(ctx, kubesource.ZoneGVR)
if err != nil {
return nil, err
}
out := make([]ZoneObject, 0, len(items))
for i := range items {
z, ok := zoneOf(&items[i])
if ok {
out = append(out, z)
}
}
return out, nil
}
func zoneOf(u *unstructured.Unstructured) (ZoneObject, bool) {
var s kubesource.DNSZoneSpec
if err := decodeSpec(u, &s); err != nil {
return ZoneObject{}, false
}
apex, err := localzone.NormalizeOrigin(s.Zone)
if err != nil {
return ZoneObject{}, false
}
return ZoneObject{Name: u.GetName(), Apex: apex, Spec: s}, true
}
// WriteResult is what a zone edit reports.
type WriteResult struct {
Zone string
// Serial is the SOA serial the runtime will serve: the object's
// generation after this write.
Serial uint32
}
// editZone applies fn to the zone's spec and writes it back, retrying on a
// conflicting concurrent edit. A zone that does not exist yet is created when
// create is set. The result is rendered with the runtime's own code before it
// is stored, so a spec the runtime would reject is never written.
func (e *Editor) editZone(ctx context.Context, apex string, create bool, actor string, fn func(spec *kubesource.DNSZoneSpec) error) (WriteResult, error) {
apex, err := localzone.NormalizeOrigin(apex)
if err != nil {
return WriteResult{}, err
}
var res WriteResult
err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
items, err := e.list(ctx, kubesource.ZoneGVR)
if err != nil {
return err
}
var cur *unstructured.Unstructured
var zo ZoneObject
for i := range items {
if z, ok := zoneOf(&items[i]); ok && z.Apex == apex {
cur, zo = &items[i], z
break
}
}
spec := zo.Spec
if cur == nil {
if !create {
return fmt.Errorf("%w: no DNSZone for %s", ErrNotFound, apex)
}
spec = kubesource.DNSZoneSpec{Zone: strings.TrimSuffix(apex, ".")}
}
spec.Records = append([]kubesource.ZoneRecord(nil), spec.Records...)
if err := fn(&spec); err != nil {
return err
}
if _, err := kubesource.CheckZone(spec); err != nil {
return err
}
var out *unstructured.Unstructured
if cur == nil {
name := strings.TrimSuffix(apex, ".")
if err := checkName("zone", name); err != nil {
return err
}
obj, err := newObject("DNSZone", name, spec)
if err != nil {
return err
}
obj.SetNamespace(e.ns)
stamp(obj, actor)
out, err = e.res(kubesource.ZoneGVR).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
return err
}
} else {
raw, err := encodeSpec(spec)
if err != nil {
return err
}
cur.Object["spec"] = raw
stamp(cur, actor)
out, err = e.res(kubesource.ZoneGVR).Update(ctx, cur, metav1.UpdateOptions{})
if err != nil {
return err
}
}
res = WriteResult{Zone: apex, Serial: uint32(max(out.GetGeneration(), 1))}
return nil
})
return res, err
}
// RecordInput is one record as the console sends it.
type RecordInput struct {
Name string
Type string
TTL uint32
Value string
}
func canon(apex string, in RecordInput) (localzone.Record, error) {
name := in.Name
if name == "@" {
name = apex
}
return localzone.CanonicalRecord(apex, localzone.Record{Name: name, Type: in.Type, TTL: in.TTL, Value: in.Value})
}
// relative stores a name the way an operator would write it.
func relative(apex, name string) string {
if name == apex {
return "@"
}
return strings.TrimSuffix(name, "."+apex)
}
func stored(apex string, r localzone.Record) kubesource.ZoneRecord {
return kubesource.ZoneRecord{Name: relative(apex, r.Name), Type: r.Type, TTL: r.TTL, Value: r.Value}
}
// existing canonicalises the spec's records for comparison. A record that does
// not canonicalise is kept as it is, under a key that matches nothing.
func existing(apex string, spec *kubesource.DNSZoneSpec) []localzone.Record {
out := make([]localzone.Record, len(spec.Records))
for i, r := range spec.Records {
c, err := canon(apex, RecordInput{Name: r.Name, Type: r.Type, TTL: r.TTL, Value: r.Value})
if err != nil {
c = localzone.Record{Name: "\x00invalid", Type: r.Type, Value: r.Value}
}
out[i] = c
}
return out
}
// AddRecord adds one record, creating the zone when it does not exist.
func (e *Editor) AddRecord(ctx context.Context, apex string, in RecordInput, actor string) (WriteResult, error) {
return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error {
a, _ := localzone.NormalizeOrigin(apex)
rec, err := canon(a, in)
if err != nil {
return err
}
for _, have := range existing(a, spec) {
if have.Key() == rec.Key() && have.Value == rec.Value {
return fmt.Errorf("%s %s already exists in %s", rec.Name, rec.Type, a)
}
}
spec.Records = append(spec.Records, stored(a, rec))
return nil
})
}
// PutRecord replaces the whole RRset of one name and type.
func (e *Editor) PutRecord(ctx context.Context, apex string, in RecordInput, actor string) (WriteResult, error) {
return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error {
a, _ := localzone.NormalizeOrigin(apex)
rec, err := canon(a, in)
if err != nil {
return err
}
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == rec.Key() })
spec.Records = append(spec.Records, stored(a, rec))
return nil
})
}
// DeleteRecord removes one record, or the whole RRset when value is empty.
func (e *Editor) DeleteRecord(ctx context.Context, apex, name, rrtype, value, actor string) (WriteResult, error) {
return e.editZone(ctx, apex, false, actor, func(spec *kubesource.DNSZoneSpec) error {
a, _ := localzone.NormalizeOrigin(apex)
var probe localzone.Record
var want string
if value != "" {
full, err := canon(a, RecordInput{Name: name, Type: rrtype, Value: value})
if err != nil {
return err
}
probe, want = full, full.Value
} else {
var err error
if probe, err = canonProbe(a, name, rrtype); err != nil {
return err
}
}
before := len(spec.Records)
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool {
return have.Key() == probe.Key() && (want == "" || have.Value == want)
})
if len(spec.Records) == before {
return fmt.Errorf("%w: no record matches %s %s in %s", ErrNotFound, name, rrtype, a)
}
return nil
})
}
// canonProbe canonicalises only the owner and type of a record, for matching.
func canonProbe(apex, name, rrtype string) (localzone.Record, error) {
for _, v := range []string{"10.0.0.1", "ns.example.", "x", "0 0 0 x", "10 ns.example.", "0 issue \"x\""} {
if r, err := canon(apex, RecordInput{Name: name, Type: rrtype, Value: v}); err == nil {
return r, nil
}
}
return localzone.Record{}, fmt.Errorf("cannot interpret record %s %s", name, rrtype)
}
func dropWhere(apex string, spec *kubesource.DNSZoneSpec, match func(localzone.Record) bool) []kubesource.ZoneRecord {
canonical := existing(apex, spec)
out := make([]kubesource.ZoneRecord, 0, len(spec.Records))
for i, r := range spec.Records {
if !match(canonical[i]) {
out = append(out, r)
}
}
return out
}
// SetDelegation hands a name below the apex to other servers: it replaces the
// NS set at that name. In a zone that does not exist yet the targets also
// become the zone's own NS set, as the file-based writer does.
func (e *Editor) SetDelegation(ctx context.Context, apex, name string, targets []string, ttl uint32, actor string) (WriteResult, error) {
return e.editZone(ctx, apex, true, actor, func(spec *kubesource.DNSZoneSpec) error {
a, _ := localzone.NormalizeOrigin(apex)
if len(targets) == 0 {
return errors.New("a delegation needs at least one NS target")
}
fresh := len(spec.Records) == 0
cut, err := canon(a, RecordInput{Name: name, Type: "NS", TTL: ttl, Value: targets[0]})
if err != nil {
return err
}
if cut.Name == a {
return errors.New("the apex NS set is the zone's own; a delegation is a name below it")
}
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == cut.Key() })
for _, t := range targets {
r, err := canon(a, RecordInput{Name: name, Type: "NS", TTL: ttl, Value: strings.TrimSpace(t)})
if err != nil {
return err
}
spec.Records = append(spec.Records, stored(a, r))
}
if fresh {
for _, t := range targets {
r, err := canon(a, RecordInput{Name: "@", Type: "NS", TTL: ttl, Value: strings.TrimSpace(t)})
if err != nil {
return err
}
spec.Records = append(spec.Records, stored(a, r))
}
}
return nil
})
}
// RemoveDelegation removes the cut at name.
func (e *Editor) RemoveDelegation(ctx context.Context, apex, name, actor string) (WriteResult, error) {
return e.editZone(ctx, apex, false, actor, func(spec *kubesource.DNSZoneSpec) error {
a, _ := localzone.NormalizeOrigin(apex)
cut, err := canon(a, RecordInput{Name: name, Type: "NS", Value: "ns.example."})
if err != nil {
return err
}
if cut.Name == a {
return errors.New("the apex NS set is the zone's own, not a delegation")
}
before := len(spec.Records)
spec.Records = dropWhere(a, spec, func(have localzone.Record) bool { return have.Key() == cut.Key() })
if len(spec.Records) == before {
return fmt.Errorf("%w: no delegation for %s in %s", ErrNotFound, name, a)
}
return nil
})
}
// DeleteZone removes the DNSZone object, which hands the names back to
// whatever answered for them before.
func (e *Editor) DeleteZone(ctx context.Context, apex string) error {
apex, err := localzone.NormalizeOrigin(apex)
if err != nil {
return err
}
zones, err := e.Zones(ctx)
if err != nil {
return err
}
for _, z := range zones {
if z.Apex == apex {
return e.remove(ctx, kubesource.ZoneGVR, z.Name)
}
}
return fmt.Errorf("%w: no DNSZone for %s", ErrNotFound, apex)
}
// ZoneApexes is the set of apexes held by DNSZone objects.
func (e *Editor) ZoneApexes(ctx context.Context) ([]string, error) {
zones, err := e.Zones(ctx)
if err != nil {
return nil, err
}
out := make([]string, len(zones))
for i, z := range zones {
out[i] = z.Apex
}
sort.Strings(out)
return out, nil
}