Files
jarl/cmd/jarl/dnssec.go
T
adamandClaude Opus 5.5 113a6913a2 Give the DS command for a parent zone in Jarl, and allow port 443
The DNSSEC tab and 'jarl zone dnssec status' give the jarl record add
command (and an Add to parent button) when the parent zone is in Jarl.
jarl.service grants CAP_NET_BIND_SERVICE so the web UI can listen on 443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:42:16 +00:00

390 lines
14 KiB
Go

package main
import (
"fmt"
"io"
"os"
"slices"
"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 from the parent zone.", 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" && st.ParentZone != "" {
fmt.Printf("\nNext: remove the DS record for %s from %s:\n %s\nthen 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), displayName(st.ParentZone), parentDSCommand(st, "delete", ""), args[0]+viewFlag(view), args[0]+viewFlag(view))
} else 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) + ", which is 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)
if r.ParentZone != "" {
data := fmt.Sprintf("%d %d %d %s", k.DS.KeyTag, k.DS.Algorithm, k.DS.DigestType, k.DS.Digest)
if slices.ContainsFunc(r.ParentDS, func(d string) bool { return strings.EqualFold(d, data) }) {
fmt.Fprintf(w, " %s has it.\n", displayName(r.ParentZone))
} else {
fmt.Fprintf(w, " %s\n", parentDSCommand(r, "add", data))
}
}
}
}
}
// parentDSCommand is the jarl command that adds (or deletes) the zone's DS
// record in its parent zone in Jarl.
func parentDSCommand(r *api.DNSSECResponse, verb, data string) string {
parts := []string{"jarl record", verb, displayName(r.ParentZone), strings.TrimSuffix(r.Zone, "."+r.ParentZone), "DS"}
if data != "" {
parts = append(parts, data)
}
if r.ParentView != "" {
parts = append(parts, "--view", r.ParentView)
}
return strings.Join(parts, " ")
}
func dash(s string) string {
if s == "" {
return "-"
}
return s
}