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
+1372 -182

No files matched your search

+27
View File
@@ -10,6 +10,21 @@ The first version, to be released as 0.1.0.
### Added ### Added
- CLI: several servers in one configuration file. Name them
(`jarl config server add ns1 URL`), pick one with `-s/--server NAME` or
`JARL_SERVER`, set a default with `jarl config use`, and group them
(`jarl config group set dns ns1 ns2`). A command aimed at a group (or
`all`) is first checked on every server as a dry run and only runs if it
succeeds everywhere, so a change one server rejects changes nothing
anywhere. Named servers always use their own token.
- Reverse zones for any network: a /32 (or /128) gets a zone that answers
for just that address, and a network between octet (or nibble)
boundaries, such as a /22, is covered by several zones (four /24s) that
are created together, from the CLI (`jarl zone create 10.0.8.0/22`) and
the web UI. Networks smaller than a /24 explain that they belong in the
enclosing /24's zone.
- docs/operations.md: setting up a secondary server with its own jarld.
- A logo: a Viking jarl drawn like a woodcut, with a spear-pointed crown - A logo: a Viking jarl drawn like a woodcut, with a spear-pointed crown
that holds a DNS tree (a root node and its branches), a stern face and a that holds a DNS tree (a root node and its branches), a stern face and a
full carved beard. White on a Nordic-blue disc (`assets/logo.svg`), plus an all-white full carved beard. White on a Nordic-blue disc (`assets/logo.svg`), plus an all-white
@@ -116,6 +131,13 @@ The first version, to be released as 0.1.0.
### Changed ### Changed
- Web UI: the review/live switch is a small "Review edits" / "Live edits"
button in the header, and the bar below it only appears while there are
pending changes.
- TSIG keys: the name help no longer calls `xfer-key` a domain name (any
letters, digits, hyphens and dots will do; both servers must use the same
name), and the keys page points to restricted API tokens for giving
scripts or people limited access to zones and records.
- Web UI, new zone dialog: choose **Domain** or **Reverse zone**. For a - Web UI, new zone dialog: choose **Domain** or **Reverse zone**. For a
reverse zone you give the network (10.10.0.0/24, 2001:db8:1::/48) and the reverse zone you give the network (10.10.0.0/24, 2001:db8:1::/48) and the
zone name is generated. Either way the dialog shows the exact zone name it zone name is generated. Either way the dialog shows the exact zone name it
@@ -141,6 +163,11 @@ The first version, to be released as 0.1.0.
### Fixed ### Fixed
- Under the shipped systemd unit (`RestrictSUIDSGID=yes`), the first zone
in a new view failed with "chmod /var/lib/bind/jarl/VIEW: operation not
permitted": setting the setgid bit on the new directory is refused there.
Jarl now falls back to the permission bits, so the directory is
group-writable for named, and only changes the mode when it needs to.
- Web UI: the "Jarl 0.1.0" brand link no longer underlines the gap between - Web UI: the "Jarl 0.1.0" brand link no longer underlines the gap between
the name and the version on hover. the name and the version on hover.
- Web UI: creating a zone from the dashboard's "New zone" link no longer - Web UI: creating a zone from the dashboard's "New zone" link no longer
-107
View File
@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -739,109 +738,3 @@ func searchCmd() *cobra.Command {
} }
// ---- CLI configuration // ---- 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
}
+41 -9
View File
@@ -25,7 +25,12 @@ import (
func main() { func main() {
root := rootCmd() 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) printError(err)
os.Exit(exitCode(err)) os.Exit(exitCode(err))
} }
@@ -45,12 +50,17 @@ var (
flagQuiet bool 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 { type cliConfig struct {
Server string `yaml:"server"` Server string `yaml:"server,omitempty"`
Token string `yaml:"token"` Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"` Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,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 { func defaultConfigPath() string {
@@ -92,9 +102,24 @@ func newClient() (*client.Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err) return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
} }
server := firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Server) ts, err := resolveTargets(cfg, targetSpec(cfg))
token := firstNonEmpty(flagToken, os.Getenv("JARL_TOKEN"), cfg.Token) 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 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'") return nil, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
} }
if strings.HasPrefix(token, "@") { if strings.HasPrefix(token, "@") {
@@ -104,10 +129,14 @@ func newClient() (*client.Client, error) {
} }
token = strings.TrimSpace(string(b)) 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 { if err != nil {
return nil, err return nil, err
} }
if os.Getenv(envPreflight) == "1" {
c.Preflight = true
preflightClients = append(preflightClients, c)
}
c.Changeset = flagChangeset c.Changeset = flagChangeset
if c.Changeset == 0 { if c.Changeset == 0 {
if v := os.Getenv("JARL_CHANGESET"); v != "" { if v := os.Getenv("JARL_CHANGESET"); v != "" {
@@ -118,7 +147,7 @@ func newClient() (*client.Client, error) {
c.Changeset = n c.Changeset = n
} }
} }
c.DryRun, c.Force = flagDryRun, flagForce c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
return c, nil return c, nil
} }
@@ -153,9 +182,12 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
SilenceUsage: true, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
Version: version.Version, Version: version.Version,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return checkFanOut(cmd)
},
} }
pf := root.PersistentFlags() 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(&flagToken, "token", "", "API token, or @FILE to read it from a file (env JARL_TOKEN)")
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file") 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)") 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" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/netip"
"net/url" "net/url"
"os" "os"
"strconv" "strconv"
@@ -13,6 +14,7 @@ import (
"jarl/internal/api" "jarl/internal/api"
"jarl/internal/client" "jarl/internal/client"
"jarl/internal/dnsname"
) )
func zoneCmd() *cobra.Command { func zoneCmd() *cobra.Command {
@@ -96,10 +98,13 @@ func zoneCmd() *cobra.Command {
Use: "create NAME", Use: "create NAME",
Short: "Create a zone", Short: "Create a zone",
Long: `Create a zone. NAME may be a network (192.168.1.0/24, 2001:db8::/48) to 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 example.com --ns ns1.example.com. --record "ns1 A 192.0.2.1"
jarl zone create 192.168.1.0/24 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 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 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 external --clone-from example.com --clone-view internal`,
@@ -164,6 +169,22 @@ create its reverse zone.
} }
body["clone_from"] = id 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) resp, err := c.Mutate(ctx(), "POST", "/zones", nil, body)
if err != nil { if err != nil {
return err return err
+60 -1
View File
@@ -14,7 +14,7 @@ jarl whoami
| Setting | Flag | Environment | Config file key | | Setting | Flag | Environment | Config file key |
|---------|------|-------------|-----------------| |---------|------|-------------|-----------------|
| Server URL | `--server` | `JARL_SERVER` | `server` | | Server URL, or a server or group name (below) | `-s/--server` | `JARL_SERVER` | `server` (`default` for a name) |
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` | | API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
| Skip TLS verification | `--insecure` | | `insecure` | | Skip TLS verification | `--insecure` | | `insecure` |
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` | | Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
@@ -27,6 +27,55 @@ file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` /
Get tokens from the web UI (Account → API tokens), from Get tokens from the web UI (Account → API tokens), from
`jarl token create`, or from `jarld init --token` on the server. `jarl token create`, or from `jarld init --token` on the server.
### Several servers and groups
With more than one Jarl (say a primary and a secondary, each with its own
jarld), give each a name; group them to run a command on several at once:
```sh
jarl config server add ns1 https://ns1.adbraun.lol:8053 # asks for ns1's token
jarl config server add ns2 https://ns2.adbraun.lol:8053
jarl config group set dns ns1 ns2
jarl config use ns1 # the default when --server is left out
jarl -s ns2 zone list # or JARL_SERVER=ns2
jarl -s dns acl update homelab --add 10.0.20.0/24
jarl -s all status # every named server
jarl -s ns2 config set token # change ns2's token
```
The file then looks like this:
```yaml
servers:
ns1: {url: "https://ns1.adbraun.lol:8053", token: jarl_...}
ns2: {url: "https://ns2.adbraun.lol:8053", token: "@/home/adam/.config/jarl/ns2.token"}
groups:
dns: [ns1, ns2]
default: ns1
```
`--server` takes a server name, a group (groups may contain groups),
`all`, a comma separated list of those, or a URL. A named server always uses
its own token; `--token` and `JARL_TOKEN` only fill in for servers without
one, so a token is never sent to the wrong server. The plain `server:` and
`token:` keys still work and are used when nothing is selected.
**How a command runs on a group.** It is first run on every server as a dry
run (in parallel). If any server rejects it (for example a record in a zone
that is a secondary there, an ACL that already exists, or a token without the
permission), the errors are shown and *nothing is changed anywhere*. Only
when it would succeed everywhere does it run for real, one server after
another, with a `== ns1 (URL) ==` header before each server's output. A
read-only command simply shows each server's answer. If a server still fails
in the real run (it went down in between, say), jarl stops and says which
servers already applied the change.
Commands that can't be dry-run (`token create`, `user ...`, `changeset
create/discard/revert`) refuse to run on a group, and `--changeset` can't be
combined with one, since changeset IDs belong to a single server. Headers go
to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
## Global flags ## Global flags
| Flag | Meaning | | Flag | Meaning |
@@ -65,6 +114,8 @@ jarl zone list [--view V] [--type primary]
jarl zone show adbraun.lol jarl zone show adbraun.lol
jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol. jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
jarl zone create 192.168.20.0/24 # reverse zone from a network jarl zone create 192.168.20.0/24 # reverse zone from a network
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
jarl zone create 10.0.10.100/32 # a zone for just that address
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer" jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600" jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
@@ -75,6 +126,14 @@ jarl zone file adbraun.lol # the generated zone file
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa] jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
``` ```
Reverse names follow the dots (the nibbles in IPv6), so a reverse zone
covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is
covered by several zones, created together in one changeset: a /22 by four
/24 zones (up to 16 zones). A /32 (or /128) gets a zone of its own that
answers for that one address: useful when another server, such as your
router, answers for the rest of the /24. IPv4 networks smaller than a /24
(/25 to /31) live in the enclosing /24's zone.
`zone import` adds the records of a zone file (`-` reads stdin). With `zone import` adds the records of a zone file (`-` reads stdin). With
`--replace` the zone is made equal to the file, so records that aren't in it `--replace` the zone is made equal to the file, so records that aren't in it
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
+5 -3
View File
@@ -219,6 +219,8 @@ journal through jarld's log.
zones. Use the API (for example `jarl acme`) instead of nsupdate. zones. Use the API (for example `jarl acme`) instead of nsupdate.
* **DNSSEC signing**: not managed yet. The zone directory is * **DNSSEC signing**: not managed yet. The zone directory is
named-writable, so `dnssec-policy` can be added later. named-writable, so `dnssec-policy` can be added later.
* **Several servers**: Jarl manages the named on its own host. Secondaries * **Several servers from one jarld**: each jarld manages the named on its
elsewhere get zones by normal AXFR/IXFR (`allow-transfer`, `also-notify`, own host. A second server runs its own jarld with secondary zones, which
TSIG keys). copy the primary's zones by normal AXFR/IXFR (see "A secondary server" in
[operations.md](operations.md)). The CLI can name both and run a command
on both at once ([cli.md](cli.md#several-servers-and-groups)).
+30
View File
@@ -105,6 +105,36 @@ Start named, then apply again.
| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` | | Lost every admin account | `sudo -u jarl jarld user add --admin rescue` |
| Need a token without the web UI | `sudo -u jarl jarld user token admin cli` | | Need a token without the web UI | `sudo -u jarl jarld user token admin cli` |
## A secondary server
A second name server runs its own named and jarld; its zones are of type
`secondary` and copy the primary's records by zone transfer, so you only
ever edit records on the primary. With both named in the CLI configuration
([cli.md](cli.md#several-servers-and-groups)):
```sh
# 1. One TSIG key with the same secret on both servers.
jarl -s ns1 tsig create xfer # prints the secret
jarl -s ns2 tsig create xfer --secret 'THE-SECRET'
# 2. On the primary: let the key transfer the zone, and list ns2 as a
# name server (named sends NOTIFY to the zone's NS records).
jarl -s ns1 option set allow-transfer '{ key xfer; }' --zone adbraun.lol
jarl -s ns1 record add adbraun.lol @ NS ns2.adbraun.lol.
# 3. On the secondary: the zone, copied from the primary with the key.
jarl -s ns2 zone create adbraun.lol --type secondary --primary "192.0.2.53 key xfer"
```
If ns2 isn't one of the zone's NS records, add `also-notify { ADDRESS; };`
on the primary so it learns about changes quickly. With views, each view's
zones are transferred separately; match the secondary's requests to the
right view, for example with `match-clients { key xfer; ... };`.
Things that both servers need, such as ACLs, can be changed on both at once
with a group (`jarl -s dns acl update ...`): the change is checked on both
first and applied only if both accept it.
## Taking Jarl out of the picture ## Taking Jarl out of the picture
The generated files are plain BIND configuration. To stop using Jarl, The generated files are plain BIND configuration. To stop using Jarl,
+6
View File
@@ -8,6 +8,12 @@
| CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. | | CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. |
| Root on the server | `jarld` subcommands (`init`, `user`, `import`, `adopt`, `check`, `resync`, `backup`) | Work directly on the database. They are for bootstrapping and recovery, and are audited as "local". | | Root on the server | `jarld` subcommands (`init`, `user`, `import`, `adopt`, `check`, `resync`, `backup`) | Work directly on the database. They are for bootstrapping and recovery, and are audited as "local". |
API tokens are how you give a script, a tool or a person limited access:
a token restricted with grants (below) can, for example, only change TXT
records under `_acme-challenge` in one zone. TSIG keys, on the TSIG keys page,
are something else: shared secrets that BIND servers use to authenticate
each other (zone transfers, NOTIFY); they don't give access to Jarl.
Failed logins are rate limited: 8 per username and 20 per client IP within Failed logins are rate limited: 8 per username and 20 per client IP within
15 minutes. Failed token attempts count against the IP too. Every attempt 15 minutes. Failed token attempts count against the IP too. Every attempt
is in the audit log. is in the audit log.
+37
View File
@@ -8,6 +8,7 @@ import (
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -30,8 +31,18 @@ type Client struct {
Changeset int64 Changeset int64
DryRun bool DryRun bool
Force bool Force bool
// Preflight lets only requests through that can't change anything:
// reads and dry runs. Running a command on several servers uses it to
// check the command everywhere before running it anywhere.
Preflight bool
// Mutated records that a preflight run sent a (dry-run) change.
Mutated bool
} }
// ErrNoPreflight is returned in Preflight mode for a request that would
// change something and has no dry run.
var ErrNoPreflight = errors.New("this command can't be checked without running it, so it can't run on several servers at once; run it on one server at a time with --server NAME")
// Options configure New. // Options configure New.
type Options struct { type Options struct {
Server string Server string
@@ -124,6 +135,12 @@ func (e *Error) Check() []CheckMessage {
// Do sends a request. body is marshaled as JSON; out (if not nil) receives // Do sends a request. body is marshaled as JSON; out (if not nil) receives
// the JSON response. // the JSON response.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error { func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
if c.Preflight && method != http.MethodGet && path != "/system/check" {
if !isDryRun(query, body) {
return ErrNoPreflight
}
c.Mutated = true
}
u := c.BaseURL + api.Prefix + path u := c.BaseURL + api.Prefix + path
if len(query) > 0 { if len(query) > 0 {
u += "?" + query.Encode() u += "?" + query.Encode()
@@ -174,6 +191,26 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
return json.Unmarshal(data, out) return json.Unmarshal(data, out)
} }
// isDryRun reports whether a request asks for a dry run, in its query or
// its body.
func isDryRun(query url.Values, body any) bool {
if query.Get("dry_run") == "1" {
return true
}
switch b := body.(type) {
case api.BatchRequest:
return b.DryRun
case *api.BatchRequest:
return b != nil && b.DryRun
case map[string]any:
v, _ := b["dry_run"].(bool)
return v
case map[string]bool:
return b["dry_run"]
}
return false
}
// Get is Do with GET. // Get is Do with GET.
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
return c.Do(ctx, http.MethodGet, path, query, nil, out) return c.Do(ctx, http.MethodGet, path, query, nil, out)
+62 -17
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/netip" "net/netip"
"strconv"
"strings" "strings"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -330,31 +331,75 @@ func ReverseName(addr netip.Addr) string {
return sb.String() return sb.String()
} }
// ReverseZone returns the name of the reverse zone covering prefix. IPv4 // MaxReverseZones is the most zones ReverseZones splits a network into.
// prefixes must be /8, /16 or /24 and IPv6 prefixes a multiple of /4; other const MaxReverseZones = 16
// lengths need RFC 2317 style delegation, which must be set up by hand.
func ReverseZone(p netip.Prefix) (string, error) { // ReverseZones returns the reverse zones that together cover prefix.
// Reverse names follow octet (IPv4) or nibble (IPv6) boundaries, so a
// prefix in between is covered by several zones: a /22 by four /24 zones.
// A single address (/32, /128) gets a zone of its own, which answers for
// just that address. IPv4 networks smaller than a /24 (/25 to /31) are
// refused: their names live in the enclosing /24 zone.
func ReverseZones(p netip.Prefix) ([]string, error) {
p = p.Masked() p = p.Masked()
addr := p.Addr() addr := p.Addr()
bits := p.Bits() bits := p.Bits()
step := 4
if addr.Is4() { if addr.Is4() {
if bits != 8 && bits != 16 && bits != 24 { step = 8
return "", fmt.Errorf("IPv4 reverse zones must be /8, /16 or /24 (got /%d); use the enclosing /24 zone instead", bits) switch {
case bits < 8:
return nil, fmt.Errorf("/%d is too large for a reverse zone; the largest IPv4 reverse zone is a /8", bits)
case bits > 24 && bits < 32:
enclosing, _ := addr.Prefix(24)
return nil, fmt.Errorf("a /%d is part of %s, and its reverse names live in that /24's zone; create %s, or a /32 zone per address to answer for single addresses only", bits, enclosing, enclosing)
} }
b := addr.As4() } else if bits < 4 {
parts := []string{} return nil, fmt.Errorf("/%d is too large for a reverse zone", bits)
for i := bits/8 - 1; i >= 0; i-- {
parts = append(parts, fmt.Sprint(b[i]))
} }
return strings.Join(parts, ".") + ".in-addr.arpa.", nil zoneBits := (bits + step - 1) / step * step
n := 1 << (zoneBits - bits)
if n > MaxReverseZones {
return nil, fmt.Errorf("a /%d spans %d /%d reverse zones; use a /%d or create the zones you need", bits, n, zoneBits, zoneBits-step)
} }
if bits%4 != 0 || bits == 0 { full := ReverseName(addr)
return "", fmt.Errorf("IPv6 reverse zones must be a multiple of /4 (got /%d)", bits) labels := strings.Split(strings.TrimSuffix(strings.TrimSuffix(full, ".in-addr.arpa."), ".ip6.arpa."), ".")
suffix := ".ip6.arpa."
if addr.Is4() {
suffix = ".in-addr.arpa."
} }
full := ReverseName(addr) // 32 nibbles // labels are least significant first; the zone keeps zoneBits/step of
nibbles := strings.Split(strings.TrimSuffix(full, ".ip6.arpa."), ".") // the most significant ones.
keep := nibbles[len(nibbles)-bits/4:] keep := zoneBits / step
return strings.Join(keep, ".") + ".ip6.arpa.", nil zones := make([]string, 0, n)
for i := 0; i < n; i++ {
zl := append([]string(nil), labels[len(labels)-keep:]...)
if n > 1 {
// The last zone label counts through the network's range.
if addr.Is4() {
v, _ := strconv.Atoi(zl[0])
zl[0] = strconv.Itoa(v + i)
} else {
v := strings.IndexByte("0123456789abcdef", zl[0][0])
zl[0] = string("0123456789abcdef"[v+i])
}
}
zones = append(zones, strings.Join(zl, ".")+suffix)
}
return zones, nil
}
// ReverseZone returns the name of the reverse zone covering prefix, or an
// error when the prefix needs several zones (see ReverseZones).
func ReverseZone(p netip.Prefix) (string, error) {
zones, err := ReverseZones(p)
if err != nil {
return "", err
}
if len(zones) > 1 {
return "", fmt.Errorf("%s spans %d reverse zones (%s ... %s); create each of them, or use `jarl zone create %s`, which does", p.Masked(), len(zones), Display(zones[0]), Display(zones[len(zones)-1]), p.Masked())
}
return zones[0], nil
} }
// AddrFromReverse parses a full reverse-lookup owner name back into an // AddrFromReverse parses a full reverse-lookup owner name back into an
+32
View File
@@ -2,6 +2,7 @@ package dnsname
import ( import (
"net/netip" "net/netip"
"strings"
"testing" "testing"
) )
@@ -185,3 +186,34 @@ func TestOwnerAddressInReverseZone(t *testing.T) {
} }
} }
} }
func TestReverseZones(t *testing.T) {
cases := []struct {
prefix string
want string // zones joined by spaces, or "error"
}{
{"10.0.10.0/24", "10.0.10.in-addr.arpa."},
{"10.0.10.100/32", "100.10.0.10.in-addr.arpa."},
{"10.0.8.0/22", "8.0.10.in-addr.arpa. 9.0.10.in-addr.arpa. 10.0.10.in-addr.arpa. 11.0.10.in-addr.arpa."},
{"10.0.9.0/22", "8.0.10.in-addr.arpa. 9.0.10.in-addr.arpa. 10.0.10.in-addr.arpa. 11.0.10.in-addr.arpa."},
{"172.16.0.0/12", "16.172.in-addr.arpa. 17.172.in-addr.arpa. 18.172.in-addr.arpa. 19.172.in-addr.arpa. 20.172.in-addr.arpa. 21.172.in-addr.arpa. 22.172.in-addr.arpa. 23.172.in-addr.arpa. 24.172.in-addr.arpa. 25.172.in-addr.arpa. 26.172.in-addr.arpa. 27.172.in-addr.arpa. 28.172.in-addr.arpa. 29.172.in-addr.arpa. 30.172.in-addr.arpa. 31.172.in-addr.arpa."},
{"10.0.0.0/17", "error"}, // 128 zones
{"10.0.10.64/26", "error"},
{"10.0.0.0/7", "error"},
{"2001:db8::/47", "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. 1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."},
{"2001:db8::1/128", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."},
}
for _, c := range cases {
zones, err := ReverseZones(netip.MustParsePrefix(c.prefix))
got := strings.Join(zones, " ")
if err != nil {
got = "error"
}
if got != c.want {
t.Errorf("ReverseZones(%s) = %s (%v), want %s", c.prefix, got, err, c.want)
}
}
if _, err := ReverseZone(netip.MustParsePrefix("10.0.8.0/22")); err == nil || !strings.Contains(err.Error(), "spans 4 reverse zones") {
t.Errorf("ReverseZone /22: %v", err)
}
}
+24 -1
View File
@@ -108,9 +108,32 @@ func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error {
return fmt.Errorf("chgrp %s: %w", dir, err) return fmt.Errorf("chgrp %s: %w", dir, err)
} }
} }
return os.Chmod(dir, mode) fi, err := os.Stat(dir)
if err != nil {
return err
}
// mkdir applies the umask, and a setgid parent passes its bit on, so
// the mode usually needs fixing. Under systemd's RestrictSUIDSGID (as in
// jarl.service) any chmod that includes the setgid bit fails with
// EPERM; the permission bits matter more (named writes journals here),
// and every file Jarl writes gets the group explicitly, so fall back to
// them alone.
if fi.Mode()&(fs.ModePerm|fs.ModeSetgid) == mode&(fs.ModePerm|fs.ModeSetgid) {
return nil
}
err = chmod(dir, mode)
if errors.Is(err, fs.ErrPermission) && mode&fs.ModeSetgid != 0 {
err = chmod(dir, mode.Perm())
}
if err != nil {
return fmt.Errorf("chmod %s: %w", dir, err)
}
return nil
} }
// chmod is os.Chmod; tests replace it to simulate a sandbox.
var chmod = os.Chmod
func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error { func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil { if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
+47
View File
@@ -0,0 +1,47 @@
package engine
import (
"io/fs"
"os"
"path/filepath"
"syscall"
"testing"
)
// Under systemd's RestrictSUIDSGID a chmod that sets the setgid bit fails
// with EPERM; EnsureDir must still create a group-writable directory.
func TestEnsureDirSetgidRestricted(t *testing.T) {
old := chmod
defer func() { chmod = old }()
chmod = func(name string, mode fs.FileMode) error {
if mode&fs.ModeSetgid != 0 {
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.EPERM}
}
return os.Chmod(name, mode)
}
oldMask := syscall.Umask(0o027)
defer syscall.Umask(oldMask)
root := t.TempDir()
d := &Deployer{NamedWritable: []string{root}}
dir := filepath.Join(root, "internal")
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o770 {
t.Errorf("mode = %v, want group-writable 0770", fi.Mode())
}
// Unrestricted, the setgid bit is set too.
chmod = os.Chmod
dir2 := filepath.Join(root, "external")
if err := d.EnsureDir(dir2, d.dirMode(dir2)); err != nil {
t.Fatal(err)
}
if fi, _ := os.Stat(dir2); fi.Mode()&(fs.ModePerm|fs.ModeSetgid) != 0o770|fs.ModeSetgid {
t.Errorf("mode = %v, want 2770", fi.Mode())
}
}
+1 -1
View File
@@ -966,7 +966,7 @@ func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error {
if in.Name != nil { if in.Name != nil {
name := model.NormalizeKeyName(*in.Name) name := model.NormalizeKeyName(*in.Name)
if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." { if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." {
return errf(KindBadRequest, "invalid key name %q (key names are domain names, e.g. xfer-key or acme.example.com)", *in.Name) return errf(KindBadRequest, "invalid key name %q (use letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.example.com)", *in.Name)
} }
if other := st.KeyByName(name); other != nil && other.ID != k.ID { if other := st.KeyByName(name); other != nil && other.ID != k.ID {
return errf(KindConflict, "TSIG key %s already exists", name) return errf(KindConflict, "TSIG key %s already exists", name)
+18 -13
View File
@@ -85,6 +85,7 @@ function renderLogin(message) {
function layout() { function layout() {
navEl = h("nav", { class: "main" }); navEl = h("nav", { class: "main" });
let modeEl;
barEl = h("div"); barEl = h("div");
mainEl = h("main"); mainEl = h("main");
const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" }); const q = h("input", { type: "search", placeholder: "Search names, data…", "aria-label": "Search" });
@@ -96,38 +97,42 @@ function layout() {
h("span", { class: "brand-version" }, me.version)), h("span", { class: "brand-version" }, me.version)),
navEl, navEl,
h("div", { class: "spacer" }), h("div", { class: "spacer" }),
modeEl = h("span"),
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q), h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username), h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null, me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out")); h("button", { class: "small", onclick: logout }, "Log out"));
mount(app, header, barEl, mainEl); mount(app, header, barEl, mainEl);
modeHolder = modeEl;
renderNav(); renderNav();
renderBar(); renderBar();
} }
let modeHolder;
function renderNav() { function renderNav() {
mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label))); mount(navEl, navItems.map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
} }
// renderBar shows the pending-changes bar (only while there are pending
// changes) and the small review/live switch in the header.
function renderBar() { function renderBar() {
if (!barEl) return; if (!barEl) return;
const d = state.draft; const d = state.draft;
const n = d ? (d.ops || []).length : 0; const n = d ? (d.ops || []).length : 0;
const mode = h("label", { class: "check small", title: "When on, every edit is applied to named right away instead of being collected for review." }, if (modeHolder) {
h("input", { type: "checkbox", checked: state.immediate, onchange: (e) => setImmediate(e.target.checked) }), "Apply edits immediately"); const live = state.immediate;
if (n > 0) { mount(modeHolder, h("button", {
mount(barEl, h("div", { class: "pending-bar" }, type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
title: live ? "Edits are applied to named right away. Click to collect them for review first."
: "Edits are collected as pending changes and applied from Changes. Click to apply edits right away.",
onclick: () => setImmediate(!live),
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
}
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")), h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."), h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply"), h("a", { class: "button primary small", href: "#/changes" }, "Review & apply")) : null);
h("div", { class: "spacer", style: { flex: 1 } }),
mode));
} else {
mount(barEl, h("div", { class: "pending-bar", style: { background: "var(--panel)" } },
h("span", { class: "muted small" }, state.immediate ? "Edits are applied to named immediately." : "Edits are collected as pending changes; review and apply them under Changes."),
h("div", { style: { flex: 1 } }),
mode));
}
} }
async function logout() { async function logout() {
+50 -18
View File
@@ -39,28 +39,57 @@ export function parseIPv6(s) {
return groups.map((g) => g.padStart(4, "0")).join("").split(""); return groups.map((g) => g.padStart(4, "0")).join("").split("");
} }
// maxReverseZones matches the server's limit for splitting a network.
const maxReverseZones = 16;
// reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse // reverseZoneFor turns a network such as 10.10.0.0/24 into its reverse
// zone. It returns {name} or {error}. // zones. Reverse names follow octets (IPv4) or nibbles (IPv6), so a /22 is
// covered by four /24 zones; a /32 gets a zone for just that address. It
// returns {names, network} (with name when there is a single zone) or
// {error}.
export function reverseZoneFor(cidr) { export function reverseZoneFor(cidr) {
const [addr, bitsText] = cidr.trim().split("/"); const [addr, bitsText] = cidr.trim().split("/");
if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24" }; if (bitsText === undefined) return { error: "give the network with its prefix length, e.g. 10.10.0.0/24 (or 10.10.0.5/32 for one address)" };
const bits = Number(bitsText); const bits = Number(bitsText);
const b4 = parseIPv4(addr); const b4 = parseIPv4(addr);
if (b4) { const n6 = b4 ? null : parseIPv6(addr);
if (![8, 16, 24].includes(bits)) return { error: "IPv4 reverse zones follow the dots: use /8, /16 or /24 (a /" + bits + " network goes in the enclosing /" + (bits > 24 ? 24 : bits > 16 ? 16 : 8) + " zone)" }; if (!b4 && !n6) return { error: "not an IPv4 or IPv6 network" };
const n = bits / 8; // Work on digits: octets for IPv4, nibbles for IPv6.
const host = b4.slice(n); const digits = b4 || n6.map((x) => parseInt(x, 16));
if (host.some((x) => x !== 0)) return { error: "that's an address inside the network; the network is " + b4.slice(0, n).concat(host.map(() => 0)).join(".") + "/" + bits }; const step = b4 ? 8 : 4, total = b4 ? 32 : 128;
return { name: b4.slice(0, n).reverse().join(".") + ".in-addr.arpa", network: b4.slice(0, n).join(".") + (n < 4 ? "." + Array(4 - n).fill(0).join(".") : "") + "/" + bits }; if (!Number.isInteger(bits) || bits < 0 || bits > total) return { error: "the prefix length must be 0 to " + total };
if (bits < step) return { error: "/" + bits + " is too large for a reverse zone" };
if (b4 && bits > 24 && bits < 32) {
const enclosing = b4.slice(0, 3).join(".") + ".0/24";
return { error: "a /" + bits + " is part of " + enclosing + ", and all its reverse names live in that /24's zone: create " + enclosing + ", or a /32 zone per address to answer for single addresses only" };
} }
const n6 = parseIPv6(addr); // The host part must be zero.
if (n6) { for (let i = 0; i < digits.length; i++) {
if (!(bits > 0 && bits <= 124 && bits % 4 === 0)) return { error: "IPv6 reverse zones need a prefix that is a multiple of 4, e.g. /48 or /64" }; const keepBits = Math.max(0, Math.min(step, bits - i * step));
const keep = n6.slice(0, bits / 4); if (digits[i] & ((1 << (step - keepBits)) - 1)) {
if (n6.slice(bits / 4).some((x) => x !== "0")) return { error: "the address has bits set after /" + bits + "; use the network address" }; const net = digits.map((d, j) => { const k = Math.max(0, Math.min(step, bits - j * step)); return d & ~((1 << (step - k)) - 1); });
return { name: keep.slice().reverse().join(".") + ".ip6.arpa", network: cidr.trim() }; return { error: "the address has bits set after /" + bits + "; the network is " + (b4 ? net.join(".") : sixOf(net.map((d) => d.toString(16)))) + "/" + bits };
} }
return { error: "not an IPv4 or IPv6 network" }; }
const zoneBits = Math.ceil(bits / step) * step;
const n = 2 ** (zoneBits - bits);
if (n > maxReverseZones) return { error: "a /" + bits + " spans " + n + " /" + zoneBits + " reverse zones; use a /" + (zoneBits - step) + " or create the zones you need" };
const keep = zoneBits / step;
const names = [];
for (let i = 0; i < n; i++) {
const d = digits.slice(0, keep);
d[keep - 1] += i;
names.push(d.map((x) => (b4 ? String(x) : x.toString(16))).reverse().join(".") + (b4 ? ".in-addr.arpa" : ".ip6.arpa"));
}
const network = (b4 ? b4.join(".") : sixOf(n6)) + "/" + bits;
return n === 1 ? { name: names[0], names, network } : { names, network };
}
// sixOf formats 32 nibbles as a compressed IPv6 address.
function sixOf(nib) {
const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(nib.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
return groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::");
} }
export function isReverseZone(name) { export function isReverseZone(name) {
@@ -73,16 +102,17 @@ export function reverseInfo(zoneName) {
const n = zoneName.replace(/\.$/, "").toLowerCase(); const n = zoneName.replace(/\.$/, "").toLowerCase();
if (n.endsWith(".in-addr.arpa")) { if (n.endsWith(".in-addr.arpa")) {
const labels = n.slice(0, -".in-addr.arpa".length).split(".").reverse(); const labels = n.slice(0, -".in-addr.arpa".length).split(".").reverse();
if (labels.length > 3 || labels.some((l) => !/^\d+$/.test(l))) return null; if (labels.length > 4 || labels.some((l) => !/^\d+$/.test(l))) return null;
const pad = Array(4 - labels.length).fill("0"); const pad = Array(4 - labels.length).fill("0");
return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join(".") }; return { family: 4, prefix: labels, network: [...labels, ...pad].join(".") + "/" + labels.length * 8, example: [...labels, ...pad.map((_, i) => (i === pad.length - 1 ? "20" : "0"))].join("."), single: labels.length === 4 };
} }
if (n.endsWith(".ip6.arpa")) { if (n.endsWith(".ip6.arpa")) {
const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse(); const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse();
const full = [...nib, ...Array(32 - nib.length).fill("0")]; const full = [...nib, ...Array(32 - nib.length).fill("0")];
const groups = []; const groups = [];
for (let i = 0; i < 32; i += 4) groups.push(full.slice(i, i + 4).join("").replace(/^0+(?=.)/, "")); for (let i = 0; i < 32; i += 4) groups.push(full.slice(i, i + 4).join("").replace(/^0+(?=.)/, ""));
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: groups.slice(0, 4).join(":") + "::20" }; const single = nib.length === 32;
return { family: 6, prefix: nib, network: groups.join(":").replace(/(^|:)0(:0)+(:|$)/, "::") + "/" + nib.length * 4, example: single ? sixOf(full) : groups.slice(0, 4).join(":") + "::20", single };
} }
return null; return null;
} }
@@ -101,6 +131,8 @@ export function ptrOwner(input, zoneName) {
const inZone = info.prefix.every((p, i) => parts[i] === p); const inZone = info.prefix.every((p, i) => parts[i] === p);
if (!inZone) return { error: s + " is not in " + info.network + ", the network this zone covers" }; if (!inZone) return { error: s + " is not in " + info.network + ", the network this zone covers" };
const rel = parts.slice(info.prefix.length).reverse().join("."); const rel = parts.slice(info.prefix.length).reverse().join(".");
// In a single-address zone the address is the zone's apex.
if (!rel) return { relative: "@", fqdn: zone };
return { relative: rel, fqdn: rel + "." + zone }; return { relative: rel, fqdn: rel + "." + zone };
} }
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") }; if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
+3 -2
View File
@@ -17,7 +17,8 @@ export async function render(el) {
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "TSIG keys"), h("div", { class: "grow" }, h("h1", null, "TSIG keys"),
h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'.")), h("div", { class: "muted" }, "Shared secrets that authenticate zone transfers, NOTIFY and other server-to-server traffic. Use them in ACLs and options as 'key NAME'."),
h("div", { class: "muted small" }, "To let a script, tool or person change only some zones or records through Jarl, create an ", h("a", { href: "#/account" }, "API token"), " restricted to them instead.")),
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => keyDialog() }, "New key"))), h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => keyDialog() }, "New key"))),
h("div", { class: "panel" }, table([ h("div", { class: "panel" }, table([
{ label: "Name", render: (k) => h("strong", { class: "mono" }, k.name) }, { label: "Name", render: (k) => h("strong", { class: "mono" }, k.name) },
@@ -52,7 +53,7 @@ async function keyDialog() {
const secret = input({ placeholder: "leave empty to generate a random secret", class: "mono" }); const secret = input({ placeholder: "leave empty to generate a random secret", class: "mono" });
const comment = input(); const comment = input();
await modal("New TSIG key", h("div", null, h("div", { class: "form-grid" }, await modal("New TSIG key", h("div", null, h("div", { class: "form-grid" },
field("Name", name, "Key names are domain names, e.g. xfer-key or secondary.example.com."), field("Name", name, "Letters, digits, hyphens and dots, e.g. xfer-key or ns2-xfer.adbraun.lol. It's only a label (it doesn't have to exist in DNS), but both servers must use exactly the same name."),
field("Algorithm", alg), field("Algorithm", alg),
field("Secret", secret, "Base64. Must be the same on both servers."), field("Secret", secret, "Base64. Must be the same on both servers."),
field("Comment", comment)), err), [ field("Comment", comment)), err), [
+21 -9
View File
@@ -1,5 +1,5 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js"; import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge } from "../ui.js";
import { get, change, describeResult, can, draftQuery } from "../api.js"; import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js"; import { checkDomain, reverseZoneFor } from "../names.js";
@@ -63,11 +63,13 @@ export async function newZoneDialog(views, zones, settings) {
const cloneSel = select([["", "– don't copy –"]], ""); const cloneSel = select([["", "– don't copy –"]], "");
const comment = input(); const comment = input();
const updateClones = () => { const updateClones = () => {
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "" : name.value.trim().toLowerCase().replace(/\.$/, "")) + "."; // Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"), mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]"))); zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
}; };
// check validates the name as typed and returns the zone name (or null). // check validates the name as typed and returns the zone name (or null);
// a network that needs several reverse zones gives a list of names.
const check = () => { const check = () => {
const v = name.value.trim(); const v = name.value.trim();
const msgs = []; const msgs = [];
@@ -80,8 +82,16 @@ export async function newZoneDialog(views, zones, settings) {
if (kind === "reverse") { if (kind === "reverse") {
const r = reverseZoneFor(v); const r = reverseZoneFor(v);
if (r.error) msgs.push(["err", r.error]); if (r.error) msgs.push(["err", r.error]);
else zoneName = r.name; else zoneName = r.name || r.names;
mount(preview, zoneName ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, " · answers \"which host has address …?\" for " + r.network)) : null, const single = /\/(32|128)$/.test(r.network || "");
const what = single ? " · answers \"which host has address " + r.network.replace(/\/\d+$/, "") + "?\" and nothing else in its network"
: " · answers \"which host has address …?\" for " + r.network;
mount(preview,
typeof zoneName === "string" ? h("div", null, "Zone name: ", h("strong", { class: "mono" }, zoneName), h("span", { class: "muted" }, what)) : null,
Array.isArray(zoneName) ? h("div", null,
h("div", null, "Reverse names follow the dots, so " + r.network + " is covered by " + zoneName.length + " zones, created together:"),
h("div", { class: "mono small" }, zoneName.join(", ")),
h("div", { class: "muted small" }, "Answers \"which host has address …?\" for " + r.network + ".")) : null,
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m))); msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
} else { } else {
const c = checkDomain(v); const c = checkDomain(v);
@@ -104,9 +114,9 @@ export async function newZoneDialog(views, zones, settings) {
msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m))); msgs.map(([k, m]) => h("div", { class: "msg " + k + " small" }, m)));
} }
if (zoneName && kind === "reverse") { if (zoneName && kind === "reverse") {
const fq = zoneName + "."; const existing = [zoneName].flat().filter((n) => zones.find((z) => z.name === n + "." && (z.view || "") === (view.value || "")));
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) { if (existing.length) {
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : ""))); mount(preview, h("div", { class: "msg err small" }, "the reverse zone" + (existing.length > 1 ? "s " : " ") + existing.join(", ") + (existing.length > 1 ? " already exist" : " already exists") + (view.value ? " in view " + view.value : "")));
return null; return null;
} }
} }
@@ -194,7 +204,9 @@ export async function newZoneDialog(views, zones, settings) {
} }
if (opts.length) data.options = opts; if (opts.length) data.options = opts;
try { try {
const r = await change("POST", "/zones", data); const r = Array.isArray(zoneName)
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
: await change("POST", "/zones", data);
toast(describeResult(r)); toast(describeResult(r));
rerender(); rerender();
return true; return true;
+6
View File
@@ -118,6 +118,12 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
padding: 8px 16px; padding: 8px 16px;
} }
.pending-bar strong { color: var(--warn); } .pending-bar strong { color: var(--warn); }
/* The review/live switch in the header. */
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
.mode-toggle .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.mode-toggle.live { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
.mode-toggle.live .dot { background: var(--warn); }
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } .page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.page-head .grow { flex: 1; min-width: 200px; } .page-head .grow { flex: 1; min-width: 200px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }