A token for one view of a linked zone (an ACME token for external) couldn't add records: a new record was shared and reached the other view. A new name and type is now local when the user may not change it in the other views. A zone name in several views resolves to the only one the user can see, and the error no longer names views they can't. GET /views lists the views a user sees zones in (name, colour, count; limited) without views:read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
901 lines
27 KiB
Go
901 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
func viewCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "view", Aliases: []string{"views"}, Short: "Manage views"}
|
|
list := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List views in match order",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var views []api.View
|
|
if err := c.Get(ctx(), "/views", nil, &views); err != nil {
|
|
return err
|
|
}
|
|
return output(views, func(w io.Writer) {
|
|
fmt.Fprintln(w, "#\tID\tVIEW\tMATCH-CLIENTS\tMATCH-DESTINATIONS\tZONES\tCOLOR")
|
|
for i, v := range views {
|
|
clients, dests := orAny(v.MatchClients), orAny(v.MatchDestinations)
|
|
if v.Limited {
|
|
// No views:read: only the zones they can see.
|
|
clients, dests = "(not shown)", "(not shown)"
|
|
}
|
|
fmt.Fprintf(w, "%d\t%d\t%s\t%s\t%s\t%d\t%s\n", i+1, v.ID, v.Name, clients, dests, v.Zones, orDefault(v.Color))
|
|
}
|
|
})
|
|
},
|
|
}
|
|
var clients, dests []string
|
|
var recursiveOnly, comment, exposure string
|
|
var adopt, cascade, yes, reverseSuggest bool
|
|
viewBody := func(cmd *cobra.Command) map[string]any {
|
|
body := map[string]any{}
|
|
f := cmd.Flags()
|
|
if f.Changed("match-clients") {
|
|
body["match_clients"] = clients
|
|
}
|
|
if f.Changed("match-destinations") {
|
|
body["match_destinations"] = dests
|
|
}
|
|
if f.Changed("recursive-only") {
|
|
switch recursiveOnly {
|
|
case "yes", "true":
|
|
body["match_recursive_only"] = true
|
|
case "no", "false":
|
|
body["match_recursive_only"] = false
|
|
default:
|
|
body["match_recursive_only"] = nil
|
|
}
|
|
}
|
|
if f.Changed("comment") {
|
|
body["comment"] = comment
|
|
}
|
|
if f.Changed("exposure") {
|
|
body["exposure"] = exposure
|
|
}
|
|
if f.Changed("reverse-suggestions") {
|
|
body["reverse_suggestions"] = reverseSuggest
|
|
}
|
|
return body
|
|
}
|
|
addViewFlags := func(c *cobra.Command) {
|
|
c.Flags().StringSliceVar(&clients, "match-clients", nil, "match-clients elements (comma separated or repeated)")
|
|
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
|
|
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
|
c.Flags().StringVar(&comment, "comment", "", "comment")
|
|
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
|
c.Flags().BoolVar(&reverseSuggest, "reverse-suggestions", true, "suggest reverse zones for the view's addresses (--reverse-suggestions=false: don't; docs/reverse.md)")
|
|
}
|
|
create := &cobra.Command{
|
|
Use: "create NAME",
|
|
Short: "Create a view (added after the existing views)",
|
|
Example: ` jarl view create internal --match-clients 192.168.0.0/16,localhost --adopt-zones
|
|
jarl view create external --match-clients any`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := viewBody(cmd)
|
|
body["name"] = args[0]
|
|
if adopt {
|
|
body["adopt_zones"] = true
|
|
}
|
|
resp, err := c.Mutate(ctx(), "POST", "/views", nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
addViewFlags(create)
|
|
create.Flags().BoolVar(&adopt, "adopt-zones", false, "move zones that are not in a view into this one (required for the first view if zones exist)")
|
|
var rename string
|
|
update := &cobra.Command{
|
|
Use: "update VIEW",
|
|
Short: "Change a view",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := viewBody(cmd)
|
|
if rename != "" {
|
|
body["name"] = rename
|
|
}
|
|
resp, err := c.Mutate(ctx(), "PATCH", "/views/"+url.PathEscape(args[0]), nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
addViewFlags(update)
|
|
update.Flags().StringVar(&rename, "rename", "", "new name")
|
|
del := &cobra.Command{
|
|
Use: "delete VIEW",
|
|
Short: "Delete a view",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if cascade && !confirm("Delete view "+args[0]+" with all its zones?", yes) {
|
|
return fmt.Errorf("canceled")
|
|
}
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qv := q()
|
|
if cascade {
|
|
qv.Set("cascade", "1")
|
|
}
|
|
resp, err := c.Mutate(ctx(), "DELETE", "/views/"+url.PathEscape(args[0]), qv, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
del.Flags().BoolVar(&cascade, "cascade", false, "also delete the view's zones")
|
|
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation")
|
|
order := &cobra.Command{
|
|
Use: "order VIEW...",
|
|
Short: "Set the order in which views are matched (list every view)",
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var views []api.View
|
|
if err := c.Get(ctx(), "/views", nil, &views); err != nil {
|
|
return err
|
|
}
|
|
byName := map[string]int64{}
|
|
for _, v := range views {
|
|
byName[strings.ToLower(v.Name)] = v.ID
|
|
}
|
|
var ids []int64
|
|
for _, a := range args {
|
|
id, ok := byName[strings.ToLower(a)]
|
|
if !ok {
|
|
return fmt.Errorf("no view %q", a)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
resp, err := c.Mutate(ctx(), "POST", "/views/order", nil, map[string]any{"ids": ids})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
color := &cobra.Command{
|
|
Use: "color VIEW COLOR",
|
|
Short: "Set the colour the web UI shows a view in",
|
|
Long: "Set the colour the web UI shows a view in, so zones of the same view are easy to spot.\n" +
|
|
"Colours: default (the link colour), " + strings.Join(model.ViewColors, ", ") + ".\n" +
|
|
"This only changes the web UI: it applies at once, with no changeset or deploy.",
|
|
Example: ` jarl view color internal green
|
|
jarl view color internal default`,
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
col := strings.ToLower(args[1])
|
|
if col == "default" {
|
|
col = ""
|
|
} else if !slices.Contains(model.ViewColors, col) {
|
|
return fmt.Errorf("unknown colour %q: use default, %s", args[1], strings.Join(model.ViewColors, ", "))
|
|
}
|
|
var v api.View
|
|
if err := c.Do(ctx(), "PUT", "/views/"+url.PathEscape(args[0])+"/color", nil, api.ViewColor{Color: col}, &v); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("View %s: colour %s\n", v.Name, orDefault(v.Color))
|
|
return nil
|
|
},
|
|
}
|
|
var keyNamed bool
|
|
key := &cobra.Command{
|
|
Use: "key VIEW",
|
|
Short: "Show the TSIG key that selects a view, to query it from anywhere",
|
|
Long: `Every view has a TSIG key (jarl-view-NAME) at the front of its
|
|
match-clients: a query signed with it lands in that view, whatever address
|
|
it comes from. Jarl signs its own checks of this server with it; use it to
|
|
see what a view answers:
|
|
|
|
jarl view key external # shows a ready dig -y command
|
|
jarl view key external --named-conf > external.key
|
|
dig -k external.key @SERVER adbraun.lol SOA
|
|
|
|
Showing it needs tsig:write and is recorded in the audit log.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var k struct {
|
|
Name string `json:"name"`
|
|
Algorithm string `json:"algorithm"`
|
|
Secret string `json:"secret"`
|
|
}
|
|
if err := c.Get(ctx(), "/views/"+url.PathEscape(args[0])+"/key", nil, &k); err != nil {
|
|
return err
|
|
}
|
|
if keyNamed {
|
|
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
|
|
return nil
|
|
}
|
|
return output(k, func(w io.Writer) {
|
|
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\ndig:\tdig -y %s:%s:%s @SERVER NAME\n", k.Name, k.Algorithm, k.Secret, k.Algorithm, k.Name, k.Secret)
|
|
})
|
|
},
|
|
}
|
|
key.Flags().BoolVar(&keyNamed, "named-conf", false, "print a key statement (for dig -k FILE)")
|
|
cmd.AddCommand(list, create, update, del, order, color, key)
|
|
return cmd
|
|
}
|
|
|
|
func orDefault(s string) string {
|
|
if s == "" {
|
|
return "default"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func orAny(list []string) string {
|
|
if len(list) == 0 {
|
|
return "any"
|
|
}
|
|
return strings.Join(list, "; ")
|
|
}
|
|
|
|
func aclCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "acl", Aliases: []string{"acls"}, Short: "Manage ACLs (named address match lists)"}
|
|
list := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List ACLs",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var acls []api.ACL
|
|
if err := c.Get(ctx(), "/acls", nil, &acls); err != nil {
|
|
return err
|
|
}
|
|
return output(acls, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tACL\tELEMENTS\tUSED BY")
|
|
for _, a := range acls {
|
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", a.ID, a.Name, strings.Join(a.Elements, "; "), strings.Join(a.UsedBy, ", "))
|
|
}
|
|
})
|
|
},
|
|
}
|
|
var comment, rename string
|
|
var add, remove []string
|
|
create := &cobra.Command{
|
|
Use: "create NAME ELEMENT...",
|
|
Short: "Create an ACL",
|
|
Example: ` jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{"name": args[0], "elements": args[1:], "comment": comment}
|
|
resp, err := c.Mutate(ctx(), "POST", "/acls", nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
create.Flags().StringVar(&comment, "comment", "", "comment")
|
|
var set []string
|
|
update := &cobra.Command{
|
|
Use: "update ACL",
|
|
Short: "Change an ACL (--add/--remove elements, --set all elements, --rename)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cur api.ACL
|
|
if err := c.Get(ctx(), "/acls/"+url.PathEscape(args[0]), nil, &cur); err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{}
|
|
elems := cur.Elements
|
|
if cmd.Flags().Changed("set") {
|
|
elems = set
|
|
}
|
|
for _, a := range add {
|
|
elems = append(elems, a)
|
|
}
|
|
if len(remove) > 0 {
|
|
drop := map[string]bool{}
|
|
for _, r := range remove {
|
|
drop[r] = true
|
|
}
|
|
var kept []string
|
|
for _, e := range elems {
|
|
if !drop[e] {
|
|
kept = append(kept, e)
|
|
}
|
|
}
|
|
elems = kept
|
|
}
|
|
if elems == nil {
|
|
elems = []string{}
|
|
}
|
|
if cmd.Flags().Changed("set") || len(add) > 0 || len(remove) > 0 {
|
|
body["elements"] = elems
|
|
}
|
|
if rename != "" {
|
|
body["name"] = rename
|
|
}
|
|
if cmd.Flags().Changed("comment") {
|
|
body["comment"] = comment
|
|
}
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("nothing to change (see --help)")
|
|
}
|
|
resp, err := c.Mutate(ctx(), "PATCH", "/acls/"+url.PathEscape(args[0]), nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
update.Flags().StringSliceVar(&add, "add", nil, "add elements")
|
|
update.Flags().StringSliceVar(&remove, "remove", nil, "remove elements (exact text)")
|
|
update.Flags().StringSliceVar(&set, "set", nil, "replace all elements")
|
|
update.Flags().StringVar(&rename, "rename", "", "new name (references are updated)")
|
|
update.Flags().StringVar(&comment, "comment", "", "comment")
|
|
del := &cobra.Command{
|
|
Use: "delete ACL",
|
|
Short: "Delete an ACL (refused while it is in use)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.Mutate(ctx(), "DELETE", "/acls/"+url.PathEscape(args[0]), nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
cmd.AddCommand(list, create, update, del)
|
|
return cmd
|
|
}
|
|
|
|
func tsigCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "tsig", Aliases: []string{"key", "keys"}, Short: "Manage TSIG keys"}
|
|
list := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List TSIG keys",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var keys []api.TSIGKey
|
|
if err := c.Get(ctx(), "/tsig-keys", nil, &keys); err != nil {
|
|
return err
|
|
}
|
|
return output(keys, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tKEY\tALGORITHM\tUSED BY")
|
|
for _, k := range keys {
|
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", k.ID, k.Name, k.Algorithm, strings.Join(k.UsedBy, ", "))
|
|
}
|
|
})
|
|
},
|
|
}
|
|
var secret, algorithm, comment string
|
|
var named bool
|
|
show := &cobra.Command{
|
|
Use: "show KEY",
|
|
Short: "Show a key and its secret",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var k api.TSIGKey
|
|
if err := c.Get(ctx(), "/tsig-keys/"+url.PathEscape(args[0]), q("secret", "1"), &k); err != nil {
|
|
return err
|
|
}
|
|
if named {
|
|
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
|
|
return nil
|
|
}
|
|
return output(k, func(w io.Writer) {
|
|
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\nUsed by:\t%s\n", k.Name, k.Algorithm, k.Secret, strings.Join(k.UsedBy, ", "))
|
|
})
|
|
},
|
|
}
|
|
show.Flags().BoolVar(&named, "named-conf", false, "print a key statement for another server's named.conf")
|
|
create := &cobra.Command{
|
|
Use: "create NAME",
|
|
Short: "Create a TSIG key (the secret is generated unless given)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{"name": args[0], "algorithm": algorithm, "comment": comment}
|
|
if secret != "" {
|
|
body["secret"] = secret
|
|
}
|
|
resp, err := c.Mutate(ctx(), "POST", "/tsig-keys", nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := report(resp); err != nil {
|
|
return err
|
|
}
|
|
if resp.Applied && flagOutput == "table" {
|
|
var k api.TSIGKey
|
|
if jsonUnmarshal(resp.Object, &k) == nil && k.Secret != "" {
|
|
fmt.Printf("secret: %s\n", k.Secret)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
create.Flags().StringVar(&algorithm, "algorithm", "hmac-sha256", "algorithm")
|
|
create.Flags().StringVar(&secret, "secret", "", "base64 secret (default: generated)")
|
|
create.Flags().StringVar(&comment, "comment", "", "comment")
|
|
rotate := &cobra.Command{
|
|
Use: "rotate KEY",
|
|
Short: "Give a key a new random secret (or --secret)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if secret == "" {
|
|
var k api.TSIGKey
|
|
if err := c.Get(ctx(), "/tsig-keys/"+url.PathEscape(args[0]), nil, &k); err != nil {
|
|
return err
|
|
}
|
|
secret = randomSecret(k.Algorithm)
|
|
}
|
|
resp, err := c.Mutate(ctx(), "PATCH", "/tsig-keys/"+url.PathEscape(args[0]), nil, map[string]any{"secret": secret})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := report(resp); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("new secret: %s\n", secret)
|
|
return nil
|
|
},
|
|
}
|
|
rotate.Flags().StringVar(&secret, "secret", "", "the new secret (default: generated)")
|
|
del := &cobra.Command{
|
|
Use: "delete KEY",
|
|
Short: "Delete a key (refused while it is in use)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.Mutate(ctx(), "DELETE", "/tsig-keys/"+url.PathEscape(args[0]), nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
cmd.AddCommand(list, show, create, rotate, del)
|
|
return cmd
|
|
}
|
|
|
|
func optionCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "option", Aliases: []string{"options", "opt"}, Short: "Manage named options (server, view, zone, top-level)"}
|
|
var view, zone, zoneView string
|
|
var toplevel bool
|
|
scopeArgs := func(c *cobra.Command) {
|
|
c.Flags().StringVar(&view, "view", "", "options of this view")
|
|
c.Flags().StringVar(&zone, "zone", "", "options of this zone")
|
|
c.Flags().StringVar(&zoneView, "zone-view", "", "view of --zone")
|
|
c.Flags().BoolVar(&toplevel, "toplevel", false, "top-level statements (logging, controls, ...)")
|
|
}
|
|
scope := func() (string, int64, error) {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
switch {
|
|
case zone != "":
|
|
id, err := resolveZoneID(c, zone, zoneView)
|
|
return model.ScopeZone, id, err
|
|
case view != "":
|
|
var v api.View
|
|
if err := c.Get(ctx(), "/views/"+url.PathEscape(view), nil, &v); err != nil {
|
|
return "", 0, err
|
|
}
|
|
return model.ScopeView, v.ID, nil
|
|
case toplevel:
|
|
return model.ScopeTopLevel, 0, nil
|
|
}
|
|
return model.ScopeOptions, 0, nil
|
|
}
|
|
list := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List options (server options by default)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qv := q()
|
|
if view != "" || zone != "" || toplevel || !cmd.Flags().Changed("all") {
|
|
sc, id, err := scope()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
qv = q("scope", sc, "scope_id", fmt.Sprint(id))
|
|
}
|
|
if all {
|
|
qv = q()
|
|
}
|
|
var opts []api.Option
|
|
if err := c.Get(ctx(), "/options", qv, &opts); err != nil {
|
|
return err
|
|
}
|
|
return output(opts, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tWHERE\tOPTION\tVALUE")
|
|
for _, o := range opts {
|
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", o.ID, o.Where, o.Name, o.Value)
|
|
}
|
|
})
|
|
},
|
|
}
|
|
scopeArgs(list)
|
|
list.Flags().BoolVar(&all, "all", false, "options of every scope")
|
|
var comment string
|
|
set := &cobra.Command{
|
|
Use: "set NAME [VALUE...]",
|
|
Short: "Set an option (replaces an existing option of the same name)",
|
|
Long: `Set an option; VALUE is named.conf syntax without the trailing semicolon.
|
|
|
|
It replaces the option of that name. Statements told apart by the name
|
|
after the keyword (tls NAME, key NAME, remote-servers NAME, http NAME,
|
|
dnssec-policy NAME, server ADDRESS, ...) replace only the one with that
|
|
name: setting tls cloudflare leaves tls quad9 alone. --add adds another
|
|
instance of any other option that may occur several times.
|
|
|
|
jarl option set recursion no
|
|
jarl option set allow-recursion '{ homelab; localhost; }'
|
|
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
|
jarl option set allow-transfer '{ key xfer; }' --zone example.com
|
|
jarl option set logging '{ channel q { file "/var/log/named/q.log"; }; category queries { q; }; }' --toplevel`,
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sc, id, err := scope()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
value := strings.Join(args[1:], " ")
|
|
var opts []api.Option
|
|
if err := c.Get(ctx(), "/options", q("scope", sc, "scope_id", fmt.Sprint(id)), &opts); err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{"value": value}
|
|
if cmd.Flags().Changed("comment") {
|
|
body["comment"] = comment
|
|
}
|
|
label := optionLabel(strings.ToLower(args[0]), value)
|
|
for _, o := range opts {
|
|
if label != "" && optionLabel(o.Name, o.Value) != label {
|
|
continue // another tls (key, server, ...) statement
|
|
}
|
|
if o.Name == strings.ToLower(args[0]) && !multiple {
|
|
resp, err := c.Mutate(ctx(), "PATCH", fmt.Sprintf("/options/%d", o.ID), nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
}
|
|
}
|
|
body["scope"], body["scope_id"], body["name"] = sc, id, args[0]
|
|
resp, err := c.Mutate(ctx(), "POST", "/options", nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
scopeArgs(set)
|
|
set.Flags().StringVar(&comment, "comment", "", "comment")
|
|
set.Flags().BoolVar(&multiple, "add", false, "add another instance instead of replacing (for options that may occur several times)")
|
|
unset := &cobra.Command{
|
|
Use: "unset NAME [LABEL] | unset --id ID",
|
|
Short: "Remove an option (BIND's default applies again)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if optID > 0 {
|
|
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", optID), nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
}
|
|
if len(args) < 1 || len(args) > 2 {
|
|
return fmt.Errorf("give the option name (and the name after it, for tls NAME and the like) or --id")
|
|
}
|
|
sc, id, err := scope()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var opts []api.Option
|
|
if err := c.Get(ctx(), "/options", q("scope", sc, "scope_id", fmt.Sprint(id)), &opts); err != nil {
|
|
return err
|
|
}
|
|
var matches []api.Option
|
|
for _, o := range opts {
|
|
if o.Name == strings.ToLower(args[0]) && (len(args) == 1 || strings.EqualFold(optionLabel(o.Name, o.Value), strings.Trim(args[1], `"`))) {
|
|
matches = append(matches, o)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return fmt.Errorf("option %s is not set here", strings.Join(args, " "))
|
|
case 1:
|
|
default:
|
|
var labels []string
|
|
for _, o := range matches {
|
|
if l := optionLabel(o.Name, o.Value); l != "" {
|
|
labels = append(labels, l)
|
|
}
|
|
}
|
|
if len(labels) == len(matches) {
|
|
return fmt.Errorf("option %s is set %d times (%s); say which: jarl option unset %s %s", args[0], len(matches), strings.Join(labels, ", "), args[0], labels[0])
|
|
}
|
|
return fmt.Errorf("option %s is set %d times; remove one with --id", args[0], len(matches))
|
|
}
|
|
resp, err := c.Mutate(ctx(), "DELETE", fmt.Sprintf("/options/%d", matches[0].ID), nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
scopeArgs(unset)
|
|
unset.Flags().Int64Var(&optID, "id", 0, "remove the option with this ID")
|
|
describe := &cobra.Command{
|
|
Use: "describe [NAME]",
|
|
Short: "Describe options from the BIND 9.20 catalog",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cs := "options"
|
|
switch {
|
|
case toplevel:
|
|
cs = "toplevel"
|
|
case view != "":
|
|
cs = "view"
|
|
case zone != "":
|
|
cs = "zone:" + zoneType
|
|
}
|
|
var stmts []struct {
|
|
Name string `json:"name"`
|
|
Grammar string `json:"grammar"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := c.Get(ctx(), "/catalog/options", q("scope", cs), &stmts); err != nil {
|
|
return err
|
|
}
|
|
for _, s := range stmts {
|
|
if len(args) == 1 && s.Name != args[0] {
|
|
continue
|
|
}
|
|
if len(args) == 0 {
|
|
fmt.Printf("%-32s %s\n", s.Name, s.Description)
|
|
continue
|
|
}
|
|
fmt.Printf("%s (%s)%s\n %s\n\n%s\n", s.Name, s.Kind, statusText(s.Status), s.Description, s.Grammar)
|
|
return nil
|
|
}
|
|
if len(args) == 1 {
|
|
return fmt.Errorf("%s is not an option in scope %s", args[0], cs)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
scopeArgs(describe)
|
|
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
|
|
generated := &cobra.Command{
|
|
Use: "generated",
|
|
Short: "What Jarl adds to named.conf by itself, why, and what controls it",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var gen []struct {
|
|
Where string `json:"where"`
|
|
Text string `json:"text"`
|
|
Why string `json:"why"`
|
|
Setting string `json:"setting"`
|
|
}
|
|
if err := c.Get(ctx(), "/options/generated", nil, &gen); err != nil {
|
|
return err
|
|
}
|
|
return output(gen, func(w io.Writer) {
|
|
for i, g := range gen {
|
|
if i > 0 {
|
|
fmt.Fprintln(w)
|
|
}
|
|
fmt.Fprintf(w, "# %s: %s\n# controlled by: %s\n%s\n", g.Where, g.Why, g.Setting, g.Text)
|
|
}
|
|
})
|
|
},
|
|
}
|
|
cmd.AddCommand(list, set, unset, describe, generated)
|
|
return cmd
|
|
}
|
|
|
|
var (
|
|
all bool
|
|
multiple bool
|
|
optID int64
|
|
zoneType string
|
|
)
|
|
|
|
func statusText(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return " [" + s + "]"
|
|
}
|
|
|
|
func settingsCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "settings", Short: "Show or change Jarl's defaults for new zones"}
|
|
show := &cobra.Command{
|
|
Use: "show",
|
|
Short: "Show settings",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var s model.Settings
|
|
if err := c.Get(ctx(), "/settings", nil, &s); err != nil {
|
|
return err
|
|
}
|
|
return output(s, func(w io.Writer) {
|
|
fmt.Fprintf(w, "Name servers:\t%s\nContact:\t%s\nDefault TTL:\t%d\nRefresh/retry/expire:\t%d/%d/%d\nNegative TTL:\t%d\nSerial policy:\t%s\n",
|
|
strings.Join(s.Nameservers, ", "), s.RName, s.DefaultTTL, s.Refresh, s.Retry, s.Expire, s.NegativeTTL, s.SerialPolicy)
|
|
})
|
|
},
|
|
}
|
|
var ns []string
|
|
var contact, policy, srvExposure string
|
|
var ttl, refresh, retry, expire, neg uint32
|
|
set := &cobra.Command{
|
|
Use: "set",
|
|
Short: "Change settings",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body := map[string]any{}
|
|
f := cmd.Flags()
|
|
if f.Changed("ns") {
|
|
body["nameservers"] = ns
|
|
}
|
|
if f.Changed("contact") {
|
|
body["rname"] = contact
|
|
}
|
|
if f.Changed("serial-policy") {
|
|
body["serial_policy"] = policy
|
|
}
|
|
if f.Changed("exposure") {
|
|
body["exposure"] = srvExposure
|
|
}
|
|
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
|
|
if f.Changed(name) {
|
|
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
|
if key == "" {
|
|
key = name
|
|
}
|
|
body[key] = v
|
|
}
|
|
}
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("nothing to change (see --help)")
|
|
}
|
|
resp, err := c.Mutate(ctx(), "PATCH", "/settings", nil, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return report(resp)
|
|
},
|
|
}
|
|
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
|
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
|
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
|
|
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
|
|
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
|
|
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
|
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
|
set.Flags().Uint32Var(&expire, "expire", 0, "SOA expire")
|
|
set.Flags().Uint32Var(&neg, "negative-ttl", 0, "SOA minimum")
|
|
cmd.AddCommand(show, set)
|
|
return cmd
|
|
}
|
|
|
|
// labelled are the statements told apart by the name after the keyword:
|
|
// tls quad9 { ... }, key xfer { ... }, server 192.0.2.1 { ... }.
|
|
var labelled = map[string]bool{"tls": true, "http": true, "key": true, "remote-servers": true, "primaries": true, "masters": true,
|
|
"parental-agents": true, "dnssec-policy": true, "key-store": true, "server": true, "view": true, "acl": true, "zone": true}
|
|
|
|
// optionLabel returns the name a labelled statement's value starts with
|
|
// ("quad9" for tls quad9 { ... }), lowercase; "" for other statements.
|
|
func optionLabel(name, value string) string {
|
|
if !labelled[name] {
|
|
return ""
|
|
}
|
|
v := strings.TrimSpace(value)
|
|
if v == "" || strings.HasPrefix(v, "{") {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(v, `"`) {
|
|
if end := strings.Index(v[1:], `"`); end >= 0 {
|
|
return strings.ToLower(v[1 : end+1])
|
|
}
|
|
}
|
|
end := strings.IndexAny(v, " \t\n{;")
|
|
if end < 0 {
|
|
end = len(v)
|
|
}
|
|
return strings.ToLower(v[:end])
|
|
}
|