Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 057575b9b5 | |||
| cc714dc2b4 | |||
| 08d46ccce0 | |||
| dc57ac1b2d | |||
| e4ed6c8052 | |||
| 4a41cbc427 |
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
+69
-17
@@ -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 == "@" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user