Files
fancy-dns/internal/kubesource/convert.go
T
matsandClaude Sonnet 5.5 4d38945d94 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>
2026-10-16 08:35:36 +02:00

192 lines
5.6 KiB
Go

package kubesource
import (
"fmt"
"net"
"strings"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
)
// maxZoneRecords bounds one DNSZone. An object is capped near 1.5 MiB by etcd
// anyway; this keeps a hostile or runaway one from costing a render.
const maxZoneRecords = 5000
// group is a converted ClientGroup with its ordering key.
type group struct {
cfg config.Group
priority int32
}
// zone is a converted DNSZone: the apex and the rendered master file.
type zone struct {
apex string
text string
}
func spec(u *unstructured.Unstructured, into any) error {
raw, ok := u.Object["spec"].(map[string]any)
if !ok {
return fmt.Errorf("the object has no spec")
}
return runtime.DefaultUnstructuredConverter.FromUnstructured(raw, into)
}
func convertGroup(u *unstructured.Unstructured) (group, error) {
var s ClientGroupSpec
if err := spec(u, &s); err != nil {
return group{}, err
}
g := config.Group{
Name: u.GetName(),
Comment: s.Comment,
Match: s.Match,
Policy: s.Policy,
AllowDomains: s.AllowDomains,
DenyDomains: s.DenyDomains,
SkipBlocklist: s.SkipBlocklist,
SkipCache: s.SkipCache,
RateLimit: s.RateLimit,
MaxConcurrent: s.MaxConcurrent,
LogQueries: s.LogQueries,
Upstreams: s.Upstreams,
}
switch g.Policy {
case "allow", "deny", "block_only":
default:
return group{}, fmt.Errorf("policy must be allow, deny or block_only (got %q)", g.Policy)
}
for _, cidr := range g.Match.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return group{}, fmt.Errorf("bad CIDR %q", cidr)
}
}
// Copy before normalising: the slice belongs to the decoded object.
g.Match.PTRSuffixes = append([]string(nil), g.Match.PTRSuffixes...)
for i, s := range g.Match.PTRSuffixes {
if !strings.HasSuffix(s, ".") {
g.Match.PTRSuffixes[i] = s + "."
}
}
if !g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 {
return group{}, fmt.Errorf("match selects no clients: set cidrs, ptr_suffixes, tags or any")
}
if g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 {
// config.Validate refuses this shape for every group. Refusing it here
// rejects one object instead of the whole merged configuration.
return group{}, fmt.Errorf("match.any alone is not accepted; use a catch-all in the YAML acl.groups")
}
if g.RateLimit < 0 || g.MaxConcurrent < 0 {
return group{}, fmt.Errorf("rate_limit and max_concurrent must not be negative")
}
return group{cfg: g, priority: s.Priority}, nil
}
func convertList(u *unstructured.Unstructured) (config.ListSource, error) {
var s BlockListSpec
if err := spec(u, &s); err != nil {
return config.ListSource{}, err
}
l := config.ListSource{
Name: u.GetName(),
Format: s.Format,
URL: s.URL,
Mirror: s.Mirror,
Enabled: !s.Disabled,
MaxBytes: s.MaxBytes,
Categories: s.Categories,
Comment: s.Comment,
InsecureSkipTLSVerify: s.InsecureSkipTLSVerify,
}
if !isHTTP(l.URL) {
return config.ListSource{}, fmt.Errorf("url must be http(s) (got %q)", l.URL)
}
for _, m := range l.Mirror {
if !isHTTP(m) {
return config.ListSource{}, fmt.Errorf("mirror must be http(s) (got %q)", m)
}
}
switch l.Format {
case "", "auto", "hosts", "adblock", "plain", "rpz":
default:
return config.ListSource{}, fmt.Errorf("unknown format %q", l.Format)
}
var err error
if l.Refresh, err = duration(s.Refresh); err != nil {
return config.ListSource{}, fmt.Errorf("refresh: %w", err)
}
if l.Timeout, err = duration(s.Timeout); err != nil {
return config.ListSource{}, fmt.Errorf("timeout: %w", err)
}
if l.MaxBytes < 0 {
return config.ListSource{}, fmt.Errorf("max_bytes must not be negative")
}
return l, nil
}
func isHTTP(u string) bool {
return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
}
func duration(s string) (config.Duration, error) {
if s == "" {
return 0, nil
}
d, err := time.ParseDuration(s)
if err != nil {
return 0, err
}
if d < 0 {
return 0, fmt.Errorf("must not be negative")
}
return config.Duration(d), nil
}
func convertZone(u *unstructured.Unstructured) (zone, error) {
var s DNSZoneSpec
if err := spec(u, &s); err != nil {
return zone{}, err
}
if s.Zone == "" {
return zone{}, fmt.Errorf("spec.zone is required")
}
if len(s.Records) > maxZoneRecords {
return zone{}, fmt.Errorf("%d records exceeds the limit of %d", len(s.Records), maxZoneRecords)
}
apex, err := localzone.NormalizeOrigin(s.Zone)
if err != nil {
return zone{}, fmt.Errorf("spec.zone: %w", err)
}
var ns []string
recs := make([]localzone.Record, 0, len(s.Records))
for _, r := range s.Records {
name := r.Name
if name == "@" {
name = apex
}
recs = append(recs, localzone.Record{Name: name, Type: r.Type, TTL: r.TTL, Value: r.Value})
if strings.EqualFold(r.Type, "NS") && name == apex {
ns = append(ns, r.Value)
}
}
if s.SOA == "" && len(ns) == 0 {
return zone{}, fmt.Errorf("a zone needs an apex NS record (name \"@\") or an explicit soa")
}
// The generation changes exactly when the spec does, so every replica
// renders the same bytes for the same object version.
serial := uint32(u.GetGeneration())
if serial == 0 {
serial = 1
}
text, err := localzone.RenderZone(apex, localzone.ZoneDefaults{TTL: s.TTL, Soa: s.SOA}, recs, serial)
if err != nil {
return zone{}, err
}
return zone{apex: apex, text: text}, nil
}