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 }