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>
202 lines
6.2 KiB
Go
202 lines
6.2 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"jarl/internal/store"
|
|
)
|
|
|
|
func newDB(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "jarl.db")
|
|
st, err := store.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
return path
|
|
}
|
|
|
|
func TestWriteVerifyInspect(t *testing.T) {
|
|
ctx := context.Background()
|
|
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, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if filepath.Base(f.Path) != "jarl-20261008T031512Z.db" || f.Size == 0 {
|
|
t.Errorf("backup = %+v", f)
|
|
}
|
|
if fi, _ := os.Stat(f.Path); fi.Mode().Perm() != 0o600 {
|
|
t.Errorf("mode %v", fi.Mode())
|
|
}
|
|
if err := Verify(ctx, f.Path); err != nil {
|
|
t.Error(err)
|
|
}
|
|
in, err := Inspect(ctx, f.Path)
|
|
if err != nil || in.Schema != store.SchemaVersion() {
|
|
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 {
|
|
t.Error("overwrote a backup")
|
|
}
|
|
// No temporary files are left.
|
|
ents, _ := os.ReadDir(dir)
|
|
if len(ents) != 1 {
|
|
t.Errorf("files: %v", ents)
|
|
}
|
|
// 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 {
|
|
t.Error("backed up a missing database")
|
|
}
|
|
if _, err := os.Stat(missing); err == nil {
|
|
t.Error("created the missing database")
|
|
}
|
|
}
|
|
|
|
func TestVerifyCorrupt(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := filepath.Join(t.TempDir(), "bad.db")
|
|
os.WriteFile(path, []byte("SQLite format 3\x00 but not really a database, just text that goes on"), 0o600)
|
|
if err := Verify(ctx, path); err == nil {
|
|
t.Error("a corrupt file passed")
|
|
}
|
|
if _, err := Inspect(ctx, path); err == nil {
|
|
t.Error("inspected a corrupt file")
|
|
}
|
|
}
|
|
|
|
func TestPrune(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Date(2026, 10, 8, 3, 0, 0, 0, time.UTC)
|
|
touch := func(name string) {
|
|
os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600)
|
|
}
|
|
// Daily backups from 0 to 39 days old, plus files that aren't ours.
|
|
for d := 0; d < 40; d++ {
|
|
touch(Name(KindJarl, now.Add(-time.Duration(d)*24*time.Hour)))
|
|
}
|
|
touch(Name(KindStats, now.Add(-100*24*time.Hour)))
|
|
touch("jarl-before-upgrade.db")
|
|
touch("jarl-20200101T000000Z.db.bak")
|
|
touch("notes.txt")
|
|
|
|
removed, err := Prune(dir, KindJarl, now, 30*24*time.Hour, 7)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(removed) != 9 { // 31..39 days old
|
|
t.Errorf("removed %d: %v", len(removed), removed)
|
|
}
|
|
files, _ := List(dir)
|
|
jarl := 0
|
|
for _, f := range files {
|
|
if f.Kind == KindJarl {
|
|
jarl++
|
|
if now.Sub(f.Time) > 30*24*time.Hour {
|
|
t.Errorf("kept %s", f.Path)
|
|
}
|
|
}
|
|
}
|
|
if jarl != 31 {
|
|
t.Errorf("%d jarl backups left", jarl)
|
|
}
|
|
for _, name := range []string{"jarl-before-upgrade.db", "jarl-20200101T000000Z.db.bak", "notes.txt", Name(KindStats, now.Add(-100*24*time.Hour))} {
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
|
t.Errorf("%s was deleted", name)
|
|
}
|
|
}
|
|
|
|
// After a long outage every backup is old: the newest keepAtLeast stay.
|
|
removed, _ = Prune(dir, KindJarl, now.Add(365*24*time.Hour), 30*24*time.Hour, 7)
|
|
files, _ = List(dir)
|
|
n := 0
|
|
for _, f := range files {
|
|
if f.Kind == KindJarl {
|
|
n++
|
|
}
|
|
}
|
|
if n != 7 || len(removed) != 24 {
|
|
t.Errorf("after a year: %d left, %d removed", n, len(removed))
|
|
}
|
|
// Retention 0 keeps everything.
|
|
if removed, _ := Prune(dir, KindJarl, now.Add(3650*24*time.Hour), 0, 1); len(removed) != 0 {
|
|
t.Errorf("retention 0 removed %v", removed)
|
|
}
|
|
}
|
|
|
|
func TestPruneTempFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
now := time.Now()
|
|
old := filepath.Join(dir, "."+Name(KindJarl, now.Add(-48*time.Hour))+".tmp")
|
|
fresh := filepath.Join(dir, "."+Name(KindJarl, now)+".tmp")
|
|
os.WriteFile(old, nil, 0o600)
|
|
os.WriteFile(fresh, nil, 0o600)
|
|
os.Chtimes(old, now.Add(-48*time.Hour), now.Add(-48*time.Hour))
|
|
Prune(dir, KindJarl, now, 24*time.Hour, 1)
|
|
if _, err := os.Stat(old); err == nil {
|
|
t.Error("old temporary file kept")
|
|
}
|
|
if _, err := os.Stat(fresh); err != nil {
|
|
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)
|
|
}
|
|
}
|
|
}
|