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