Files
fancy-dns/internal/kubesource/convert.go
T
matsandClaude Sonnet 5.5 88479b9a12 Add AddressOverride and DomainRule objects for what lived in a ConfigMap and the config file
The address redirects page rewrote an RPZ ConfigMap through the resolver's own
service account, and the domains page rewrote allow_entries/block_entries in the
config file. Both become cluster objects like the rest.

AddressOverride is one name answered with fixed addresses (the name and
everything under it), exactly what an RPZ "name A address" line did. DomainRule
is a block or allow list of inline names. The resolver reads both with the
existing watch: overrides are rendered into the managed RPZ source the blocklist
manager already has, and rule names join the config's inline entries in the
merged overlay, so the file's config is never written to.

Names are checked to be DNS names or wildcard patterns before they are used,
because they are joined into line-oriented parser input and a newline or a
comment character in one would otherwise add a rule of its own.

The console edits both: the address-overrides and domains endpoints now write
objects. The console keeps its added rules in one DomainRule per action
(inline-block, inline-allow) instead of an object per name, and removes a name
from whichever DomainRule holds it, wildcard or not. An end-to-end test drives
the pages' endpoints and checks the resolver's answers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:08:08 +02:00

349 lines
10 KiB
Go

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()
}