222 lines
6.4 KiB
Go
222 lines
6.4 KiB
Go
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
|
|
}
|
|
|
|
// SeedPlan is the decision taken before writing a skeleton zone file.
|
|
type SeedPlan struct {
|
|
WriteSeed bool
|
|
Serial int64
|
|
QuarantineZoneFile bool
|
|
QuarantineJournal bool
|
|
QuarantineSuffix 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 := fmt.Sprintf(".orphaned-%d", highestSerial(st))
|
|
|
|
if !st.ZoneFile {
|
|
p := SeedPlan{WriteSeed: true, Serial: nextSerial(st), QuarantineSuffix: suffix}
|
|
// A journal without its zone file can only refuse to replay.
|
|
p.QuarantineJournal = st.Journal
|
|
return p
|
|
}
|
|
|
|
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{}
|
|
}
|
|
}
|
|
|
|
func highestSerial(st ZoneDiskState) int64 {
|
|
var h int64
|
|
if st.ZoneFile && st.ZoneSerialOK {
|
|
h = st.ZoneSerial
|
|
}
|
|
if st.Journal && st.JournalOK && serialLT(h, st.JournalEnd) {
|
|
h = st.JournalEnd
|
|
}
|
|
return h
|
|
}
|
|
|
|
func nextSerial(st ZoneDiskState) int64 {
|
|
next := int64(uint32(highestSerial(st)) + 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
|
|
}
|
|
|
|
const journalMagic = ";BIND LOG V9"
|
|
|
|
// 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 || !strings.HasPrefix(string(b[:16]), journalMagic) {
|
|
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])
|
|
}
|
|
|
|
// 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 {
|
|
jnl := JournalPath(path)
|
|
return strings.Join([]string{
|
|
fmt.Sprintf("if [ -f '%s' ]; then printf 'zonefile=1\\nhead<<\\n'; head -c 4096 '%s'; printf '\\n>>head\\n'; else printf 'zonefile=0\\n'; fi", path, path),
|
|
fmt.Sprintf("if [ -f '%s' ]; then printf 'journal=1\\njnl=%%s\\n' \"$(od -An -v -tx1 -N32 '%s' | tr -d ' \\n')\"; else printf 'journal=0\\n'; fi", jnl, jnl),
|
|
}, "\n")
|
|
}
|
|
|
|
func parseZoneDiskState(out string) ZoneDiskState {
|
|
var st ZoneDiskState
|
|
var head []string
|
|
inHead := false
|
|
for _, line := range strings.Split(out, "\n") {
|
|
switch {
|
|
case inHead && line == ">>head":
|
|
inHead = false
|
|
case inHead:
|
|
head = append(head, line)
|
|
case line == "head<<":
|
|
inHead = true
|
|
case line == "zonefile=1":
|
|
st.ZoneFile = true
|
|
case line == "journal=1":
|
|
st.Journal = true
|
|
case strings.HasPrefix(line, "jnl="):
|
|
if raw, err := hex.DecodeString(strings.TrimPrefix(line, "jnl=")); err == nil {
|
|
st.JournalBegin, st.JournalEnd, st.JournalOK = parseJournalHeader(raw)
|
|
}
|
|
}
|
|
}
|
|
if st.ZoneFile {
|
|
st.ZoneSerial, st.ZoneSerialOK = ParseZoneSerial(strings.Join(head, "\n"))
|
|
}
|
|
return st
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
return parseZoneDiskState(out), 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", strings.Join(cmds, "\n")}
|
|
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
|
|
}
|
|
|
|
func moveAside(path, suffix string) string {
|
|
return fmt.Sprintf("if [ -f '%s' ]; then mv -- '%s' '%s%s'; fi", path, path, path, suffix)
|
|
}
|