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>
390 lines
14 KiB
Go
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
|
|
}
|