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:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+167 -14
View File
@@ -7,8 +7,11 @@ import (
"fmt"
"io"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"text/tabwriter"
"time"
@@ -18,11 +21,13 @@ import (
"jarl/internal/auth"
"jarl/internal/backup"
"jarl/internal/config"
"jarl/internal/render"
"jarl/internal/store"
)
func backupCmd() *cobra.Command {
return &cobra.Command{
var withoutKeys bool
cmd := &cobra.Command{
Use: "backup [FILE]",
Short: "Back up the database (safe while jarld runs)",
Long: `Writes a consistent copy of Jarl's database and checks it.
@@ -35,10 +40,18 @@ say. This is what the jarl-backup.timer systemd timer runs every day.
With FILE it writes a single copy there, which is never deleted
automatically.
Each backup also holds the DNSSEC keys of signed zones (from Jarl's key
directory, private keys included), so that 'jarld restore' can put them
back: a zone whose keys are lost stops validating while its parent still
has a DS record for them. named's private keys are readable only by named's
user, so run this as root; jarl-backup.service has the capability to read
them. --without-keys backs up only the database.
Every backup is recorded in the database, and the dashboard shows the newest.
Restore one with 'jarld restore'.`,
Example: ` sudo -u jarl jarld backup
sudo -u jarl jarld backup /root/jarl-before-upgrade.db`,
Example: ` sudo jarld backup
sudo jarld backup /root/jarl-before-upgrade.db
sudo systemctl start jarl-backup.service`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath, true)
@@ -59,7 +72,14 @@ Restore one with 'jarld restore'.`,
defer newDirOwner(filepath.Dir(cfg.Database)).fix(filepath.Dir(cfg.Database))
ctx := context.Background()
now := time.Now()
keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir()
if withoutKeys {
keysDir = ""
}
fail := func(err error) error {
if strings.Contains(err.Error(), "DNSSEC keys") {
err = fmt.Errorf("%w; or back up only the database with --without-keys", err)
}
if rerr := st.RecordBackup(ctx, store.BackupRecord{At: now, Error: err.Error()}); rerr != nil {
fmt.Fprintln(os.Stderr, "warning: couldn't record the failure in the database:", rerr)
}
@@ -74,27 +94,33 @@ Restore one with 'jarld restore'.`,
os.Remove(dst)
return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err))
}
if keysDir != "" {
if _, err := backup.AddFiles(ctx, dst, keysDir); err != nil {
os.Remove(dst)
return fail(err)
}
}
if err := backup.Verify(ctx, dst); err != nil {
os.Remove(dst)
return fail(fmt.Errorf("the backup failed its check: %w", err))
}
fi, _ := os.Stat(dst)
st.RecordBackup(ctx, store.BackupRecord{At: now, File: dst, Size: fi.Size()})
fmt.Printf("Database backed up to %s (%s)\n", dst, size(fi.Size()))
fmt.Printf("Database backed up to %s (%s)%s\n", dst, size(fi.Size()), keysNote(ctx, dst))
return nil
}
dir := cfg.Backup.Dir
defer newDirOwner(dir).fix(dir)
f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now)
f, err := backup.Write(ctx, cfg.Database, dir, backup.KindJarl, now, keysDir)
if err != nil {
return fail(fmt.Errorf("backing up %s: %w", cfg.Database, err))
}
fmt.Printf("Database backed up to %s (%s)\n", f.Path, size(f.Size))
fmt.Printf("Database backed up to %s (%s)%s\n", f.Path, size(f.Size), keysNote(ctx, f.Path))
kinds := []string{backup.KindJarl}
if cfg.Backup.Stats {
if _, err := os.Stat(cfg.Stats.Database); err == nil {
sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now)
sf, err := backup.Write(ctx, cfg.Stats.Database, dir, backup.KindStats, now, "")
if err != nil {
return fail(fmt.Errorf("backing up the statistics database %s: %w", cfg.Stats.Database, err))
}
@@ -117,10 +143,20 @@ Restore one with 'jarld restore'.`,
return nil
},
}
cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "back up only the database, not the DNSSEC keys")
return cmd
}
// keysNote says how many DNSSEC key files a backup holds.
func keysNote(ctx context.Context, path string) string {
if in, err := backup.Inspect(ctx, path); err == nil && in.KeyFiles > 0 {
return ", with " + plural(in.KeyFiles, "DNSSEC key file")
}
return ""
}
func restoreCmd() *cobra.Command {
var yes bool
var yes, withoutKeys bool
cmd := &cobra.Command{
Use: "restore [FILE | latest]",
Short: "Replace the database with a backup (jarld must be stopped)",
@@ -132,13 +168,20 @@ confirm replaces the database with it. jarld must be stopped first. The
current database is kept as DATABASE.before-restore-<UTC time>, and the
restore is recorded in the restored database's audit log.
The DNSSEC keys stored in the backup go back into Jarl's key directory,
owned by named's user (bind.named_user), which needs root. Key files that
are there but not in the backup are left alone. --without-keys restores
only the database.
Then start jarld and bring named's files in line with the restored database:
systemctl start jarl
jarl resync --dry-run # what differs
jarl resync --force # write everything and reload named
See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
Jarl raises the serials of zones whose data goes back to an older state
above what named and the secondaries have, so they pick it up. See
"Restoring" in docs/operations.md.`,
Example: ` sudo jarld restore
sudo systemctl stop jarl
sudo jarld restore latest
@@ -180,6 +223,24 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
if info.Schema > store.SchemaVersion() {
return fmt.Errorf("%s was made by a newer Jarl (schema %d, this one knows %d); install that version to restore it", src, info.Schema, store.SchemaVersion())
}
keysDir := render.Paths{ZoneDir: cfg.BIND.ZoneDir}.KeysDir()
var keyFiles []backup.StoredFile
if !withoutKeys {
var root string
root, keyFiles, err = backup.Files(ctx, src)
if err != nil {
return fmt.Errorf("reading the DNSSEC keys in %s: %w", src, err)
}
if len(keyFiles) > 0 && os.Geteuid() != 0 && !runningAs(cfg.BIND.NamedUser) {
return fmt.Errorf("the backup holds %s, and putting them back for named needs root: run this as root (or add --without-keys to restore only the database)", plural(len(keyFiles), "DNSSEC key file"))
}
if root != "" && root != keysDir && len(keyFiles) > 0 {
fmt.Printf("The keys were backed up from %s; they go to %s now.\n", root, keysDir)
}
}
if info.SignedZones > 0 && len(keyFiles) == 0 && !withoutKeys {
fmt.Fprintf(os.Stderr, "warning: the backup has %s but no DNSSEC keys (it was made with --without-keys, or before Jarl backed them up). Unless the keys are still in %s, named makes new ones, and a DS record for the old ones at the parent stops matching.\n", plural(info.SignedZones, "signed zone"), keysDir)
}
unlock, err := lockDatabase(cfg.Database)
if errors.Is(err, errInUse) {
return fmt.Errorf("jarld is running; stop it first (systemctl stop jarl), then run this again")
@@ -192,6 +253,9 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "Backup:\t%s\n", src)
describe(w, info, backupTime(src))
if len(keyFiles) > 0 {
fmt.Fprintf(w, " DNSSEC keys\t%s (go back to %s)\n", plural(len(keyFiles), "file"), keysDir)
}
if cur, err := backup.Inspect(ctx, cfg.Database); err == nil {
fmt.Fprintf(w, "\nCurrent database:\t%s\n", cfg.Database)
describe(w, cur, time.Time{})
@@ -224,13 +288,27 @@ See "Restoring" in docs/operations.md, also for zone serials and secondaries.`,
moved = true
}
}
if err := copyFile(src, db, 0o640); err != nil {
restoreErr := copyFile(src, db, 0o640)
if restoreErr == nil {
// The keys go back to their own files, not into the
// live database.
restoreErr = backup.DropFiles(ctx, db)
}
if restoreErr != nil {
os.Remove(db)
if moved {
os.Rename(aside, db)
os.Rename(aside+"-wal", db+"-wal")
os.Rename(aside+"-shm", db+"-shm")
}
return fmt.Errorf("restoring: %w (the current database was left in place)", err)
return fmt.Errorf("restoring: %w (the current database was left in place)", restoreErr)
}
if len(keyFiles) > 0 {
n, err := restoreKeys(cfg, keysDir, keyFiles)
if err != nil {
return fmt.Errorf("the database was restored, but putting back the DNSSEC keys failed after %d files: %w", n, err)
}
fmt.Printf("Put back %s in %s.\n", plural(n, "DNSSEC key file"), keysDir)
}
// Open it as jarld will: an older backup gets the current
// schema now, and the restore goes into its audit log.
@@ -255,17 +333,89 @@ Next:
jarl resync --dry-run # what differs between the restored database and named's files
jarl resync --force # write them and reload named
If you have secondary servers, zone serials older than what they last saw
are ignored by them: raise them with 'jarl zone update ZONE --serial N'
(see "Restoring" in docs/operations.md).
Zones whose data goes back to an older state get a serial above the one
named has, so named and the secondaries load them (see "Restoring" in
docs/operations.md).
`)
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
cmd.Flags().BoolVar(&withoutKeys, "without-keys", false, "restore only the database, not the DNSSEC keys")
return cmd
}
// runningAs reports whether this process runs as the named user.
func runningAs(name string) bool {
u, err := user.Lookup(name)
return err == nil && u.Uid == strconv.Itoa(os.Geteuid())
}
// restoreKeys writes DNSSEC key files from a backup into keysDir, owned
// by named's user and Jarl's file group with their backed-up modes, so
// named can use them. Directories get the owner of the zone directory
// (jarld's user), like the ones jarld makes. Files already there are
// replaced; others are left alone.
func restoreKeys(cfg *config.Config, keysDir string, files []backup.StoredFile) (int, error) {
uid, gid := -1, -1
if u, err := user.Lookup(cfg.BIND.NamedUser); err == nil {
uid, _ = strconv.Atoi(u.Uid)
} else {
return 0, fmt.Errorf("named's user %q (bind.named_user): %w", cfg.BIND.NamedUser, err)
}
if g, err := user.LookupGroup(cfg.BIND.FileGroup); err == nil {
gid, _ = strconv.Atoi(g.Gid)
}
dirUID, dirGID := -1, gid
if fi, err := os.Stat(cfg.BIND.ZoneDir); err == nil {
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
dirUID = int(st.Uid)
}
}
var mkdir func(dir string) error
mkdir = func(dir string) error {
if _, err := os.Stat(dir); err == nil {
return nil
}
if err := mkdir(filepath.Dir(dir)); err != nil {
return err
}
if err := os.Mkdir(dir, 0o770); err != nil && !os.IsExist(err) {
return err
}
os.Chown(dir, dirUID, dirGID)
return os.Chmod(dir, 0o770|os.ModeSetgid)
}
n := 0
for _, f := range files {
if filepath.IsAbs(f.Path) || strings.HasPrefix(filepath.Clean(f.Path), "..") {
return n, fmt.Errorf("refusing the file name %q in the backup", f.Path)
}
dst := filepath.Join(keysDir, f.Path)
if err := mkdir(filepath.Dir(dst)); err != nil {
return n, err
}
tmp := dst + ".restore-tmp"
if err := os.WriteFile(tmp, f.Data, 0o600); err != nil {
return n, err
}
if err := os.Chown(tmp, uid, gid); err != nil {
os.Remove(tmp)
return n, err
}
if err := os.Chmod(tmp, f.Mode); err != nil {
os.Remove(tmp)
return n, err
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return n, err
}
n++
}
return n, nil
}
func listBackups(dir string) error {
files, err := backup.List(dir)
if err != nil {
@@ -290,6 +440,9 @@ func describe(w io.Writer, in *backup.Info, made time.Time) {
fmt.Fprintf(w, " made\t%s (%s ago)\n", made.Local().Format("2006-01-02 15:04:05"), fmtDuration(time.Since(made)))
}
fmt.Fprintf(w, " contents\t%s, %s, %s (schema %d)\n", plural(in.Zones, "zone"), plural(in.Records, "record"), plural(in.Users, "user"), in.Schema)
if in.SignedZones > 0 {
fmt.Fprintf(w, " signed zones\t%d\n", in.SignedZones)
}
if in.LastChange != nil {
fmt.Fprintf(w, " last change\t%s\n", in.LastChange.Local().Format("2006-01-02 15:04:05"))
}
+9
View File
@@ -27,6 +27,7 @@ import (
"jarl/internal/config"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/porkbun"
"jarl/internal/render"
"jarl/internal/server"
"jarl/internal/stats"
@@ -291,12 +292,20 @@ func serveCmd() *cobra.Command {
// database closes, however serving ends.
defer func() { cancel(); <-collectorDone }()
}
var pb *porkbun.Client
if a.cfg.Porkbun.Enabled() {
pb = &porkbun.Client{BaseURL: a.cfg.Porkbun.APIURL, APIKey: a.cfg.Porkbun.APIKey, Secret: a.cfg.Porkbun.SecretAPIKey}
if fi, err := os.Stat(configPath); err == nil && fi.Mode().Perm()&0o004 != 0 {
a.log.Warn("the configuration file holds the Porkbun API key and everyone can read it; make it readable only by root and jarld (chown root:jarl, chmod 640)", "file", configPath)
}
}
srv := server.New(server.Config{
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile,
TrustedProxies: a.cfg.Proxies(), SessionLifetime: time.Duration(a.cfg.Session.Lifetime),
SessionIdle: time.Duration(a.cfg.Session.IdleTimeout), CookieSecure: a.cfg.Session.CookieSecure,
Version: version.Version, Stats: collector, Socket: a.cfg.Socket,
BackupDir: a.cfg.Backup.Dir, BackupWarnAfter: time.Duration(a.cfg.Backup.WarnAfter),
Porkbun: pb, PorkbunAllows: a.cfg.Porkbun.Allows,
HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,