Files
jarl/cmd/jarl/zones.go
T
adamandClaude Opus 5.5 89663e366b Disable zones instead of deleting them
A disabled zone stays in Jarl with its records, options and history,
still editable, but named doesn't load it; enabling it serves it as it is
then. Signed zones, and zones another view shares, can't be disabled.
jarl zone disable|enable, the zone page's Disable/Enable button.
Database schema 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:45:55 +00:00

815 lines
26 KiB
Go

package main
import (
"encoding/json"
"fmt"
"golang.org/x/term"
"io"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"unicode/utf8"
"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)
}
typ := z.Type
if z.Disabled {
typ += " (disabled)"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, typ, 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.Disabled {
fmt.Fprintf(w, "Disabled:\tyes: named doesn't load it ('jarl zone enable %s')\n", args[0])
}
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)
if z.DisabledRecords > 0 {
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0])
} else {
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")
toggle := func(disable bool) *cobra.Command {
verb := map[bool]string{true: "disable", false: "enable"}[disable]
long := `Load a disabled zone in named again, as it is in Jarl now (changes made
while it was disabled included).`
if disable {
long = `Disable a zone: it stays in Jarl with its records, options and history,
and can still be edited, but named no longer loads it. Its names are then
answered by recursion where the view offers it (the internet's version of
the zone, if any), and REFUSED elsewhere. Signed zones can't be disabled;
turn signing off first.
jarl zone disable old.adbraun.lol
jarl zone enable old.adbraun.lol`
}
c := &cobra.Command{
Use: verb + " ZONE",
Short: map[bool]string{true: "Stop serving a zone, keeping it in Jarl", false: "Serve a disabled zone again"}[disable],
Long: long,
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)
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"disabled": disable})
if err != nil {
return err
}
return report(resp)
},
}
c.Flags().StringVar(&view, "view", "", "view of the zone")
return c
}
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, toggle(true), toggle(false), 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, onlyDisabled, wide 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
}
if onlyDisabled {
qv.Set("disabled", "1")
}
var recs []api.Record
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
return err
}
return output(recs, func(w io.Writer) {
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
for _, r := range recs {
ttl := fmt.Sprint(r.EffectiveTTL)
if r.TTL == nil {
ttl += "*"
}
comment := r.Comment
if r.Disabled {
comment = strings.TrimSpace("[disabled] " + comment)
}
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
}
if !wide {
fitColumn(rows, 4)
}
for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
})
},
}
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")
list.Flags().BoolVar(&onlyDisabled, "disabled", false, "only disabled records")
list.Flags().BoolVarP(&wide, "wide", "w", false, "show long data in full (on a terminal it is shortened to fit)")
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")
toggle := func(disable bool) *cobra.Command {
verb := "enable"
long := `Enable disabled records again: by name and type (all of them, or only the
one with DATA), or one record by ID. The record is checked as if it were
added, so a CNAME added in the meantime at its name stops it.
jarl record enable example.com www A 192.0.2.10
jarl record enable --id 1234 --ptr`
if disable {
verb = "disable"
long = `Disable records: they stay in Jarl, with their TTL, comment and ID, but are
left out of the zone (the zone file lists them as comments) until they are
enabled again. Select them by name and type (all of them, or only the one
with DATA), or one record by ID.
jarl record disable example.com www A 192.0.2.10 --ptr # and its PTR record
jarl record disable example.com old CNAME
jarl record disable --id 1234
jarl record list example.com --disabled`
}
var tview string
var tid int64
var tptr, tmissing bool
c := &cobra.Command{
Use: verb + " ZONE NAME TYPE [DATA...] | " + verb + " --id ID",
Short: strings.ToUpper(verb[:1]) + verb[1:] + " records",
Long: long,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
qv := q()
if tptr {
qv.Set("ptr", "1")
}
if tid > 0 {
resp, err := c.Mutate(ctx(), "POST", "/records/"+strconv.FormatInt(tid, 10)+"/"+verb, qv, nil)
if err != nil {
return err
}
return report(resp)
}
if len(args) < 3 {
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
}
path, zq := zonePath(args[0], tview)
for k, v := range zq {
qv[k] = v
}
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
qv.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil
}
if err != nil {
return err
}
return report(resp)
},
}
c.Flags().StringVar(&tview, "view", "", "view of the zone")
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
return c
}
cmd.AddCommand(list, add, update, del, set, toggle(true), toggle(false))
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
}
// fitColumn shortens column col of a table (header row first) so that the
// rows fit the terminal: the table pads every row to its longest cell, so
// one long TXT record would make every row wrap. Nothing is shortened when
// standard output isn't a terminal.
func fitColumn(rows [][]string, col int) {
fd := int(os.Stdout.Fd())
if !term.IsTerminal(fd) {
return
}
width, _, err := term.GetSize(fd)
if err != nil || width <= 0 {
return
}
widths := make([]int, len(rows[0]))
for _, row := range rows {
for i, c := range row {
widths[i] = max(widths[i], utf8.RuneCountInString(c))
}
}
used := 0
for i, w := range widths {
if i != col {
used += w + 2 // the table's padding
}
}
if col == len(widths)-1 {
used -= 2
}
limit := max(width-used-2, 24)
for _, row := range rows[1:] {
if r := []rune(row[col]); len(r) > limit {
row[col] = string(r[:limit-1]) + "…"
}
}
}