Files
adamandClaude Opus 5.5 7f3564c0a5 CLI: don't print "and N more" when the server sends no PTR list
A 0.6.1-rc.2 server sends ptr_other_views without ptr_other_views_list,
so zone show printed only "and 1 more" under "Other views:".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:05:50 +01:00

1391 lines
47 KiB
Go

package main
import (
"encoding/json"
"fmt"
"golang.org/x/term"
"io"
"net/netip"
"net/url"
"os"
"slices"
"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.AutoPTR {
fmt.Fprintf(w, "PTR records:\tkept in step with the A and AAAA records\n")
}
if z.Prefix != "" {
fmt.Fprintf(w, "Network:\t%s\n", z.Prefix)
if z.PTRMissing > 0 {
fmt.Fprintf(w, "Missing PTRs:\t%d (addresses the view's records use here; add them: jarl zone fill-ptr %s)\n", z.PTRMissing, args[0])
printPTRFills(w, z.PTRMissingList, z.PTRMissing)
}
if z.PTROtherViews > 0 {
what := "addresses"
if z.PTROtherViews == 1 {
what = "address"
}
fmt.Fprintf(w, "Other views:\t%d %s with A or AAAA records only in %s, so not missing here (add PTRs for them in this view too: jarl zone fill-ptr %s --other-views)\n",
z.PTROtherViews, what, fromViews(z.PTROtherViewsList), args[0])
printPTRFills(w, z.PTROtherViewsList, z.PTROtherViews)
}
}
if len(z.LinkedWith) > 0 {
fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", "))
}
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, linkWith string
var ttl uint32
var fill, fillOther bool
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). Without --view, a reverse zone goes to the private
view whose records use its addresses; --fill adds a PTR record for each
of them, and --other-views also for the addresses only other views'
records use, named from there (docs/reverse.md).
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 10.0.10.0/24 --fill # with PTR records for the view's A records there
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
jarl zone create example.com --view vps --link-with external # linked: shared records kept in step`,
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
}
if linkWith != "" {
body["link_with"] = linkWith
}
if fill || fillOther {
body["fill"] = true
}
if fillOther {
body["fill_other_views"] = true
}
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(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
create.Flags().StringVar(&comment, "comment", "", "comment")
create.Flags().BoolVar(&fill, "fill", false, "reverse zones: add PTR records for the view's A and AAAA records in the network")
create.Flags().BoolVar(&fillOther, "other-views", false, "reverse zones: --fill, also naming the addresses only other views' records use")
var refresh, retry, expire, minimum, serial uint32
var mname, policy, moveView string
var autoPTR bool
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 f.Changed("auto-ptr") {
body["auto_ptr"] = autoPTR
}
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)")
update.Flags().BoolVar(&autoPTR, "auto-ptr", false, "keep the PTR records of the zone's A and AAAA records in step (--auto-ptr=false: stop)")
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(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd(), zoneFillPTRCmd(), zoneSuggestReverseCmd())
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
// viewsFlagHelp describes --view of the record commands that change
// records.
const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\" make the same change in each, together"
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, local, shared bool
var id int64
// linkFlags adds --local and --shared (linked zones) to a command.
linkFlags := func(c *cobra.Command, what string) {
c.Flags().BoolVar(&local, "local", false, "linked zones: "+what+" in this view only (overriding the shared records of this name and type here)")
c.Flags().BoolVar(&shared, "shared", false, "linked zones: "+what+" in every linked view (this version becomes every view's)")
}
linkBody := func(body map[string]any) error {
switch {
case local && shared:
return fmt.Errorf("give --local or --shared, not both")
case local:
body["local"] = true
localFanOut = true
case shared:
body["local"] = false
}
return nil
}
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
}
linked := false
for _, r := range recs {
linked = linked || r.Linked != ""
}
return output(recs, func(w io.Writer) {
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
if linked {
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "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)
}
if linked {
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Linked, r.Data, comment})
continue
}
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
}
if !wide {
col := 4
if linked {
col = 5
}
fitColumn(rows, col)
}
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
}
if err := linkBody(body); err != nil {
return err
}
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return perView(c, views, "add "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "POST", path+"/records", qv, body)
})
},
}
add.Flags().StringVar(&view, "view", "", viewsFlagHelp)
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)")
linkFlags(add, "add it")
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 err := linkBody(body); err != nil {
return err
}
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)")
update.Flags().BoolVar(&local, "local", false, "linked zones: make this name and type local to the record's view (an override)")
update.Flags().BoolVar(&shared, "shared", false, "linked zones: share this name and type with the linked views; this view's version becomes every view's")
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")
}
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return perView(c, views, "delete "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
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")
}
return c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
})
},
}
del.Flags().StringVar(&view, "view", "", viewsFlagHelp)
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
}
if err := linkBody(body); err != nil {
return err
}
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return perView(c, views, "set "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
})
},
}
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
linkFlags(set, "set it")
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")
}
views, err := zoneViews(c, args[0], tview)
if err != nil {
return err
}
return perView(c, views, verb+" "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, zq := zonePath(args[0], view)
vq := q()
for k, v := range qv {
vq[k] = v
}
for k, v := range zq {
vq[k] = v
}
vq.Set("name", args[1])
vq.Set("type", args[2])
if len(args) > 3 {
vq.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
vq.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, vq, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil, nil
}
return resp, err
})
},
}
c.Flags().StringVar(&tview, "view", "", viewsFlagHelp)
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]) + "…"
}
}
}
// zoneLinkCmd is 'zone link' and 'zone unlink' (docs/views.md#linked-zones).
func zoneLinkCmd(link bool) *cobra.Command {
var view string
var with []string
cmd := &cobra.Command{
Use: "link ZONE --view VIEW [--with VIEW...]",
Short: "Link zones of the same name in several views: shared records are kept in step",
Long: `Links a zone with the zones of the same name in other views (--with, or
every view that has one). Jarl then keeps their shared records in step: a
change to one reaches the others in the same changeset. Each view can
still have records of its own (record add/set --local), which override
the shared records of that name and type there, and every zone keeps its
own options, so views can still differ in who they answer and how.
Linking never changes what named answers: a name and type with the same
records in every view becomes shared, and one that differs stays local to
each view. Share one later from the view whose version is right:
jarl record update ID --shared.`,
Example: ` jarl zone link adbraun.lol --view external --with vps
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
jarl zone unlink adbraun.lol --view vps`,
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)
verb := "unlink"
var body any
if link {
verb = "link"
body = map[string]any{"with": with}
}
resp, err := c.Mutate(ctx(), "POST", path+"/"+verb, qv, body)
if err != nil {
return err
}
return report(resp)
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
if link {
cmd.Flags().StringSliceVar(&with, "with", nil, "views to link with (default: every view with a zone of that name)")
return cmd
}
cmd.Use = "unlink ZONE --view VIEW"
cmd.Short = "Take a zone out of its link: its records stay, all local"
cmd.Long = `Takes a zone out of its link. Its records stay as they are, all local to
its view, and the other zones of the link keep theirs. A link left with one
zone is dissolved.`
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
return cmd
}
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
// that have it.
type compareSet struct {
View string `json:"view"`
Records []string `json:"records"`
IDs []int64 `json:"ids"`
Linked string `json:"linked"`
}
type compareRow struct {
Name string `json:"name"`
Type string `json:"type"`
Views []compareSet `json:"views"`
}
type compareReport struct {
Zone string `json:"zone"`
Base string `json:"base"`
Views []string `json:"views"`
Linked bool `json:"linked"`
Summary []struct {
View string `json:"view"`
Different int `json:"different"`
OnlyBase int `json:"only_base"`
OnlyOther int `json:"only_other"`
Same int `json:"same"`
} `json:"summary"`
Different []compareRow `json:"different"`
Only []struct {
Views []string `json:"views"`
Rows []compareRow `json:"rows"`
} `json:"only"`
Same int `json:"same"`
SameRows []compareRow `json:"same_rows"`
}
func zoneDiffCmd() *cobra.Command {
var view string
var with []string
var all, wide bool
cmd := &cobra.Command{
Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]",
Short: "Compare a zone with the same zone in other views",
Long: `Compares a zone in one view (--view, default the first view that has it)
with the same zone in the other views (--with, default all of them): how
many names each answers differently, the names and types only some views
have, and how many are the same. NAME (and TYPE) narrows it to one name,
the same records included. One line per record; long values are cut to
fit the terminal (--wide shows them whole).
In a linked zone a difference is a view's own (local) records, on
purpose; between zones that aren't linked it is drift. 'jarl record
update ID --shared' makes a linked view's version every view's.`,
Example: ` jarl zone diff adbraun.lol
jarl zone diff adbraun.lol --view internal --with external
jarl zone diff adbraun.lol nas
jarl zone diff adbraun.lol @ TXT --wide`,
Args: cobra.RangeArgs(1, 3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path, qv := zonePath(args[0], view)
if len(with) > 0 {
qv.Set("with", strings.Join(with, ","))
}
if len(args) > 1 {
qv.Set("name", args[1])
}
if len(args) > 2 {
qv.Set("type", args[2])
}
if all {
qv.Set("all", "1")
}
var r compareReport
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
return err
}
width := 0
if !wide {
width = terminalWidth()
}
return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) })
},
}
cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)")
cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)")
cmd.Flags().BoolVar(&all, "all", false, "also list what is the same")
cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal")
return cmd
}
// printCompare prints a comparison as sections, one line per record,
// each cut to width characters (0: whole).
func printCompare(w io.Writer, r *compareReport, oneName bool, width int) {
vw := 0 // the views column
for _, v := range r.Views {
vw = max(vw, len(v))
}
line := func(indent, label, text string) {
s := indent + label + text
if width > 0 && utf8.RuneCountInString(s) > width {
keep := max(width-utf8.RuneCountInString(indent+label)-1, 8)
text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…"
}
fmt.Fprintln(w, indent+label+text)
}
link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)"
if r.Linked {
link = "linked: a difference is a view's own records, there on purpose"
}
fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link)
if !oneName {
for _, s := range r.Summary {
fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same)
}
}
name := func(row compareRow) string { return row.Name + " " + row.Type }
sets := func(row compareRow, local bool) {
for _, vs := range row.Views {
label := fmt.Sprintf(" %-*s ", vw, vs.View)
if len(vs.Records) == 0 {
line("", label, "-")
continue
}
for i, d := range vs.Records {
if i > 0 {
label = strings.Repeat(" ", 4+vw+2)
}
suffix := ""
if local && vs.Linked == "local" && i == 0 {
suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
}
line("", label, d+suffix)
}
}
}
if len(r.Different) > 0 {
fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different))
for _, row := range r.Different {
fmt.Fprintln(w, " "+name(row))
sets(row, true)
}
}
for _, o := range r.Only {
fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows))
nw := 0
for _, row := range o.Rows {
nw = max(nw, len(name(row)))
}
for _, row := range o.Rows {
var recs []string
for _, vs := range row.Views {
if len(vs.Records) > 0 {
recs = vs.Records
break
}
}
for i, d := range recs {
label := fmt.Sprintf(" %-*s ", nw, name(row))
if i > 0 {
label = strings.Repeat(" ", 2+nw+2)
}
line("", label, d)
}
}
}
if len(r.SameRows) > 0 {
fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows))
nw := 0
for _, row := range r.SameRows {
nw = max(nw, len(name(row)))
}
for _, row := range r.SameRows {
for i, d := range row.Views[0].Records {
label := fmt.Sprintf(" %-*s ", nw, name(row))
if i > 0 {
label = strings.Repeat(" ", 2+nw+2)
}
line("", label, d)
}
}
} else if r.Same > 0 && !oneName {
fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same)
}
if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 {
fmt.Fprintln(w, "\nNo records of that name in these views.")
}
}
// terminalWidth returns the terminal's width, or 0 when standard output
// isn't a terminal (then nothing is cut).
func terminalWidth() int {
fd := int(os.Stdout.Fd())
if !term.IsTerminal(fd) {
return 0
}
width, _, err := term.GetSize(fd)
if err != nil || width <= 0 {
return 0
}
return width
}
// printPTRFills lists PTR records a reverse zone lacks under zone show's
// line about them: address -> name, and the view the name is from. A server
// older than the CLI may send the count without the list; then nothing is
// listed, rather than "and N more" under no addresses.
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
if len(fills) == 0 {
return
}
for _, f := range fills {
from := ""
if f.From != "" {
from = " (view " + f.From + ")"
}
fmt.Fprintf(w, "\t %s -> %s%s\n", f.Address, dnsname.Display(f.Target), from)
}
if total > len(fills) {
fmt.Fprintf(w, "\t and %d more\n", total-len(fills))
}
}
// fromViews names the views PTR fills take their names from: "view
// internal", "views internal and lab".
func fromViews(fills []api.PTRFill) string {
var vs []string
for _, f := range fills {
if f.From != "" && !slices.Contains(vs, f.From) {
vs = append(vs, f.From)
}
}
switch len(vs) {
case 0:
return "other views"
case 1:
return "view " + vs[0]
}
return "views " + strings.Join(vs[:len(vs)-1], ", ") + " and " + vs[len(vs)-1]
}
func zoneFillPTRCmd() *cobra.Command {
var view string
var otherViews bool
cmd := &cobra.Command{
Use: "fill-ptr ZONE [--view VIEW] [--other-views]",
Short: "Add the PTR records a reverse zone lacks for its view's A and AAAA records",
Long: `Adds a PTR record to a reverse zone for every address in its network that
the view's A and AAAA records use and that has none yet. An address with
several names gets the mail or name server's name, else a host's rather
than the domain's, else the shortest (docs/reverse.md).
--other-views also adds the addresses in the network that only other
views' records use, naming them from there. That publishes those views'
host names in this one, so it is never done unasked.`,
Example: ` jarl zone fill-ptr 10.0.10.0/24
jarl zone fill-ptr 10.0.10.in-addr.arpa --view internal
jarl zone fill-ptr 10.0.10.0/24 --view vps --other-views`,
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)
if otherViews {
qv.Set("other_views", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/fill-ptr", qv, nil)
if err != nil {
return err
}
return report(resp)
},
}
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
cmd.Flags().BoolVar(&otherViews, "other-views", false, "also name the addresses only other views' records use")
return cmd
}
type reverseSuggestion struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Prefix string `json:"prefix"`
Zone string `json:"zone"`
Group string `json:"group"`
Hidden string `json:"hidden"`
Why string `json:"why"`
Fill int `json:"fill"`
Samples []string `json:"samples"`
}
func zoneSuggestReverseCmd() *cobra.Command {
var create, all bool
var dismiss, undismiss []string
var view string
cmd := &cobra.Command{
Use: "suggest-reverse [--create] [--dismiss NETWORK --view V]",
Short: "The reverse zones your private views lack for their records' addresses",
Long: `Lists the reverse zones the private views (or the server, without views)
lack for the addresses of their A and AAAA records, in two groups:
yours the /24 of a private IPv4 network, the /48 of a ULA or of an IPv6
prefix this server has an address in
public a /32 (or /128) for any other public address: create it only if
the address is yours, not a web host's
--create creates the ones that are yours, filled with PTR records, in one
changeset; create a public one with 'jarl zone create NETWORK --view V
--fill'. --dismiss hides a suggestion for good (--undismiss shows it
again). A view can stop suggestions: jarl view update V
--reverse-suggestions=false (docs/reverse.md).
--all lists every network the views' records use without a reverse zone,
also where nothing is suggested unasked (a view with suggestions off, a
dismissed network, a public view), to create one by hand; --view narrows
the list to one view and adds the networks only other views' records use
(hidden "records in VIEW"): create one with --fill --other-views to name
them from there. Change the size as you need: a /48 suggested for
IPv6 can be created as a /56 or /64 instead.`,
Example: ` jarl zone suggest-reverse
jarl zone suggest-reverse --create
jarl zone create 139.144.144.154/32 --view internal --fill
jarl zone suggest-reverse --dismiss 185.199.108.153/32 --view internal
jarl zone suggest-reverse --all --view vps
jarl zone create 2a02:8013:7f3d::/48 --view internal --fill`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if len(dismiss) > 0 || len(undismiss) > 0 {
path, prefixes := "/reverse-zones/dismiss", dismiss
if len(undismiss) > 0 {
path, prefixes = "/reverse-zones/undismiss", undismiss
}
resp, err := c.Mutate(ctx(), "POST", path, nil, map[string]any{"view": view, "prefixes": prefixes})
if err != nil {
return err
}
return report(resp)
}
if create && (all || view != "") {
return fmt.Errorf("--create creates the suggestions that are yours; create others one by one: jarl zone create NETWORK --view VIEW --fill")
}
qv := q("view", view)
if all {
qv.Set("all", "1")
}
var sugg []reverseSuggestion
if err := c.Get(ctx(), "/reverse-zones/suggestions", qv, &sugg); err != nil {
return err
}
if !create {
return output(sugg, func(w io.Writer) {
if len(sugg) == 0 {
fmt.Fprintln(w, "No reverse zones missing.")
return
}
head := "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tFOR EXAMPLE"
if all {
head = "GROUP\tVIEW\tNETWORK\tZONE\tPTRS\tHIDDEN\tFOR EXAMPLE"
}
fmt.Fprintln(w, head)
for _, s := range sugg {
v := s.View
if v == "" {
v = "-"
}
cols := []string{s.Group, v, s.Prefix, s.Zone, fmt.Sprint(s.Fill)}
if all {
cols = append(cols, orDash(s.Hidden))
}
cols = append(cols, strings.Join(s.Samples, ", "))
fmt.Fprintln(w, strings.Join(cols, "\t"))
}
fmt.Fprintln(w, "\nCreate yours, filled: jarl zone suggest-reverse --create. A public one only if the address is yours:\njarl zone create NETWORK --view VIEW --fill (or hide it: --dismiss NETWORK --view VIEW).")
})
}
var reqs []api.ChangeRequest
for _, s := range sugg {
if s.Group != "yours" {
continue
}
body := map[string]any{"name": s.Prefix, "fill": true}
if s.ViewID != 0 {
body["view_id"] = s.ViewID
}
data, err := json.Marshal(body)
if err != nil {
return err
}
reqs = append(reqs, api.ChangeRequest{Op: "zone.create", Data: data})
}
if len(reqs) == 0 {
fmt.Println("No reverse zones of yours missing (public addresses are created one by one: see 'jarl zone suggest-reverse').")
return nil
}
return submitBatch(c, fmt.Sprintf("%d suggested reverse zones", len(reqs)), reqs)
},
}
cmd.Flags().BoolVar(&create, "create", false, "create the ones that are yours, filled with PTR records")
cmd.Flags().StringSliceVar(&dismiss, "dismiss", nil, "hide the suggestions for these networks (comma separated or repeated)")
cmd.Flags().StringSliceVar(&undismiss, "undismiss", nil, "show dismissed suggestions again")
cmd.Flags().StringVar(&view, "view", "", "only this view; with --dismiss/--undismiss: the suggestion's view")
cmd.Flags().BoolVar(&all, "all", false, "also networks not suggested unasked (suggestions off, dismissed, public views)")
return cmd
}