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>
This commit is contained in:
1 parent
293e6ddd33
commit
5630e7a848
72 files changed
+5760
-153
No files matched your search
+144
-3
@@ -59,9 +59,11 @@ func parse(name string) (kind string, t time.Time, ok bool) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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
|
||||
}
|
||||
@@ -78,6 +80,12 @@ func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, er
|
||||
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)
|
||||
@@ -144,6 +152,130 @@ func openCopy(path string) (*sql.DB, error) {
|
||||
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"`
|
||||
@@ -151,6 +283,10 @@ type Info struct {
|
||||
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
|
||||
@@ -168,6 +304,11 @@ func Inspect(ctx context.Context, path string) (*Info, error) {
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
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)
|
||||
f, err := Write(ctx, src, dir, KindJarl, now, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -44,7 +45,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
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 {
|
||||
if _, err := Write(ctx, src, dir, KindJarl, now, ""); err == nil {
|
||||
t.Error("overwrote a backup")
|
||||
}
|
||||
// No temporary files are left.
|
||||
@@ -54,7 +55,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
}
|
||||
// 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 {
|
||||
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 {
|
||||
@@ -149,3 +150,52 @@ func TestPruneTempFiles(t *testing.T) {
|
||||
t.Error("a running backup's temporary file was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := newDB(t)
|
||||
keys := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(keys, "view-3"), 0o755)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.key"), []byte("public"), 0o644)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), []byte("private-key-material-xyz"), 0o600)
|
||||
dir := t.TempDir()
|
||||
f, err := Write(ctx, src, dir, KindJarl, time.Now(), keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, files, err := Files(ctx, f.Path)
|
||||
if err != nil || root != keys || len(files) != 2 {
|
||||
t.Fatalf("files: %q %+v %v", root, files, err)
|
||||
}
|
||||
if files[1].Path != "view-3/Kexample.com.+013+12345.private" || string(files[1].Data) != "private-key-material-xyz" || files[1].Mode != 0o600 {
|
||||
t.Errorf("private key: %+v", files[1])
|
||||
}
|
||||
if in, err := Inspect(ctx, f.Path); err != nil || in.KeyFiles != 2 {
|
||||
t.Errorf("inspect: %+v %v", in, err)
|
||||
}
|
||||
// A restored database loses the table again.
|
||||
restored := filepath.Join(t.TempDir(), "jarl.db")
|
||||
b, _ := os.ReadFile(f.Path)
|
||||
os.WriteFile(restored, b, 0o600)
|
||||
if err := DropFiles(ctx, restored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, files, _ := Files(ctx, restored); len(files) != 0 {
|
||||
t.Error("files left after DropFiles")
|
||||
}
|
||||
if b, _ := os.ReadFile(restored); strings.Contains(string(b), "private-key-material-xyz") {
|
||||
t.Error("the private key is still in the file")
|
||||
}
|
||||
// No key directory: nothing stored. An unreadable key: an error.
|
||||
if f, err := Write(ctx, src, dir, KindJarl, time.Now().Add(time.Second), filepath.Join(keys, "missing")); err != nil {
|
||||
t.Error(err)
|
||||
} else if _, files, _ := Files(ctx, f.Path); len(files) != 0 {
|
||||
t.Errorf("files from a missing directory: %v", files)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
os.Chmod(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), 0)
|
||||
if _, err := Write(ctx, src, dir, KindJarl, time.Now().Add(2*time.Second), keys); err == nil || !strings.Contains(err.Error(), "back up as root") {
|
||||
t.Errorf("unreadable key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user