converge apex NS per record, not by RRset replace
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

BIND ignores an RRset-wide delete of apex NS, so the previous replace only
appended to the seed placeholder.
This commit is contained in:
2026-09-26 18:50:55 +10:00
parent 4a41cbc427
commit e4ed6c8052
9 changed files with 304 additions and 130 deletions
+1
View File
@@ -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.
+37 -17
View File
@@ -19,35 +19,55 @@ 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
}
// 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 == "@" {
+42
View File
@@ -0,0 +1,42 @@
package bind
import "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)
}
}
+18
View File
@@ -84,3 +84,21 @@ func (e *Executor) ZoneSerial(ctx context.Context, namespace, pod, zone, view st
}
return 0, nil
}
// ApexNS returns the zone's currently published apex NS names, queried from the
// local server so the operator can converge the RRset rather than append to it.
func (e *Executor) ApexNS(ctx context.Context, namespace, pod, zone string) ([]string, error) {
out, err := e.Exec(ctx, namespace, pod, []string{DigBin, "+short", "@127.0.0.1", dot(zone), "NS"}, "")
if err != nil {
return nil, fmt.Errorf("query apex NS of %s: %w (out: %s)", zone, err, out)
}
var ns []string
for _, line := range strings.Split(out, "\n") {
// dig +short prints one fully-qualified name per line; anything without
// a trailing dot is not an answer.
if line = strings.TrimSpace(line); strings.HasSuffix(line, ".") {
ns = append(ns, line)
}
}
return ns, nil
}