jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1027 lines
34 KiB
Go
1027 lines
34 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"golang.org/x/term"
|
|
"io"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/client"
|
|
"jarl/internal/dnsname"
|
|
)
|
|
|
|
func zoneCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "zone", Aliases: []string{"zones"}, Short: "Manage zones"}
|
|
var view, typ string
|
|
// list has its own type filter: create's --type defaults to "primary"
|
|
// and would otherwise leak into list through the shared variable.
|
|
var listType string
|
|
|
|
list := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List zones",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var zones []api.Zone
|
|
if err := c.Get(ctx(), "/zones", q("view", view, "type", listType), &zones); err != nil {
|
|
return err
|
|
}
|
|
return output(zones, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tZONE\tVIEW\tTYPE\tRECORDS\tSERIAL")
|
|
for _, z := range zones {
|
|
serial, recs := "", ""
|
|
if z.Type == "primary" {
|
|
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records)
|
|
}
|
|
typ := z.Type
|
|
if z.Disabled {
|
|
typ += " (disabled)"
|
|
}
|
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, typ, recs, serial)
|
|
}
|
|
})
|
|
},
|
|
}
|
|
list.Flags().StringVar(&view, "view", "", "only zones in this view")
|
|
list.Flags().StringVar(&listType, "type", "", "only zones of this type")
|
|
|
|
show := &cobra.Command{
|
|
Use: "show ZONE",
|
|
Short: "Show a zone",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path, qv := zonePath(args[0], view)
|
|
var z api.Zone
|
|
if err := c.Get(ctx(), path, qv, &z); err != nil {
|
|
return err
|
|
}
|
|
return output(z, func(w io.Writer) {
|
|
fmt.Fprintf(w, "Zone:\t%s (id %d)\n", z.Display, z.ID)
|
|
if z.View != "" {
|
|
fmt.Fprintf(w, "View:\t%s\n", z.View)
|
|
}
|
|
fmt.Fprintf(w, "Type:\t%s\n", z.Type)
|
|
if z.Disabled {
|
|
fmt.Fprintf(w, "Disabled:\tyes: named doesn't load it ('jarl zone enable %s')\n", args[0])
|
|
}
|
|
if z.Type == "primary" {
|
|
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial)
|
|
fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum)
|
|
fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
|
|
if z.DisabledRecords > 0 {
|
|
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0])
|
|
} else {
|
|
fmt.Fprintf(w, "Records:\t%d\n", z.Records)
|
|
}
|
|
}
|
|
if z.InView != "" {
|
|
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
|
}
|
|
if 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
|
|
create := &cobra.Command{
|
|
Use: "create NAME",
|
|
Short: "Create a zone",
|
|
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to
|
|
create its reverse zone: a /32 (or /128) gives a zone for that one address,
|
|
and a network between octet boundaries, like a /22, gets one zone per /24
|
|
(created together).
|
|
|
|
jarl zone create example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
|
|
jarl zone create 192.168.1.0/24
|
|
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
|
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
|
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
|
jarl zone create example.com --view external --clone-from example.com --clone-view internal
|
|
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
|
|
}
|
|
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")
|
|
|
|
var refresh, retry, expire, minimum, serial uint32
|
|
var mname, policy, moveView string
|
|
update := &cobra.Command{
|
|
Use: "update ZONE",
|
|
Short: "Change a zone's settings (SOA, TTL, view, comment)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{}
|
|
soa := map[string]any{}
|
|
f := cmd.Flags()
|
|
if f.Changed("mname") {
|
|
soa["mname"] = mname
|
|
}
|
|
if f.Changed("contact") {
|
|
soa["rname"] = contact
|
|
}
|
|
for name, v := range map[string]uint32{"refresh": refresh, "retry": retry, "expire": expire, "minimum": minimum, "serial": serial} {
|
|
if f.Changed(name) {
|
|
soa[name] = v
|
|
}
|
|
}
|
|
if len(soa) > 0 {
|
|
body["soa"] = soa
|
|
}
|
|
if f.Changed("ttl") {
|
|
body["default_ttl"] = ttl
|
|
}
|
|
if f.Changed("serial-policy") {
|
|
body["serial_policy"] = policy
|
|
}
|
|
if f.Changed("comment") {
|
|
body["comment"] = comment
|
|
}
|
|
if f.Changed("move-to-view") {
|
|
body["view"] = moveView
|
|
}
|
|
if f.Changed("file") {
|
|
body["file"] = file
|
|
}
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("nothing to change (see --help)")
|
|
}
|
|
path, qv := zonePath(args[0], view)
|
|
resp, err := c.Mutate(ctx(), "PATCH", path, qv, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
update.Flags().StringVar(&view, "view", "", "view of the zone")
|
|
update.Flags().StringVar(&mname, "mname", "", "SOA primary name server")
|
|
update.Flags().StringVar(&contact, "contact", "", "SOA contact email")
|
|
update.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
|
update.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
|
update.Flags().Uint32Var(&expire, "expire", 0, "SOA expire")
|
|
update.Flags().Uint32Var(&minimum, "minimum", 0, "SOA minimum (negative caching TTL)")
|
|
update.Flags().Uint32Var(&serial, "serial", 0, "set the serial explicitly")
|
|
update.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
|
|
update.Flags().StringVar(&policy, "serial-policy", "", "serial policy: date, increment or unixtime")
|
|
update.Flags().StringVar(&comment, "comment", "", "comment")
|
|
update.Flags().StringVar(&moveView, "move-to-view", "", "move the zone to this view")
|
|
update.Flags().StringVar(&file, "file", "", "zone file (secondary, hint and redirect zones)")
|
|
|
|
var yes bool
|
|
del := &cobra.Command{
|
|
Use: "delete ZONE",
|
|
Short: "Delete a zone and its records",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if !confirm("Delete zone "+args[0]+" and all its records?", yes) {
|
|
return fmt.Errorf("canceled")
|
|
}
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path, qv := zonePath(args[0], view)
|
|
resp, err := c.Mutate(ctx(), "DELETE", path, qv, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
del.Flags().StringVar(&view, "view", "", "view of the zone")
|
|
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
|
|
|
toggle := func(disable bool) *cobra.Command {
|
|
verb := map[bool]string{true: "disable", false: "enable"}[disable]
|
|
long := `Load a disabled zone in named again, as it is in Jarl now (changes made
|
|
while it was disabled included).`
|
|
if disable {
|
|
long = `Disable a zone: it stays in Jarl with its records, options and history,
|
|
and can still be edited, but named no longer loads it. Its names are then
|
|
answered by recursion where the view offers it (the internet's version of
|
|
the zone, if any), and REFUSED elsewhere. Signed zones can't be disabled;
|
|
turn signing off first.
|
|
|
|
jarl zone disable old.adbraun.lol
|
|
jarl zone enable old.adbraun.lol`
|
|
}
|
|
c := &cobra.Command{
|
|
Use: verb + " ZONE",
|
|
Short: map[bool]string{true: "Stop serving a zone, keeping it in Jarl", false: "Serve a disabled zone again"}[disable],
|
|
Long: long,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path, qv := zonePath(args[0], view)
|
|
resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"disabled": disable})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
c.Flags().StringVar(&view, "view", "", "view of the zone")
|
|
return c
|
|
}
|
|
|
|
fileCmd := &cobra.Command{
|
|
Use: "file ZONE",
|
|
Short: "Print the zone file Jarl generates",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path, qv := zonePath(args[0], view)
|
|
var text string
|
|
if err := c.Get(ctx(), path+"/file", qv, &text); err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(text)
|
|
return nil
|
|
},
|
|
}
|
|
fileCmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
|
|
|
var replace, useSOA bool
|
|
importCmd := &cobra.Command{
|
|
Use: "import ZONE FILE",
|
|
Short: "Import records from a zone file ('-' reads standard input)",
|
|
Long: `Adds the records of a zone file to a zone. With --replace the zone is made
|
|
equal to the file: records not in the file are deleted.`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var data []byte
|
|
if args[1] == "-" {
|
|
data, err = io.ReadAll(os.Stdin)
|
|
} else {
|
|
data, err = os.ReadFile(args[1])
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mode := "merge"
|
|
if replace {
|
|
mode = "replace"
|
|
}
|
|
path, qv := zonePath(args[0], view)
|
|
resp, err := c.Mutate(ctx(), "POST", path+"/import", qv, map[string]any{"text": string(data), "mode": mode, "use_soa": useSOA, "dry_run": flagDryRun})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(resp.Object) > 0 && !flagQuiet {
|
|
var o struct {
|
|
Skipped []string `json:"skipped"`
|
|
}
|
|
if jsonUnmarshal(resp.Object, &o) == nil {
|
|
for _, s := range o.Skipped {
|
|
fmt.Fprintln(os.Stderr, "skipped:", s)
|
|
}
|
|
}
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
importCmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
|
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
|
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
|
|
|
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd())
|
|
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.
|
|
func zoneDiffCmd() *cobra.Command {
|
|
var views []string
|
|
var all bool
|
|
cmd := &cobra.Command{
|
|
Use: "diff ZONE [--view V --view W] [--all]",
|
|
Short: "Show what differs between the views that have a zone",
|
|
Long: `Lines up a zone's records in the views that have it (or the views
|
|
given), one row per name and type, and shows the ones that differ: a
|
|
local override in a linked zone, a record one view lacks. With --all, the
|
|
ones that are the same too. In a linked zone, 'jarl record update ID
|
|
--shared' makes a view's version every view's.`,
|
|
Example: ` jarl zone diff adbraun.lol
|
|
jarl zone diff adbraun.lol --view internal --view external --all`,
|
|
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], "")
|
|
if len(views) > 0 {
|
|
qv.Set("views", strings.Join(views, ","))
|
|
}
|
|
if all {
|
|
qv.Set("all", "1")
|
|
}
|
|
var r struct {
|
|
Zone string `json:"zone"`
|
|
Views []string `json:"views"`
|
|
Linked bool `json:"linked"`
|
|
Same int `json:"same"`
|
|
Rows []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Same bool `json:"same"`
|
|
Views []struct {
|
|
Records []string `json:"records"`
|
|
IDs []int64 `json:"ids"`
|
|
Linked string `json:"linked"`
|
|
} `json:"views"`
|
|
} `json:"rows"`
|
|
}
|
|
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
|
return err
|
|
}
|
|
return output(r, func(w io.Writer) {
|
|
head := []string{"NAME", "TYPE"}
|
|
for _, v := range r.Views {
|
|
head = append(head, strings.ToUpper(v))
|
|
}
|
|
fmt.Fprintln(w, strings.Join(head, "\t"))
|
|
for _, row := range r.Rows {
|
|
cols := []string{row.Name, row.Type}
|
|
for _, vs := range row.Views {
|
|
cell := "-"
|
|
if len(vs.Records) > 0 {
|
|
cell = strings.Join(vs.Records, ", ")
|
|
if vs.Linked == "local" {
|
|
cell += " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
|
}
|
|
}
|
|
cols = append(cols, cell)
|
|
}
|
|
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
|
}
|
|
link := "not linked (jarl zone link " + r.Zone + ")"
|
|
if r.Linked {
|
|
link = "linked"
|
|
}
|
|
fmt.Fprintf(w, "\n%d names and types differ, %d are the same; %s\n", len(r.Rows)-boolCount(all, r.Same), r.Same, link)
|
|
})
|
|
},
|
|
}
|
|
cmd.Flags().StringArrayVar(&views, "view", nil, "a view to compare (repeat; default: every view with the zone)")
|
|
cmd.Flags().BoolVar(&all, "all", false, "also show what is the same everywhere")
|
|
return cmd
|
|
}
|
|
|
|
func boolCount(b bool, n int) int {
|
|
if b {
|
|
return n
|
|
}
|
|
return 0
|
|
}
|