Files
abrown38andClaude Opus 5.5 5630e7a848 Add DNSSEC signing, key backups and Porkbun support
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>
2026-10-08 21:19:04 +00:00

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()
}