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>
1391 lines
47 KiB
Go
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
|
|
}
|