diff --git a/deploy/k8s/console/console.yaml b/deploy/k8s/console/console.yaml index 298b861..41bfee6 100644 --- a/deploy/k8s/console/console.yaml +++ b/deploy/k8s/console/console.yaml @@ -43,7 +43,7 @@ metadata: namespace: dns rules: - apiGroups: [fancydns.k6n.net] - resources: [dnszones, clientgroups, blocklists] + resources: [dnszones, clientgroups, blocklists, addressoverrides, domainrules] verbs: [get, list, create, update, delete] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/deploy/k8s/crds/crds.yaml b/deploy/k8s/crds/crds.yaml index 5e0ae64..1630745 100644 --- a/deploy/k8s/crds/crds.yaml +++ b/deploy/k8s/crds/crds.yaml @@ -122,3 +122,66 @@ spec: categories: {type: array, items: {type: string}} comment: {type: string, maxLength: 512} insecure_skip_tls_verify: {type: boolean} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: addressoverrides.fancydns.k6n.net +spec: + group: fancydns.k6n.net + scope: Namespaced + names: {kind: AddressOverride, listKind: AddressOverrideList, plural: addressoverrides, singular: addressoverride, shortNames: [ao]} + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - {name: Domain, type: string, jsonPath: .spec.domain} + - {name: Addresses, type: string, jsonPath: .spec.addresses} + - {name: Age, type: date, jsonPath: .metadata.creationTimestamp} + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [domain, addresses] + properties: + domain: {type: string, minLength: 1, maxLength: 253, description: "Answered with the addresses below, together with every name under it"} + addresses: + type: array + minItems: 1 + maxItems: 16 + items: {type: string, description: "IPv4 or IPv6 literal"} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: domainrules.fancydns.k6n.net +spec: + group: fancydns.k6n.net + scope: Namespaced + names: {kind: DomainRule, listKind: DomainRuleList, plural: domainrules, singular: domainrule, shortNames: [dr]} + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - {name: Action, type: string, jsonPath: .spec.action} + - {name: Age, type: date, jsonPath: .metadata.creationTimestamp} + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + spec: + type: object + required: [action, names] + properties: + action: {type: string, enum: [block, allow], description: "allow rescues a name that a blocklist would block"} + names: + type: array + maxItems: 5000 + items: {type: string, description: "A name, or *.name (subdomains) / **.name (subdomains and the name)"} + comment: {type: string, maxLength: 512} diff --git a/deploy/k8s/crds/examples.yaml b/deploy/k8s/crds/examples.yaml index 445f6a1..57c055c 100644 --- a/deploy/k8s/crds/examples.yaml +++ b/deploy/k8s/crds/examples.yaml @@ -34,3 +34,21 @@ spec: format: hosts refresh: 24h categories: [ads, malware] +--- +apiVersion: fancydns.k6n.net/v1alpha1 +kind: AddressOverride +metadata: + name: k6n-net + namespace: dns +spec: + domain: k6n.net + addresses: [10.10.3.10] +--- +apiVersion: fancydns.k6n.net/v1alpha1 +kind: DomainRule +metadata: + name: inline-block + namespace: dns +spec: + action: block + names: ["*.tracker.example"] diff --git a/deploy/k8s/crds/rbac.yaml b/deploy/k8s/crds/rbac.yaml index ecc0c86..ef6e097 100644 --- a/deploy/k8s/crds/rbac.yaml +++ b/deploy/k8s/crds/rbac.yaml @@ -8,7 +8,7 @@ metadata: namespace: dns rules: - apiGroups: [fancydns.k6n.net] - resources: [dnszones, clientgroups, blocklists] + resources: [dnszones, clientgroups, blocklists, addressoverrides, domainrules] verbs: [get, list, watch] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/internal/app/console_e2e_test.go b/internal/app/console_e2e_test.go index e3c537f..9690e22 100644 --- a/internal/app/console_e2e_test.go +++ b/internal/app/console_e2e_test.go @@ -153,4 +153,55 @@ func TestConsoleEditsReachTheResolver(t *testing.T) { m := h.query("www.edit.test", dns.TypeA) return !m.Authoritative }) + + // Address redirects: the page that used to rewrite a ConfigMap. + ok("PUT", "/api/address-overrides", `{"domain":"pin.test","address":"10.7.7.1"}`) + var ov struct { + Items []struct{ Domain, Address string } `json:"items"` + } + if err := json.Unmarshal([]byte(ok("GET", "/api/address-overrides", "")), &ov); err != nil || len(ov.Items) != 1 || ov.Items[0].Address != "10.7.7.1" { + t.Fatalf("overrides right after save = %+v (%v)", ov, err) + } + eventually(t, "the resolver to answer the override", func() bool { + m := h.query("pin.test", dns.TypeA) + a, isA := dnsFirstA(m) + return isA && a == "10.7.7.1" + }) + ok("DELETE", "/api/address-overrides/pin.test", "") + eventually(t, "the deleted override to stop answering", func() bool { + a, isA := dnsFirstA(h.query("pin.test", dns.TypeA)) + return isA && a == "203.0.113.7" + }) + + // Domains: inline rules become DomainRule objects. + ok("POST", "/api/domains", `{"name":"console-blocked.test","action":"block"}`) + ok("POST", "/api/domains", `{"name":"x","action":"block","content":"# a comment\nbulk-one.test\n\nbulk-two.test"}`) + eventually(t, "the resolver to block the new rules", func() bool { + return h.query("console-blocked.test", dns.TypeA).Rcode == dns.RcodeNameError && + h.query("bulk-two.test", dns.TypeA).Rcode == dns.RcodeNameError + }) + if code, out := do("POST", "/api/domains", `{"name":"x.test","action":"redirect"}`, "console-key"); code != http.StatusBadRequest || !strings.Contains(out, "block or allow") { + t.Errorf("unsupported action = %d %s", code, out) + } + if code, _ := do("POST", "/api/domains", `{"name":"a.test\nb.test","action":"block"}`, "console-key"); code != http.StatusBadRequest { + t.Errorf("a name with a newline = %d, want 400", code) + } + ok("DELETE", "/api/domains/console-blocked.test", "") + eventually(t, "the removed rule to stop blocking", func() bool { + return h.query("console-blocked.test", dns.TypeA).Rcode == dns.RcodeSuccess + }) + if code, _ := do("DELETE", "/api/domains/never-added.test", "", "console-key"); code != http.StatusNotFound { + t.Errorf("deleting an unknown rule = %d, want 404", code) + } +} + +func dnsFirstA(m *dns.Msg) (string, bool) { + if len(m.Answer) == 0 { + return "", false + } + a, ok := m.Answer[0].(*dns.A) + if !ok { + return "", false + } + return a.A.String(), true } diff --git a/internal/app/kube.go b/internal/app/kube.go index 8eb622d..c1e835d 100644 --- a/internal/app/kube.go +++ b/internal/app/kube.go @@ -135,6 +135,9 @@ func (a *App) applyKube(snap *kubesource.Snapshot, live bool) { } else { a.clearDegraded(kubeZoneFail) } + if a.blocks != nil { + a.blocks.SetManagedRPZ(kubesource.RenderRPZ(snap.Overrides)) + } a.cfg.Reapply() if prev == nil { return diff --git a/internal/app/kube_test.go b/internal/app/kube_test.go index 0d71eb4..97f8e0a 100644 --- a/internal/app/kube_test.go +++ b/internal/app/kube_test.go @@ -30,6 +30,9 @@ func kubeClient() *dynfake.FakeDynamicClient { kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList", kubesource.ListGVR: "BlockListList", + + kubesource.OverrideGVR: "AddressOverrideList", + kubesource.RuleGVR: "DomainRuleList", }) } @@ -116,3 +119,74 @@ func TestKubeObjectsReachTheResolver(t *testing.T) { } eventually(t, "the deleted DNSZone to stop being authoritative", func() bool { return answer() == "" }) } + +// TestKubeOverridesAndRules: address overrides and inline domain rules, the +// two things that used to live in a ConfigMap and the config file. +func TestKubeOverridesAndRules(t *testing.T) { + client := kubeClient() + h := newHarness(t, nil, func(o *Options) { + o.Kube = KubeOptions{Enabled: true, Namespace: "dns", Client: client, SyncTimeout: 5 * time.Second} + }) + ctx := context.Background() + overrides := client.Resource(kubesource.OverrideGVR).Namespace("dns") + rules := client.Resource(kubesource.RuleGVR).Namespace("dns") + + addr := func(name string) string { + m := h.query(name, dns.TypeA) + if len(m.Answer) == 1 { + if a, ok := m.Answer[0].(*dns.A); ok { + return a.A.String() + } + } + return "" + } + inline := func() int { + b := h.app.cfg.Base().Block + return len(b.BlockEntries) + len(b.AllowEntries) + } + inlineBefore := inline() + // Before: the fake upstream's answer. + if got := addr("pinned.test"); got != "203.0.113.7" { + t.Fatalf("setup: pinned.test = %q", got) + } + + if _, err := overrides.Create(ctx, kubeObj("AddressOverride", "pinned", map[string]any{ + "domain": "pinned.test", "addresses": []any{"10.9.9.9"}, + }), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + eventually(t, "the override to answer", func() bool { return addr("pinned.test") == "10.9.9.9" }) + // It covers the names under it too, like the RPZ line it replaces. + eventually(t, "the override to cover subdomains", func() bool { return addr("www.pinned.test") == "10.9.9.9" }) + + // A block rule from the cluster. + if _, err := rules.Create(ctx, kubeObj("DomainRule", "blocks", map[string]any{ + "action": "block", "names": []any{"crd-blocked.test"}, + }), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + eventually(t, "the block rule to apply", func() bool { + return h.query("crd-blocked.test", dns.TypeA).Rcode == dns.RcodeNameError + }) + // An allow rule rescues a name the harness's hosts list blocks. + if h.query("blocked.example.test", dns.TypeA).Rcode != dns.RcodeNameError { + t.Fatal("setup: blocked.example.test is not blocked") + } + if _, err := rules.Create(ctx, kubeObj("DomainRule", "allows", map[string]any{ + "action": "allow", "names": []any{"blocked.example.test"}, + }), metav1.CreateOptions{}); err != nil { + t.Fatal(err) + } + eventually(t, "the allow rule to rescue the name", func() bool { + return h.query("blocked.example.test", dns.TypeA).Rcode == dns.RcodeSuccess + }) + + if err := overrides.Delete(ctx, "pinned", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + eventually(t, "the deleted override to stop answering", func() bool { return addr("pinned.test") == "203.0.113.7" }) + // None of it leaked into the YAML. + if n := inline(); n != inlineBefore { + t.Fatalf("the file's config went from %d to %d inline entries", inlineBefore, n) + } +} diff --git a/internal/console/console.go b/internal/console/console.go index 72023b0..3aadd2a 100644 --- a/internal/console/console.go +++ b/internal/console/console.go @@ -88,6 +88,11 @@ func (c *Console) Handler() (http.Handler, error) { "DELETE " + base + "/zones/delegations", "DELETE " + base + "/zones", "POST " + base + "/blocklists/toggle", + "GET " + base + "/address-overrides", + "PUT " + base + "/address-overrides", + "DELETE " + base + "/address-overrides/{domain}", + "POST " + base + "/domains", + "DELETE " + base + "/domains/{name...}", } { mux.Handle(p, own) } diff --git a/internal/console/hooks.go b/internal/console/hooks.go index 73f958a..ad9074a 100644 --- a/internal/console/hooks.go +++ b/internal/console/hooks.go @@ -32,6 +32,12 @@ func (c *Console) hooks() api.Hooks { AddZoneDelegation: c.addZoneDelegation, DeleteZoneDelegation: c.deleteZoneDelegation, DeleteZone: c.deleteZone, + + AddressOverrides: c.listOverrides, + SaveAddressOverride: c.saveOverride, + DeleteAddressOverride: c.deleteOverride, + AddDomain: c.addDomain, + DeleteDomain: c.deleteDomain, } } @@ -274,3 +280,68 @@ func (c *Console) handleListSourceDelete(w http.ResponseWriter, r *http.Request) } api.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "name": name}) } + +// addressOverride is one row of the Address redirects page. +type addressOverride struct { + Domain string `json:"domain"` + Address string `json:"address"` +} + +func (c *Console) listOverrides(ctx context.Context) (any, error) { + objs, err := c.opt.Editor.Overrides(ctx) + if err != nil { + return nil, err + } + items := []addressOverride{} + for _, o := range objs { + for _, a := range o.Addresses { + items = append(items, addressOverride{Domain: o.Domain, Address: a}) + } + } + return map[string]any{"items": items, "writable": true, "initialized": true}, nil +} + +func (c *Console) saveOverride(ctx context.Context, domain, address string) error { + return c.opt.Editor.SetOverride(ctx, domain, address, actor(ctx)) +} + +func (c *Console) deleteOverride(ctx context.Context, domain string) error { + return c.opt.Editor.DeleteOverride(ctx, domain) +} + +// addDomain adds inline block or allow rules. A bulk paste arrives as content, +// one name per line. +func (c *Console) addDomain(ctx context.Context, in api.DomainInput) (any, error) { + action := strings.ToLower(strings.TrimSpace(in.Action)) + if action != "block" && action != "allow" { + return nil, fmt.Errorf("inline rules can block or allow a name; %q is not one of those (use an AddressOverride to answer with an address)", in.Action) + } + raw := []string{in.Name} + if in.Content != "" { + raw = strings.Split(in.Content, "\n") + } + var names []string + for _, line := range raw { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + names = append(names, line) + } + if len(names) == 0 { + return nil, errors.New("name is required") + } + n, err := c.opt.Editor.AddRules(ctx, action, names, actor(ctx)) + if err != nil { + return nil, err + } + return map[string]any{"ok": true, "name": strings.TrimSpace(in.Name), "count": n}, nil +} + +func (c *Console) deleteDomain(ctx context.Context, name, _, list string) error { + _, err := c.opt.Editor.DeleteRule(ctx, name, actor(ctx)) + if errors.Is(err, kubeedit.ErrNotFound) && list != "" { + return fmt.Errorf("%q comes from the list %q; edit that list instead", name, list) + } + return err +} diff --git a/internal/kubeedit/edit_test.go b/internal/kubeedit/edit_test.go index 4fe07a6..1811cd2 100644 --- a/internal/kubeedit/edit_test.go +++ b/internal/kubeedit/edit_test.go @@ -20,6 +20,9 @@ func newEditor() *Editor { kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList", kubesource.ListGVR: "BlockListList", + + kubesource.OverrideGVR: "AddressOverrideList", + kubesource.RuleGVR: "DomainRuleList", }) return New(c, "dns") } @@ -216,3 +219,99 @@ func TestListEditing(t *testing.T) { t.Fatal(err) } } + +func TestOverrideEditing(t *testing.T) { + e := newEditor() + if err := e.SetOverride(ctx, "Tornberg.ME.", "10.10.3.10", "a"); err != nil { + t.Fatal(err) + } + // Setting again replaces rather than adds, as the RPZ file editor did. + if err := e.SetOverride(ctx, "tornberg.me", "10.10.3.11", "a"); err != nil { + t.Fatal(err) + } + if err := e.SetOverride(ctx, "slask_x.local", "10.10.3.10", "a"); err != nil { + t.Fatalf("a name that is not a legal object name must still work: %v", err) + } + got, _ := e.Overrides(ctx) + if len(got) != 2 { + t.Fatalf("overrides = %+v", got) + } + byDomain := map[string][]string{} + for _, o := range got { + byDomain[o.Domain] = o.Addresses + } + if a := byDomain["tornberg.me"]; len(a) != 1 || a[0] != "10.10.3.11" { + t.Fatalf("tornberg.me = %v", a) + } + if err := e.SetOverride(ctx, "x.test", "not-an-ip", "a"); err == nil { + t.Fatal("a bad address was accepted") + } + if err := e.DeleteOverride(ctx, "TORNBERG.me."); err != nil { + t.Fatal(err) + } + if err := e.DeleteOverride(ctx, "tornberg.me"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v", err) + } + if got, _ := e.Overrides(ctx); len(got) != 1 { + t.Fatalf("after delete: %+v", got) + } +} + +func TestRuleEditing(t *testing.T) { + e := newEditor() + n, err := e.AddRules(ctx, "block", []string{"Ads.Example.com", "*.tracker.example", "ads.example.com"}, "a") + if err != nil || n != 3 { + t.Fatalf("add: %d %v", n, err) + } + if _, err := e.AddRules(ctx, "allow", []string{"good.example"}, "a"); err != nil { + t.Fatal(err) + } + if _, err := e.AddRules(ctx, "block", []string{"bad name\nother.test"}, "a"); err == nil { + t.Fatal("a name with a newline was accepted") + } + if _, err := e.AddRules(ctx, "drop", []string{"x.test"}, "a"); err == nil { + t.Fatal("an unknown action was accepted") + } + items, _ := e.list(ctx, kubesource.RuleGVR) + if len(items) != 2 { + t.Fatalf("%d DomainRule objects, want one per action", len(items)) + } + var spec kubesource.DomainRuleSpec + for i := range items { + if items[i].GetName() == inlineBlock { + _ = decodeSpec(&items[i], &spec) + } + } + if len(spec.Names) != 2 { // ads.example.com once, and the wildcard + t.Fatalf("block names = %v", spec.Names) + } + // A wildcard rule is deleted by its plain name, as the console sends it. + if removed, err := e.DeleteRule(ctx, "tracker.example", "a"); err != nil || removed != 1 { + t.Fatalf("delete: %d %v", removed, err) + } + if _, err := e.DeleteRule(ctx, "never.added", "a"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v", err) + } + // Removing the last name of the console's own list removes the object. + if _, err := e.DeleteRule(ctx, "good.example", "a"); err != nil { + t.Fatal(err) + } + if items, _ = e.list(ctx, kubesource.RuleGVR); len(items) != 1 { + t.Fatalf("%d objects left, want only the block list", len(items)) + } +} + +func TestObjectNames(t *testing.T) { + for in, same := range map[string]bool{"tornberg.me": true, "a-b.example": true, "slask_x.local": false, "UPPER": false} { + got := objectName(in) + if (got == in) != same { + t.Errorf("objectName(%q) = %q", in, got) + } + if err := checkName("x", got); err != nil { + t.Errorf("objectName(%q) = %q is not a legal object name: %v", in, got, err) + } + } + if objectName("a_b.test") == objectName("a-b.test") { + t.Error("distinct names collided") + } +} diff --git a/internal/kubeedit/overrides.go b/internal/kubeedit/overrides.go new file mode 100644 index 0000000..b23dd84 --- /dev/null +++ b/internal/kubeedit/overrides.go @@ -0,0 +1,116 @@ +package kubeedit + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +// objectName turns a DNS name into a Kubernetes object name. Most names are +// already one; a name that is not (an underscore, say) is cleaned and given a +// short hash so two different names cannot collide. +func objectName(domain string) string { + var b strings.Builder + changed := false + for _, c := range domain { + if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '.' { + b.WriteRune(c) + } else { + b.WriteByte('-') + changed = true + } + } + name := strings.Trim(b.String(), "-.") + if name == "" { + name, changed = "root", true + } + if changed { + sum := sha256.Sum256([]byte(domain)) + name += "-" + hex.EncodeToString(sum[:3]) + } + return name +} + +func (e *Editor) overrideObjects(ctx context.Context) ([]unstructured.Unstructured, error) { + return e.list(ctx, kubesource.OverrideGVR) +} + +// Overrides lists the address overrides, one entry per name. +func (e *Editor) Overrides(ctx context.Context) ([]kubesource.Override, error) { + items, err := e.overrideObjects(ctx) + if err != nil { + return nil, err + } + var out []kubesource.Override + for i := range items { + var s kubesource.AddressOverrideSpec + if decodeSpec(&items[i], &s) != nil { + continue + } + if o, err := kubesource.CheckOverride(s); err == nil { + out = append(out, o) + } + } + return out, nil +} + +// SetOverride makes domain (and everything under it) answer with address, +// replacing whatever it answered with before. +func (e *Editor) SetOverride(ctx context.Context, domain, address, actor string) error { + o, err := kubesource.CheckOverride(kubesource.AddressOverrideSpec{Domain: domain, Addresses: []string{address}}) + if err != nil { + return err + } + name := objectName(o.Domain) + items, err := e.overrideObjects(ctx) + if err != nil { + return err + } + for i := range items { + var s kubesource.AddressOverrideSpec + if decodeSpec(&items[i], &s) == nil { + if have, err := kubesource.CheckOverride(s); err == nil && have.Domain == o.Domain { + name = items[i].GetName() // keep the object an operator may have named by hand + break + } + } + } + if err := checkName("address override", name); err != nil { + return err + } + _, err = e.upsert(ctx, kubesource.OverrideGVR, "AddressOverride", name, actor, + kubesource.AddressOverrideSpec{Domain: o.Domain, Addresses: o.Addresses}) + return err +} + +// DeleteOverride removes the override for domain. +func (e *Editor) DeleteOverride(ctx context.Context, domain string) error { + want := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") + items, err := e.overrideObjects(ctx) + if err != nil { + return err + } + removed := false + for i := range items { + var s kubesource.AddressOverrideSpec + if decodeSpec(&items[i], &s) != nil { + continue + } + if have, err := kubesource.CheckOverride(s); err == nil && have.Domain == want { + if err := e.remove(ctx, kubesource.OverrideGVR, items[i].GetName()); err != nil { + return err + } + removed = true + } + } + if !removed { + return fmt.Errorf("%w: no address override for %s", ErrNotFound, want) + } + return nil +} diff --git a/internal/kubeedit/rules.go b/internal/kubeedit/rules.go new file mode 100644 index 0000000..58d0e68 --- /dev/null +++ b/internal/kubeedit/rules.go @@ -0,0 +1,112 @@ +package kubeedit + +import ( + "context" + "fmt" + "slices" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource" +) + +// The console keeps the rules it adds in one DomainRule per action, so the +// Domains page does not scatter an object per name. Objects an operator wrote +// by hand are read, and a rule in them can be removed, but never added to. +const ( + inlineBlock = "inline-block" + inlineAllow = "inline-allow" +) + +// AddRules adds names to the console's block or allow rule list and reports how +// many it was given. Names already present are kept once. +func (e *Editor) AddRules(ctx context.Context, action string, names []string, actor string) (int, error) { + if _, err := kubesource.CheckRules(kubesource.DomainRuleSpec{Action: action, Names: names}); err != nil { + return 0, err + } + canonical, _ := kubesource.CheckRules(kubesource.DomainRuleSpec{Action: action, Names: names}) + obj := inlineBlock + if action == "allow" { + obj = inlineAllow + } + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + cur, err := e.res(kubesource.RuleGVR).Get(ctx, obj, metav1.GetOptions{}) + var spec kubesource.DomainRuleSpec + switch { + case apierrors.IsNotFound(err): + spec = kubesource.DomainRuleSpec{Action: action, Comment: "Added from the console"} + case err != nil: + return err + default: + if err := decodeSpec(cur, &spec); err != nil { + return err + } + } + for _, n := range canonical.Names { + if !slices.Contains(spec.Names, n) { + spec.Names = append(spec.Names, n) + } + } + // The whole list must still be one the runtime accepts. + if _, err := kubesource.CheckRules(spec); err != nil { + return err + } + _, err = e.upsert(ctx, kubesource.RuleGVR, "DomainRule", obj, actor, spec) + return err + }) + return len(canonical.Names), err +} + +// ruleKey is how a rule is compared: the runtime strips wildcards and the +// trailing dot, so "ads.example.com", "*.ads.example.com." and +// "**.ads.example.com" all name the same rule to delete. +func ruleKey(n string) string { + n = strings.ToLower(strings.TrimSpace(n)) + n = strings.TrimPrefix(strings.TrimPrefix(n, "*."), "**.") + return strings.TrimSuffix(n, ".") +} + +// DeleteRule removes every inline rule that names target, in any DomainRule. +func (e *Editor) DeleteRule(ctx context.Context, target, actor string) (int, error) { + want := ruleKey(target) + items, err := e.list(ctx, kubesource.RuleGVR) + if err != nil { + return 0, err + } + removed := 0 + for i := range items { + var spec kubesource.DomainRuleSpec + if decodeSpec(&items[i], &spec) != nil { + continue + } + keep := spec.Names[:0:0] + for _, n := range spec.Names { + if ruleKey(n) == want { + removed++ + continue + } + keep = append(keep, n) + } + if len(keep) == len(spec.Names) { + continue + } + name := items[i].GetName() + if len(keep) == 0 && (name == inlineBlock || name == inlineAllow) { + if err := e.remove(ctx, kubesource.RuleGVR, name); err != nil { + return removed, err + } + continue + } + spec.Names = keep + if _, err := e.upsert(ctx, kubesource.RuleGVR, "DomainRule", name, actor, spec); err != nil { + return removed, err + } + } + if removed == 0 { + return 0, fmt.Errorf("%w: no inline rule matches %q", ErrNotFound, target) + } + return removed, nil +} diff --git a/internal/kubesource/convert.go b/internal/kubesource/convert.go index 5a0b50c..d9329ac 100644 --- a/internal/kubesource/convert.go +++ b/internal/kubesource/convert.go @@ -3,6 +3,7 @@ package kubesource import ( "fmt" "net" + "net/netip" "strings" "time" @@ -220,3 +221,128 @@ func zoneFromSpec(s DNSZoneSpec, generation int64) (zone, error) { } 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() +} diff --git a/internal/kubesource/manifests_test.go b/internal/kubesource/manifests_test.go index 666e234..c70c6c6 100644 --- a/internal/kubesource/manifests_test.go +++ b/internal/kubesource/manifests_test.go @@ -56,6 +56,9 @@ func TestCRDSchemasMatchTheGoSpecs(t *testing.T) { "DNSZone": reflect.TypeOf(DNSZoneSpec{}), "ClientGroup": reflect.TypeOf(ClientGroupSpec{}), "BlockList": reflect.TypeOf(BlockListSpec{}), + + "AddressOverride": reflect.TypeOf(AddressOverrideSpec{}), + "DomainRule": reflect.TypeOf(DomainRuleSpec{}), } seen := 0 for _, doc := range yamlDocs(t, "../../deploy/k8s/crds/crds.yaml") { @@ -80,6 +83,7 @@ func TestCRDSchemasMatchTheGoSpecs(t *testing.T) { } if plural := names["plural"].(string); plural != map[string]string{ "DNSZone": ZoneGVR.Resource, "ClientGroup": GroupGVR.Resource, "BlockList": ListGVR.Resource, + "AddressOverride": OverrideGVR.Resource, "DomainRule": RuleGVR.Resource, }[kind] { t.Errorf("%s: plural %q does not match the GVR the runtime watches", kind, plural) } @@ -103,6 +107,10 @@ func TestExampleObjectsConvert(t *testing.T) { _, err = convertGroup(u) case "BlockList": _, err = convertList(u) + case "AddressOverride": + _, err = convertOverride(u) + case "DomainRule": + _, err = convertRules(u) default: t.Fatalf("unknown kind %s", u.GetKind()) } @@ -111,7 +119,7 @@ func TestExampleObjectsConvert(t *testing.T) { } n++ } - if n != 3 { - t.Fatalf("converted %d examples, want 3", n) + if n != 5 { + t.Fatalf("converted %d examples, want 5", n) } } diff --git a/internal/kubesource/merge.go b/internal/kubesource/merge.go index 53fdfdb..794deec 100644 --- a/internal/kubesource/merge.go +++ b/internal/kubesource/merge.go @@ -18,7 +18,10 @@ func signature(s *Snapshot) string { G []config.Group L []config.ListSource Z map[string]string - }{s.Groups, s.Lists, s.Zones}) + O []Override + B []string + A []string + }{s.Groups, s.Lists, s.Zones, s.Overrides, s.BlockNames, s.AllowNames}) sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } @@ -38,6 +41,10 @@ func Merge(base *config.Config, snap *Snapshot) (*config.Config, []string) { next.Block = base.Block next.Block.Lists = append([]config.ListSource(nil), base.Block.Lists...) + // Inline rules from DomainRule objects join the config's own. + next.Block.BlockEntries = append(append([]string(nil), base.Block.BlockEntries...), snap.BlockNames...) + next.Block.AllowEntries = append(append([]string(nil), base.Block.AllowEntries...), snap.AllowNames...) + var dropped []string groups := map[string]bool{} for _, g := range base.ACL.Groups { diff --git a/internal/kubesource/overrides_test.go b/internal/kubesource/overrides_test.go new file mode 100644 index 0000000..858b270 --- /dev/null +++ b/internal/kubesource/overrides_test.go @@ -0,0 +1,79 @@ +package kubesource + +import ( + "strings" + "testing" + + "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" +) + +func TestOverridesAndRulesReachTheSnapshot(t *testing.T) { + first := obj("AddressOverride", "tornberg", map[string]any{"domain": "Tornberg.ME.", "addresses": []any{"10.10.3.10", "fd00::1"}}) + dup := obj("AddressOverride", "a-dup", map[string]any{"domain": "tornberg.me", "addresses": []any{"10.0.0.9"}}) + // The oldest claim wins, whatever the names sort like. + first.SetCreationTimestamp(metav1Time(1000)) + dup.SetCreationTimestamp(metav1Time(2000)) + _, _, ch := start(t, first, dup, + obj("AddressOverride", "bad", map[string]any{"domain": "x.test", "addresses": []any{"not-an-ip"}}), + obj("DomainRule", "blocks", map[string]any{"action": "block", "names": []any{"Ads.Example.com", "*.tracker.example"}}), + obj("DomainRule", "allows", map[string]any{"action": "allow", "names": []any{"**.good.example"}}), + obj("DomainRule", "bogus", map[string]any{"action": "drop", "names": []any{"x.test"}}), + ) + sn := next(t, ch) + if len(sn.Overrides) != 1 || sn.Overrides[0].Domain != "tornberg.me" || len(sn.Overrides[0].Addresses) != 2 { + t.Fatalf("overrides = %+v", sn.Overrides) + } + if got := strings.Join(sn.BlockNames, ","); got != "ads.example.com,*.tracker.example" { + t.Fatalf("block names = %s", got) + } + if got := strings.Join(sn.AllowNames, ","); got != "**.good.example" { + t.Fatalf("allow names = %s", got) + } + if len(sn.Problems) != 3 { + t.Fatalf("problems = %+v, want the duplicate, the bad address and the bad action", sn.Problems) + } + rpz := RenderRPZ(sn.Overrides) + for _, want := range []string{"$TTL 60", "tornberg.me A 10.10.3.10", "tornberg.me AAAA fd00::1"} { + if !strings.Contains(rpz, want) { + t.Errorf("RPZ is missing %q:\n%s", want, rpz) + } + } +} + +// TestRuleNamesCannotSmuggleRules: names are joined into line-oriented parser +// input, so anything that is not a DNS name must be refused before it gets there. +func TestRuleNamesCannotSmuggleRules(t *testing.T) { + for _, bad := range []string{ + "a.test\nb.test", "a.test # comment", "a.test A 10.0.0.1", "a b", "", ".", "a..test", + "-.test"[:0] + "$ORIGIN x.", "*.", "*", + } { + if _, err := CheckRules(DomainRuleSpec{Action: "block", Names: []string{bad}}); err == nil { + t.Errorf("name %q accepted", bad) + } + if _, err := CheckOverride(AddressOverrideSpec{Domain: bad, Addresses: []string{"10.0.0.1"}}); err == nil { + t.Errorf("override domain %q accepted", bad) + } + } + if _, err := CheckRules(DomainRuleSpec{Action: "block", Names: []string{"*.ok.example", "**.ok2.example", "plain.example."}}); err != nil { + t.Errorf("valid names rejected: %v", err) + } + // Surrounding whitespace is trimmed, never passed on. + if r, err := CheckRules(DomainRuleSpec{Action: "block", Names: []string{" evil.test\r\n"}}); err != nil || r.Names[0] != "evil.test" { + t.Errorf("trailing control characters survived: %q %v", r.Names, err) + } + if _, err := CheckOverride(AddressOverrideSpec{Domain: "x.test", Addresses: []string{"::ffff:10.0.0.1"}}); err != nil { + t.Errorf("mapped IPv4 rejected: %v", err) + } +} + +func TestMergeAddsInlineRulesWithoutTouchingBase(t *testing.T) { + base := config.Default() + base.Block.BlockEntries = []string{"yaml.example"} + merged, _ := Merge(base, &Snapshot{BlockNames: []string{"crd.example"}, AllowNames: []string{"ok.example"}}) + if got := strings.Join(merged.Block.BlockEntries, ","); got != "yaml.example,crd.example" { + t.Fatalf("block entries = %s", got) + } + if len(merged.Block.AllowEntries) != 1 || len(base.Block.BlockEntries) != 1 || len(base.Block.AllowEntries) != 0 { + t.Fatal("Merge modified its input or lost an entry") + } +} diff --git a/internal/kubesource/source.go b/internal/kubesource/source.go index 6c57b80..36f0419 100644 --- a/internal/kubesource/source.go +++ b/internal/kubesource/source.go @@ -39,8 +39,14 @@ type Snapshot struct { Groups []config.Group `json:"groups"` Lists []config.ListSource `json:"lists"` // Zones maps an apex to its rendered master file. - Zones map[string]string `json:"zones"` - Problems []Problem `json:"problems,omitempty"` + Zones map[string]string `json:"zones"` + // Overrides are the address overrides, sorted by name. + Overrides []Override `json:"overrides,omitempty"` + // BlockNames and AllowNames are the inline domain rules from all + // DomainRule objects. + BlockNames []string `json:"block_names,omitempty"` + AllowNames []string `json:"allow_names,omitempty"` + Problems []Problem `json:"problems,omitempty"` } // Options configure a Source. @@ -207,11 +213,11 @@ func (s *Source) objects(inf cache.SharedIndexInformer) []*unstructured.Unstruct func (s *Source) rebuild(informers map[schema.GroupVersionResource]cache.SharedIndexInformer) { s.mu.Lock() - snap := s.build( - s.objects(informers[GroupGVR]), - s.objects(informers[ListGVR]), - s.objects(informers[ZoneGVR]), - ) + in := make(map[schema.GroupVersionResource][]*unstructured.Unstructured, len(informers)) + for gvr, inf := range informers { + in[gvr] = s.objects(inf) + } + snap := s.build(in) sig := signature(snap) changed := sig != s.sig if changed { @@ -232,6 +238,7 @@ func (s *Source) rebuild(informers map[schema.GroupVersionResource]cache.SharedI } s.log.Info("cluster configuration changed", "revision", snap.Revision, "groups", len(snap.Groups), "lists", len(snap.Lists), "zones", len(snap.Zones), + "overrides", len(snap.Overrides), "block_names", len(snap.BlockNames), "allow_names", len(snap.AllowNames), "rejected", len(snap.Problems)) for _, fn := range hooks { fn(snap) @@ -239,7 +246,8 @@ func (s *Source) rebuild(informers map[schema.GroupVersionResource]cache.SharedI } // build converts every object. Caller holds s.mu. -func (s *Source) build(groups, lists, zones []*unstructured.Unstructured) *Snapshot { +func (s *Source) build(in map[schema.GroupVersionResource][]*unstructured.Unstructured) *Snapshot { + groups, lists, zones := in[GroupGVR], in[ListGVR], in[ZoneGVR] snap := &Snapshot{Zones: map[string]string{}} live := map[string]bool{} @@ -318,6 +326,35 @@ func (s *Source) build(groups, lists, zones []*unstructured.Unstructured) *Snaps snap.Zones[z.apex] = z.text } + seenOverride := map[string]bool{} + for _, u := range in[OverrideGVR] { + v, ok := resolve("AddressOverride", u, func() (any, error) { return convertOverride(u) }) + if !ok { + continue + } + o := v.(Override) + if seenOverride[o.Domain] { + problem("AddressOverride", u, "another AddressOverride already covers "+o.Domain, false) + continue + } + seenOverride[o.Domain] = true + snap.Overrides = append(snap.Overrides, o) + } + sort.Slice(snap.Overrides, func(i, j int) bool { return snap.Overrides[i].Domain < snap.Overrides[j].Domain }) + + for _, u := range in[RuleGVR] { + v, ok := resolve("DomainRule", u, func() (any, error) { return convertRules(u) }) + if !ok { + continue + } + r := v.(Rules) + if r.Action == "block" { + snap.BlockNames = append(snap.BlockNames, r.Names...) + } else { + snap.AllowNames = append(snap.AllowNames, r.Names...) + } + } + for key := range s.good { if !live[key] { delete(s.good, key) diff --git a/internal/kubesource/source_test.go b/internal/kubesource/source_test.go index 467c9a0..ff2ff1a 100644 --- a/internal/kubesource/source_test.go +++ b/internal/kubesource/source_test.go @@ -34,6 +34,9 @@ func fakeClient(objs ...runtime.Object) *dynfake.FakeDynamicClient { ZoneGVR: "DNSZoneList", GroupGVR: "ClientGroupList", ListGVR: "BlockListList", + + OverrideGVR: "AddressOverrideList", + RuleGVR: "DomainRuleList", }, objs...) } diff --git a/internal/kubesource/types.go b/internal/kubesource/types.go index 798cc7b..6545d64 100644 --- a/internal/kubesource/types.go +++ b/internal/kubesource/types.go @@ -21,11 +21,12 @@ const ( // The resources the runtime watches. var ( - ZoneGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "dnszones"} - GroupGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "clientgroups"} - ListGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "blocklists"} - allGVRs = []schema.GroupVersionResource{ZoneGVR, GroupGVR, ListGVR} - kindNames = map[string]string{ZoneGVR.Resource: "DNSZone", GroupGVR.Resource: "ClientGroup", ListGVR.Resource: "BlockList"} + ZoneGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "dnszones"} + GroupGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "clientgroups"} + ListGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "blocklists"} + OverrideGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "addressoverrides"} + RuleGVR = schema.GroupVersionResource{Group: APIGroup, Version: Version, Resource: "domainrules"} + allGVRs = []schema.GroupVersionResource{ZoneGVR, GroupGVR, ListGVR, OverrideGVR, RuleGVR} ) // DNSZoneSpec declares one authoritative zone. The zone's name is spec.zone, @@ -81,3 +82,24 @@ type BlockListSpec struct { Comment string `json:"comment,omitempty"` InsecureSkipTLSVerify bool `json:"insecure_skip_tls_verify,omitempty"` } + +// AddressOverrideSpec answers a name, and everything under it, with fixed +// addresses instead of asking upstream: "tornberg.me -> 10.10.3.10" for split +// horizon DNS. It is what an RPZ "name A address" line does, one object per +// name. +type AddressOverrideSpec struct { + Domain string `json:"domain"` + // Addresses are IPv4 and IPv6 literals; each becomes an A or AAAA answer. + Addresses []string `json:"addresses"` +} + +// DomainRuleSpec is an inline allow or block rule list, the objects behind the +// console's Domains page. A name may carry a wildcard prefix: "*.example.com" +// is the subdomains, "**.example.com" the name and its subdomains. +type DomainRuleSpec struct { + // Action is "block" or "allow". An allow rule rescues a name that any + // blocklist would block. + Action string `json:"action"` + Names []string `json:"names"` + Comment string `json:"comment,omitempty"` +}