package bind import ( "context" "encoding/hex" "fmt" "strconv" "strings" ) // JournalPath returns the BIND journal that accompanies a zone database file. func JournalPath(zonePath string) string { return zonePath + ".jnl" } // ZoneDiskState is what the primary pod's filesystem holds for one zone. // A journal is only replayable onto a zone file whose SOA serial lies within // [JournalBegin, JournalEnd]; outside that range BIND fails the load with // "out of range" and the zone never comes up. type ZoneDiskState struct { ZoneFile bool ZoneSerial int64 ZoneSerialOK bool Journal bool JournalBegin int64 JournalEnd int64 JournalOK bool // OrphanSerial is the furthest serial recorded in a quarantine sibling on // disk. It is the only record of how far the zone had advanced once a // transition is interrupted between the renames and the new file landing. OrphanSerial int64 OrphanSerialOK bool } // SeedPlan is the decision taken before writing a skeleton zone file. type SeedPlan struct { WriteSeed bool Serial int64 QuarantineZoneFile bool QuarantineJournal bool QuarantineSuffix string // Blocked names the reason the disk state could not be judged safely. The // caller must touch nothing and surface it. Blocked string } // PlanSeed decides how to make a zone loadable without discarding live data. // Nothing is ever deleted: unusable files are renamed aside so they stay // recoverable on the PVC. func PlanSeed(st ZoneDiskState) SeedPlan { suffix := quarantineSuffix(st) if !st.ZoneFile { // The journal's serial range is the only evidence of how far the zone // had advanced; without it a seed could regress below live data. if st.Journal && !st.JournalOK { return SeedPlan{Blocked: "journal present but its header could not be read"} } return SeedPlan{ WriteSeed: true, Serial: nextSerial(st), QuarantineJournal: st.Journal, QuarantineSuffix: suffix, } } if !st.Journal || !st.JournalOK || !st.ZoneSerialOK { return SeedPlan{} } switch { case serialLT(st.ZoneSerial, st.JournalBegin): // The file has regressed behind the journal: it is the skeleton a // previous reconcile clobbered it with. Keep both aside and reseed // above the journal so secondaries still see a serial increase. return SeedPlan{ WriteSeed: true, Serial: nextSerial(st), QuarantineZoneFile: true, QuarantineJournal: true, QuarantineSuffix: suffix, } case serialLT(st.JournalEnd, st.ZoneSerial): return SeedPlan{QuarantineJournal: true, QuarantineSuffix: suffix} default: return SeedPlan{} } } // highestSerial reports the furthest serial on disk. The second result is false // when no serial could be read at all: 0 is a legitimate serial, so it cannot // double as "nothing found" in RFC 1982 sequence space. RFC 1982 ordering is // not total, so the fold is order-dependent once the inputs span more than // 2^31; a zone cannot advance that far between reconciles. func highestSerial(st ZoneDiskState) (int64, bool) { var h int64 var known bool if st.ZoneFile && st.ZoneSerialOK { h, known = st.ZoneSerial, true } if st.Journal && st.JournalOK && (!known || serialLT(h, st.JournalEnd)) { h, known = st.JournalEnd, true } if st.OrphanSerialOK && (!known || serialLT(h, st.OrphanSerial)) { h, known = st.OrphanSerial, true } return h, known } func quarantineSuffix(st ZoneDiskState) string { h, _ := highestSerial(st) return quarantineMarker + strconv.FormatInt(h, 10) } // parseOrphanSerial reads the serial back out of a name moveAside produced, // with or without the counter it appends when the destination is taken. func parseOrphanSerial(name string) (int64, bool) { i := strings.LastIndex(name, quarantineMarker) if i < 0 { return 0, false } digits := name[i+len(quarantineMarker):] n := 0 for n < len(digits) && digits[n] >= '0' && digits[n] <= '9' { n++ } if n == 0 { return 0, false } serial, err := strconv.ParseUint(digits[:n], 10, 32) if err != nil { return 0, false } return int64(serial), true } func orphanSerial(names []string) (int64, bool) { var h int64 var known bool for _, name := range names { s, ok := parseOrphanSerial(name) if !ok { continue } if !known || serialLT(h, s) { h, known = s, true } } return h, known } func nextSerial(st ZoneDiskState) int64 { h, known := highestSerial(st) if !known { return 1 } next := int64(uint32(h) + 1) if next == 0 { return 1 } return next } // serialLT compares DNS serials in RFC 1982 sequence space. func serialLT(a, b int64) bool { if a == b { return false } return uint32(b)-uint32(a) < 1<<31 } // ParseZoneSerial extracts the SOA serial from the head of a zone file, in // both the operator's seed layout and BIND's own multi-line dump layout. func ParseZoneSerial(content string) (int64, bool) { var tokens []string for _, line := range strings.Split(content, "\n") { if i := strings.IndexByte(line, ';'); i >= 0 { line = line[:i] } line = strings.ReplaceAll(line, "(", " ( ") line = strings.ReplaceAll(line, ")", " ) ") tokens = append(tokens, strings.Fields(line)...) } for i, tok := range tokens { if !strings.EqualFold(tok, "SOA") { continue } rest := tokens[i+1:] if len(rest) < 3 { return 0, false } rest = rest[2:] // MNAME, RNAME if rest[0] == "(" { rest = rest[1:] } if len(rest) == 0 { return 0, false } serial, err := strconv.ParseUint(rest[0], 10, 32) if err != nil { return 0, false } return int64(serial), true } return 0, false } // journalFormats are the zero-padded 16-byte format fields BIND writes and // compares whole (lib/dns/journal.c). var journalFormats = [][16]byte{ journalFormat(";BIND LOG V9\n"), journalFormat(";BIND LOG V9.2\n"), } func journalFormat(magic string) [16]byte { var f [16]byte copy(f[:], magic) return f } // parseJournalHeader reads the begin and end serials from a BIND journal // header: a 16-byte format magic followed by two {serial,offset} big-endian // pairs. func parseJournalHeader(b []byte) (begin, end int64, ok bool) { if len(b) < 28 { return 0, 0, false } var format [16]byte copy(format[:], b[:16]) known := false for _, f := range journalFormats { if format == f { known = true break } } if !known { return 0, 0, false } return int64(beUint32(b[16:20])), int64(beUint32(b[24:28])), true } func beUint32(b []byte) uint32 { return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) } // Probe framing. Every field is declared exactly once between the sentinels, so // stdout that was truncated, empty or partially written is rejected rather than // read as "fresh install". const ( probeBegin = "zonestate-begin-v1" probeEnd = "zonestate-end-v1" probeHeadOpen = "head<<" probeHeadShut = ">>head" probeOrphanOpen = "orphans<<" probeOrphanShut = ">>orphans" // quarantineMarker joins a preserved file to the serial floor a reseed must // stay above, which highestSerial may take from a sibling rather than from // the renamed file itself. quarantineMarker = ".orphaned-" ) // zoneStateProbe reads only the head of the zone file: the SOA is the first // record in both layouts the operator has to read. func zoneStateProbe(path string) string { zone, jnl := shellQuote(path), shellQuote(JournalPath(path)) return shellScript( fmt.Sprintf("printf '%s\\n'", probeBegin), fmt.Sprintf("if [ -f %s ]; then printf 'zonefile=1\\n%s\\n'; head -c 4096 %s; printf '\\n%s\\n'; else printf 'zonefile=0\\n'; fi", zone, probeHeadOpen, zone, probeHeadShut), // The hex is folded in its own assignment so a failing tr aborts the // probe instead of reporting an unreadable journal header. fmt.Sprintf("if [ -f %s ]; then printf 'journal=1\\n'; hdr=$(od -An -v -tx1 -N32 %s); hdr=$(printf '%%s' \"$hdr\" | tr -d ' \\n'); printf 'jnl=%%s\\n' \"$hdr\"; else printf 'journal=0\\n'; fi", jnl, jnl), // The quarantine siblings outlive the files they replaced, so they are // the floor a reseed must stay above after an interrupted transition. fmt.Sprintf("printf '%s\\n'\nfor f in %s* %s*; do if [ -e \"$f\" ]; then printf '%%s\\n' \"$f\"; fi; done\nprintf '%s\\n'", probeOrphanOpen, shellQuote(path+quarantineMarker), shellQuote(JournalPath(path)+quarantineMarker), probeOrphanShut), fmt.Sprintf("printf '%s\\n'", probeEnd), ) } // parseZoneDiskState returns false unless the probe output is complete: a // half-written or empty probe must never be mistaken for an empty filesystem. func parseZoneDiskState(out string) (ZoneDiskState, bool) { var st ZoneDiskState var head, orphans []string var sawBegin, sawEnd, inHead, headShut, inOrphans, orphansShut bool var zoneDecls, journalDecls, headerDecls, orphanDecls int for _, line := range strings.Split(out, "\n") { switch { case inHead && line == probeHeadShut: inHead, headShut = false, true case inHead: head = append(head, line) case inOrphans && line == probeOrphanShut: inOrphans, orphansShut = false, true case inOrphans: if line != "" { orphans = append(orphans, line) } case line == probeBegin: sawBegin = true case line == probeEnd: sawEnd = true case line == probeHeadOpen: inHead = true case line == probeOrphanOpen: inOrphans, orphanDecls = true, orphanDecls+1 case line == "zonefile=1": st.ZoneFile = true zoneDecls++ case line == "zonefile=0": zoneDecls++ case line == "journal=1": st.Journal = true journalDecls++ case line == "journal=0": journalDecls++ case strings.HasPrefix(line, "jnl="): headerDecls++ if raw, err := hex.DecodeString(strings.TrimPrefix(line, "jnl=")); err == nil { st.JournalBegin, st.JournalEnd, st.JournalOK = parseJournalHeader(raw) } } } switch { case !sawBegin || !sawEnd || inHead || inOrphans: return ZoneDiskState{}, false case orphanDecls != 1 || !orphansShut: return ZoneDiskState{}, false case zoneDecls != 1 || journalDecls != 1: return ZoneDiskState{}, false case st.ZoneFile && !headShut: return ZoneDiskState{}, false case st.Journal && headerDecls != 1: return ZoneDiskState{}, false case !st.Journal && headerDecls != 0: return ZoneDiskState{}, false } if st.ZoneFile { st.ZoneSerial, st.ZoneSerialOK = ParseZoneSerial(strings.Join(head, "\n")) } st.OrphanSerial, st.OrphanSerialOK = orphanSerial(orphans) return st, true } // ZoneDiskState inspects the zone database file and journal on the pod. func (e *Executor) ZoneDiskState(ctx context.Context, namespace, pod, path string) (ZoneDiskState, error) { out, err := e.Exec(ctx, namespace, pod, []string{"sh", "-c", zoneStateProbe(path)}, "") if err != nil { return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: %w (out: %s)", path, err, out) } st, ok := parseZoneDiskState(out) if !ok { return ZoneDiskState{}, fmt.Errorf("inspect zone files %s: incomplete probe output %q", path, out) } return st, nil } // Quarantine renames the files the plan marks unusable, leaving them on the // PVC under a suffixed name. func (e *Executor) Quarantine(ctx context.Context, namespace, pod, path string, plan SeedPlan) error { var cmds []string if plan.QuarantineZoneFile { cmds = append(cmds, moveAside(path, plan.QuarantineSuffix)) } if plan.QuarantineJournal { cmds = append(cmds, moveAside(JournalPath(path), plan.QuarantineSuffix)) } if len(cmds) == 0 { return nil } cmd := []string{"sh", "-c", shellScript(cmds...)} if out, err := e.Exec(ctx, namespace, pod, cmd, ""); err != nil { return fmt.Errorf("quarantine zone files %s: %w (out: %s)", path, err, out) } return nil } // moveAside renames path out of the way. A repeat incident can compute the same // suffix, so the destination is numbered until it is free: a preserved copy is // never overwritten. func moveAside(path, suffix string) string { src, dest := shellQuote(path), shellQuote(path+suffix) return fmt.Sprintf("if [ -f %s ]; then d=%s; n=0; while [ -e \"$d\" ]; do n=$((n+1)); d=%s.$n; done; mv -- %s \"$d\"; fi", src, dest, dest, src) } // shellQuote renders s as a single POSIX shell word. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // shellScript joins commands into a script that stops at the first failure and // exits non-zero. A plain script runs every line regardless of the previous // one's status, which would let an install proceed over a failed quarantine and // still report success to the caller. func shellScript(cmds ...string) string { return strings.Join(append([]string{"set -e"}, cmds...), "\n") }