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
@@ -0,0 +1,320 @@
|
||||
// Package dnssec reads the DNSSEC keys named keeps for the zones it signs
|
||||
// (dnssec-policy with inline signing) and works out where a zone is in the
|
||||
// steps of turning signing on or off.
|
||||
//
|
||||
// named writes three files per key into the zone's key-directory:
|
||||
// K<zone>.+<alg>+<tag>.key (the public DNSKEY, world-readable),
|
||||
// .private (the private key, readable only by named's user) and .state
|
||||
// (the key's timing and state, see the dnssec-keymgr states in the BIND
|
||||
// ARM). Jarl only reads the .key and .state files.
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Key states, as named writes them in .state files.
|
||||
const (
|
||||
Hidden = "hidden" // not in the zone (or the parent)
|
||||
Rumoured = "rumoured" // published, but caches may not have it yet
|
||||
Omnipresent = "omnipresent" // published long enough that everyone has it
|
||||
Unretentive = "unretentive" // removed, but caches may still have it
|
||||
)
|
||||
|
||||
// Status describes how a zone is signed.
|
||||
type Status struct {
|
||||
Policy string `json:"policy"`
|
||||
// KeyDir is the zone's key-directory.
|
||||
KeyDir string `json:"key_dir"`
|
||||
Keys []*Key `json:"keys"`
|
||||
Phase Phase `json:"phase"`
|
||||
Named *NamedSigning `json:"named,omitempty"`
|
||||
// NamedError says why named couldn't be asked.
|
||||
NamedError string `json:"named_error,omitempty"`
|
||||
// KeyError says why the key directory couldn't be read.
|
||||
KeyError string `json:"key_error,omitempty"`
|
||||
// Policies are the policies the zone could use: default and the
|
||||
// custom dnssec-policy statements.
|
||||
Policies []string `json:"policies"`
|
||||
}
|
||||
|
||||
// NamedSigning is what named reports about a zone it signs.
|
||||
type NamedSigning struct {
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
Serial uint32 `json:"serial"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
}
|
||||
|
||||
// DS is a DS record for a key: what the parent zone publishes to vouch
|
||||
// for it.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
// Record is the DS record in zone file format.
|
||||
Record string `json:"record"`
|
||||
}
|
||||
|
||||
// Key is one DNSSEC key of a zone.
|
||||
type Key struct {
|
||||
Tag uint16 `json:"tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
AlgorithmName string `json:"algorithm_name"`
|
||||
// Role is CSK (signs everything: BIND's default policy), KSK (signs
|
||||
// the DNSKEY set; the parent's DS points at it) or ZSK.
|
||||
Role string `json:"role"`
|
||||
// DNSKEY is the public key record, and DS its DS record (KSK and CSK
|
||||
// only).
|
||||
DNSKEY string `json:"dnskey,omitempty"`
|
||||
DS *DS `json:"ds,omitempty"`
|
||||
// States from the .state file.
|
||||
Goal string `json:"goal,omitempty"`
|
||||
DNSKEYState string `json:"dnskey_state,omitempty"`
|
||||
DSState string `json:"ds_state,omitempty"`
|
||||
ZoneRRSIG string `json:"zone_rrsig_state,omitempty"`
|
||||
KeyRRSIG string `json:"key_rrsig_state,omitempty"`
|
||||
// Times from the .state file.
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Published *time.Time `json:"published,omitempty"`
|
||||
Active *time.Time `json:"active,omitempty"`
|
||||
Retired *time.Time `json:"retired,omitempty"`
|
||||
Removed *time.Time `json:"removed,omitempty"`
|
||||
// PublishCDS is when named publishes CDS/CDNSKEY records: from then on
|
||||
// the DS can safely be added at the parent.
|
||||
PublishCDS *time.Time `json:"publish_cds,omitempty"`
|
||||
// DSPublished and DSRemoved are when named was told (or saw) that the
|
||||
// parent added or removed the DS.
|
||||
DSPublished *time.Time `json:"ds_published,omitempty"`
|
||||
DSRemoved *time.Time `json:"ds_removed,omitempty"`
|
||||
// Files are the key's file names in the key directory.
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// SigningKey reports whether the parent's DS should point at the key.
|
||||
func (k *Key) SigningKey() bool { return k.Role == "CSK" || k.Role == "KSK" }
|
||||
|
||||
// Gone reports whether named has finished removing the key: every state
|
||||
// is hidden.
|
||||
func (k *Key) Gone() bool {
|
||||
for _, s := range []string{k.Goal, k.DNSKEYState, k.DSState, k.ZoneRRSIG, k.KeyRRSIG} {
|
||||
if s != "" && s != Hidden {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FilePrefix is how named's key files for zone (a name with the trailing
|
||||
// dot) begin.
|
||||
func FilePrefix(zone string) string { return "K" + strings.ToLower(zone) + "+" }
|
||||
|
||||
// Files lists the names of zone's key files in dir (every .key, .private
|
||||
// and .state file, and any other file named with the zone's key prefix).
|
||||
// A missing directory has none.
|
||||
func Files(dir, zone string) ([]string, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := FilePrefix(zone)
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && strings.HasPrefix(strings.ToLower(e.Name()), strings.ToLower(prefix)) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Keys reads zone's keys from dir, ordered with the signing keys first.
|
||||
func Keys(dir, zone string) ([]*Key, error) {
|
||||
files, err := Files(dir, zone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byBase := map[string]*Key{}
|
||||
var order []string
|
||||
for _, f := range files {
|
||||
ext := filepath.Ext(f)
|
||||
base := strings.TrimSuffix(f, ext)
|
||||
k := byBase[base]
|
||||
if k == nil {
|
||||
k = &Key{}
|
||||
byBase[base] = k
|
||||
order = append(order, base)
|
||||
}
|
||||
k.Files = append(k.Files, f)
|
||||
switch ext {
|
||||
case ".key":
|
||||
if err := k.readPublic(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ".state":
|
||||
if err := k.readState(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []*Key
|
||||
for _, b := range order {
|
||||
k := byBase[b]
|
||||
if k.DNSKEY == "" && k.Goal == "" {
|
||||
continue // only a .private file: nothing to show
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SigningKey() != out[j].SigningKey() {
|
||||
return out[i].SigningKey()
|
||||
}
|
||||
return out[i].Tag < out[j].Tag
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *Key) readPublic(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zp := dns.NewZoneParser(strings.NewReader(string(b)), "", path)
|
||||
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
|
||||
key, isKey := rr.(*dns.DNSKEY)
|
||||
if !isKey {
|
||||
continue
|
||||
}
|
||||
k.Tag = key.KeyTag()
|
||||
k.Algorithm = key.Algorithm
|
||||
k.AlgorithmName = dns.AlgorithmToString[key.Algorithm]
|
||||
k.DNSKEY = key.String()
|
||||
if k.Role == "" {
|
||||
switch {
|
||||
case key.Flags&dns.SEP != 0:
|
||||
k.Role = "KSK"
|
||||
default:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
}
|
||||
if key.Flags&dns.SEP != 0 {
|
||||
if ds := key.ToDS(dns.SHA256); ds != nil {
|
||||
k.DS = &DS{KeyTag: ds.KeyTag, Algorithm: ds.Algorithm, DigestType: ds.DigestType, Digest: strings.ToUpper(ds.Digest), Record: ds.String()}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := zp.Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("%s: no DNSKEY record", path)
|
||||
}
|
||||
|
||||
func (k *Key) readState(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var ksk, zsk bool
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
name, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
t := func() *time.Time {
|
||||
// 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
f := strings.Fields(v)
|
||||
if len(f) == 0 {
|
||||
return nil
|
||||
}
|
||||
ts, err := time.Parse("20060102150405", f[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ts
|
||||
}
|
||||
switch name {
|
||||
case "Algorithm":
|
||||
if n, err := strconv.Atoi(v); err == nil && k.Algorithm == 0 {
|
||||
k.Algorithm = uint8(n)
|
||||
k.AlgorithmName = dns.AlgorithmToString[uint8(n)]
|
||||
}
|
||||
case "KSK":
|
||||
ksk = v == "yes"
|
||||
case "ZSK":
|
||||
zsk = v == "yes"
|
||||
case "Generated":
|
||||
k.Created = t()
|
||||
case "Published":
|
||||
k.Published = t()
|
||||
case "Active":
|
||||
k.Active = t()
|
||||
case "Retired":
|
||||
k.Retired = t()
|
||||
case "Removed":
|
||||
k.Removed = t()
|
||||
case "PublishCDS":
|
||||
k.PublishCDS = t()
|
||||
case "DSPublish":
|
||||
k.DSPublished = t()
|
||||
case "DSRemoved":
|
||||
k.DSRemoved = t()
|
||||
case "GoalState":
|
||||
k.Goal = v
|
||||
case "DNSKEYState":
|
||||
k.DNSKEYState = v
|
||||
case "DSState":
|
||||
k.DSState = v
|
||||
case "ZRRSIGState":
|
||||
k.ZoneRRSIG = v
|
||||
case "KRRSIGState":
|
||||
k.KeyRRSIG = v
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ksk && zsk:
|
||||
k.Role = "CSK"
|
||||
case ksk:
|
||||
k.Role = "KSK"
|
||||
case zsk:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// ZoneFileSuffixes are the files named keeps next to a zone file: the
|
||||
// journal of the zone Jarl wrote (.jnl, and .jbk while it rewrites it) and,
|
||||
// for a signed zone, the signed copy and its journal.
|
||||
var ZoneFileSuffixes = []string{".jnl", ".jbk", ".signed", ".signed.jnl", ".signed.jbk"}
|
||||
|
||||
// DerivedFiles returns the files named made next to zoneFile that exist.
|
||||
func DerivedFiles(zoneFile string) []string {
|
||||
var out []string
|
||||
for _, s := range ZoneFileSuffixes {
|
||||
if _, err := os.Lstat(zoneFile + s); err == nil {
|
||||
out = append(out, zoneFile+s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user