Files
fancy-dns/internal/app/kube_test.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

193 lines
6.7 KiB
Go

package app
import (
"context"
"testing"
"time"
"github.com/miekg/dns"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynfake "k8s.io/client-go/dynamic/fake"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
)
func kubeObj(kind, name string, spec map[string]any) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": kubesource.APIGroup + "/" + kubesource.Version,
"kind": kind,
"metadata": map[string]any{"name": name, "namespace": "dns", "generation": int64(1)},
"spec": spec,
}}
}
func kubeClient() *dynfake.FakeDynamicClient {
return dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
map[schema.GroupVersionResource]string{
kubesource.ZoneGVR: "DNSZoneList",
kubesource.GroupGVR: "ClientGroupList",
kubesource.ListGVR: "BlockListList",
kubesource.OverrideGVR: "AddressOverrideList",
kubesource.RuleGVR: "DomainRuleList",
})
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if ok() {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// TestKubeObjectsReachTheResolver is the point of the Kubernetes source: an
// object applied to the cluster changes what this resolver answers, with no
// reload, and deleting it takes the change back.
func TestKubeObjectsReachTheResolver(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()
zones := client.Resource(kubesource.ZoneGVR).Namespace("dns")
groups := client.Resource(kubesource.GroupGVR).Namespace("dns")
answer := func() string {
m := h.query("www.kube.test", dns.TypeA)
if len(m.Answer) == 1 {
if a, ok := m.Answer[0].(*dns.A); ok && m.Authoritative {
return a.A.String()
}
}
return ""
}
if _, err := zones.Create(ctx, kubeObj("DNSZone", "kube", map[string]any{
"zone": "kube.test",
"records": []any{
map[string]any{"name": "@", "type": "NS", "value": "ns1.kube.test."},
map[string]any{"name": "ns1", "type": "A", "value": "10.1.2.1"},
map[string]any{"name": "www", "type": "A", "value": "10.1.2.3"},
},
}), metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
eventually(t, "the DNSZone to be served", func() bool { return answer() == "10.1.2.3" })
// The cluster's data never lands in the YAML the console writes back.
if got := len(h.app.cfg.Base().ACL.Groups); got != 0 {
t.Fatalf("base has %d groups", got)
}
if _, err := groups.Create(ctx, kubeObj("ClientGroup", "no-loopback", map[string]any{
"policy": "deny", "match": map[string]any{"cidrs": []any{"127.0.0.0/8"}},
}), metav1.CreateOptions{}); err != nil {
t.Fatal(err)
}
eventually(t, "the ClientGroup to refuse the client", func() bool {
return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeRefused
})
if len(h.app.cfg.Base().ACL.Groups) != 0 || len(h.app.cfg.Get().ACL.Groups) != 1 {
t.Fatalf("base=%d effective=%d groups", len(h.app.cfg.Base().ACL.Groups), len(h.app.cfg.Get().ACL.Groups))
}
// The console cannot overwrite a cluster-owned group.
if _, err := h.app.putACL(ctx, nil, ""); err != nil {
t.Fatalf("saving the console's own (empty) list: %v", err)
}
if len(h.app.cfg.Get().ACL.Groups) != 1 {
t.Fatal("saving the file's groups removed the cluster's")
}
if err := groups.Delete(ctx, "no-loopback", metav1.DeleteOptions{}); err != nil {
t.Fatal(err)
}
eventually(t, "the deleted ClientGroup to stop applying", func() bool {
return h.query("www.example.test", dns.TypeA).Rcode == dns.RcodeSuccess
})
if err := zones.Delete(ctx, "kube", metav1.DeleteOptions{}); err != nil {
t.Fatal(err)
}
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)
}
}