Files
fancy-dns/internal/kubesource/convert.go
T
mats 66f36cfac6
build / build-push (push) Successful in 1m11s
more stuff
2026-10-16 14:49:50 +02:00

387 lines
11 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)
}
// FieldError is a validation failure tied to one field of a ClientGroup, so an
// editor can show the message next to the input that caused it. Field is the
// JSON path inside the group ("policy", "match.cidrs", "rate_limit"); "name"
// and "match" are used for the whole-object cases.
type FieldError struct {
Field string
Msg string
}
func (e FieldError) Error() string { return e.Msg }
// CheckGroup validates a ClientGroup spec the way the runtime will, so an
// editor can refuse a bad one before it is written. It reports the first
// problem; CheckGroupAll reports every one.
func CheckGroup(name string, s ClientGroupSpec) (config.Group, error) {
g, errs := checkGroup(name, s)
if len(errs) > 0 {
return g.cfg, errs[0]
}
return g.cfg, nil
}
// CheckGroupAll is CheckGroup for an editor that wants to show every problem
// at once instead of one per save.
func CheckGroupAll(name string, s ClientGroupSpec) []FieldError {
_, errs := checkGroup(name, s)
return errs
}
func groupFromSpec(name string, s ClientGroupSpec) (group, error) {
g, errs := checkGroup(name, s)
if len(errs) > 0 {
return group{}, errs[0]
}
return g, nil
}
func checkGroup(name string, s ClientGroupSpec) (group, []FieldError) {
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,
}
var errs []FieldError
bad := func(field, format string, args ...any) {
errs = append(errs, FieldError{Field: field, Msg: fmt.Sprintf(format, args...)})
}
switch g.Policy {
case "allow", "deny", "block_only":
default:
bad("policy", "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 {
bad("match.cidrs", "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 + "."
}
}
selectsSomething := len(g.Match.CIDRs) > 0 || len(g.Match.PTRSuffixes) > 0 || len(g.Match.Tags) > 0
switch {
case !g.Match.Any && !selectsSomething:
bad("match", "match selects no clients: set cidrs, ptr_suffixes, tags or any")
case g.Match.Any && !selectsSomething:
// config.Validate refuses this shape for every group. Refusing it here
// rejects one object instead of the whole merged configuration.
bad("match.any", "match.any alone is not accepted; use a catch-all in the YAML acl.groups")
}
if g.RateLimit < 0 {
bad("rate_limit", "rate_limit must not be negative")
}
if g.MaxConcurrent < 0 {
bad("max_concurrent", "max_concurrent must not be negative")
}
return group{cfg: g, priority: s.Priority}, errs
}
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()
}