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:
1 parent
1afed741bf
commit
293e6ddd33
47 files changed
+3717
-95
No files matched your search
@@ -0,0 +1,256 @@
|
||||
// Package backup writes, checks, lists and prunes copies of Jarl's SQLite
|
||||
// databases ('jarld backup' and 'jarld restore').
|
||||
//
|
||||
// A backup made into the backup directory is named after its kind and the
|
||||
// UTC time it was made, e.g. jarl-20261008T031512Z.db (the configuration
|
||||
// database) or stats-20261008T031512Z.db (query statistics). Pruning goes
|
||||
// by the time in the name, not the file's modification time (which a copy
|
||||
// or a restore of the directory changes), and only ever touches files
|
||||
// named like this: anything else in the directory is left alone.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // registers the "sqlite" driver
|
||||
)
|
||||
|
||||
// Kinds of database.
|
||||
const (
|
||||
KindJarl = "jarl"
|
||||
KindStats = "stats"
|
||||
)
|
||||
|
||||
const stamp = "20060102T150405Z"
|
||||
|
||||
var nameRe = regexp.MustCompile(`^(jarl|stats)-([0-9]{8}T[0-9]{6}Z)\.db$`)
|
||||
|
||||
// File is a backup in the backup directory.
|
||||
type File struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"`
|
||||
Time time.Time `json:"time"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Name is the file name of a backup of kind made at t.
|
||||
func Name(kind string, t time.Time) string {
|
||||
return kind + "-" + t.UTC().Format(stamp) + ".db"
|
||||
}
|
||||
|
||||
// parse returns the kind and time in a backup's file name.
|
||||
func parse(name string) (kind string, t time.Time, ok bool) {
|
||||
m := nameRe.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(stamp, m[2])
|
||||
return m[1], t, err == nil
|
||||
}
|
||||
|
||||
// Write makes a consistent copy of the live SQLite database src as
|
||||
// dir/Name(kind, now), and checks the copy before giving it its final
|
||||
// name: a file with a backup's name is always a complete, intact backup.
|
||||
func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, error) {
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
final := filepath.Join(dir, Name(kind, now))
|
||||
if _, err := os.Lstat(final); err == nil {
|
||||
return nil, fmt.Errorf("%s already exists", final)
|
||||
}
|
||||
tmp := filepath.Join(dir, "."+Name(kind, now)+".tmp")
|
||||
os.Remove(tmp)
|
||||
if err := Copy(ctx, src, tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if err := Verify(ctx, tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, fmt.Errorf("the new backup failed its check: %w", err)
|
||||
}
|
||||
if err := syncFile(tmp); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
syncFile(dir)
|
||||
fi, err := os.Stat(final)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &File{Path: final, Kind: kind, Time: now.UTC().Truncate(time.Second), Size: fi.Size()}, nil
|
||||
}
|
||||
|
||||
// Copy writes a consistent copy of the live SQLite database src to dst
|
||||
// (which must not exist) with SQLite's VACUUM INTO, which is safe while
|
||||
// jarld uses the database. dst is created with mode 0600: the database
|
||||
// holds TSIG secrets and password hashes.
|
||||
func Copy(ctx context.Context, src, dst string) error {
|
||||
// mode=rw: never create src if it's missing.
|
||||
db, err := sql.Open("sqlite", "file:"+escape(src)+"?mode=rw&_pragma=busy_timeout(10000)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
old := syscall.Umask(0o077)
|
||||
_, err = db.ExecContext(ctx, `VACUUM INTO ?`, dst)
|
||||
syscall.Umask(old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(dst, 0o600)
|
||||
}
|
||||
|
||||
// Verify checks that path is an intact SQLite database.
|
||||
func Verify(ctx context.Context, path string) error {
|
||||
db, err := openCopy(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
var res string
|
||||
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&res); err != nil {
|
||||
return err
|
||||
}
|
||||
if res != "ok" {
|
||||
return fmt.Errorf("integrity check: %s", res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openCopy opens a backup read-only. immutable=1 tells SQLite the file
|
||||
// can't change, so it takes no locks and writes nothing next to it.
|
||||
func openCopy(path string) (*sql.DB, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sql.Open("sqlite", "file:"+escape(path)+"?mode=ro&immutable=1")
|
||||
}
|
||||
|
||||
// Info describes the contents of a configuration database (KindJarl).
|
||||
type Info struct {
|
||||
Schema int `json:"schema"`
|
||||
Zones int `json:"zones"`
|
||||
Records int `json:"records"`
|
||||
Users int `json:"users"`
|
||||
LastChange *time.Time `json:"last_change,omitempty"`
|
||||
}
|
||||
|
||||
// Inspect reads a configuration database's schema version and size. It
|
||||
// works on backups and, read-only, on the live database.
|
||||
func Inspect(ctx context.Context, path string) (*Info, error) {
|
||||
db, err := openCopy(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
in := &Info{}
|
||||
if err := db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&in.Schema); err != nil {
|
||||
return nil, fmt.Errorf("%s doesn't look like a Jarl database: %w", path, err)
|
||||
}
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM zones`).Scan(&in.Zones)
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM records`).Scan(&in.Records)
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&in.Users)
|
||||
var last sql.NullString
|
||||
db.QueryRowContext(ctx, `SELECT max(applied_at) FROM changesets`).Scan(&last)
|
||||
if t, err := time.Parse(time.RFC3339Nano, last.String); err == nil {
|
||||
in.LastChange = &t
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// List returns the backups in dir, newest first. A missing directory has
|
||||
// none.
|
||||
func List(dir string) ([]File, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []File
|
||||
for _, e := range ents {
|
||||
kind, t, ok := parse(e.Name())
|
||||
if !ok || !e.Type().IsRegular() {
|
||||
continue
|
||||
}
|
||||
f := File{Path: filepath.Join(dir, e.Name()), Kind: kind, Time: t}
|
||||
if fi, err := e.Info(); err == nil {
|
||||
f.Size = fi.Size()
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Prune deletes backups of kind in dir that are older than retention,
|
||||
// always keeping the newest keepAtLeast of them however old they are, so
|
||||
// a backup that stopped running for a while never leaves you with none.
|
||||
// retention 0 keeps everything. It returns the deleted files.
|
||||
func Prune(dir, kind string, now time.Time, retention time.Duration, keepAtLeast int) ([]File, error) {
|
||||
if retention <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := List(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var removed []File
|
||||
n := 0
|
||||
for _, f := range all {
|
||||
if f.Kind != kind {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if n <= keepAtLeast || now.Sub(f.Time) <= retention {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(f.Path); err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed = append(removed, f)
|
||||
}
|
||||
// Temporary files left by a backup that was killed halfway.
|
||||
ents, _ := os.ReadDir(dir)
|
||||
for _, e := range ents {
|
||||
if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".db.tmp") {
|
||||
if fi, err := e.Info(); err == nil && now.Sub(fi.ModTime()) > 24*time.Hour {
|
||||
os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func syncFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// escape makes a path safe inside a file: URI.
|
||||
func escape(path string) string {
|
||||
return (&url.URL{Path: path}).EscapedPath()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user