// Package migrate turns what a ConfigMap-and-YAML deployment held into fancy-dns // objects: zone master files become DNSZone, the address-overrides RPZ becomes // AddressOverride, URL blocklists become BlockList, inline allow/block entries // become DomainRule, and ACL groups become ClientGroup. // // It converts, validates with the same code the resolver uses to read the // objects, and reports what it could not carry over. It never deletes anything // and never guesses: an input it cannot represent faithfully is listed and left // where it is. package migrate import ( "fmt" "reflect" "sort" "strings" "time" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" "git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone" ) // Inputs is everything to convert. Any part may be empty. type Inputs struct { // ZoneFiles maps an apex ("home.example.com") to its master file. ZoneFiles map[string]string // RPZ is the address-overrides RPZ text. RPZ string // Config is the resolver's YAML configuration. Config *config.Config // Namespace is where the objects will live. Namespace string } // Result is the converted objects and the account of what was not converted. type Result struct { Objects []*unstructured.Unstructured // Notes say what was converted and what to remove from the config afterwards. Notes []string // Skipped are inputs that could not be represented, each with the reason. Skipped []string } const managedBy = "fancy-dns-migrate" // Convert does the whole conversion. func Convert(in Inputs) Result { var r Result r.zones(in) r.overrides(in) if in.Config != nil { r.config(in) } sort.SliceStable(r.Objects, func(i, j int) bool { a, b := r.Objects[i], r.Objects[j] if a.GetKind() != b.GetKind() { return a.GetKind() < b.GetKind() } return a.GetName() < b.GetName() }) return r } func (r *Result) add(kind, name, ns string, spec any) { m, err := toMap(spec) if err != nil { r.Skipped = append(r.Skipped, fmt.Sprintf("%s %s: %v", kind, name, err)) return } u := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": kubesource.APIGroup + "/" + kubesource.Version, "kind": kind, "metadata": map[string]any{ "name": name, "namespace": ns, "labels": map[string]any{"app.kubernetes.io/managed-by": managedBy}, }, "spec": m, }} r.Objects = append(r.Objects, u) } func (r *Result) skip(format string, args ...any) { r.Skipped = append(r.Skipped, fmt.Sprintf(format, args...)) } func (r *Result) note(format string, args ...any) { r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) } // zones converts master files. func (r *Result) zones(in Inputs) { apexes := make([]string, 0, len(in.ZoneFiles)) for a := range in.ZoneFiles { apexes = append(apexes, a) } sort.Strings(apexes) for _, name := range apexes { apex, err := localzone.NormalizeOrigin(name) if err != nil { r.skip("zone file %q: %v", name, err) continue } zf, err := localzone.ParseZoneFile(apex, in.ZoneFiles[name]) if err != nil { r.skip("zone file %q: does not parse: %v", name, err) continue } spec := kubesource.DNSZoneSpec{Zone: strings.TrimSuffix(apex, "."), TTL: zf.TTL, SOA: zf.Soa} for _, rec := range zf.Records { rel := "@" if rec.Name != apex { rel = strings.TrimSuffix(rec.Name, "."+apex) } ttl := rec.TTL if ttl == zf.TTL { ttl = 0 // inherits the zone's, as it did in the file } spec.Records = append(spec.Records, kubesource.ZoneRecord{Name: rel, Type: rec.Type, TTL: ttl, Value: rec.Value}) } if _, err := kubesource.CheckZone(spec); err != nil { r.skip("zone %s: not representable as a DNSZone (%v); it stays a master file", apex, err) continue } objName := strings.TrimSuffix(apex, ".") r.add("DNSZone", objName, in.Namespace, spec) r.note("zone %s: %d records -> DNSZone/%s", apex, len(spec.Records), objName) } } // overrides converts the address-overrides RPZ. func (r *Result) overrides(in Inputs) { if strings.TrimSpace(in.RPZ) == "" { return } byName := map[string][]string{} for i, line := range strings.Split(in.RPZ, "\n") { if j := strings.IndexAny(line, ";#"); j >= 0 { line = line[:j] } f := strings.Fields(line) if len(f) == 0 || strings.HasPrefix(f[0], "$") { continue } // "name [ttl] [IN] A|AAAA address" rest := f[1:] for len(rest) > 0 && (strings.EqualFold(rest[0], "IN") || isNumber(rest[0])) { rest = rest[1:] } if len(rest) < 2 || !(strings.EqualFold(rest[0], "A") || strings.EqualFold(rest[0], "AAAA")) { r.skip("address overrides line %d %q: only A and AAAA overrides are converted", i+1, strings.TrimSpace(line)) continue } name := strings.TrimSuffix(strings.ToLower(f[0]), ".") byName[name] = append(byName[name], rest[1]) } names := make([]string, 0, len(byName)) for n := range byName { names = append(names, n) } sort.Strings(names) for _, n := range names { spec := kubesource.AddressOverrideSpec{Domain: n, Addresses: byName[n]} o, err := kubesource.CheckOverride(spec) if err != nil { r.skip("address override %s: %v", n, err) continue } r.add("AddressOverride", kubeedit.ObjectName(o.Domain), in.Namespace, kubesource.AddressOverrideSpec{Domain: o.Domain, Addresses: o.Addresses}) } if len(names) > 0 { r.note("address overrides: %d names -> AddressOverride objects", len(names)) } } func isNumber(s string) bool { for _, c := range s { if c < '0' || c > '9' { return false } } return s != "" } // config converts the parts of the resolver's YAML that cluster objects replace. func (r *Result) config(in Inputs) { cfg := in.Config for _, l := range cfg.Block.Lists { switch { case l.URL == "" && strings.EqualFold(l.Format, "rpz") && strings.Contains(l.Path, "address-overrides"): r.note("list %q is the address-overrides file; the AddressOverride objects replace it, remove it from blocklist.lists", l.Name) case l.URL == "": r.skip("list %q: it is a file on the pod (%s), which a cluster object cannot point at; host it at a URL or keep it in the config", l.Name, l.Path) case !strings.HasPrefix(l.URL, "http://") && !strings.HasPrefix(l.URL, "https://"): r.skip("list %q: url %q is not http(s)", l.Name, l.URL) default: spec := kubesource.BlockListSpec{ URL: l.URL, Format: l.Format, Mirror: l.Mirror, Disabled: !l.Enabled, MaxBytes: l.MaxBytes, Categories: l.Categories, Comment: l.Comment, InsecureSkipTLSVerify: l.InsecureSkipTLSVerify, } if l.Refresh > 0 { spec.Refresh = time.Duration(l.Refresh).String() } // The config loader fills these in; only a value the operator chose // is worth writing down. if l.Timeout > 0 && l.Timeout != cfg.Server.Timeouts.List { spec.Timeout = time.Duration(l.Timeout).String() } if spec.MaxBytes == 512<<20 { spec.MaxBytes = 0 } if _, err := kubesource.CheckList(l.Name, spec); err != nil { r.skip("list %q: %v", l.Name, err) continue } if name := kubeedit.ObjectName(l.Name); name != l.Name { r.note("list %q: object name will be %q (not a legal Kubernetes name as written)", l.Name, name) r.add("BlockList", name, in.Namespace, spec) } else { r.add("BlockList", l.Name, in.Namespace, spec) } r.note("list %q -> BlockList; remove it from blocklist.lists", l.Name) } } for _, rules := range []struct { action string entries []string }{{"block", cfg.Block.BlockEntries}, {"allow", cfg.Block.AllowEntries}} { if len(rules.entries) == 0 { continue } var ok []string for _, e := range rules.entries { e = strings.TrimSpace(e) if e == "" || strings.HasPrefix(e, "#") { continue } if _, err := kubesource.CheckRules(kubesource.DomainRuleSpec{Action: rules.action, Names: []string{e}}); err != nil { r.skip("%s entry %q: %v", rules.action, e, err) continue } ok = append(ok, e) } if len(ok) == 0 { continue } r.add("DomainRule", "inline-"+rules.action, in.Namespace, kubesource.DomainRuleSpec{ Action: rules.action, Names: ok, Comment: "Migrated from blocklist." + rules.action + "_entries", }) r.note("%d %s entries -> DomainRule/inline-%s; remove blocklist.%s_entries", len(ok), rules.action, rules.action, rules.action) } for i, g := range cfg.ACL.Groups { spec := kubeedit.SpecFromGroup(g, int32(i+1)*10) if _, err := kubesource.CheckGroup(g.Name, spec); err != nil { r.skip("acl group %q: %v", g.Name, err) continue } if name := g.Name; kubeedit.ObjectName(name) != name { r.skip("acl group %q: not a legal Kubernetes object name; rename it", g.Name) continue } r.add("ClientGroup", g.Name, in.Namespace, spec) } if len(cfg.ACL.Groups) > 0 { r.note("%d acl groups -> ClientGroup; remove acl.groups from the config (default_policy stays)", len(cfg.ACL.Groups)) } } // YAML renders the objects as a multi-document manifest. func YAML(objs []*unstructured.Unstructured) ([]byte, error) { var b strings.Builder for i, o := range objs { out, err := yaml.Marshal(o.Object) if err != nil { return nil, err } if i > 0 { b.WriteString("---\n") } b.Write(out) } return []byte(b.String()), nil } // toMap converts a spec struct to unstructured form. ToUnstructured wants a // pointer to the concrete type. func toMap(spec any) (map[string]any, error) { v := reflect.New(reflect.TypeOf(spec)) v.Elem().Set(reflect.ValueOf(spec)) return runtime.DefaultUnstructuredConverter.ToUnstructured(v.Interface()) }