Add a read-only Kubernetes source for zones, client groups and blocklists

The runtime can now take ClientGroup, BlockList and DNSZone objects
(fancydns.k6n.net/v1alpha1) from a cluster with -kube, from a pod's service
account or a kubeconfig alike. internal/kubesource watches them with dynamic
informers, converts and validates each object on its own, and publishes an
immutable snapshot. A bad object is skipped and keeps its last good version,
so one broken edit cannot remove a running policy; an invalid merged config is
dropped whole. The last snapshot is saved so a restart without apiserver access
still serves.

config.Store gains an overlay: Base() is what the YAML says, Get() is base plus
cluster objects. Everything that writes the config back (mutateConfig,
SetAdminAddr, the settings editor) now starts from Base(), otherwise cluster
objects would be copied into the file and shadow their own objects. YAML wins
every name collision. putACL and toggleBlocklist refuse to edit cluster-owned
objects. BlockList has no path field and refuses file:// URLs so a namespaced
object cannot pick files the runtime reads or writes. DNSZone is rendered
through the zone writer's own prove-by-reparse step, with the SOA serial taken
from metadata.generation so every replica renders identical bytes.

qlog: Flush reset the pending slice to [:0], so entries logged while it was
still storing the batch were appended over the same backing array. The race
detector found it through the new Kubernetes integration test. pending is now
set to nil, with a regression test that fails without the change.

CRD manifests, RBAC and examples are in deploy/k8s/crds; tests pin the schemas
to the Go spec structs so the two cannot drift.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 08:35:36 +02:00
1 parent 340b4eaf80
commit 4d38945d94
27 files changed
+2250 -19

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
// Package kubesource turns fancy-dns custom resources (DNSZone, ClientGroup,
// BlockList) into the runtime's own configuration types. It is read-only: the
// runtime watches the cluster and never writes to it.
//
// Objects are decoded from unstructured data into the plain structs below, so
// there is no generated clientset or deepcopy code to keep in step with the
// CRD manifests in deploy/k8s/crds.
package kubesource
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// APIGroup and Version name the custom resources.
const (
APIGroup = "fancydns.k6n.net"
Version = "v1alpha1"
)
// The resources the runtime watches.
var (
ZoneGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "dnszones"}
GroupGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "clientgroups"}
ListGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "blocklists"}
allGVRs = []schema.GroupVersionResource{ZoneGVR, GroupGVR, ListGVR}
kindNames = map[string]string{ZoneGVR.Resource: "DNSZone", GroupGVR.Resource: "ClientGroup", ListGVR.Resource: "BlockList"}
)
// DNSZoneSpec declares one authoritative zone. The zone's name is spec.zone,
// not metadata.name, because a DNS name is not always a legal object name.
type DNSZoneSpec struct {
Zone string `json:"zone"`
// TTL is the zone's $TTL and SOA minimum; 0 means the writer's default.
TTL uint32 `json:"ttl,omitempty"`
// SOA is the SOA rdata ("ns1.example.com. hostmaster.example.com. ..."),
// or empty to synthesise one from the first apex NS record.
SOA string `json:"soa,omitempty"`
Records []ZoneRecord `json:"records,omitempty"`
}
// ZoneRecord is one resource record. Value is presentation-format RDATA.
type ZoneRecord struct {
Name string `json:"name"`
Type string `json:"type"`
TTL uint32 `json:"ttl,omitempty"`
Value string `json:"value"`
}
// ClientGroupSpec mirrors config.Group. The group's name is metadata.name.
// Groups are evaluated first-match-wins, so Priority orders them: lower runs
// first, ties break on name.
type ClientGroupSpec struct {
Priority int32 `json:"priority,omitempty"`
Comment string `json:"comment,omitempty"`
Match config.Match `json:"match"`
Policy string `json:"policy"`
AllowDomains []string `json:"allow_domains,omitempty"`
DenyDomains []string `json:"deny_domains,omitempty"`
SkipBlocklist bool `json:"skip_blocklist,omitempty"`
SkipCache bool `json:"skip_cache,omitempty"`
RateLimit int `json:"rate_limit,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
LogQueries bool `json:"log_queries,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
}
// BlockListSpec mirrors config.ListSource for a remote list. There is no path
// field on purpose: a namespace-scoped object must not be able to name a file
// the runtime reads or writes. file:// URLs are refused for the same reason.
type BlockListSpec struct {
URL string `json:"url"`
Format string `json:"format,omitempty"`
Mirror []string `json:"mirror,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Refresh string `json:"refresh,omitempty"`
Timeout string `json:"timeout,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
Categories []string `json:"categories,omitempty"`
Comment string `json:"comment,omitempty"`
InsecureSkipTLSVerify bool `json:"insecure_skip_tls_verify,omitempty"`
}