Files
jarl/cmd/jarl/registrar.go
T
abrown38andClaude Opus 5.5 5630e7a848 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>
2026-10-08 21:19:04 +00:00

188 lines
6.8 KiB
Go

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
}