package kubesource import ( "fmt" "net" "net/netip" "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 } // Override is a converted AddressOverride. type Override struct { Domain string `json:"domain"` Addresses []string `json:"addresses"` } // Rules is a converted DomainRule. type Rules struct { Action string `json:"action"` Names []string `json:"names"` } func convertOverride(u *unstructured.Unstructured) (Override, error) { var s AddressOverrideSpec if err := spec(u, &s); err != nil { return Override{}, err } return CheckOverride(s) } // CheckOverride validates an AddressOverride spec and returns it in canonical // form (lower-case name without the trailing dot, addresses as printed by // netip). func CheckOverride(s AddressOverrideSpec) (Override, error) { domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s.Domain)), ".") if err := checkRuleName(domain, false); err != nil { return Override{}, fmt.Errorf("domain: %w", err) } if len(s.Addresses) == 0 { return Override{}, fmt.Errorf("at least one address is required") } out := Override{Domain: domain} seen := map[string]bool{} for _, raw := range s.Addresses { ip, err := netip.ParseAddr(strings.TrimSpace(raw)) if err != nil { return Override{}, fmt.Errorf("%q is not an IPv4 or IPv6 address", raw) } ip = ip.Unmap() if !seen[ip.String()] { seen[ip.String()] = true out.Addresses = append(out.Addresses, ip.String()) } } return out, nil } func convertRules(u *unstructured.Unstructured) (Rules, error) { var s DomainRuleSpec if err := spec(u, &s); err != nil { return Rules{}, err } return CheckRules(s) } // CheckRules validates a DomainRule spec. func CheckRules(s DomainRuleSpec) (Rules, error) { if s.Action != "block" && s.Action != "allow" { return Rules{}, fmt.Errorf("action must be block or allow (got %q)", s.Action) } if len(s.Names) > maxRuleNames { return Rules{}, fmt.Errorf("%d names exceeds the limit of %d; use a BlockList for large sets", len(s.Names), maxRuleNames) } out := Rules{Action: s.Action} for _, n := range s.Names { n = strings.ToLower(strings.TrimSpace(n)) if err := checkRuleName(n, true); err != nil { return Rules{}, fmt.Errorf("name %q: %w", n, err) } out.Names = append(out.Names, n) } return out, nil } // maxRuleNames bounds one DomainRule; an object is capped near 1.5 MiB by etcd // anyway, and a bigger set belongs in a BlockList that is fetched. const maxRuleNames = 5000 // checkRuleName accepts only what a DNS name or a wildcard pattern can contain. // This is a security check, not tidiness: names are joined into the blocklist // parser's line-oriented input, so a newline or a comment character in a name // could smuggle in a rule of its own. func checkRuleName(n string, wildcard bool) error { if n == "" { return fmt.Errorf("a name is required") } if wildcard { n = strings.TrimPrefix(strings.TrimPrefix(n, "**."), "*.") } n = strings.TrimSuffix(n, ".") if n == "" || len(n) > 253 { return fmt.Errorf("not a valid DNS name") } for _, label := range strings.Split(n, ".") { if label == "" || len(label) > 63 { return fmt.Errorf("not a valid DNS name") } for _, c := range label { ok := c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_' if !ok { return fmt.Errorf("not a valid DNS name (only letters, digits, '-' and '_' are allowed)") } } } return nil } // RenderRPZ renders the overrides as the RPZ text the blocklist manager reads: // one "name A address" line per address, applying to the name and everything // under it. func RenderRPZ(in []Override) string { var b strings.Builder b.WriteString("$TTL 60\n") for _, o := range in { for _, a := range o.Addresses { typ := "A" if strings.Contains(a, ":") { typ = "AAAA" } fmt.Fprintf(&b, "%s %s %s\n", o.Domain, typ, a) } } return b.String() }