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

+256
View File
@@ -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()
}