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 }