Files
jarl/cmd/jarl/zones.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

639 lines
20 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/dnsname"
)
func zoneCmd() *cobra.Command {
cmd := &cobra.Command{Use: "zone", Aliases: []string{"zones"}, Short: "Manage zones"}
var view, typ string
// list has its own type filter: create's --type defaults to "primary"
// and would otherwise leak into list through the shared variable.
var listType string
list := &cobra.Command{
Use: "list",
Short: "List zones",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var zones []api.Zone
if err := c.Get(ctx(), "/zones", q("view", view, "type", listType), &zones); err != nil {
return err
}
return output(zones, func(w io.Writer) {
fmt.Fprintln(w, "ID\tZONE\tVIEW\tTYPE\tRECORDS\tSERIAL")
for _, z := range zones {
serial, recs := "", ""
if z.Type == "primary" {
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, z.Type, recs, serial)
}
})
},
}
list.Flags().StringVar(&view, "view", "", "only zones in this view")
list.Flags().StringVar(&listType, "type", "", "only zones of this type")
show := &cobra.Command{
Use: "show ZONE",
Short: "Show a zone",
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 z api.Zone
if err := c.Get(ctx(), path, qv, &z); err != nil {
return err
}
return output(z, func(w io.Writer) {
fmt.Fprintf(w, "Zone:\t%s (id %d)\n", z.Display, z.ID)
if z.View != "" {
fmt.Fprintf(w, "View:\t%s\n", z.View)
}
fmt.Fprintf(w, "Type:\t%s\n", z.Type)
if z.Type == "primary" {
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
}
if z.InView != "" {
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
}
if z.ZoneFile != "" {
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
}
if z.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", z.Comment)
}
})
},
}
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
var ns, records, primaries, forwarders, opts []string
var contact, comment, cloneFrom, file, inView, forward string
var ttl uint32
create := &cobra.Command{
Use: "create NAME",
Short: "Create a zone",
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
create its reverse zone: a /32 (or /128) gives a zone for that one address,
and a network between octet boundaries, like a /22, gets one zone per /24
(created together).
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
jarl zone create 192.168.1.0/24
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
jarl zone create partner.example --type secondary --primary 192.0.2.53
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
jarl zone create example.com --view external --clone-from example.com --clone-view internal`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"name": args[0], "type": typ}
if view != "" {
body["view"] = view
}
if len(ns) > 0 {
body["nameservers"] = ns
}
if contact != "" {
body["soa"] = map[string]any{"rname": contact}
}
if ttl > 0 {
body["default_ttl"] = ttl
}
if comment != "" {
body["comment"] = comment
}
if file != "" {
body["file"] = file
}
if inView != "" {
body["in_view"] = inView
}
var recs []map[string]any
for _, r := range records {
f := strings.Fields(r)
if len(f) < 3 {
return fmt.Errorf("--record %q: use \"NAME TYPE DATA\"", r)
}
recs = append(recs, map[string]any{"name": f[0], "type": f[1], "data": strings.Join(f[2:], " ")})
}
if len(recs) > 0 {
body["records"] = recs
}
var zopts []map[string]string
if len(primaries) > 0 {
zopts = append(zopts, map[string]string{"name": "primaries", "value": listValue(primaries)})
}
if len(forwarders) > 0 || typ == "forward" {
zopts = append(zopts, map[string]string{"name": "forwarders", "value": listValue(forwarders)})
zopts = append(zopts, map[string]string{"name": "forward", "value": forward})
}
for _, o := range opts {
n, v, _ := strings.Cut(strings.TrimSpace(o), " ")
zopts = append(zopts, map[string]string{"name": n, "value": v})
}
if len(zopts) > 0 {
body["options"] = zopts
}
if cloneFrom != "" {
id, err := resolveZoneID(c, cloneFrom, cloneView)
if err != nil {
return err
}
body["clone_from"] = id
}
// A network between octet (or nibble) boundaries, like a /22,
// needs several reverse zones; create them in one changeset.
if p, err := netip.ParsePrefix(strings.TrimSpace(args[0])); err == nil {
if zones, err := dnsname.ReverseZones(p); err == nil && len(zones) > 1 {
var reqs []api.ChangeRequest
for _, z := range zones {
body["name"] = z
data, err := json.Marshal(body)
if err != nil {
return err
}
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
}
return submitBatch(c, fmt.Sprintf("reverse zones for %s", p.Masked()), reqs)
}
}
resp, err := c.Mutate(ctx(), "POST", "/zones", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
create.Flags().StringVar(&view, "view", "", "create the zone in this view")
create.Flags().StringVar(&typ, "type", "primary", "zone type: primary, secondary, forward, stub, static-stub, mirror, hint, redirect, in-view")
create.Flags().StringSliceVar(&ns, "ns", nil, "name server (repeatable; default: the configured defaults)")
create.Flags().StringArrayVar(&records, "record", nil, `initial record "NAME TYPE DATA" (repeatable), e.g. glue for in-zone name servers`)
create.Flags().StringVar(&contact, "contact", "", "SOA contact email (default: the configured default)")
create.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
create.Flags().StringArrayVar(&primaries, "primary", nil, `primary server for secondary/stub zones, e.g. "192.0.2.53 key xfer" (repeatable)`)
create.Flags().StringArrayVar(&forwarders, "forwarder", nil, `forwarder for forward zones, e.g. "192.0.2.1 port 5353" (repeatable)`)
create.Flags().StringVar(&forward, "forward", "only", "forward mode for forward zones: only or first")
create.Flags().StringArrayVar(&opts, "option", nil, `zone option "NAME VALUE" (repeatable), e.g. "allow-transfer { key xfer; }"`)
create.Flags().StringVar(&cloneFrom, "clone-from", "", "copy records from this zone (same name, other view)")
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
create.Flags().StringVar(&comment, "comment", "", "comment")
var refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string
update := &cobra.Command{
Use: "update ZONE",
Short: "Change a zone's settings (SOA, TTL, view, comment)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
soa := map[string]any{}
f := cmd.Flags()
if f.Changed("mname") {
soa["mname"] = mname
}
if f.Changed("contact") {
soa["rname"] = contact
}
for name, v := range map[string]uint32{"refresh": refresh, "retry": retry, "expire": expire, "minimum": minimum, "serial": serial} {
if f.Changed(name) {
soa[name] = v
}
}
if len(soa) > 0 {
body["soa"] = soa
}
if f.Changed("ttl") {
body["default_ttl"] = ttl
}
if f.Changed("serial-policy") {
body["serial_policy"] = policy
}
if f.Changed("comment") {
body["comment"] = comment
}
if f.Changed("move-to-view") {
body["view"] = moveView
}
if f.Changed("file") {
body["file"] = file
}
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PATCH", path, qv, body)
if err != nil {
return err
}
return report(resp)
},
}
update.Flags().StringVar(&view, "view", "", "view of the zone")
update.Flags().StringVar(&mname, "mname", "", "SOA primary name server")
update.Flags().StringVar(&contact, "contact", "", "SOA contact email")
update.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
update.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
update.Flags().Uint32Var(&expire, "expire", 0, "SOA expire")
update.Flags().Uint32Var(&minimum, "minimum", 0, "SOA minimum (negative caching TTL)")
update.Flags().Uint32Var(&serial, "serial", 0, "set the serial explicitly")
update.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
update.Flags().StringVar(&policy, "serial-policy", "", "serial policy: date, increment or unixtime")
update.Flags().StringVar(&comment, "comment", "", "comment")
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
var yes bool
del := &cobra.Command{
Use: "delete ZONE",
Short: "Delete a zone and its records",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete zone "+args[0]+" and all its records?", yes) {
return fmt.Errorf("canceled")
}
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "DELETE", path, qv, nil)
if err != nil {
return err
}
return report(resp)
},
}
del.Flags().StringVar(&view, "view", "", "view of the zone")
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
fileCmd := &cobra.Command{
Use: "file ZONE",
Short: "Print the zone file Jarl generates",
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 text string
if err := c.Get(ctx(), path+"/file", qv, &text); err != nil {
return err
}
fmt.Print(text)
return nil
},
}
fileCmd.Flags().StringVar(&view, "view", "", "view of the zone")
var replace, useSOA bool
importCmd := &cobra.Command{
Use: "import ZONE FILE",
Short: "Import records from a zone file ('-' reads standard input)",
Long: `Adds the records of a zone file to a zone. With --replace the zone is made
equal to the file: records not in the file are deleted.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var data []byte
if args[1] == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(args[1])
}
if err != nil {
return err
}
mode := "merge"
if replace {
mode = "replace"
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "POST", path+"/import", qv, map[string]any{"text": string(data), "mode": mode, "use_soa": useSOA, "dry_run": flagDryRun})
if err != nil {
return err
}
if len(resp.Object) > 0 && !flagQuiet {
var o struct {
Skipped []string `json:"skipped"`
}
if jsonUnmarshal(resp.Object, &o) == nil {
for _, s := range o.Skipped {
fmt.Fprintln(os.Stderr, "skipped:", s)
}
}
}
return report(resp)
},
}
importCmd.Flags().StringVar(&view, "view", "", "view of the zone")
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
return cmd
}
var cloneView string
func listValue(items []string) string {
var parts []string
for _, it := range items {
it = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(it), ";"))
if it != "" {
parts = append(parts, it+";")
}
}
return "{ " + strings.Join(parts, " ") + " }"
}
// resolveZoneID looks up a zone's ID by name (or ID) and view.
func resolveZoneID(c *client.Client, zone, view string) (int64, error) {
path, qv := zonePath(zone, view)
var z api.Zone
if err := c.Get(ctx(), path, qv, &z); err != nil {
return 0, err
}
return z.ID, nil
}
func urlPathEscape(s string) string { return url.PathEscape(s) }
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// ---- records
func recordCmd() *cobra.Command {
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
var view, name, typ, search, comment, ttlArg string
var ptr, missingOK, yes bool
var id int64
list := &cobra.Command{
Use: "list ZONE",
Short: "List records",
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)
for k, v := range q("name", name, "type", strings.ToUpper(typ), "q", search) {
qv[k] = v
}
var recs []api.Record
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
return err
}
return output(recs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tNAME\tTTL\tTYPE\tDATA\tCOMMENT")
for _, r := range recs {
ttl := fmt.Sprint(r.EffectiveTTL)
if r.TTL == nil {
ttl += "*"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, r.Comment)
}
})
},
}
list.Flags().StringVar(&view, "view", "", "view of the zone")
list.Flags().StringVar(&name, "name", "", "only this name")
list.Flags().StringVarP(&typ, "type", "t", "", "only this type")
list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this")
add := &cobra.Command{
Use: "add ZONE NAME TYPE DATA...",
Short: "Add a record",
Long: `Add a record. NAME is relative to the zone ("@" for the apex) unless it ends
with a dot. DATA is in zone file format; TXT data is plain text (quoting
and splitting are automatic).
jarl record add example.com www A 192.0.2.10 --ttl 300 --ptr
jarl record add example.com @ MX 10 mail
jarl record add example.com @ TXT "v=spf1 mx -all"
jarl record add example.com _sip._tcp SRV 0 5 5060 sip`,
Args: cobra.MinimumNArgs(4),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"name": args[1], "type": args[2], "data": strings.Join(args[3:], " ")}
if err := setTTL(body, ttlArg); err != nil {
return err
}
if comment != "" {
body["comment"] = comment
}
if ptr {
body["ptr"] = true
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body)
if err != nil {
return err
}
return report(resp)
},
}
add.Flags().StringVar(&view, "view", "", "view of the zone")
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
var data string
update := &cobra.Command{
Use: "update ID",
Short: "Change a record (see 'record list' for IDs)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{}
f := cmd.Flags()
if f.Changed("name") {
body["name"] = name
}
if f.Changed("type") {
body["type"] = typ
}
if f.Changed("data") {
body["data"] = data
}
if f.Changed("comment") {
body["comment"] = comment
}
if f.Changed("ttl") {
if err := setTTL(body, ttlArg); err != nil {
return err
}
}
if ptr {
body["ptr"] = true
}
if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)")
}
resp, err := c.Mutate(ctx(), "PATCH", "/records/"+args[0], nil, body)
if err != nil {
return err
}
return report(resp)
},
}
update.Flags().StringVar(&name, "name", "", "new name")
update.Flags().StringVarP(&typ, "type", "t", "", "new type (needs --data)")
update.Flags().StringVar(&data, "data", "", "new data")
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
update.Flags().StringVar(&comment, "comment", "", "new comment")
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
del := &cobra.Command{
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
Short: "Delete records",
Long: `Delete records by name and type (all of them, or only the one with DATA),
or one record by ID.
jarl record delete example.com www A # all A records of www
jarl record delete example.com www A 192.0.2.10 # just this one
jarl record delete --id 1234`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if id > 0 {
qv := q()
if ptr {
qv.Set("ptr", "1")
}
resp, err := c.Mutate(ctx(), "DELETE", "/records/"+strconv.FormatInt(id, 10), qv, nil)
if err != nil {
return err
}
return report(resp)
}
if len(args) < 3 {
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
}
if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) {
return fmt.Errorf("canceled")
}
path, qv := zonePath(args[0], view)
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if missingOK {
qv.Set("missing_ok", "1")
}
if ptr {
qv.Set("ptr", "1")
}
resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
if err != nil {
return err
}
return report(resp)
},
}
del.Flags().StringVar(&view, "view", "", "view of the zone")
del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID")
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask before deleting a whole RRset")
set := &cobra.Command{
Use: "set ZONE NAME TYPE DATA [DATA...]",
Short: "Make NAME/TYPE have exactly these records (each DATA argument is one record)",
Long: `Replaces the whole RRset. Each DATA argument is one record; quote data with
spaces.
jarl record set example.com api A 192.0.2.30 192.0.2.31 --ttl 60
jarl record set example.com @ MX "10 mail1" "20 mail2"`,
Args: cobra.MinimumNArgs(4),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
body := map[string]any{"data": args[3:]}
if err := setTTL(body, ttlArg); err != nil {
return err
}
if comment != "" {
body["comment"] = comment
}
path, qv := zonePath(args[0], view)
resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
if err != nil {
return err
}
return report(resp)
},
}
set.Flags().StringVar(&view, "view", "", "view of the zone")
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
cmd.AddCommand(list, add, update, del, set)
return cmd
}
func setTTL(body map[string]any, ttl string) error {
switch ttl {
case "":
case "default", "zone", "inherit":
body["ttl"] = nil
default:
n, err := strconv.ParseUint(ttl, 10, 32)
if err != nil {
return fmt.Errorf("invalid TTL %q", ttl)
}
body["ttl"] = n
}
return nil
}