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:
1 parent
a7d389be27
commit
785b0006f9
22 files changed
+1375
-185
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user