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>
188 lines
6.8 KiB
Go
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
|
|
}
|