Files
jarl/cmd/jarl/zones.go
T
adamandClaude Opus 5.5 057433c198 Linked zones: shared records kept in step across views
Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:53:58 +01:00

940 lines
31 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))
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
}