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

+87 -6
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"regexp"
@@ -34,6 +35,62 @@ type Config struct {
Backup Backup `yaml:"backup"`
Stats Stats `yaml:"stats"`
ACME ACME `yaml:"acme"`
Porkbun Porkbun `yaml:"porkbun"`
}
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
// DS records at the registrar Porkbun (docs/dnssec.md#porkbun). The key
// lives only here, in a file root and jarld can read, and never in the
// database (so not in backups either).
type Porkbun struct {
APIKey string `yaml:"api_key"`
SecretAPIKey string `yaml:"secret_api_key"`
// Domains limits which domains Jarl may manage at Porkbun (default:
// every Jarl zone the key can reach).
Domains []string `yaml:"domains"`
// APIURL is the API's base URL (for tests).
APIURL string `yaml:"api_url"`
}
// Enabled reports whether a Porkbun key is configured.
func (p *Porkbun) Enabled() bool { return p.APIKey != "" }
// Allows reports whether Jarl may manage domain (no trailing dot) at
// Porkbun.
func (p *Porkbun) Allows(domain string) bool {
if !p.Enabled() {
return false
}
if len(p.Domains) == 0 {
return true
}
for _, d := range p.Domains {
if strings.EqualFold(strings.TrimSuffix(d, "."), domain) {
return true
}
}
return false
}
func (p *Porkbun) check() error {
if (p.APIKey == "") != (p.SecretAPIKey == "") {
return fmt.Errorf("set both api_key and secret_api_key (or neither)")
}
if p.APIURL != "" {
u, err := url.Parse(p.APIURL)
if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname()))) {
return fmt.Errorf("api_url must be an https:// URL")
}
}
return nil
}
func isLoopbackHost(h string) bool {
if h == "localhost" {
return true
}
a, err := netip.ParseAddr(h)
return err == nil && a.IsLoopback()
}
// Backup configures 'jarld backup' run without a file name, which is what
@@ -94,11 +151,14 @@ type Session struct {
// BIND says where BIND's files and tools are.
type BIND struct {
MainConfig string `yaml:"main_config"`
ConfigDir string `yaml:"config_dir"`
ZoneDir string `yaml:"zone_dir"`
CacheDir string `yaml:"cache_dir"`
FileGroup string `yaml:"file_group"`
MainConfig string `yaml:"main_config"`
ConfigDir string `yaml:"config_dir"`
ZoneDir string `yaml:"zone_dir"`
CacheDir string `yaml:"cache_dir"`
FileGroup string `yaml:"file_group"`
// NamedUser is the user named runs as. 'jarld restore' gives it the
// DNSSEC keys it puts back.
NamedUser string `yaml:"named_user"`
NamedCheckConf string `yaml:"named_checkconf"`
NamedCheckZone string `yaml:"named_checkzone"`
RNDC string `yaml:"rndc"`
@@ -200,6 +260,7 @@ func Default() *Config {
ZoneDir: "/var/lib/bind/jarl",
CacheDir: "/var/cache/bind/jarl",
FileGroup: "bind",
NamedUser: "bind",
NamedCheckConf: "/usr/bin/named-checkconf",
NamedCheckZone: "/usr/bin/named-checkzone",
RNDC: "/usr/sbin/rndc",
@@ -273,6 +334,9 @@ func (c *Config) check() error {
if err := c.Backup.check(); err != nil {
return fmt.Errorf("backup: %w", err)
}
if err := c.Porkbun.check(); err != nil {
return fmt.Errorf("porkbun: %w", err)
}
switch c.Log.Format {
case "text", "json":
default:
@@ -415,6 +479,8 @@ bind:
cache_dir: /var/cache/bind/jarl
# Group of generated files; named must be able to read them.
file_group: bind
# The user named runs as ('jarld restore' gives it the DNSSEC keys back).
named_user: bind
named_checkconf: /usr/bin/named-checkconf
named_checkzone: /usr/bin/named-checkzone
rndc: /usr/sbin/rndc
@@ -434,7 +500,9 @@ audit:
# 'systemctl edit jarl-backup.timer'. Each run writes
# DIR/jarl-<UTC time>.db, checks it, then deletes backups older than
# 'retention', always keeping the newest 'keep_at_least'. Only files named
# like that are ever deleted. Restore with 'jarld restore'.
# like that are ever deleted. Each backup also holds the DNSSEC keys of
# signed zones (private keys included), so keep the directory private.
# Restore with 'jarld restore'.
backup:
dir: /var/backups/jarl # the timer's sandbox may write only here (see the docs to move it)
retention: 30d # 0 keeps every backup
@@ -478,4 +546,17 @@ acme:
# this server. Leave false if a reverse proxy runs on this server but
# isn't in trusted_proxies: its requests would look local.
allow_loopback_http: false
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
# show and set a domain's name servers, glue records and DNSSEC DS records
# there, after checking that the change won't break the domain. Off without
# a key. Create the key at porkbun.com (Account -> API Access), switch on
# "API Access" for the domain, and limit the key to that domain if you can.
# The key can move your domain elsewhere: it lives only in this file (keep
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
# Jarl's database or backups.
porkbun:
api_key: "" # pk1_...
secret_api_key: "" # sk1_...
domains: [] # only these domains (default: any Jarl zone the key reaches)
`