diff --git a/api/v1alpha1/bindzone_types.go b/api/v1alpha1/bindzone_types.go index f2b7354..e7925b3 100644 --- a/api/v1alpha1/bindzone_types.go +++ b/api/v1alpha1/bindzone_types.go @@ -64,6 +64,17 @@ type BindZoneSpec struct { // +optional DefaultTTL int32 `json:"defaultTTL,omitempty"` + // Nameservers are the names published in the zone's apex NS RRset, kept in + // sync on every reconcile. Each entry is a full domain name, never relative + // to the zone. Prefer out-of-zone names glued by the parent: an + // in-zone name needs an address record in the zone, and the seed can only + // supply the primary pod's (unstable) IP for it. When empty the operator + // leaves the apex NS alone and a newly seeded zone gets the primary's stable + // in-cluster DNS name; clearing the field later does not retract what it + // published. + // +optional + Nameservers []string `json:"nameservers,omitempty"` + // Records are static record sets seeded into a primary zone. // +optional Records []Record `json:"records,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d0eb94e..c9cb4cc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -970,6 +970,11 @@ func (in *BindZoneList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BindZoneSpec) DeepCopyInto(out *BindZoneSpec) { *out = *in + if in.Nameservers != nil { + in, out := &in.Nameservers, &out.Nameservers + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Records != nil { in, out := &in.Records, &out.Records *out = make([]Record, len(*in)) diff --git a/config/crd/bases/bind.unkin.net_bindzones.yaml b/config/crd/bases/bind.unkin.net_bindzones.yaml index 37f7406..11e193c 100644 --- a/config/crd/bases/bind.unkin.net_bindzones.yaml +++ b/config/crd/bases/bind.unkin.net_bindzones.yaml @@ -94,6 +94,19 @@ spec: items: type: string type: array + nameservers: + description: |- + Nameservers are the names published in the zone's apex NS RRset, kept in + sync on every reconcile. Each entry is a full domain name, never relative + to the zone. Prefer out-of-zone names glued by the parent: an + in-zone name needs an address record in the zone, and the seed can only + supply the primary pod's (unstable) IP for it. When empty the operator + leaves the apex NS alone and a newly seeded zone gets the primary's stable + in-cluster DNS name; clearing the field later does not retract what it + published. + items: + type: string + type: array primaries: description: Primaries lists source servers for a secondary/stub-type zone. diff --git a/config/crd/install.yaml b/config/crd/install.yaml index 407d905..74507d8 100644 --- a/config/crd/install.yaml +++ b/config/crd/install.yaml @@ -2760,6 +2760,19 @@ spec: items: type: string type: array + nameservers: + description: |- + Nameservers are the names published in the zone's apex NS RRset, kept in + sync on every reconcile. Each entry is a full domain name, never relative + to the zone. Prefer out-of-zone names glued by the parent: an + in-zone name needs an address record in the zone, and the seed can only + supply the primary pod's (unstable) IP for it. When empty the operator + leaves the apex NS alone and a newly seeded zone gets the primary's stable + in-cluster DNS name; clearing the field later does not retract what it + published. + items: + type: string + type: array primaries: description: Primaries lists source servers for a secondary/stub-type zone. diff --git a/config/samples/01-authoritative.yaml b/config/samples/01-authoritative.yaml index 1ba73ca..adf6412 100644 --- a/config/samples/01-authoritative.yaml +++ b/config/samples/01-authoritative.yaml @@ -71,10 +71,11 @@ spec: - key transfer-key updateKeyRef: transfer-key dynamicUpdate: true + # Published apex NS, kept in sync on every reconcile. Full names only; an + # in-zone name (as here) needs its address record below. + nameservers: + - ns1.internal.example.com. records: - - name: "@" - type: NS - values: ["ns1.internal.example.com."] - name: ns1 type: A values: ["10.0.0.53"] diff --git a/internal/bind/consts.go b/internal/bind/consts.go index 7f2e383..b574fc0 100644 --- a/internal/bind/consts.go +++ b/internal/bind/consts.go @@ -23,6 +23,7 @@ const ( NamedBin = "/usr/sbin/named" RndcBin = "/usr/sbin/rndc" NsupdateBin = "/usr/bin/nsupdate" + DigBin = "/usr/bin/dig" ) // Config file paths derived from ConfigDir. diff --git a/internal/bind/nsupdate.go b/internal/bind/nsupdate.go index 5fd4795..eccb6a2 100644 --- a/internal/bind/nsupdate.go +++ b/internal/bind/nsupdate.go @@ -19,35 +19,87 @@ type RecordUpdate struct { Type string // RR type TTL int32 // record TTL Values []string // RDATA entries - Delete bool // when true, delete the RRset instead of replacing it + Delete bool // when true, delete instead of add + // PerValue operates on individual records rather than the whole RRset: adds + // leave existing records in place, deletes remove only the listed Values. + // Required at a zone apex, where BIND silently ignores an RRset-wide delete + // of NS or SOA and would turn a replace into an append. + PerValue bool } // NSUpdate applies a set of record changes to zone by executing nsupdate on the // primary pod, targeting the local server and authenticating with creds. All // changes are sent in a single atomic transaction. func (e *Executor) NSUpdate(ctx context.Context, namespace, pod, zone string, creds TSIGCreds, updates []RecordUpdate) error { - var b strings.Builder - b.WriteString("server 127.0.0.1\n") - b.WriteString(fmt.Sprintf("zone %s\n", dot(zone))) - for _, u := range updates { - // Replace semantics: clear the RRset first, then add the desired values. - b.WriteString(fmt.Sprintf("update delete %s %s\n", dot(u.FQDN), u.Type)) - if u.Delete { - continue - } - for _, v := range u.Values { - b.WriteString(fmt.Sprintf("update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v)) - } - } - b.WriteString("send\n") - cmd := []string{NsupdateBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)} - if out, err := e.Exec(ctx, namespace, pod, cmd, b.String()); err != nil { + if out, err := e.Exec(ctx, namespace, pod, cmd, nsupdateScript(zone, updates)); err != nil { return fmt.Errorf("nsupdate zone %s: %w (out: %s)", zone, err, out) } return nil } +// ApexNS returns the zone's currently published apex NS names, so the operator +// can converge the RRset rather than append to it. The query is TSIG-signed with +// the same creds as an update: a zone behind a view whose match-clients is a key +// is unreachable to an unsigned query, which named answers REFUSED (with an empty +// body and a zero exit status), and the caller must not read that as "no NS". +func (e *Executor) ApexNS(ctx context.Context, namespace, pod, zone string, creds TSIGCreds) ([]string, error) { + cmd := []string{ + DigBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret), + "+short", "+time=5", "+tries=1", "@127.0.0.1", dot(zone), "NS", + } + out, err := e.Exec(ctx, namespace, pod, cmd, "") + if err != nil { + return nil, fmt.Errorf("query apex NS of %s: %w (out: %s)", zone, err, out) + } + return parseDigNames(out), nil +} + +// parseDigNames picks the answers out of `dig +short` output: one fully-qualified +// name per line. Anything without a trailing dot is not a name, and dig prefixes +// its diagnostics (a missing or mismatched TSIG key among them) with ';'. +func parseDigNames(out string) []string { + var names []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, ";") || !strings.HasSuffix(line, ".") { + continue + } + names = append(names, line) + } + return names +} + +// nsupdateScript renders the nsupdate input for a set of changes. +func nsupdateScript(zone string, updates []RecordUpdate) string { + var b strings.Builder + b.WriteString("server 127.0.0.1\n") + fmt.Fprintf(&b, "zone %s\n", dot(zone)) + for _, u := range updates { + switch { + case u.PerValue && u.Delete: + for _, v := range u.Values { + fmt.Fprintf(&b, "update delete %s %s %s\n", dot(u.FQDN), u.Type, v) + } + case u.PerValue: + for _, v := range u.Values { + fmt.Fprintf(&b, "update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v) + } + default: + // Replace semantics: clear the RRset first, then add the values. + fmt.Fprintf(&b, "update delete %s %s\n", dot(u.FQDN), u.Type) + if u.Delete { + continue + } + for _, v := range u.Values { + fmt.Fprintf(&b, "update add %s %d %s %s\n", dot(u.FQDN), u.TTL, u.Type, v) + } + } + } + b.WriteString("send\n") + return b.String() +} + // dot ensures a name is fully qualified with a trailing dot. func dot(name string) string { if name == "" || name == "@" { diff --git a/internal/bind/nsupdate_test.go b/internal/bind/nsupdate_test.go new file mode 100644 index 0000000..26d6344 --- /dev/null +++ b/internal/bind/nsupdate_test.go @@ -0,0 +1,64 @@ +package bind + +import ( + "strings" + "testing" +) + +func TestNSUpdateScriptReplaceSemantics(t *testing.T) { + got := nsupdateScript("acme.unkin.net", []RecordUpdate{ + {FQDN: "www", Type: "A", TTL: 60, Values: []string{"10.0.0.1", "10.0.0.2"}}, + {FQDN: "old.acme.unkin.net.", Type: "TXT", Delete: true}, + }) + want := `server 127.0.0.1 +zone acme.unkin.net. +update delete www. A +update add www. 60 A 10.0.0.1 +update add www. 60 A 10.0.0.2 +update delete old.acme.unkin.net. TXT +send +` + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +// At the apex BIND ignores an RRset-wide delete of NS, so the apex sync must add +// the new names and delete the old ones record by record, adds first: named +// refuses to leave an apex with no NS record. +func TestNSUpdateScriptPerValueApexNS(t *testing.T) { + got := nsupdateScript("acme.unkin.net", []RecordUpdate{ + {FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"acme-ns1.unkin.net."}, PerValue: true}, + {FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true}, + {FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true}, + }) + want := `server 127.0.0.1 +zone acme.unkin.net. +update add acme.unkin.net. 60 NS acme-ns1.unkin.net. +update delete acme.unkin.net. NS ns1.acme.unkin.net. +update delete ns1.acme.unkin.net. A +send +` + if got != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestParseDigNames(t *testing.T) { + cases := []struct { + name, out, want string + }{ + {"answers", "a.ns.unkin.net.\nb.ns.unkin.net.\n", "a.ns.unkin.net.,b.ns.unkin.net."}, + {"REFUSED, SERVFAIL and NXDOMAIN all answer empty", "", ""}, + // A zone behind a key-matched view answers an unsigned query REFUSED, and + // dig reports the key problem on a ';' line that happens to end in a dot. + {"dig diagnostics are not answers", ";; WARNING -- TSIG key was not used.\n", ""}, + {"relative or partial lines are not names", "10.0.0.1\nns1\n", ""}, + {"whitespace is trimmed", " ns1.unkin.net. \n\n", "ns1.unkin.net."}, + } + for _, c := range cases { + if got := strings.Join(parseDigNames(c.out), ","); got != c.want { + t.Errorf("%s: parseDigNames(%q) = %q; want %q", c.name, c.out, got, c.want) + } + } +} diff --git a/internal/bind/seed.go b/internal/bind/seed.go index a7a8425..f144982 100644 --- a/internal/bind/seed.go +++ b/internal/bind/seed.go @@ -26,36 +26,65 @@ func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view st return err == nil } -// renderSeedZone renders a minimal loadable zone (SOA + apex NS + glue). The -// apex NS is the in-zone name ns1, and a glue A record pointing at primaryIP is -// included so BIND's check-integrity accepts the zone (an in-zone NS without an -// address record is a load error). -func renderSeedZone(zone, primaryIP string, serial int64) string { +// renderSeedZone renders a minimal loadable zone (SOA + apex NS). nameservers +// are the names published in the apex NS RRset; when empty the in-zone name ns1 +// is used. A glue A pointing at primaryIP is emitted only for a nameserver that +// falls inside the zone, because BIND refuses to load a zone whose in-zone NS +// has no address record. Out-of-zone nameservers therefore keep pod IPs out of +// the zone file entirely. +func renderSeedZone(zone, primaryIP string, nameservers []string, serial int64) string { origin := dot(zone) - ns := "ns1." + origin + ns := make([]string, 0, len(nameservers)) + for _, n := range nameservers { + ns = append(ns, dot(n)) + } + if len(ns) == 0 { + ns = []string{"ns1." + origin} + } // Short refresh/retry so a secondary that misses a NOTIFY (e.g. its pod IP // changed and the primary's also-notify was briefly stale) still converges // in minutes, not the hour a 3600s refresh would impose. minimum is the // negative-cache TTL: keep it low so a stale-secondary NXDOMAIN does not // stick in downstream resolvers for long. NOTIFY (also-notify on the // primary) remains the fast path; these are the fallback. - return fmt.Sprintf(`$TTL 3600 + var b strings.Builder + fmt.Fprintf(&b, `$TTL 3600 @ IN SOA %s hostmaster.%s ( %d ; serial 300 ; refresh 60 ; retry 1209600 ; expire 60 ) ; minimum -@ IN NS %s -ns1 IN A %s -`, ns, origin, serial, ns, primaryIP) +`, ns[0], origin, serial) + for _, n := range ns { + fmt.Fprintf(&b, "@ IN NS %s\n", n) + } + for _, n := range ns { + if owner, ok := InZoneOwner(n, zone); ok { + fmt.Fprintf(&b, "%s IN A %s\n", owner, primaryIP) + } + } + return b.String() +} + +// InZoneOwner reports whether name sits inside zone, and if so returns its owner +// name relative to the apex ("@" for the apex itself). +func InZoneOwner(name, zone string) (string, bool) { + name, origin := dot(name), dot(zone) + switch { + case name == origin: + return "@", true + case strings.HasSuffix(name, "."+origin): + return strings.TrimSuffix(name, "."+origin), true + } + return "", false } // EnsureSeedZone makes path loadable without discarding live data: it probes // the zone file and journal, moves aside whatever cannot load, and writes a // skeleton only when there is nothing to preserve. Every caller that needs a // zone database file on disk goes through here. -func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string) error { +func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, path, primaryIP string, nameservers []string) error { state, err := e.ZoneDiskState(ctx, namespace, pod, path) if err != nil { return err @@ -67,7 +96,7 @@ func (e *Executor) EnsureSeedZone(ctx context.Context, namespace, pod, zone, pat if !plan.WriteSeed { return e.Quarantine(ctx, namespace, pod, path, plan) } - content := renderSeedZone(zone, primaryIP, plan.Serial) + content := renderSeedZone(zone, primaryIP, nameservers, plan.Serial) cmd := []string{"sh", "-c", seedScript(path, plan, len(content))} if out, err := e.Exec(ctx, namespace, pod, cmd, content); err != nil { return fmt.Errorf("seed zone %s: %w (out: %s)", zone, err, out) diff --git a/internal/bind/seed_nameservers_test.go b/internal/bind/seed_nameservers_test.go new file mode 100644 index 0000000..09639ea --- /dev/null +++ b/internal/bind/seed_nameservers_test.go @@ -0,0 +1,75 @@ +package bind + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// checkZone runs named-checkzone with full integrity checking, the same check +// named applies when loading a primary zone. Skips where the tool is absent. +func checkZone(t *testing.T, zone, content string) { + t.Helper() + bin, err := exec.LookPath("named-checkzone") + if err != nil { + t.Skip("named-checkzone not installed") + } + path := filepath.Join(t.TempDir(), "db") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(bin, "-i", "full", zone, path).CombinedOutput(); err != nil { + t.Fatalf("zone not loadable: %v\n%s\n%s", err, out, content) + } +} + +func TestRenderSeedZoneDeclaredNameservers(t *testing.T) { + got := renderSeedZone("acme.unkin.net", "10.42.6.38", []string{"ns1.unkin.net", "ns2.unkin.net."}, 7) + if strings.Contains(got, "10.42.6.38") { + t.Errorf("declared nameservers must not pull a pod IP into the zone:\n%s", got) + } + for _, want := range []string{"@ IN NS ns1.unkin.net.\n", "@ IN NS ns2.unkin.net.\n", "SOA ns1.unkin.net. hostmaster.acme.unkin.net."} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } + checkZone(t, "acme.unkin.net", got) +} + +// An in-zone nameserver has no address anywhere else, so the seed must glue it +// or named refuses to load the zone. +func TestRenderSeedZoneInZoneNameserverGetsGlue(t *testing.T) { + got := renderSeedZone("example.com", "10.0.0.1", []string{"ns.example.com"}, 1) + if !strings.Contains(got, "ns IN A 10.0.0.1\n") { + t.Errorf("in-zone nameserver needs glue:\n%s", got) + } + checkZone(t, "example.com", got) +} + +func TestRenderSeedZoneFallbackLoads(t *testing.T) { + got := renderSeedZone("example.com", "10.0.0.1", nil, 1) + if !strings.Contains(got, "@ IN NS ns1.example.com.\n") || !strings.Contains(got, "ns1 IN A 10.0.0.1\n") { + t.Errorf("fallback seed changed shape:\n%s", got) + } + checkZone(t, "example.com", got) +} + +func TestInZoneOwner(t *testing.T) { + cases := []struct { + name, zone, owner string + in bool + }{ + {"ns1.example.com.", "example.com", "ns1", true}, + {"example.com", "example.com.", "@", true}, + {"ns1.unkin.net", "acme.unkin.net", "", false}, + {"notexample.com", "example.com", "", false}, + } + for _, c := range cases { + owner, in := InZoneOwner(c.name, c.zone) + if in != c.in || owner != c.owner { + t.Errorf("InZoneOwner(%q, %q) = %q, %v; want %q, %v", c.name, c.zone, owner, in, c.owner, c.in) + } + } +} diff --git a/internal/bind/seed_test.go b/internal/bind/seed_test.go index 71aaa9e..af77322 100644 --- a/internal/bind/seed_test.go +++ b/internal/bind/seed_test.go @@ -29,7 +29,7 @@ func inFlightZone(t *testing.T) (dir, path string) { t.Helper() dir = t.TempDir() path = filepath.Join(dir, "db.example.com") - if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", 1)), 0o600); err != nil { + if err := os.WriteFile(path, []byte(renderSeedZone("example.com", "10.0.0.1", nil, 1)), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(JournalPath(path), journalHeader(";BIND LOG V9.2\n", 10, 16), 0o600); err != nil { @@ -134,7 +134,7 @@ func TestSeedScriptInterruptedWriteLeavesDiskUntouched(t *testing.T) { if !plan.WriteSeed || !plan.QuarantineZoneFile || !plan.QuarantineJournal { t.Fatalf("expected a reseed over both files, got %+v", plan) } - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScript(t, sh, path, plan, content, content[:len(content)/2]); err == nil { t.Fatal("a truncated seed write must fail rather than install a torn zone file") @@ -165,7 +165,7 @@ func TestSeedScriptInstallsOverQuarantinedFiles(t *testing.T) { dir, path := inFlightZone(t) plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScript(t, sh, path, plan, content, content); err != nil { t.Fatalf("seed script: %v", err) } @@ -198,7 +198,7 @@ func TestSeedScriptFreshInstall(t *testing.T) { path := filepath.Join(t.TempDir(), "zones", "db.example.com") plan := PlanSeed(ZoneDiskState{}) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScript(t, sh, path, plan, content, content); err != nil { t.Fatalf("seed script: %v", err) } @@ -225,7 +225,7 @@ func TestSeedScriptFailedQuarantineAbortsInstall(t *testing.T) { exec `+realTool(t, "mv")+` "$@"`) plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil { t.Fatal("a failed quarantine must fail the seed") } @@ -240,7 +240,7 @@ func TestSeedScriptUnmeasurableStagingAbortsInstall(t *testing.T) { pathEnv := shimPath(t, "wc", "exit 127") plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil { t.Fatal("an unmeasurable staging file must fail the seed") } @@ -256,7 +256,7 @@ func TestSeedScriptFailedMkdirAbortsInstall(t *testing.T) { pathEnv := shimPath(t, "mkdir", "exit 1") plan := PlanSeed(probeState(t, sh, path)) - content := renderSeedZone("example.com", "10.0.0.1", plan.Serial) + content := renderSeedZone("example.com", "10.0.0.1", nil, plan.Serial) if err := runSeedScriptWithPath(t, sh, path, plan, content, content, pathEnv); err == nil { t.Fatal("a failed mkdir must fail the seed") } diff --git a/internal/bind/zonestate_test.go b/internal/bind/zonestate_test.go index 5d78490..2d9de1a 100644 --- a/internal/bind/zonestate_test.go +++ b/internal/bind/zonestate_test.go @@ -48,7 +48,7 @@ k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.u ok bool }{ {"bind dump", bindDump, 16, true}, - {"seed", renderSeedZone("example.com", "10.0.0.1", 42), 42, true}, + {"seed", renderSeedZone("example.com", "10.0.0.1", nil, 42), 42, true}, {"single line", "@ IN SOA ns1.example.com. hostmaster.example.com. 7 300 60 1209600 60\n", 7, true}, {"glued paren", "@ IN SOA ns. host. (9 300 60 1209600 60)\n", 9, true}, {"no soa", "$TTL 3600\nwww IN A 192.0.2.1\n", 0, false}, @@ -308,7 +308,7 @@ func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) { } func TestSeedZoneRoundTripsThroughParser(t *testing.T) { - content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", 17) + content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", nil, 17) got, ok := ParseZoneSerial(content) if !ok || got != 17 { t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok) @@ -418,12 +418,12 @@ func TestZoneStateProbeRoundTrip(t *testing.T) { {name: "fresh install"}, { name: "zone file only", - zone: renderSeedZone("example.com", "10.0.0.1", 42), + zone: renderSeedZone("example.com", "10.0.0.1", nil, 42), want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true}, }, { name: "zone file and journal", - zone: renderSeedZone("example.com", "10.0.0.1", 12), + zone: renderSeedZone("example.com", "10.0.0.1", nil, 12), jnl: journalHeader(";BIND LOG V9.2\n", 10, 16), want: ZoneDiskState{ ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true, @@ -452,7 +452,7 @@ func TestZoneStateProbeRoundTrip(t *testing.T) { }, { name: "live zone beside old quarantine evidence", - zone: renderSeedZone("example.com", "10.0.0.1", 42), + zone: renderSeedZone("example.com", "10.0.0.1", nil, 42), orphans: []string{".orphaned-16"}, want: ZoneDiskState{ ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true, diff --git a/internal/controller/apex_ns_test.go b/internal/controller/apex_ns_test.go new file mode 100644 index 0000000..25aee2b --- /dev/null +++ b/internal/controller/apex_ns_test.go @@ -0,0 +1,165 @@ +package controller + +import ( + "strings" + "testing" + + bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1" + "git.unkin.net/unkin/bind-operator/internal/bind" +) + +func zoneWith(spec bindv1alpha1.BindZoneSpec) *bindv1alpha1.BindZone { + spec.ZoneName = "acme.unkin.net" + return &bindv1alpha1.BindZone{Spec: spec} +} + +func testCluster() *bindv1alpha1.BindCluster { + c := &bindv1alpha1.BindCluster{} + c.Name, c.Namespace = "auth", "bind-internal" + return c +} + +const stableNS = "auth-0.auth-headless.bind-internal.svc.cluster.local." + +func TestZoneNameservers(t *testing.T) { + ttl60 := int32(60) + cases := []struct { + name string + spec bindv1alpha1.BindZoneSpec + want []string + ttl int32 + declared bool + }{ + {"fallback is out-of-zone, so it needs no glue", bindv1alpha1.BindZoneSpec{}, []string{stableNS}, 3600, false}, + {"declared wins", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}}, []string{"ns1.unkin.net."}, 3600, true}, + {"spec.defaultTTL applies", bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60}, []string{"ns1.unkin.net."}, 60, true}, + // An apex NS in spec.records cannot converge on its own: BIND ignores an + // RRset-wide delete at the apex, so it has to go through the apex path. + {"apex NS in records counts as declared", bindv1alpha1.BindZoneSpec{Records: []bindv1alpha1.Record{ + {Name: "@", Type: "ns", Values: []string{"a.ns.unkin.net.", "b.ns.unkin.net."}}, + {Name: "www", Type: "A", Values: []string{"10.0.0.1"}}, + }}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600, true}, + // A TTL on the apex NS record itself must survive the fold. + {"record TTL beats the zone default", bindv1alpha1.BindZoneSpec{DefaultTTL: 3600, Records: []bindv1alpha1.Record{ + {Name: "@", Type: "NS", TTL: &ttl60, Values: []string{"a.ns.unkin.net."}}, + }}, []string{"a.ns.unkin.net."}, 60, true}, + {"spec.nameservers beats records", bindv1alpha1.BindZoneSpec{ + Nameservers: []string{"ns1.unkin.net."}, + Records: []bindv1alpha1.Record{{Name: "@", Type: "NS", Values: []string{"other.unkin.net."}}}, + }, []string{"ns1.unkin.net."}, 3600, true}, + } + for _, c := range cases { + got, ttl, declared := zoneNameservers(zoneWith(c.spec), testCluster()) + if declared != c.declared || ttl != c.ttl || strings.Join(got, ",") != strings.Join(c.want, ",") { + t.Errorf("%s: got %v/%d/%v; want %v/%d/%v", c.name, got, ttl, declared, c.want, c.ttl, c.declared) + } + } +} + +// A query that cannot see the zone returns nothing, which must not be read as an +// empty apex: retracting blind means deleting the last NS record, which named +// rejects, leaving the zone stuck. +func TestApexNSUpdatesUnreadableLiveSetIsAdditive(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}}) + got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, nil, 3600) + assertUpdates(t, got, []bind.RecordUpdate{ + {FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true}, + }) +} + +// The apex NS RRset must be converged per record: an RRset-wide delete at the +// apex is ignored by BIND, which would leave the placeholder published alongside +// the real nameservers. Retracting an in-zone name takes its glue with it. +func TestApexNSUpdatesConverges(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net."}, DefaultTTL: 60}) + got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 60) + assertUpdates(t, got, []bind.RecordUpdate{ + {FQDN: "acme.unkin.net.", Type: "NS", TTL: 60, Values: []string{"ns1.unkin.net."}, PerValue: true}, + {FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true}, + {FQDN: "ns1.acme.unkin.net.", Type: "A", Delete: true}, + }) +} + +// Glue retirement is not special-cased to the name ns1: the seed glues every +// declared in-zone nameserver. +func TestApexNSUpdatesRetiresAnyInZoneGlue(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net."}}) + got := apexNSUpdates(zone, []string{"a.ns.unkin.net."}, []string{"dns.acme.unkin.net."}, 3600) + assertUpdates(t, got, []bind.RecordUpdate{ + {FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"a.ns.unkin.net."}, PerValue: true}, + {FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"dns.acme.unkin.net."}, PerValue: true, Delete: true}, + {FQDN: "dns.acme.unkin.net.", Type: "A", Delete: true}, + }) +} + +func TestApexNSUpdatesNoopWhenConverged(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.unkin.net"}}) + // Case differs: DNS names compare case-insensitively, so this is converged. + if got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"NS1.Unkin.Net."}, 3600); len(got) != 0 { + t.Fatalf("expected no updates, got %+v", got) + } +} + +// Changing the declared set replaces only what changed, keeping the overlap. +func TestApexNSUpdatesPartialChange(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"a.ns.unkin.net.", "c.ns.unkin.net."}}) + got := apexNSUpdates(zone, []string{"a.ns.unkin.net.", "c.ns.unkin.net."}, []string{"a.ns.unkin.net.", "b.ns.unkin.net."}, 3600) + assertUpdates(t, got, []bind.RecordUpdate{ + {FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"c.ns.unkin.net."}, PerValue: true}, + {FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"b.ns.unkin.net."}, PerValue: true, Delete: true}, + }) +} + +// A declared in-zone nameserver owns its glue; removing it would fail named's +// post-update nameserver sanity check. +func TestApexNSUpdatesKeepsNeededGlue(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{Nameservers: []string{"ns1.acme.unkin.net."}}) + if got := apexNSUpdates(zone, []string{"ns1.acme.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600); len(got) != 0 { + t.Fatalf("expected no updates, got %+v", got) + } +} + +// spec.records owning the ns1 address means the glue is real data, not the seed +// placeholder. +func TestApexNSUpdatesLeavesRecordOwnedGlue(t *testing.T) { + zone := zoneWith(bindv1alpha1.BindZoneSpec{ + Nameservers: []string{"ns1.unkin.net."}, + Records: []bindv1alpha1.Record{{Name: "ns1", Type: "a", Values: []string{"10.0.0.53"}}}, + }) + got := apexNSUpdates(zone, []string{"ns1.unkin.net."}, []string{"ns1.acme.unkin.net."}, 3600) + assertUpdates(t, got, []bind.RecordUpdate{ + {FQDN: "acme.unkin.net.", Type: "NS", TTL: 3600, Values: []string{"ns1.unkin.net."}, PerValue: true}, + {FQDN: "acme.unkin.net.", Type: "NS", Values: []string{"ns1.acme.unkin.net."}, PerValue: true, Delete: true}, + }) +} + +// The apex NS is converged by the apex path, so it must not also be emitted as a +// record: an RRset-wide delete there is ignored and the add would append. +func TestRecordsToUpdatesSkipsApexNS(t *testing.T) { + records := []bindv1alpha1.Record{ + {Name: "@", Type: "NS", Values: []string{"a.ns.unkin.net."}}, + {Name: "sub", Type: "NS", Values: []string{"d.ns.unkin.net."}}, + {Name: "@", Type: "MX", Values: []string{"10 mail.unkin.net."}}, + } + got := recordsToUpdates("acme.unkin.net", records, 3600) + if len(got) != 2 || got[0].FQDN != "sub.acme.unkin.net." || got[1].Type != "MX" { + t.Fatalf("got %+v", got) + } +} + +func assertUpdates(t *testing.T, got, want []bind.RecordUpdate) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %d updates %+v; want %d %+v", len(got), got, len(want), want) + } + for i := range want { + g, w := got[i], want[i] + if g.FQDN != w.FQDN || g.Type != w.Type || g.TTL != w.TTL || g.Delete != w.Delete || g.PerValue != w.PerValue { + t.Errorf("update %d = %+v; want %+v", i, g, w) + continue + } + if strings.Join(g.Values, ",") != strings.Join(w.Values, ",") { + t.Errorf("update %d values = %v; want %v", i, g.Values, w.Values) + } + } +} diff --git a/internal/controller/bindcatalogzone_controller.go b/internal/controller/bindcatalogzone_controller.go index 28c9d98..5edd904 100644 --- a/internal/controller/bindcatalogzone_controller.go +++ b/internal/controller/bindcatalogzone_controller.go @@ -54,7 +54,7 @@ func (r *BindCatalogZoneReconciler) Reconcile(ctx context.Context, req ctrl.Requ if primaryIP == "" { return r.fail(ctx, &catalog, "PrimaryNoIP", "waiting for primary pod IP") } - if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP); err != nil { + if err := r.Exec.EnsureSeedZone(ctx, catalog.Namespace, primaryPod, catalog.Spec.ZoneName, bind.CatalogFilePath(catalog.Spec.ZoneName), primaryIP, clusterNameservers(cluster)); err != nil { return r.fail(ctx, &catalog, "SeedFailed", err.Error()) } } diff --git a/internal/controller/bindpolicy_controller.go b/internal/controller/bindpolicy_controller.go index 33c15fc..e0f51b0 100644 --- a/internal/controller/bindpolicy_controller.go +++ b/internal/controller/bindpolicy_controller.go @@ -67,7 +67,7 @@ func (r *BindPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) if primaryIP == "" { return r.fail(ctx, &policy, "PrimaryNoIP", "waiting for primary pod IP") } - if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP); err != nil { + if err := r.Exec.EnsureSeedZone(ctx, policy.Namespace, primaryPod, policy.Spec.ZoneName, bind.ZoneFilePath(policy.Spec.ZoneName), primaryIP, clusterNameservers(cluster)); err != nil { return r.fail(ctx, &policy, "SeedFailed", err.Error()) } } diff --git a/internal/controller/bindzone_controller.go b/internal/controller/bindzone_controller.go index f326ff8..9d02f10 100644 --- a/internal/controller/bindzone_controller.go +++ b/internal/controller/bindzone_controller.go @@ -99,6 +99,8 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return r.setPhase(ctx, &zone, "Error", "ConfigError", err.Error()) } + nameservers, nsTTL, nsDeclared := zoneNameservers(&zone, cluster) + created := !r.Exec.ZoneExists(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, zone.Spec.ViewRef) if created && (zone.Spec.Type == bindv1alpha1.ZonePrimary || zone.Spec.Type == "") { primaryIP := primaryPodIP(ctx, r.Client, cluster) @@ -108,7 +110,7 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c // The zone is absent from named's memory, but its database file and // journal may still be on the PVC from a previous incarnation. path := bind.ZoneFilePath(zone.Spec.ZoneName) - if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP); err != nil { + if err := r.Exec.EnsureSeedZone(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, path, primaryIP, nameservers); err != nil { return r.setPhase(ctx, &zone, "Error", "SeedFailed", err.Error()) } } @@ -116,18 +118,42 @@ func (r *BindZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return r.setPhase(ctx, &zone, "Error", "AddZoneFailed", err.Error()) } - // Seed static records (primary zones only). + // Records are applied before the apex NS: an in-zone nameserver's address + // record has to exist first, or named rejects the apex transaction with a + // post-update nameserver sanity check failure. recordCount := 0 - if isPrimaryType(zone.Spec.Type) && len(zone.Spec.Records) > 0 { + records := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL) + if isPrimaryType(zone.Spec.Type) && (len(records) > 0 || nsDeclared) { creds, err := r.zoneUpdateCreds(ctx, &zone) if err != nil { return r.setPhase(ctx, &zone, "Error", "NoUpdateKey", err.Error()) } - updates := recordsToUpdates(zone.Spec.ZoneName, zone.Spec.Records, zone.Spec.DefaultTTL) - if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, updates); err != nil { - return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error()) + if len(records) > 0 { + if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, records); err != nil { + return r.setPhase(ctx, &zone, "Error", "RecordUpdateFailed", err.Error()) + } + recordCount = len(records) + } + // Converge the apex NS on every pass, not only at seed time, so a zone + // seeded with the placeholder moves onto its real nameservers. Only for a + // zone that declared them: otherwise the operator would fight whoever else + // manages the RRset. + if nsDeclared { + live, err := r.Exec.ApexNS(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds) + if err != nil { + return r.setPhase(ctx, &zone, "Error", "ApexNSQueryFailed", err.Error()) + } + if len(live) == 0 { + logger.Info("apex NS not readable, publishing without retracting", "zone", zone.Spec.ZoneName) + } + if apex := apexNSUpdates(&zone, nameservers, live, nsTTL); len(apex) > 0 { + if err := r.Exec.NSUpdate(ctx, zone.Namespace, primaryPod, zone.Spec.ZoneName, creds, apex); err != nil { + return r.setPhase(ctx, &zone, "Error", "ApexNSSyncFailed", + fmt.Sprintf("%s (a nameserver inside the zone needs an address record here)", err)) + } + logger.Info("apex NS converged", "zone", zone.Spec.ZoneName, "nameservers", nameservers) + } } - recordCount = len(updates) } // Register in the catalog so secondaries auto-provision. diff --git a/internal/controller/zone_helpers.go b/internal/controller/zone_helpers.go index 7af8c5f..683e180 100644 --- a/internal/controller/zone_helpers.go +++ b/internal/controller/zone_helpers.go @@ -42,6 +42,11 @@ func fqdn(name, zone string) string { func recordsToUpdates(zone string, records []bindv1alpha1.Record, defaultTTL int32) []bind.RecordUpdate { updates := make([]bind.RecordUpdate, 0, len(records)) for _, rec := range records { + // The apex NS RRset is converged by apexNSUpdates: an RRset-wide delete + // here is ignored by BIND and would only append to the live set. + if strings.EqualFold(rec.Type, "NS") && fqdn(rec.Name, zone) == fqdn("@", zone) { + continue + } ttl := defaultTTL if rec.TTL != nil { ttl = *rec.TTL @@ -114,3 +119,119 @@ func alsoNotifyList(addrs []string, key string) string { } return strings.Join(parts, " ") } + +// absolute qualifies a nameserver name. Unlike record owner names, a +// spec.nameservers entry is always a full domain name, never relative to the +// zone: an in-zone nameserver is spelled out in full. +func absolute(name string) string { return strings.TrimSuffix(name, ".") + "." } + +// zoneNameservers resolves the names to publish in a zone's apex NS RRset, the +// TTL to publish them with, and whether the zone declared them. An apex NS in +// spec.records counts as a declaration: BIND ignores an RRset-wide delete at the +// apex, so records alone can only append to what the zone was seeded with, never +// replace it. Undeclared zones fall back to the primary's stable in-cluster name, +// which is deliberately out-of-zone so no pod IP is needed as glue. +func zoneNameservers(zone *bindv1alpha1.BindZone, cluster *bindv1alpha1.BindCluster) (names []string, ttl int32, declared bool) { + ttl = zone.Spec.DefaultTTL + for _, ns := range zone.Spec.Nameservers { + names = append(names, absolute(ns)) + } + for _, rec := range zone.Spec.Records { + if len(names) > 0 { + break + } + if !strings.EqualFold(rec.Type, "NS") || fqdn(rec.Name, zone.Spec.ZoneName) != fqdn("@", zone.Spec.ZoneName) { + continue + } + for _, v := range rec.Values { + names = append(names, absolute(v)) + } + if rec.TTL != nil { + ttl = *rec.TTL + } + } + if ttl <= 0 { + ttl = 3600 + } + if len(names) > 0 { + return names, ttl, true + } + return clusterNameservers(cluster), ttl, false +} + +// apexNSUpdates moves a zone's apex NS RRset from live onto desired, and retires +// the glue of any in-zone name it retracts. Adds come first: BIND refuses to +// leave an apex with no NS record, so the replacement must exist before the old +// name goes, and deleting glue still referenced by an in-zone NS fails named's +// post-update nameserver sanity check. +// +// An empty live set means the query could not see the zone, not that the apex has +// no NS records — a primary always has one. Nothing is retracted in that case: +// retracting blind is what turns a delete into "delete the last NS", which named +// rejects outright. +// ponytail: names already published are diffed by name only, so an edit to just +// the TTL never republishes them (dig +short cannot report a TTL). Re-add the +// whole desired set each pass if TTL edits need to converge. +func apexNSUpdates(zone *bindv1alpha1.BindZone, desired, live []string, ttl int32) []bind.RecordUpdate { + apex := fqdn("@", zone.Spec.ZoneName) + add := missing(desired, live) + + var updates []bind.RecordUpdate + if len(add) > 0 { + updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", TTL: ttl, Values: add, PerValue: true}) + } + // missing() yields nothing against an empty live set, so an unreadable RRset + // retracts nothing on its own. + del := missing(live, desired) + if len(del) == 0 { + return updates + } + updates = append(updates, bind.RecordUpdate{FQDN: apex, Type: "NS", Values: del, PerValue: true, Delete: true}) + // The seed glues an in-zone nameserver to the primary pod's IP, which goes + // stale on the first reschedule. Drop that address with the name, unless + // spec.records owns it (then it is real data, not the placeholder). + for _, ns := range del { + owner, in := bind.InZoneOwner(ns, zone.Spec.ZoneName) + if in && owner != "@" && !recordsOwn(zone, owner, "A") { + updates = append(updates, bind.RecordUpdate{FQDN: fqdn(owner, zone.Spec.ZoneName), Type: "A", Delete: true}) + } + } + return updates +} + +// clusterNameservers is the apex NS for the operator's own internal zones +// (catalog, policy): the primary's stable in-cluster name, never a pod IP. +func clusterNameservers(cluster *bindv1alpha1.BindCluster) []string { + return []string{primaryAddress(cluster.Name, cluster.Namespace) + "."} +} + +// missing returns the names in want with no match in have. DNS names compare +// case-insensitively. +func missing(want, have []string) (out []string) { + for _, w := range want { + if !containsName(have, w) { + out = append(out, w) + } + } + return out +} + +func containsName(names []string, name string) bool { + for _, n := range names { + if strings.EqualFold(absolute(n), absolute(name)) { + return true + } + } + return false +} + +// recordsOwn reports whether spec.records already manages an owner/type pair, in +// which case the apex sync must leave it alone. +func recordsOwn(zone *bindv1alpha1.BindZone, name, typ string) bool { + for _, rec := range zone.Spec.Records { + if strings.EqualFold(rec.Type, typ) && strings.EqualFold(fqdn(rec.Name, zone.Spec.ZoneName), fqdn(name, zone.Spec.ZoneName)) { + return true + } + } + return false +}