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>
346 lines
10 KiB
Go
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
|
|
}
|