Add backups, token expiry warnings and name server checks

- Daily database backups: jarl-backup.timer runs 'jarld backup', which
  writes jarl-<UTC time>.db into backup.dir, checks it before naming it,
  and prunes by the time in the name while always keeping the newest
  keep_at_least. New backup: section in jarl.yaml; the dashboard and
  'jarl status' show the newest backup and warn when it is stale or
  failed. The Debian package enables the timer.
- 'jarld restore [FILE | latest]': checks the backup, shows it next to
  the current database, keeps the current one aside and records the
  restore. 'jarld serve' holds a database lock, so a restore can't run
  under a live jarld and two jarlds can't share a database.
- Token expiry warnings from 14 days ahead: dashboard, token lists, a
  daily log line, and a CLI warning via the Jarl-Token-Expires header;
  'jarl token list --expiring'.
- 'jarl zone serials' and the zone page's Name servers tab: compare the
  serial on a primary zone's secondaries (NS records, also-notify) or a
  secondary zone's primaries with this server's.
- 'jarl zone delegation': for zones on the internet, check the parent's
  delegation and glue against the zone; an undelegated zone gets a note.
- Server: Jarl/<version> header; CLI exit code 2 for failed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 19:02:34 +00:00
1 parent 1afed741bf
commit 293e6ddd33
47 files changed
+3717 -95

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"jarl/internal/store"
)
func newDB(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "jarl.db")
st, err := store.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return path
}
func TestWriteVerifyInspect(t *testing.T) {
ctx := context.Background()
src := newDB(t)
dir := filepath.Join(t.TempDir(), "backups")
now := time.Date(2026, 10, 8, 3, 15, 12, 0, time.UTC)
f, err := Write(ctx, src, dir, KindJarl, now)
if err != nil {
t.Fatal(err)
}
if filepath.Base(f.Path) != "jarl-20261008T031512Z.db" || f.Size == 0 {
t.Errorf("backup = %+v", f)
}
if fi, _ := os.Stat(f.Path); fi.Mode().Perm() != 0o600 {
t.Errorf("mode %v", fi.Mode())
}
if err := Verify(ctx, f.Path); err != nil {
t.Error(err)
}
in, err := Inspect(ctx, f.Path)
if err != nil || in.Schema != store.SchemaVersion() {
t.Errorf("inspect: %+v %v", in, err)
}
// The same second again is refused rather than overwritten.
if _, err := Write(ctx, src, dir, KindJarl, now); err == nil {
t.Error("overwrote a backup")
}
// No temporary files are left.
ents, _ := os.ReadDir(dir)
if len(ents) != 1 {
t.Errorf("files: %v", ents)
}
// A missing database is an error, and isn't created.
missing := filepath.Join(t.TempDir(), "nope.db")
if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second)); err == nil {
t.Error("backed up a missing database")
}
if _, err := os.Stat(missing); err == nil {
t.Error("created the missing database")
}
}
func TestVerifyCorrupt(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "bad.db")
os.WriteFile(path, []byte("SQLite format 3\x00 but not really a database, just text that goes on"), 0o600)
if err := Verify(ctx, path); err == nil {
t.Error("a corrupt file passed")
}
if _, err := Inspect(ctx, path); err == nil {
t.Error("inspected a corrupt file")
}
}
func TestPrune(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 10, 8, 3, 0, 0, 0, time.UTC)
touch := func(name string) {
os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600)
}
// Daily backups from 0 to 39 days old, plus files that aren't ours.
for d := 0; d < 40; d++ {
touch(Name(KindJarl, now.Add(-time.Duration(d)*24*time.Hour)))
}
touch(Name(KindStats, now.Add(-100*24*time.Hour)))
touch("jarl-before-upgrade.db")
touch("jarl-20200101T000000Z.db.bak")
touch("notes.txt")
removed, err := Prune(dir, KindJarl, now, 30*24*time.Hour, 7)
if err != nil {
t.Fatal(err)
}
if len(removed) != 9 { // 31..39 days old
t.Errorf("removed %d: %v", len(removed), removed)
}
files, _ := List(dir)
jarl := 0
for _, f := range files {
if f.Kind == KindJarl {
jarl++
if now.Sub(f.Time) > 30*24*time.Hour {
t.Errorf("kept %s", f.Path)
}
}
}
if jarl != 31 {
t.Errorf("%d jarl backups left", jarl)
}
for _, name := range []string{"jarl-before-upgrade.db", "jarl-20200101T000000Z.db.bak", "notes.txt", Name(KindStats, now.Add(-100*24*time.Hour))} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("%s was deleted", name)
}
}
// After a long outage every backup is old: the newest keepAtLeast stay.
removed, _ = Prune(dir, KindJarl, now.Add(365*24*time.Hour), 30*24*time.Hour, 7)
files, _ = List(dir)
n := 0
for _, f := range files {
if f.Kind == KindJarl {
n++
}
}
if n != 7 || len(removed) != 24 {
t.Errorf("after a year: %d left, %d removed", n, len(removed))
}
// Retention 0 keeps everything.
if removed, _ := Prune(dir, KindJarl, now.Add(3650*24*time.Hour), 0, 1); len(removed) != 0 {
t.Errorf("retention 0 removed %v", removed)
}
}
func TestPruneTempFiles(t *testing.T) {
dir := t.TempDir()
now := time.Now()
old := filepath.Join(dir, "."+Name(KindJarl, now.Add(-48*time.Hour))+".tmp")
fresh := filepath.Join(dir, "."+Name(KindJarl, now)+".tmp")
os.WriteFile(old, nil, 0o600)
os.WriteFile(fresh, nil, 0o600)
os.Chtimes(old, now.Add(-48*time.Hour), now.Add(-48*time.Hour))
Prune(dir, KindJarl, now, 24*time.Hour, 1)
if _, err := os.Stat(old); err == nil {
t.Error("old temporary file kept")
}
if _, err := os.Stat(fresh); err != nil {
t.Error("a running backup's temporary file was deleted")
}
}