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>
639 lines
20 KiB
Go
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
|
|
}
|