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

+365
View File
@@ -0,0 +1,365 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"jarl/internal/api"
"jarl/internal/dnssec"
)
func zoneDNSSECCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "dnssec",
Short: "Sign a zone with DNSSEC, and turn signing off safely",
Long: `named signs the zone itself (BIND's dnssec-policy with inline signing): Jarl
keeps writing the unsigned zone, and named keeps a signed copy next to it
and its keys in Jarl's key directory. See docs/dnssec.md.
Turning signing on is safe at any time; validation only starts once you add
the zone's DS record at the parent zone (your registrar), and 'status' says
when that is safe. Turning it off goes through BIND's insecure policy, which
keeps the zone signed until the DS is gone from the parent.`,
}
var view string
status := &cobra.Command{
Use: "status ZONE",
Short: "Show the zone's keys, its DS record and the next step",
Example: " jarl zone dnssec status adbraun.lol --view external",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := dnssecStatus(args[0], view)
if err != nil {
return err
}
return output(resp, func(w io.Writer) { printDNSSEC(w, resp) })
},
}
status.Flags().StringVar(&view, "view", "", "view of the zone")
var policy string
enable := &cobra.Command{
Use: "enable ZONE",
Short: "Start signing the zone",
Long: `Starts signing the zone with BIND's default policy (one ECDSA P-256 key that
is never replaced, so the DS record at the parent never has to change) or a
custom dnssec-policy statement.
Nothing changes for resolvers until the zone's DS record is added at the
parent zone. named first publishes the key for long enough that every cache
has it ('status' shows when: about a day with the default policy); add the
DS after that.`,
Example: " jarl zone dnssec enable adbraun.lol --view external",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"dnssec_policy": policy})
if err != nil {
return err
}
if err := report(resp); err != nil || !resp.Applied {
return err
}
if !flagQuiet {
fmt.Printf("\nnamed is signing %s now. Add the DS record at your registrar only when\n'jarl zone dnssec status %s' says the zone is ready.\n", strings.TrimSuffix(args[0], "."), args[0]+viewFlag(view))
}
return nil
},
}
enable.Flags().StringVar(&view, "view", "", "view of the zone")
enable.Flags().StringVar(&policy, "policy", "default", "dnssec-policy: default, or a custom policy defined as a top-level dnssec-policy statement")
var yes bool
disable := &cobra.Command{
Use: "disable ZONE",
Short: "Turn signing off, one safe step at a time",
Long: `Turns signing off in two steps, so the zone never goes unsigned while the
parent zone still has its DS record:
1. The first run switches the zone to BIND's insecure policy. named keeps it
signed and publishes a CDS "delete" record. Remove the DS record at your
registrar, then run 'jarl zone dnssec check-ds' so named knows. named
waits until resolvers have forgotten the DS (the parent's DS TTL, often a
day) and then removes the keys and signatures.
2. Run it again when 'status' says turning off has finished: DNSSEC is then
switched off for the zone.
A zone whose DS never reached the parent can be wiped at once instead
('jarl zone dnssec wipe').`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
st, err := dnssecStatus(args[0], view)
if err != nil {
return err
}
var next string
switch {
case st.Policy == "":
fmt.Println("DNSSEC is already off for this zone.")
if st.Phase.Code == dnssec.PhaseLeftover {
fmt.Println("Old keys are still in its key directory; 'jarl zone dnssec wipe' removes them.")
}
return nil
case st.Policy != "insecure":
if !confirm(fmt.Sprintf("Start turning DNSSEC off for %s? named keeps it signed until you've removed the DS record at your registrar.", displayName(st.Zone)), yes) {
return fmt.Errorf("canceled")
}
next = "insecure"
case !st.Phase.CanFinish:
fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Summary)
if st.Phase.Next != "" {
fmt.Fprintf(os.Stderr, "%s\n", st.Phase.Next)
}
return &checkFailed{"turning DNSSEC off hasn't finished yet"}
}
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"dnssec_policy": next})
if err != nil {
return err
}
if err := report(resp); err != nil || !resp.Applied || flagQuiet {
return err
}
if next == "insecure" {
fmt.Printf("\nNext: remove the DS record for %s at your registrar, then run\n jarl zone dnssec check-ds %s\nRun 'jarl zone dnssec disable %s' again when 'status' says it has finished.\n", displayName(st.Zone), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else {
fmt.Println("\nDNSSEC is off. The old keys stay in the key directory until you wipe them ('jarl zone dnssec wipe').")
}
return nil
},
}
disable.Flags().StringVar(&view, "view", "", "view of the zone")
disable.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
var resolver string
checkDS := &cobra.Command{
Use: "check-ds ZONE",
Short: "Check the parent zone's DS records and tell named",
Long: `Asks every server of the parent zone for the zone's DS records, and tells
named (rndc dnssec -checkds) when its key's DS has appeared there (after you
added it at the registrar) or is gone (while turning DNSSEC off). named
moves to the next step only once it knows.
The parent is found through jarld's resolver, or --resolver. Exits with 2
when the parent couldn't be checked.`,
Example: " jarl zone dnssec check-ds adbraun.lol --view external",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if resolver != "" {
qv.Set("resolver", resolver)
}
var resp api.CheckDSResponse
if err := c.Do(ctx(), "POST", path+"/dnssec/check-ds", qv, nil, &resp); err != nil {
return err
}
err = output(resp, func(w io.Writer) {
p := resp.Parent
if p.Error != "" {
fmt.Fprintf(w, "error: %s\n", p.Error)
} else {
fmt.Fprintf(w, "Parent zone:\t%s (%d of %d servers answered, found through %s)\n", displayName(p.Parent), p.Reached, len(p.Servers), p.Resolver)
if !p.Delegated {
fmt.Fprintf(w, "Delegation:\tnone (the zone isn't delegated from %s)\n", displayName(p.Parent))
}
if len(p.DS) == 0 {
fmt.Fprintln(w, "DS records:\tnone")
}
for _, d := range p.DS {
fmt.Fprintf(w, "DS record:\tkey %d, algorithm %d, digest type %d\n", d.KeyTag, d.Algorithm, d.DigestType)
}
}
flush(w)
for _, m := range resp.Marked {
fmt.Printf("named told: %s\n", m)
}
for _, n := range resp.Notes {
fmt.Printf("note: %s\n", n)
}
if resp.Status != nil {
fmt.Printf("\n%s\n", resp.Status.Phase.Summary)
if resp.Status.Phase.Next != "" {
fmt.Println(resp.Status.Phase.Next)
}
}
})
if err != nil {
return err
}
if resp.Parent.Error != "" {
return &checkFailed{"the parent zone couldn't be checked"}
}
return nil
},
}
checkDS.Flags().StringVar(&view, "view", "", "view of the zone")
checkDS.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with, e.g. 9.9.9.9 (default: the server's)")
var force bool
wipe := &cobra.Command{
Use: "wipe ZONE",
Short: "Switch signing off at once and delete the zone's keys (a clean slate)",
Long: `Switches DNSSEC off for the zone at once (skipping the insecure policy) and
deletes its keys and named's signed copy, so that signing it again starts
from scratch. Also cleans up a zone that is off but still has old keys.
This is only safe when the parent zone has no DS record for the zone: Jarl
checks the parent first and refuses while it has one. When the parent can't
be checked (a zone that isn't on the internet, or no internet access),
--force wipes anyway. Needs an administrator.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm(fmt.Sprintf("Switch DNSSEC off for %s and delete its keys? Make sure the parent zone has no DS record for it.", strings.TrimSuffix(args[0], ".")), yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if resolver != "" {
qv.Set("resolver", resolver)
}
if flagDryRun {
qv.Set("dry_run", "1")
}
var resp api.DNSSECWipeResponse
if err := c.Do(ctx(), "POST", path+"/dnssec/wipe", qv, api.DNSSECWipe{Force: force, DryRun: flagDryRun}, &resp); err != nil {
return err
}
return output(resp, func(w io.Writer) {
verb := "removed"
if resp.DryRun {
verb = "would remove"
}
for _, f := range resp.Removed {
fmt.Printf("%s %s\n", verb, f)
}
if resp.DryRun {
fmt.Println("dry run: nothing was changed")
} else {
fmt.Println("DNSSEC is off and the zone's keys are gone.")
}
})
},
}
wipe.Flags().StringVar(&view, "view", "", "view of the zone")
wipe.Flags().BoolVar(&force, "force", false, "wipe although the parent zone couldn't be checked")
wipe.Flags().StringVar(&resolver, "resolver", "", "resolver to find the parent zone with (default: the server's)")
wipe.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
cmd.AddCommand(status, enable, disable, checkDS, wipe)
return cmd
}
func viewFlag(view string) string {
if view == "" {
return ""
}
return " --view " + view
}
func dnssecStatus(zone, view string) (*api.DNSSECResponse, error) {
c, err := newClient()
if err != nil {
return nil, err
}
path, qv := zonePath(zone, view)
var resp api.DNSSECResponse
if err := c.Get(ctx(), path+"/dnssec", qv, &resp); err != nil {
return nil, err
}
return &resp, nil
}
func printDNSSEC(w io.Writer, r *api.DNSSECResponse) {
head := displayName(r.Zone)
if r.View != "" {
head += " [view " + r.View + "]"
}
fmt.Fprintf(w, "Zone:\t%s\n", head)
policy := r.Policy
switch policy {
case "":
policy = "none (not signed)"
case "insecure":
policy = "insecure (turning DNSSEC off)"
}
fmt.Fprintf(w, "Policy:\t%s\n", policy)
fmt.Fprintf(w, "Status:\t%s\n", r.Phase.Summary)
if r.Phase.Next != "" {
fmt.Fprintf(w, "Next:\t%s\n", r.Phase.Next)
}
if r.Named != nil && r.Named.Secure {
fmt.Fprintf(w, "named:\tsigned serial %d (zone file %d)", r.Named.SignedSerial, r.Named.Serial)
if r.Named.NextKeyEvent != "" {
fmt.Fprintf(w, ", next key event %s", r.Named.NextKeyEvent)
}
fmt.Fprintln(w)
}
if r.NamedError != "" {
fmt.Fprintf(w, "named:\t%s\n", r.NamedError)
}
if r.Policy != "" || len(r.Keys) > 0 {
fmt.Fprintf(w, "Key directory:\t%s\n", r.KeyDir)
}
if r.KeyError != "" {
fmt.Fprintf(w, "Keys:\tcan't be read: %s\n", r.KeyError)
}
flush(w)
if len(r.Keys) > 0 {
fmt.Fprintln(w, "\nKEY\tROLE\tALGORITHM\tGOAL\tDNSKEY\tDS AT PARENT\tCREATED")
for _, k := range r.Keys {
created := "-"
if k.Created != nil {
created = k.Created.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\n", k.Tag, k.Role, k.AlgorithmName, dash(k.Goal), dash(k.DNSKEYState), dash(k.DSState), created)
}
flush(w)
}
var ds []*dnssec.Key
for _, k := range r.Keys {
if k.DS != nil && k.Goal == dnssec.Omnipresent {
ds = append(ds, k)
}
}
if len(ds) > 0 && r.Policy != "insecure" {
where := "at your registrar"
if r.ParentZone != "" {
where = "in the parent zone " + displayName(r.ParentZone) + " (in Jarl: add it as a DS record there)"
}
fmt.Fprintf(w, "\nDS record for the parent zone (%s):\n", where)
if !r.Phase.CanPublishDS && r.Phase.DSReadyAt != nil {
fmt.Fprintf(w, " (not yet: wait until %s)\n", r.Phase.DSReadyAt.Local().Format(time.RFC1123))
}
for _, k := range ds {
fmt.Fprintf(w, " %s\n", strings.ReplaceAll(k.DS.Record, "\t", " "))
fmt.Fprintf(w, " key tag %d, algorithm %d (%s), digest type %d (SHA-256), digest %s\n", k.DS.KeyTag, k.DS.Algorithm, k.AlgorithmName, k.DS.DigestType, k.DS.Digest)
}
}
}
func dash(s string) string {
if s == "" {
return "-"
}
return s
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"jarl/internal/api"
)
func zoneRegistrarCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "registrar",
Short: "The domain's name servers, glue and DS records at the registrar (Porkbun)",
Long: `Shows and changes a domain's delegation at the registrar Porkbun: its name
servers, the glue records of name servers inside the domain, and its DNSSEC
DS records. Needs a Porkbun API key in jarl.yaml (porkbun:, see
docs/dnssec.md#porkbun) and an administrator.
Every change checks first that it won't break the domain (for example that
the new name servers serve the zone, or that the zone is signed with the
key a DS record points at) and is refused otherwise; --dry-run shows those
checks, --force skips them.`,
}
var view string
status := &cobra.Command{
Use: "status ZONE",
Short: "Compare the registrar's name servers, glue and DS records with the zone",
Example: " jarl zone registrar status adbraun.lol --view external",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
var resp api.RegistrarResponse
if err := c.Get(ctx(), path+"/registrar", qv, &resp); err != nil {
return err
}
err = output(resp, func(w io.Writer) {
if resp.Error != "" {
fmt.Fprintf(w, "error: %s\n", resp.Error)
return
}
fmt.Fprintf(w, "Domain:\t%s at %s\n", resp.Domain, resp.Registrar)
fmt.Fprintf(w, "Name servers:\t%s\n", strings.Join(resp.NS, ", "))
fmt.Fprintf(w, "Zone's NS records:\t%s\n", strings.Join(resp.JarlNS, ", "))
flush(w)
if len(resp.Glue) > 0 {
fmt.Fprintln(w, "\nGLUE FOR\tAT REGISTRAR\tIN THE ZONE\tTO DO")
for _, g := range resp.Glue {
todo := g.Action
if todo == "" {
todo = "-"
if !g.Needed && !g.InUse {
todo = "unused"
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", g.Host, firstNonEmpty(strings.Join(g.Registry, ", "), "none"), firstNonEmpty(strings.Join(g.Jarl, ", "), "-"), todo)
}
flush(w)
}
if len(resp.DS) > 0 {
fmt.Fprintln(w, "\nDS KEY TAG\tALGORITHM\tDIGEST TYPE\tMATCHES THE ZONE")
for _, d := range resp.DS {
fmt.Fprintf(w, "%d\t%d\t%d\t%v\n", d.KeyTag, d.Algorithm, d.DigestType, d.Matches)
}
flush(w)
}
fmt.Println()
for _, f := range resp.Findings {
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
}
})
if err != nil {
return err
}
if resp.Error != "" {
return &checkFailed{"the registrar couldn't be asked"}
}
for _, f := range resp.Findings {
if f.Level == "error" {
return &checkFailed{"the registrar's records don't match the zone"}
}
}
return nil
},
}
status.Flags().StringVar(&view, "view", "", "view of the zone")
var force bool
change := func(use, short, long, method, sub string, args cobra.PositionalArgs, body func(args []string) (string, any, error)) *cobra.Command {
c := &cobra.Command{
Use: use,
Short: short,
Long: long,
Args: args,
RunE: func(cmd *cobra.Command, args []string) error {
cl, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if flagDryRun {
qv.Set("dry_run", "1")
}
extra, b, err := body(args)
if err != nil {
return err
}
var resp api.RegistrarChangeResponse
if err := cl.Do(ctx(), method, path+"/registrar/"+sub+extra, qv, b, &resp); err != nil {
return err
}
return output(resp, func(w io.Writer) {
for _, f := range resp.Checks {
fmt.Printf("%-8s %s\n", f.Level+":", f.Message)
}
verb := "done:"
if resp.DryRun {
verb = "would:"
}
for _, d := range resp.Done {
fmt.Printf("%-8s %s\n", verb, d)
}
if len(resp.Done) == 0 {
fmt.Println("nothing to change")
}
for _, wn := range resp.Warnings {
fmt.Printf("note: %s\n", wn)
}
if resp.DryRun {
fmt.Println("dry run: nothing was changed")
}
})
},
}
c.Flags().StringVar(&view, "view", "", "view of the zone")
c.Flags().BoolVar(&force, "force", false, "skip the checks that the change won't break the domain")
return c
}
syncGlue := change("sync-glue ZONE", "Create or update glue records for the zone's name servers inside the domain",
`Gives every name server in the zone's NS records that is inside the domain
(ns1.adbraun.lol for adbraun.lol) a glue record at the registrar with the
zone's A and AAAA records for it: one glue record per name server, each with
all its addresses. Resolvers need glue to find such a server at all.
Changing the glue of a name server the domain is delegated to sends
resolvers to the new addresses, so they must answer for the zone first.`,
"POST", "glue", cobra.ExactArgs(1), func([]string) (string, any, error) {
return "", api.RegistrarChange{Force: force, DryRun: flagDryRun}, nil
})
var key uint16
publishDS := change("publish-ds ZONE", "Publish the zone's DS record at the registrar (turns DNSSEC validation on)",
`Adds the DS record of the zone's key at the registrar, which publishes it in
the parent zone: from then on validating resolvers check the zone's
signatures. Refused until named says the zone is ready, and unless the
domain is delegated to name servers that serve the signed zone.
Afterwards run 'jarl zone dnssec check-ds ZONE' once the parent zone has it.`,
"POST", "ds", cobra.ExactArgs(1), func([]string) (string, any, error) {
return "", api.RegistrarChange{KeyTag: key, Force: force, DryRun: flagDryRun}, nil
})
publishDS.Flags().Uint16Var(&key, "key", 0, "key tag (default: every key that needs a DS)")
removeDS := change("remove-ds ZONE KEYTAG", "Remove a DS record at the registrar",
`Removes a DS record at the registrar. Without the zone's DS record, resolvers
stop validating it (it stays signed). This is the step 'jarl zone dnssec
disable' asks for; a DS record that matches none of the zone's keys breaks
the domain and should go at once.`,
"DELETE", "ds/", cobra.ExactArgs(2), func(args []string) (string, any, error) {
return args[1], nil, nil
})
setNS := change("set-ns ZONE [NAMESERVER...]", "Delegate the domain to the zone's name servers (or the ones given)",
`Sets the domain's name servers at the registrar: by default to the zone's NS
records. Checked first: each name server answers for the zone with the
current serial, name servers inside the domain have glue records that match
the zone (run sync-glue before), and DS records at the registrar match the
zone's keys (otherwise the domain goes dark for validating resolvers).`,
"PUT", "ns", cobra.MinimumNArgs(1), func(args []string) (string, any, error) {
return "", api.RegistrarChange{NS: args[1:], Force: force, DryRun: flagDryRun}, nil
})
cmd.AddCommand(status, syncGlue, publishDS, removeDS, setNS)
return cmd
}
+1 -1
View File
@@ -367,7 +367,7 @@ equal to the file: records not in the file are deleted.`,
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd())
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
return cmd
}