H1/H2/M1: atomicity at the file boundary
H1 — Concurrent-modification detection. loadRRs now returns a fileSnapshot capturing (mtime, size) at read time. handleUpdate calls zf.checkUnchanged(snap) immediately before writeAtomic. If anything modified the file between load and write — rsync push, manual edit, `git checkout` — the UPDATE is refused with SERVFAIL. Caddy retries with a fresh load. Protects against the CLAUDE.md-documented rsync workflow racing the plugin. H2 — Git commit-failure policy. The previous code logged at WARN and continued, breaking the documented "file + git both updated" contract. Now logs at ERROR with structured fields (zone, path, error, recovery command) so operators discover the divergence. We do NOT roll back the file write: by the time the commit fails, the auto plugin may have already noticed the new mtime and reloaded; rolling back creates more races than it solves. Recovery is `git -C <dir> status` + manual commit. M1 — exec.CommandContext with 10s timeout on git invocations. If git hangs (NFS stall, gpg-sign prompt, broken pre-commit hook waiting on stdin), the per-zone mutex would otherwise be held forever and queue all subsequent UPDATEs. gitCommandTimeout caps the hang. M2 deferred. Dropping the separate `git add` cleanly requires either `-a` (wrong scope: auto-stages all tracked modifications) or `--include` (still needs prior staging). The race window between add and commit is theoretical for our setup (single-writer plugin + occasional `git status`). M1's timeout already mitigates the worst hang case. New tests: - TestZoneFile_CheckUnchanged_DetectsExternalModification (H1)
This commit is contained in:
parent
8e421f925e
commit
93ed180d8f
4 changed files with 131 additions and 19 deletions
28
update.go
28
update.go
|
|
@ -2,6 +2,7 @@ package rfc2136
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -80,8 +81,8 @@ func (p *RFC2136) handleUpdate(w dns.ResponseWriter, r *dns.Msg, verified bool)
|
|||
zf.mu.Lock()
|
||||
defer zf.mu.Unlock()
|
||||
|
||||
// 3. Load the current zone contents.
|
||||
rrs, err := zf.loadRRs()
|
||||
// 3. Load the current zone contents (with file-identity snapshot).
|
||||
rrs, snap, err := zf.loadRRs()
|
||||
if err != nil {
|
||||
log.Errorf("UPDATE failed: %v", err)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
|
|
@ -126,17 +127,34 @@ func (p *RFC2136) handleUpdate(w dns.ResponseWriter, r *dns.Msg, verified bool)
|
|||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 6b. Concurrent-modification check (Hamilton H1). Before clobbering
|
||||
// the on-disk file, verify nothing changed it out from under us
|
||||
// between loadRRs and now. The per-zone mutex serializes us against
|
||||
// other in-process UPDATEs, but external editors (rsync push,
|
||||
// manual edit, `git checkout`) can race in any time. If the file
|
||||
// changed, refuse with SERVFAIL so Caddy retries on a fresh load.
|
||||
if err := zf.checkUnchanged(snap); err != nil {
|
||||
log.Warningf("UPDATE refused: %v", err)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 7. Atomic write.
|
||||
if err := zf.writeAtomic(updated, now); err != nil {
|
||||
log.Errorf("UPDATE write failed: %v", err)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 8. Auto-commit. Failure to commit isn't fatal to the UPDATE —
|
||||
// the on-disk state is authoritative — but we log loudly.
|
||||
// 8. Auto-commit. Failure to commit means the file is correct but
|
||||
// the git audit trail diverges (Hamilton H2). We log at ERROR with
|
||||
// structured detail so operators discover the divergence; recovery
|
||||
// is `git -C <zonesDir> status` + `git add` + manual commit. We do
|
||||
// NOT roll back the file write — by the time the commit fails, the
|
||||
// auto plugin may have already noticed the new mtime, and rolling
|
||||
// back creates more races than it solves.
|
||||
msg := summarizeUpdate(zone, r.Ns)
|
||||
if err := zf.commit(msg); err != nil {
|
||||
log.Warningf("git auto-commit failed: %v", err)
|
||||
log.Errorf("git auto-commit failed; zone file is correct but audit trail diverged: zone=%s path=%s err=%v — recover with `git -C %s status` + manual commit",
|
||||
zone, zf.Path, err, filepath.Dir(zf.Path))
|
||||
}
|
||||
|
||||
log.Infof("UPDATE applied: zone=%s prereqs=%d updates=%d msg=%q",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue