Zones get a DNSSEC setting: named signs them with BIND's default (or a custom) dnssec-policy and inline signing, with keys in a per-view key directory. Jarl shows the keys, the DS record and when it is safe to publish it, checks the parent's DS records (check-ds, which also tells named), and refuses steps that would break a signed domain: switching straight to unsigned, finishing a turn-off too early, deleting or moving a signed zone, TTLs above max-zone-ttl. Turning off goes through the insecure policy; wipe starts over once the parent has no DS. Backups store the DNSSEC keys and restore puts them back for named; the backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's serial above named's whenever it writes an older one (after a restore). The serials check compares the signed serial. With a Porkbun API key in jarl.yaml, the registrar's name servers, glue records and DS records can be shown and changed, each change checked first (dry run, force to override). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
12 KiB
Go
398 lines
12 KiB
Go
// 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), stores the files under filesDir in it (the DNSSEC
|
|
// keys; "" for none, see AddFiles), 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, filesDir string) (*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 filesDir != "" {
|
|
if _, err := AddFiles(ctx, tmp, filesDir); 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")
|
|
}
|
|
|
|
// FilesTable holds the files a backup stores next to the database. It
|
|
// exists only in backups: 'jarld restore' writes the files back and drops
|
|
// the table.
|
|
const FilesTable = "backup_files"
|
|
|
|
// StoredFile is a file stored in a backup.
|
|
type StoredFile struct {
|
|
// Path is relative to the directory the files came from (Root).
|
|
Path string
|
|
Mode os.FileMode
|
|
Data []byte
|
|
}
|
|
|
|
// AddFiles stores every regular file under root in the backup database
|
|
// at path (the DNSSEC keys named keeps under Jarl's key directory,
|
|
// private keys included). A missing root stores nothing. Files that can't
|
|
// be read are an error: named's private keys are readable only by named's
|
|
// user, so the backup must run as root or with CAP_DAC_READ_SEARCH (as
|
|
// jarl-backup.service does).
|
|
func AddFiles(ctx context.Context, path, root string) (int, error) {
|
|
var files []StoredFile
|
|
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
if os.IsNotExist(err) && p == root {
|
|
return filepath.SkipAll
|
|
}
|
|
return err
|
|
}
|
|
if !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fi, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(root, p)
|
|
files = append(files, StoredFile{Path: rel, Mode: fi.Mode().Perm(), Data: b})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if os.IsPermission(err) {
|
|
return 0, fmt.Errorf("reading the DNSSEC keys: %w (named's private keys are readable only by its own user: back up as root, or through jarl-backup.service)", err)
|
|
}
|
|
return 0, fmt.Errorf("reading the DNSSEC keys: %w", err)
|
|
}
|
|
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer db.Close()
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.ExecContext(ctx, `CREATE TABLE `+FilesTable+` (root TEXT NOT NULL, path TEXT NOT NULL, mode INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (root, path))`); err != nil {
|
|
return 0, err
|
|
}
|
|
for _, f := range files {
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO `+FilesTable+` (root, path, mode, data) VALUES (?, ?, ?, ?)`, root, f.Path, int(f.Mode), f.Data); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return len(files), tx.Commit()
|
|
}
|
|
|
|
// Files returns the files stored in a backup and the directory they came
|
|
// from ("" and none for a backup without files).
|
|
func Files(ctx context.Context, path string) (string, []StoredFile, error) {
|
|
db, err := openCopy(path)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer db.Close()
|
|
if !hasTable(ctx, db, FilesTable) {
|
|
return "", nil, nil
|
|
}
|
|
rows, err := db.QueryContext(ctx, `SELECT root, path, mode, data FROM `+FilesTable+` ORDER BY path`)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer rows.Close()
|
|
var root string
|
|
var out []StoredFile
|
|
for rows.Next() {
|
|
var f StoredFile
|
|
var mode int
|
|
if err := rows.Scan(&root, &f.Path, &mode, &f.Data); err != nil {
|
|
return "", nil, err
|
|
}
|
|
f.Mode = os.FileMode(mode).Perm()
|
|
out = append(out, f)
|
|
}
|
|
return root, out, rows.Err()
|
|
}
|
|
|
|
func hasTable(ctx context.Context, db *sql.DB, name string) bool {
|
|
var n int
|
|
db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n)
|
|
return n > 0
|
|
}
|
|
|
|
// DropFiles removes the stored files from a database restored from a
|
|
// backup, and vacuums it so the private keys don't linger in free pages.
|
|
func DropFiles(ctx context.Context, path string) error {
|
|
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
if !hasTable(ctx, db, FilesTable) {
|
|
return nil
|
|
}
|
|
if _, err := db.ExecContext(ctx, `DROP TABLE `+FilesTable); err != nil {
|
|
return err
|
|
}
|
|
_, err = db.ExecContext(ctx, `VACUUM`)
|
|
return err
|
|
}
|
|
|
|
// 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"`
|
|
// KeyFiles is the number of DNSSEC key files stored in a backup, and
|
|
// SignedZones how many zones were signed.
|
|
KeyFiles int `json:"key_files"`
|
|
SignedZones int `json:"signed_zones"`
|
|
}
|
|
|
|
// 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)
|
|
if hasTable(ctx, db, FilesTable) {
|
|
db.QueryRowContext(ctx, `SELECT count(*) FROM `+FilesTable).Scan(&in.KeyFiles)
|
|
}
|
|
// dnssec_policy exists from schema 3 on.
|
|
db.QueryRowContext(ctx, `SELECT count(*) FROM zones WHERE dnssec_policy != ''`).Scan(&in.SignedZones)
|
|
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()
|
|
}
|