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
@@ -10,6 +10,21 @@ The first version, to be released as 0.1.0.
|
||||
|
||||
### 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
|
||||
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
|
||||
@@ -116,6 +131,13 @@ The first version, to be released as 0.1.0.
|
||||
|
||||
### 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
|
||||
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
|
||||
@@ -141,6 +163,11 @@ The first version, to be released as 0.1.0.
|
||||
|
||||
### 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
|
||||
the name and the version on hover.
|
||||
- Web UI: creating a zone from the dashboard's "New zone" link no longer
|
||||
|
||||
@@ -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
|
||||
|
||||
+60
-1
@@ -14,7 +14,7 @@ jarl whoami
|
||||
|
||||
| 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` |
|
||||
| Skip TLS verification | `--insecure` | | `insecure` |
|
||||
| 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
|
||||
`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
|
||||
|
||||
| Flag | Meaning |
|
||||
@@ -65,6 +114,8 @@ jarl zone list [--view V] [--type primary]
|
||||
jarl zone show 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 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 partner.example --type secondary --primary "192.0.2.53 key xfer"
|
||||
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]
|
||||
```
|
||||
|
||||
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
|
||||
`--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
|
||||
|
||||
+5
-3
@@ -219,6 +219,8 @@ journal through jarld's log.
|
||||
zones. Use the API (for example `jarl acme`) instead of nsupdate.
|
||||
* **DNSSEC signing**: not managed yet. The zone directory is
|
||||
named-writable, so `dnssec-policy` can be added later.
|
||||
* **Several servers**: Jarl manages the named on its own host. Secondaries
|
||||
elsewhere get zones by normal AXFR/IXFR (`allow-transfer`, `also-notify`,
|
||||
TSIG keys).
|
||||
* **Several servers from one jarld**: each jarld manages the named on its
|
||||
own host. A second server runs its own jarld with secondary zones, which
|
||||
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)).
|
||||
@@ -105,6 +105,36 @@ Start named, then apply again.
|
||||
| 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` |
|
||||
|
||||
## 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
|
||||
|
||||
The generated files are plain BIND configuration. To stop using Jarl,
|
||||
|
||||
@@ -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. |
|
||||
| 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
|
||||
15 minutes. Failed token attempts count against the IP too. Every attempt
|
||||
is in the audit log.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -30,8 +31,18 @@ type Client struct {
|
||||
Changeset int64
|
||||
DryRun 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.
|
||||
type Options struct {
|
||||
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
|
||||
// the JSON response.
|
||||
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
|
||||
if len(query) > 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
|
||||
+62
-17
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
@@ -330,31 +331,75 @@ func ReverseName(addr netip.Addr) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ReverseZone returns the name of the reverse zone covering prefix. IPv4
|
||||
// prefixes must be /8, /16 or /24 and IPv6 prefixes a multiple of /4; other
|
||||
// lengths need RFC 2317 style delegation, which must be set up by hand.
|
||||
func ReverseZone(p netip.Prefix) (string, error) {
|
||||
// MaxReverseZones is the most zones ReverseZones splits a network into.
|
||||
const MaxReverseZones = 16
|
||||
|
||||
// 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()
|
||||
addr := p.Addr()
|
||||
bits := p.Bits()
|
||||
step := 4
|
||||
if addr.Is4() {
|
||||
if bits != 8 && bits != 16 && bits != 24 {
|
||||
return "", fmt.Errorf("IPv4 reverse zones must be /8, /16 or /24 (got /%d); use the enclosing /24 zone instead", bits)
|
||||
step = 8
|
||||
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()
|
||||
parts := []string{}
|
||||
for i := bits/8 - 1; i >= 0; i-- {
|
||||
parts = append(parts, fmt.Sprint(b[i]))
|
||||
} else if bits < 4 {
|
||||
return nil, fmt.Errorf("/%d is too large for a reverse zone", bits)
|
||||
}
|
||||
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)
|
||||
}
|
||||
full := ReverseName(addr)
|
||||
labels := strings.Split(strings.TrimSuffix(strings.TrimSuffix(full, ".in-addr.arpa."), ".ip6.arpa."), ".")
|
||||
suffix := ".ip6.arpa."
|
||||
if addr.Is4() {
|
||||
suffix = ".in-addr.arpa."
|
||||
}
|
||||
// labels are least significant first; the zone keeps zoneBits/step of
|
||||
// the most significant ones.
|
||||
keep := zoneBits / step
|
||||
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])
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".") + ".in-addr.arpa.", nil
|
||||
zones = append(zones, strings.Join(zl, ".")+suffix)
|
||||
}
|
||||
if bits%4 != 0 || bits == 0 {
|
||||
return "", fmt.Errorf("IPv6 reverse zones must be a multiple of /4 (got /%d)", bits)
|
||||
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
|
||||
}
|
||||
full := ReverseName(addr) // 32 nibbles
|
||||
nibbles := strings.Split(strings.TrimSuffix(full, ".ip6.arpa."), ".")
|
||||
keep := nibbles[len(nibbles)-bits/4:]
|
||||
return strings.Join(keep, ".") + ".ip6.arpa.", nil
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ package dnsname
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,32 @@ func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error {
|
||||
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 {
|
||||
dir := filepath.Dir(path)
|
||||
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -966,7 +966,7 @@ func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.Name != nil {
|
||||
name := model.NormalizeKeyName(*in.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 {
|
||||
return errf(KindConflict, "TSIG key %s already exists", name)
|
||||
|
||||
@@ -85,6 +85,7 @@ function renderLogin(message) {
|
||||
|
||||
function layout() {
|
||||
navEl = h("nav", { class: "main" });
|
||||
let modeEl;
|
||||
barEl = h("div");
|
||||
mainEl = h("main");
|
||||
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)),
|
||||
navEl,
|
||||
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("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { class: "small", onclick: logout }, "Log out"));
|
||||
mount(app, header, barEl, mainEl);
|
||||
modeHolder = modeEl;
|
||||
renderNav();
|
||||
renderBar();
|
||||
}
|
||||
|
||||
let modeHolder;
|
||||
|
||||
function renderNav() {
|
||||
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() {
|
||||
if (!barEl) return;
|
||||
const d = state.draft;
|
||||
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." },
|
||||
h("input", { type: "checkbox", checked: state.immediate, onchange: (e) => setImmediate(e.target.checked) }), "Apply edits immediately");
|
||||
if (n > 0) {
|
||||
mount(barEl, h("div", { class: "pending-bar" },
|
||||
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("a", { class: "button primary small", href: "#/changes" }, "Review & apply"),
|
||||
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));
|
||||
if (modeHolder) {
|
||||
const live = state.immediate;
|
||||
mount(modeHolder, h("button", {
|
||||
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("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")) : null);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
|
||||
@@ -39,28 +39,57 @@ export function parseIPv6(s) {
|
||||
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
|
||||
// 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) {
|
||||
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 b4 = parseIPv4(addr);
|
||||
if (b4) {
|
||||
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)" };
|
||||
const n = bits / 8;
|
||||
const host = b4.slice(n);
|
||||
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 };
|
||||
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 };
|
||||
const n6 = b4 ? null : parseIPv6(addr);
|
||||
if (!b4 && !n6) return { error: "not an IPv4 or IPv6 network" };
|
||||
// Work on digits: octets for IPv4, nibbles for IPv6.
|
||||
const digits = b4 || n6.map((x) => parseInt(x, 16));
|
||||
const step = b4 ? 8 : 4, total = b4 ? 32 : 128;
|
||||
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);
|
||||
if (n6) {
|
||||
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 keep = n6.slice(0, bits / 4);
|
||||
if (n6.slice(bits / 4).some((x) => x !== "0")) return { error: "the address has bits set after /" + bits + "; use the network address" };
|
||||
return { name: keep.slice().reverse().join(".") + ".ip6.arpa", network: cidr.trim() };
|
||||
// The host part must be zero.
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
const keepBits = Math.max(0, Math.min(step, bits - i * step));
|
||||
if (digits[i] & ((1 << (step - keepBits)) - 1)) {
|
||||
const net = digits.map((d, j) => { const k = Math.max(0, Math.min(step, bits - j * step)); return d & ~((1 << (step - k)) - 1); });
|
||||
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) {
|
||||
@@ -73,16 +102,17 @@ export function reverseInfo(zoneName) {
|
||||
const n = zoneName.replace(/\.$/, "").toLowerCase();
|
||||
if (n.endsWith(".in-addr.arpa")) {
|
||||
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");
|
||||
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")) {
|
||||
const nib = n.slice(0, -".ip6.arpa".length).split(".").reverse();
|
||||
const full = [...nib, ...Array(32 - nib.length).fill("0")];
|
||||
const groups = [];
|
||||
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;
|
||||
}
|
||||
@@ -101,6 +131,8 @@ export function ptrOwner(input, zoneName) {
|
||||
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" };
|
||||
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 };
|
||||
}
|
||||
if (s.endsWith(".")) return { relative: s, fqdn: s.replace(/\.$/, "") };
|
||||
|
||||
@@ -17,7 +17,8 @@ export async function render(el) {
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
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: "panel" }, table([
|
||||
{ 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 comment = input();
|
||||
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("Secret", secret, "Base64. Must be the same on both servers."),
|
||||
field("Comment", comment)), err), [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { checkDomain, reverseZoneFor } from "../names.js";
|
||||
|
||||
@@ -63,11 +63,13 @@ export async function newZoneDialog(views, zones, settings) {
|
||||
const cloneSel = select([["", "– don't copy –"]], "");
|
||||
const comment = input();
|
||||
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 –"),
|
||||
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 v = name.value.trim();
|
||||
const msgs = [];
|
||||
@@ -80,8 +82,16 @@ export async function newZoneDialog(views, zones, settings) {
|
||||
if (kind === "reverse") {
|
||||
const r = reverseZoneFor(v);
|
||||
if (r.error) msgs.push(["err", r.error]);
|
||||
else zoneName = r.name;
|
||||
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,
|
||||
else zoneName = r.name || r.names;
|
||||
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)));
|
||||
} else {
|
||||
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)));
|
||||
}
|
||||
if (zoneName && kind === "reverse") {
|
||||
const fq = zoneName + ".";
|
||||
if (zones.find((z) => z.name === fq && (z.view || "") === (view.value || ""))) {
|
||||
mount(preview, h("div", { class: "msg err small" }, "the reverse zone " + zoneName + " already exists" + (view.value ? " in view " + view.value : "")));
|
||||
const existing = [zoneName].flat().filter((n) => zones.find((z) => z.name === n + "." && (z.view || "") === (view.value || "")));
|
||||
if (existing.length) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -194,7 +204,9 @@ export async function newZoneDialog(views, zones, settings) {
|
||||
}
|
||||
if (opts.length) data.options = opts;
|
||||
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));
|
||||
rerender();
|
||||
return true;
|
||||
|
||||
@@ -118,6 +118,12 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.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 .grow { flex: 1; min-width: 200px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
Reference in new issue
Block a user