Multi-server CLI, more reverse zones, and a fix for new view directories

- CLI: name several servers and groups in the config file; select them
  with -s/--server NAME|GROUP|all|URL or JARL_SERVER. A command aimed at
  several servers is dry-run on all of them first and only runs if every
  one accepts it. Named servers always use their own token.
- Reverse zones: /32 and /128 get single-address zones; networks between
  octet or nibble boundaries (e.g. a /22) are covered by several zones
  created together, from the CLI and the web UI.
- Fix: under jarl.service's RestrictSUIDSGID, creating a view directory
  failed on chmod with the setgid bit; fall back to the permission bits.
- Web UI: the review/live switch is a small header button, and the
  pending-changes bar only shows when there are pending changes.
- TSIG key wording; the keys page points to restricted API tokens.
- Docs: several servers (cli.md), a secondary server (operations.md),
  tokens vs TSIG keys (permissions.md); CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 08:12:03 +00:00
1 parent a7d389be27
commit 785b0006f9
22 files changed
+1375 -185

No files matched your search

-107
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"strconv"
"strings"
@@ -739,109 +738,3 @@ func searchCmd() *cobra.Command {
}
// ---- CLI configuration
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "Show or change the CLI's own configuration file"}
keys := "server, token, insecure, ca-cert"
set := &cobra.Command{
Use: "set KEY [VALUE]",
Short: "Set " + keys + " (the token is read from the terminal when VALUE is left out)",
Example: ` jarl config set server https://ns1.adbraun.lol:8053
jarl config set token # paste it at the prompt, keeping it out of shell history`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
value := ""
if len(args) == 2 {
value = args[1]
} else if args[0] == "token" {
if value, err = readSecret("API token: "); err != nil {
return err
}
} else {
return fmt.Errorf("give a value for %s", args[0])
}
switch args[0] {
case "server":
u, err := url.Parse(value)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("invalid server URL %q (use e.g. https://ns1.example.com:8053)", value)
}
cfg.Server = strings.TrimSuffix(value, "/")
case "token":
if !strings.HasPrefix(value, "jarl_") && !strings.HasPrefix(value, "@") {
return fmt.Errorf("that doesn't look like a Jarl token (they start with jarl_)")
}
cfg.Token = value
case "insecure":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("insecure must be true or false")
}
cfg.Insecure = b
case "ca-cert", "ca_cert":
cfg.CACert = value
default:
return fmt.Errorf("unknown key %q (use %s)", args[0], keys)
}
if err := saveCLIConfig(cfg); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("saved %s in %s\n", args[0], flagConfig)
}
return nil
},
}
show := &cobra.Command{
Use: "show",
Short: "Show the configuration (the token is masked)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
tok := cfg.Token
if len(tok) > 14 && !strings.HasPrefix(tok, "@") {
tok = tok[:14] + "..."
}
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca-cert: %s\n", flagConfig, cfg.Server, tok, cfg.Insecure, cfg.CACert)
if v := os.Getenv("JARL_SERVER"); v != "" {
fmt.Printf("(JARL_SERVER overrides the server: %s)\n", v)
}
if os.Getenv("JARL_TOKEN") != "" {
fmt.Println("(JARL_TOKEN overrides the token)")
}
return nil
},
}
unset := &cobra.Command{
Use: "unset KEY",
Short: "Remove a setting",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
switch args[0] {
case "server":
cfg.Server = ""
case "token":
cfg.Token = ""
case "insecure":
cfg.Insecure = false
case "ca-cert", "ca_cert":
cfg.CACert = ""
default:
return fmt.Errorf("unknown key %q (use %s)", args[0], keys)
}
return saveCLIConfig(cfg)
},
}
cmd.AddCommand(set, show, unset)
return cmd
}
+422
View File
@@ -0,0 +1,422 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
)
// configCmd manages the CLI's own configuration file: the simple form (one
// server and token) and named servers and groups.
func configCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Show or change the CLI's own configuration file (servers, groups, tokens)",
Long: `Show or change the CLI's configuration file.
With one Jarl server, set its URL and token:
jarl config set server https://ns1.adbraun.lol:8053
jarl config set token # paste it at the prompt
With several, name them and pick one with --server NAME (or JARL_SERVER).
Groups run a command on several servers: it is first checked on all of them
as a dry run, and only runs if it would succeed everywhere.
jarl config server add ns1 https://ns1.adbraun.lol:8053 # asks for the token
jarl config server add ns2 https://ns2.adbraun.lol:8053
jarl config group set dns ns1 ns2
jarl config use ns1 # the default
jarl --server ns2 zone list
jarl --server dns acl update homelab --add 10.0.20.0/24
jarl --server all status`,
}
keys := "server, token, insecure, ca-cert"
set := &cobra.Command{
Use: "set KEY [VALUE]",
Short: "Set " + keys + " of the selected server (the token is read from the terminal when VALUE is left out)",
Example: ` jarl config set server https://ns1.adbraun.lol:8053
jarl config set token # paste it at the prompt, keeping it out of shell history
jarl --server ns2 config set token # the token of the named server ns2`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
value := ""
if len(args) == 2 {
value = args[1]
} else if args[0] == "token" {
if value, err = readSecret("API token: "); err != nil {
return err
}
} else {
return fmt.Errorf("give a value for %s", args[0])
}
e, name, err := selectedEntry(cfg)
if err != nil {
return err
}
switch args[0] {
case "server", "url":
if e.URL, err = validServerURL(value); err != nil {
return err
}
case "token":
if !strings.HasPrefix(value, "jarl_") && !strings.HasPrefix(value, "@") {
return fmt.Errorf("that doesn't look like a Jarl token (they start with jarl_)")
}
e.Token = value
case "insecure":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("insecure must be true or false")
}
e.Insecure = b
case "ca-cert", "ca_cert":
e.CACert = value
default:
return fmt.Errorf("unknown key %q (use %s)", args[0], keys)
}
storeEntry(cfg, name, e)
if err := saveCLIConfig(cfg); err != nil {
return err
}
if !flagQuiet {
where := ""
if name != "" {
where = " of server " + name
}
fmt.Printf("saved %s%s in %s\n", args[0], where, flagConfig)
}
return nil
},
}
unset := &cobra.Command{
Use: "unset KEY",
Short: "Remove a setting of the selected server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
e, name, err := selectedEntry(cfg)
if err != nil {
return err
}
switch args[0] {
case "server", "url":
if name != "" {
return fmt.Errorf("a named server needs its URL; remove the server with 'jarl config server remove %s'", name)
}
e.URL = ""
case "token":
e.Token = ""
case "insecure":
e.Insecure = false
case "ca-cert", "ca_cert":
e.CACert = ""
default:
return fmt.Errorf("unknown key %q (use %s)", args[0], keys)
}
storeEntry(cfg, name, e)
return saveCLIConfig(cfg)
},
}
show := &cobra.Command{
Use: "show",
Short: "Show the configuration (tokens are masked)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
fmt.Printf("file: %s\n", flagConfig)
if cfg.Server != "" || cfg.Token != "" || len(cfg.Servers) == 0 {
fmt.Printf("server: %s\ntoken: %s\ninsecure: %v\nca-cert: %s\n", cfg.Server, maskToken(cfg.Token), cfg.Insecure, cfg.CACert)
}
if len(cfg.Servers) > 0 {
fmt.Println("servers:")
for _, name := range sortedKeys(cfg.Servers) {
s := cfg.Servers[name]
extra := ""
if s.Insecure {
extra += " insecure"
}
if s.CACert != "" {
extra += " ca-cert=" + s.CACert
}
fmt.Printf(" %-12s %s token %s%s\n", name, s.URL, maskToken(s.Token), extra)
}
}
if len(cfg.Groups) > 0 {
fmt.Println("groups:")
for _, name := range sortedKeys(cfg.Groups) {
fmt.Printf(" %-12s %s\n", name, strings.Join(cfg.Groups[name], ", "))
}
}
if cfg.Default != "" {
fmt.Printf("default: %s\n", cfg.Default)
}
if v := os.Getenv("JARL_SERVER"); v != "" {
fmt.Printf("(JARL_SERVER selects: %s)\n", v)
}
if os.Getenv("JARL_TOKEN") != "" {
fmt.Println("(JARL_TOKEN overrides the token of servers that have none of their own)")
}
return nil
},
}
use := &cobra.Command{
Use: "use NAME",
Short: "Make a server or group the default (\"none\" clears it)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
if args[0] == "none" || args[0] == "" {
cfg.Default = ""
} else {
if _, err := resolveTargets(cfg, args[0]); err != nil {
return err
}
cfg.Default = args[0]
}
return saveCLIConfig(cfg)
},
}
cmd.AddCommand(set, unset, show, use, serverConfigCmd(), groupConfigCmd())
return cmd
}
func serverConfigCmd() *cobra.Command {
cmd := &cobra.Command{Use: "server", Aliases: []string{"servers"}, Short: "Name Jarl servers in the configuration file"}
var insecure, noToken bool
var caCert string
add := &cobra.Command{
Use: "add NAME URL",
Short: "Add (or change) a named server; its token is read from the terminal",
Example: ` jarl config server add ns1 https://ns1.adbraun.lol:8053
jarl config server add ns2 https://ns2.adbraun.lol:8053 --token @/home/adam/.config/jarl/ns2.token`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if err := checkConfigName(name); err != nil {
return err
}
cfg, err := loadCLIConfig()
if err != nil {
return err
}
if _, ok := cfg.Groups[name]; ok {
return fmt.Errorf("%s is already a group", name)
}
u, err := validServerURL(args[1])
if err != nil {
return err
}
e := &serverEntry{URL: u, Insecure: insecure, CACert: caCert}
if old := cfg.Servers[name]; old != nil {
e.Token = old.Token
}
switch {
case flagToken != "":
e.Token = flagToken
case noToken:
case e.Token == "":
if e.Token, err = readSecret("API token for " + name + ": "); err != nil {
return err
}
}
if e.Token != "" && !strings.HasPrefix(e.Token, "jarl_") && !strings.HasPrefix(e.Token, "@") {
return fmt.Errorf("that doesn't look like a Jarl token (they start with jarl_)")
}
if cfg.Servers == nil {
cfg.Servers = map[string]*serverEntry{}
}
cfg.Servers[name] = e
if err := saveCLIConfig(cfg); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("saved server %s in %s\n", name, flagConfig)
if cfg.Server != "" && cfg.Default == "" {
fmt.Printf("note: without --server, commands still go to %s (the server: setting); 'jarl config use %s' changes that\n", cfg.Server, name)
}
}
return nil
},
}
add.Flags().BoolVar(&insecure, "insecure", false, "don't verify the server's TLS certificate")
add.Flags().StringVar(&caCert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
add.Flags().BoolVar(&noToken, "no-token", false, "don't ask for a token now")
remove := &cobra.Command{
Use: "remove NAME",
Short: "Remove a named server (and take it out of groups)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
if _, ok := cfg.Servers[args[0]]; !ok {
return fmt.Errorf("no server %q", args[0])
}
delete(cfg.Servers, args[0])
for g, members := range cfg.Groups {
cfg.Groups[g] = without(members, args[0])
}
if cfg.Default == args[0] {
cfg.Default = ""
}
return saveCLIConfig(cfg)
},
}
list := &cobra.Command{
Use: "list",
Short: "List named servers",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
for _, name := range sortedKeys(cfg.Servers) {
mark := " "
if name == cfg.Default {
mark = "*"
}
fmt.Printf("%s %-12s %s\n", mark, name, cfg.Servers[name].URL)
}
return nil
},
}
cmd.AddCommand(add, remove, list)
return cmd
}
func groupConfigCmd() *cobra.Command {
cmd := &cobra.Command{Use: "group", Aliases: []string{"groups"}, Short: "Group servers to run commands on several at once"}
set := &cobra.Command{
Use: "set NAME MEMBER...",
Short: "Create or replace a group of servers (members may be servers or groups)",
Example: ` jarl config group set dns ns1 ns2`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if err := checkConfigName(name); err != nil {
return err
}
cfg, err := loadCLIConfig()
if err != nil {
return err
}
if _, ok := cfg.Servers[name]; ok {
return fmt.Errorf("%s is already a server", name)
}
for _, m := range args[1:] {
if m == name {
return fmt.Errorf("a group can't contain itself")
}
_, isServer := cfg.Servers[m]
_, isGroup := cfg.Groups[m]
if !isServer && !isGroup {
return fmt.Errorf("no server or group %q (add it with 'jarl config server add')", m)
}
}
if cfg.Groups == nil {
cfg.Groups = map[string][]string{}
}
cfg.Groups[name] = args[1:]
if _, err := resolveTargets(cfg, name); err != nil {
return err
}
return saveCLIConfig(cfg)
},
}
remove := &cobra.Command{
Use: "remove NAME",
Short: "Remove a group (its servers stay)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
if _, ok := cfg.Groups[args[0]]; !ok {
return fmt.Errorf("no group %q", args[0])
}
delete(cfg.Groups, args[0])
for g, members := range cfg.Groups {
cfg.Groups[g] = without(members, args[0])
}
if cfg.Default == args[0] {
cfg.Default = ""
}
return saveCLIConfig(cfg)
},
}
cmd.AddCommand(set, remove)
return cmd
}
// selectedEntry returns the server that config set/unset change: the
// named server picked by --server, JARL_SERVER or the default, or else the
// simple server/token settings (name "").
func selectedEntry(cfg *cliConfig) (*serverEntry, string, error) {
spec := firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Default)
if s, ok := cfg.Servers[spec]; ok {
c := *s
return &c, spec, nil
}
if spec != "" && (flagServer != "" || os.Getenv("JARL_SERVER") != "") {
if _, ok := cfg.Groups[spec]; ok || spec == "all" || strings.Contains(spec, ",") {
return nil, "", fmt.Errorf("--server %s names a group; choose one server", spec)
}
if !strings.Contains(spec, "://") {
return nil, "", fmt.Errorf("no server %q (add it with 'jarl config server add %s URL')", spec, spec)
}
}
return &serverEntry{URL: cfg.Server, Token: cfg.Token, Insecure: cfg.Insecure, CACert: cfg.CACert}, "", nil
}
func storeEntry(cfg *cliConfig, name string, e *serverEntry) {
if name != "" {
cfg.Servers[name] = e
return
}
cfg.Server, cfg.Token, cfg.Insecure, cfg.CACert = e.URL, e.Token, e.Insecure, e.CACert
}
func checkConfigName(name string) error {
if name == "" || name == "all" || name == "none" || strings.ContainsAny(name, ",:/ \t") {
return fmt.Errorf("invalid name %q: use letters, digits, - and _ (not \"all\" or \"none\")", name)
}
return nil
}
func without(list []string, drop string) []string {
var out []string
for _, v := range list {
if v != drop {
out = append(out, v)
}
}
return out
}
func maskToken(tok string) string {
if len(tok) > 14 && !strings.HasPrefix(tok, "@") {
return tok[:14] + "..."
}
if tok == "" {
return "(none)"
}
return tok
}
+43 -11
View File
@@ -25,7 +25,12 @@ import (
func main() {
root := rootCmd()
if err := root.Execute(); err != nil {
err := root.Execute()
if errors.Is(err, errFanOut) {
os.Exit(fanOut(fanOutTargets))
}
finishPreflight()
if err != nil {
printError(err)
os.Exit(exitCode(err))
}
@@ -45,12 +50,17 @@ var (
flagQuiet bool
)
// cliConfig is ~/.config/jarl/config.yaml.
// cliConfig is ~/.config/jarl/config.yaml. Server and Token are the
// simple, single-server form; Servers and Groups name several servers
// (see targets.go).
type cliConfig struct {
Server string `yaml:"server"`
Token string `yaml:"token"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
Server string `yaml:"server,omitempty"`
Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
Default string `yaml:"default,omitempty"`
Servers map[string]*serverEntry `yaml:"servers,omitempty"`
Groups map[string][]string `yaml:"groups,omitempty"`
}
func defaultConfigPath() string {
@@ -92,9 +102,24 @@ func newClient() (*client.Client, error) {
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
}
server := firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Server)
token := firstNonEmpty(flagToken, os.Getenv("JARL_TOKEN"), cfg.Token)
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil {
return nil, err
}
if len(ts) > 1 {
return nil, fmt.Errorf("--server names %d servers; this command works on one", len(ts))
}
t := ts[0]
// A named server's own token wins: a token from the environment
// belongs to some other server more often than not.
token := firstNonEmpty(flagToken, os.Getenv("JARL_TOKEN"), t.Token)
if t.Name != "" && t.Token != "" {
token = t.Token
}
if token == "" {
if t.Name != "" {
return nil, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
}
return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
}
if strings.HasPrefix(token, "@") {
@@ -104,10 +129,14 @@ func newClient() (*client.Client, error) {
}
token = strings.TrimSpace(string(b))
}
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
c, err := client.New(client.Options{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert)})
if err != nil {
return nil, err
}
if os.Getenv(envPreflight) == "1" {
c.Preflight = true
preflightClients = append(preflightClients, c)
}
c.Changeset = flagChangeset
if c.Changeset == 0 {
if v := os.Getenv("JARL_CHANGESET"); v != "" {
@@ -118,7 +147,7 @@ func newClient() (*client.Client, error) {
c.Changeset = n
}
}
c.DryRun, c.Force = flagDryRun, flagForce
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
return c, nil
}
@@ -153,9 +182,12 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
SilenceUsage: true,
SilenceErrors: true,
Version: version.Version,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return checkFanOut(cmd)
},
}
pf := root.PersistentFlags()
pf.StringVar(&flagServer, "server", "", "Jarl server URL (env JARL_SERVER)")
pf.StringVarP(&flagServer, "server", "s", "", "server: a name or group from the config file, \"all\", a comma separated list, or a URL (env JARL_SERVER)")
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env JARL_TOKEN)")
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
+402
View File
@@ -0,0 +1,402 @@
package main
// Several Jarl servers in one CLI configuration, like an inventory:
//
// servers:
// ns1: {url: https://ns1.example.com:8053, token: jarl_...}
// ns2: {url: https://ns2.example.com:8053, token: "@/home/me/.config/jarl/ns2.token"}
// groups:
// dns: [ns1, ns2]
// default: ns1
//
// --server (or JARL_SERVER) takes a server name, a group, "all", a comma
// separated list of those, or a URL. A command aimed at several servers is
// first run on every one of them as a dry run (the "preflight"); only if it
// succeeds everywhere is it run for real, one server after another. So a
// change that one server rejects, such as a record in a zone that is a
// secondary there, changes nothing anywhere.
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"sort"
"strings"
"sync"
"github.com/spf13/cobra"
"jarl/internal/client"
)
// serverEntry is one named server in the CLI configuration.
type serverEntry struct {
URL string `yaml:"url"`
Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
}
// target is a resolved server to talk to. Name is empty for the plain
// server/token at the top of the configuration file and for URLs.
type target struct {
Name string
URL string
Token string
Insecure bool
CACert string
}
func (t target) label() string {
if t.Name == "" {
return t.URL
}
return t.Name + " (" + t.URL + ")"
}
// Environment used between the CLI and the copies of itself that it runs
// for each server of a group.
const (
envTarget = "JARL_TARGET" // the one server a copy talks to
envPreflight = "JARL_PREFLIGHT" // "1": allow only reads and dry runs
envPreflightReport = "JARL_PREFLIGHT_REPORT" // file the copy marks when it would change something
)
// targetSpec is the --server value in effect.
func targetSpec(cfg *cliConfig) string {
if t := os.Getenv(envTarget); t != "" {
return t
}
return firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Default)
}
// resolveTargets turns a --server value into servers, in order and without
// duplicates.
func resolveTargets(cfg *cliConfig, spec string) ([]target, error) {
if strings.TrimSpace(spec) == "" {
if cfg.Server != "" || len(cfg.Servers) != 1 {
return []target{{URL: cfg.Server, Token: cfg.Token, Insecure: cfg.Insecure, CACert: cfg.CACert}}, nil
}
for name := range cfg.Servers { // exactly one
spec = name
}
}
var out []target
seen := map[string]bool{}
var add func(item string, depth int) error
add = func(item string, depth int) error {
item = strings.TrimSpace(item)
switch {
case item == "":
return nil
case strings.Contains(item, "://"):
if seen[item] {
return nil
}
seen[item] = true
// A URL that matches a configured server uses its settings.
for name, s := range cfg.Servers {
if strings.TrimSuffix(s.URL, "/") == strings.TrimSuffix(item, "/") {
return add(name, depth)
}
}
t := target{URL: strings.TrimSuffix(item, "/")}
if strings.TrimSuffix(cfg.Server, "/") == t.URL {
t.Token, t.Insecure, t.CACert = cfg.Token, cfg.Insecure, cfg.CACert
}
out = append(out, t)
return nil
}
if s, ok := cfg.Servers[item]; ok {
if !seen[item] {
seen[item] = true
out = append(out, target{Name: item, URL: strings.TrimSuffix(s.URL, "/"), Token: s.Token, Insecure: s.Insecure, CACert: s.CACert})
}
return nil
}
if members, ok := cfg.Groups[item]; ok {
if depth > 8 {
return fmt.Errorf("group %s: groups nest too deeply (is there a loop?)", item)
}
for _, m := range members {
if err := add(m, depth+1); err != nil {
return err
}
}
return nil
}
if item == "all" && len(cfg.Servers) > 0 {
for _, name := range sortedKeys(cfg.Servers) {
if err := add(name, depth); err != nil {
return err
}
}
return nil
}
known := append(sortedKeys(cfg.Servers), sortedKeys(cfg.Groups)...)
if len(known) == 0 {
return fmt.Errorf("no server or group %q: %s defines none (see 'jarl config server add --help'); give a URL instead", item, flagConfig)
}
return fmt.Errorf("no server or group %q in %s (known: %s)", item, flagConfig, strings.Join(known, ", "))
}
for _, item := range strings.Split(spec, ",") {
if err := add(item, 0); err != nil {
return nil, err
}
}
if len(out) == 0 {
return nil, fmt.Errorf("--server %q names no servers", spec)
}
return out, nil
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// ---- running a command on several servers
var errFanOut = errors.New("run on several servers")
// fanOutTargets is set by checkFanOut when the command must run on several
// servers.
var fanOutTargets []target
// preflightClients are the clients made in a preflight copy; their
// Mutated flags tell the parent whether the command changes anything.
var preflightClients []*client.Client
// checkFanOut runs before every command. It stops the command (with
// errFanOut) when --server names several servers; main then runs it on
// each of them.
func checkFanOut(cmd *cobra.Command) error {
if os.Getenv(envPreflight) == "1" {
flagDryRun = true // also answers confirmations: nothing is changed
}
if os.Getenv(envTarget) != "" || localCommand(cmd) {
return nil
}
cfg, err := loadCLIConfig()
if err != nil {
return nil // newClient reports it
}
ts, err := resolveTargets(cfg, targetSpec(cfg))
if err != nil || len(ts) < 2 {
return nil
}
if flagChangeset > 0 || os.Getenv("JARL_CHANGESET") != "" {
return fmt.Errorf("changeset IDs belong to one server, so --changeset can't be used with several servers")
}
fanOutTargets = ts
return errFanOut
}
// localCommand reports whether cmd works without a server (and so never
// runs on several).
func localCommand(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
switch c.Name() {
case "config", "help", "completion", "__complete", "__completeNoDesc":
return true
}
}
return false
}
// finishPreflight tells the parent whether this copy would change
// anything.
func finishPreflight() {
path := os.Getenv(envPreflightReport)
if path == "" {
return
}
for _, c := range preflightClients {
if c.Mutated {
os.WriteFile(path, []byte("changes\n"), 0o600)
return
}
}
}
type runResult struct {
t target
code int
stdout, stderr bytes.Buffer
changes bool
err error
}
// fanOut runs the command line on every target and returns the exit code.
func fanOut(ts []target) int {
exe, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
args := os.Args[1:]
tmp, err := os.MkdirTemp("", "jarl-preflight-")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
defer os.RemoveAll(tmp)
// Pass 1: a dry run on every server, in parallel.
results := make([]*runResult, len(ts))
var wg sync.WaitGroup
for i, t := range ts {
r := &runResult{t: t}
results[i] = r
wg.Add(1)
go func(i int) {
defer wg.Done()
report := fmt.Sprintf("%s/%d", tmp, i)
cmd := exec.Command(exe, args...)
cmd.Env = childEnv(r.t, envPreflight+"=1", envPreflightReport+"="+report)
cmd.Stdout, cmd.Stderr = &r.stdout, &r.stderr
r.code, r.err = exitStatus(cmd.Run())
if _, err := os.Stat(report); err == nil {
r.changes = true
}
}(i)
}
wg.Wait()
var failed []*runResult
changes := false
for _, r := range results {
if r.code != 0 {
failed = append(failed, r)
}
changes = changes || r.changes
}
// A command that only reads (or a --dry-run) is finished: show what
// each server answered.
if !changes || flagDryRun {
code := 0
for _, r := range results {
header(headerOut(), r.t)
if r.err != nil {
fmt.Fprintln(os.Stderr, "error:", r.err)
}
os.Stdout.Write(r.stdout.Bytes())
os.Stderr.Write(r.stderr.Bytes())
if code == 0 {
code = r.code
}
}
return code
}
if len(failed) > 0 {
for _, r := range failed {
header(os.Stderr, r.t)
if r.err != nil {
fmt.Fprintln(os.Stderr, "error:", r.err)
}
os.Stderr.Write(r.stderr.Bytes())
}
names := make([]string, len(failed))
for i, r := range failed {
names[i] = nameOf(r.t)
}
fmt.Fprintf(os.Stderr, "\nNothing was changed on any server: the command was checked on all %d first and failed on %s.\n", len(ts), strings.Join(names, ", "))
return failed[0].code
}
// Pass 2: run it for real, one server after another.
for i, t := range ts {
header(headerOut(), t)
cmd := exec.Command(exe, args...)
cmd.Env = childEnv(t)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
code, err := exitStatus(cmd.Run())
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
}
if code != 0 {
var done, rest []string
for _, d := range ts[:i] {
done = append(done, nameOf(d))
}
for _, d := range ts[i+1:] {
rest = append(rest, nameOf(d))
}
fmt.Fprintf(os.Stderr, "\nFailed on %s although its dry run passed.", nameOf(t))
if len(done) > 0 {
fmt.Fprintf(os.Stderr, " Already applied on: %s.", strings.Join(done, ", "))
}
if len(rest) > 0 {
fmt.Fprintf(os.Stderr, " Not run on: %s.", strings.Join(rest, ", "))
}
fmt.Fprintln(os.Stderr)
return code
}
}
return 0
}
// childEnv is the environment for running the command on t.
func childEnv(t target, extra ...string) []string {
var env []string
for _, kv := range os.Environ() {
k, _, _ := strings.Cut(kv, "=")
switch k {
case envTarget, envPreflight, envPreflightReport:
continue
}
env = append(env, kv)
}
spec := t.Name
if spec == "" {
spec = t.URL
}
return append(append(env, envTarget+"="+spec), extra...)
}
func exitStatus(err error) (int, error) {
if err == nil {
return 0, nil
}
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.ExitCode(), nil
}
return 1, err
}
func nameOf(t target) string {
if t.Name != "" {
return t.Name
}
return t.URL
}
// headerOut keeps machine-readable output on stdout free of headers.
func headerOut() io.Writer {
if flagOutput == "json" || flagOutput == "yaml" {
return os.Stderr
}
return os.Stdout
}
func header(w io.Writer, t target) {
fmt.Fprintf(w, "== %s ==\n", t.label())
}
// validServerURL checks a server URL from the command line.
func validServerURL(value string) (string, error) {
u, err := url.Parse(value)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("invalid server URL %q (use e.g. https://ns1.example.com:8053)", value)
}
return strings.TrimSuffix(value, "/"), nil
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"strings"
"testing"
)
func TestResolveTargets(t *testing.T) {
cfg := &cliConfig{
Server: "https://old.example:8053", Token: "jarl_old",
Servers: map[string]*serverEntry{
"ns1": {URL: "https://ns1.example:8053/", Token: "jarl_1"},
"ns2": {URL: "https://ns2.example:8053", Token: "jarl_2"},
"ns3": {URL: "https://ns3.example:8053"},
},
Groups: map[string][]string{"dns": {"ns1", "ns2"}, "everything": {"dns", "ns3", "ns1"}, "loop": {"loop2"}, "loop2": {"loop"}},
}
names := func(ts []target) string {
var out []string
for _, t := range ts {
out = append(out, nameOf(t)+"="+t.Token)
}
return strings.Join(out, " ")
}
cases := []struct{ spec, want string }{
{"", "https://old.example:8053=jarl_old"},
{"ns2", "ns2=jarl_2"},
{"dns", "ns1=jarl_1 ns2=jarl_2"},
{"everything", "ns1=jarl_1 ns2=jarl_2 ns3="},
{"ns2,dns", "ns2=jarl_2 ns1=jarl_1"},
{"all", "ns1=jarl_1 ns2=jarl_2 ns3="},
{"https://ns2.example:8053/", "ns2=jarl_2"}, // a configured URL uses its entry
{"https://old.example:8053", "https://old.example:8053=jarl_old"},
{"https://new.example", "https://new.example="}, // an unknown URL gets no token
}
for _, c := range cases {
ts, err := resolveTargets(cfg, c.spec)
if err != nil {
t.Errorf("%q: %v", c.spec, err)
continue
}
if got := names(ts); got != c.want {
t.Errorf("%q = %s, want %s", c.spec, got, c.want)
}
}
for _, bad := range []string{"nope", "loop", ","} {
if _, err := resolveTargets(cfg, bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
// With only named servers and no default, a single one is used.
one := &cliConfig{Servers: map[string]*serverEntry{"ns1": {URL: "https://ns1.example"}}}
if ts, err := resolveTargets(one, ""); err != nil || nameOf(ts[0]) != "ns1" {
t.Errorf("single server: %v %v", ts, err)
}
}
+22 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/netip"
"net/url"
"os"
"strconv"
@@ -13,6 +14,7 @@ import (
"jarl/internal/api"
"jarl/internal/client"
"jarl/internal/dnsname"
)
func zoneCmd() *cobra.Command {
@@ -96,10 +98,13 @@ func zoneCmd() *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.
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`,
@@ -164,6 +169,22 @@ create its reverse zone.
}
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