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 } return groupFromSpec(u.GetName(), s) } // CheckGroup validates a ClientGroup spec the way the runtime will, so an // editor can refuse a bad one before it is written. func CheckGroup(name string, s ClientGroupSpec) (config.Group, error) { g, err := groupFromSpec(name, s) return g.cfg, err } func groupFromSpec(name string, s ClientGroupSpec) (group, error) { g := config.Group{ Name: name, 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 } return listFromSpec(u.GetName(), s) } // CheckList validates a BlockList spec the way the runtime will. func CheckList(name string, s BlockListSpec) (config.ListSource, error) { return listFromSpec(name, s) } func listFromSpec(name string, s BlockListSpec) (config.ListSource, error) { l := config.ListSource{ Name: name, 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 } return zoneFromSpec(s, u.GetGeneration()) } // CheckZone validates a DNSZone spec by rendering it exactly as the runtime // will, and returns the master file. func CheckZone(s DNSZoneSpec) (string, error) { z, err := zoneFromSpec(s, 1) return z.text, err } func zoneFromSpec(s DNSZoneSpec, generation int64) (zone, error) { 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(generation) 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 }