package bind import ( "context" "fmt" "strings" ) // TSIGCreds carries the material needed to authenticate a dynamic update. type TSIGCreds struct { Name string // TSIG key name Algorithm string // e.g. hmac-sha256 Secret string // base64-encoded key } // RecordUpdate describes a desired record set to apply to a zone. type RecordUpdate struct { FQDN string // fully-qualified owner name, trailing dot recommended Type string // RR type TTL int32 // record TTL Values []string // RDATA entries 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 { cmd := []string{NsupdateBin, "-y", fmt.Sprintf("%s:%s:%s", creds.Algorithm, creds.Name, creds.Secret)} 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 == "@" { return "@" } if strings.HasSuffix(name, ".") { return name } return name + "." }