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>
321 lines
8.8 KiB
Go
321 lines
8.8 KiB
Go
// 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
|
|
}
|