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>
192 lines
5.6 KiB
Go
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
|
|
}
|