package bind import ( "context" "fmt" "strings" ) // ZoneFilePath returns the on-pod path of a zone database file. func ZoneFilePath(zone string) string { return fmt.Sprintf("%s/zones/db.%s", DataDir, strings.TrimSuffix(zone, ".")) } // CatalogFilePath returns the on-pod path of a catalog zone database file. func CatalogFilePath(zone string) string { return fmt.Sprintf("%s/catalog/db.%s", DataDir, strings.TrimSuffix(zone, ".")) } // ZoneExists reports whether a zone is currently loaded on the pod. func (e *Executor) ZoneExists(ctx context.Context, namespace, pod, zone, view string) bool { args := []string{"zonestatus", zone} if view != "" { args = append(args, "in", view) } _, err := e.Rndc(ctx, namespace, pod, args...) return err == nil } // 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 := 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. var b strings.Builder fmt.Fprintf(&b, `$TTL 3600 @ IN SOA %s hostmaster.%s ( %d ; serial 300 ; refresh 60 ; retry 1209600 ; expire 60 ) ; minimum `, 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, nameservers []string) error { state, err := e.ZoneDiskState(ctx, namespace, pod, path) if err != nil { return err } plan := PlanSeed(state) if plan.Blocked != "" { return fmt.Errorf("seed zone %s: %s", zone, plan.Blocked) } if !plan.WriteSeed { return e.Quarantine(ctx, namespace, pod, path, plan) } 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) } return nil } // seedTempSuffix names the staging file, a sibling of the zone file so the // install is a same-filesystem rename. const seedTempSuffix = ".seed-tmp" // seedScript stages the skeleton, checks it arrived whole, then quarantines and // installs it in that order. Doing all of it in one exec keeps an interrupted // seed from leaving the PVC with no zone data, which the next reconcile would // read as a fresh install and reseed at serial 1; the rename means a torn write // is never visible at path. shellScript aborts the run at the first failure, so // no step can install the skeleton over data an earlier step failed to preserve. func seedScript(path string, plan SeedPlan, size int) string { q, tmp := shellQuote(path), shellQuote(path+seedTempSuffix) cmds := []string{ fmt.Sprintf("mkdir -p \"$(dirname %s)\"", q), fmt.Sprintf("cat > %s", tmp), // A stdin stream cut mid-transfer gives cat a short file and exit 0. fmt.Sprintf("n=$(wc -c < %s | tr -d ' \\n')", tmp), // Compared as strings: an unmeasurable size is empty, not a number, and // a numeric test would exit 2 there and be swallowed by the if. fmt.Sprintf("if [ \"$n\" != '%d' ]; then rm -f %s; exit 1; fi", size, tmp), } if plan.QuarantineZoneFile { cmds = append(cmds, moveAside(path, plan.QuarantineSuffix)) } if plan.QuarantineJournal { cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix)) } return shellScript(append(cmds, fmt.Sprintf("mv -- %s %s", tmp, q))...) } // AddCatalogMember registers a member zone in a catalog zone by adding the // catalog PTR record, so secondaries auto-provision it. func (e *Executor) AddCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error { hash := catalogHash(memberZone) owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone)) updates := []RecordUpdate{{ FQDN: owner, Type: "PTR", TTL: 3600, Values: []string{dot(memberZone)}, }} return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates) } // RemoveCatalogMember deregisters a member zone from a catalog zone. func (e *Executor) RemoveCatalogMember(ctx context.Context, namespace, pod, catalogZone, memberZone string, creds TSIGCreds) error { hash := catalogHash(memberZone) owner := fmt.Sprintf("%s.zones.%s", hash, dot(catalogZone)) updates := []RecordUpdate{{FQDN: owner, Type: "PTR", Delete: true}} return e.NSUpdate(ctx, namespace, pod, catalogZone, creds, updates) }