// Command jarl is the command line client for Jarl. It talks to jarld's // REST API with an API token. package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/url" "os" "path/filepath" "strconv" "strings" "text/tabwriter" "github.com/spf13/cobra" "go.yaml.in/yaml/v3" "jarl/internal/api" "jarl/internal/client" "jarl/internal/version" ) func main() { root := rootCmd() err := root.Execute() if errors.Is(err, errFanOut) { os.Exit(fanOut(fanOutTargets)) } finishPreflight() if err != nil { printError(err) os.Exit(exitCode(err)) } } // Global flags. var ( flagServer string flagToken string flagConfig string flagChangeset int64 flagDryRun bool flagForce bool flagOutput string flagInsecure bool flagCACert string flagQuiet bool ) // 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,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 { if p := os.Getenv("JARL_CLI_CONFIG"); p != "" { return p } dir, err := os.UserConfigDir() if err != nil { dir = filepath.Join(os.Getenv("HOME"), ".config") } return filepath.Join(dir, "jarl", "config.yaml") } func loadCLIConfig() (*cliConfig, error) { c := &cliConfig{} b, err := os.ReadFile(flagConfig) if errors.Is(err, os.ErrNotExist) { return c, nil } if err != nil { return nil, err } return c, yaml.Unmarshal(b, c) } func saveCLIConfig(c *cliConfig) error { if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil { return err } b, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(flagConfig, b, 0o600) } func newClient() (*client.Client, error) { cfg, err := loadCLIConfig() if err != nil { return nil, fmt.Errorf("reading %s: %w", flagConfig, err) } 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, "@") { b, err := os.ReadFile(token[1:]) if err != nil { return nil, fmt.Errorf("reading token file: %w", err) } token = strings.TrimSpace(string(b)) } 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 != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil { return nil, fmt.Errorf("invalid JARL_CHANGESET %q", v) } c.Changeset = n } } c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce return c, nil } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } func rootCmd() *cobra.Command { root := &cobra.Command{ Use: "jarl", Short: "Command line client for Jarl, the BIND manager", Long: `jarl manages a BIND server through Jarl's API. Every command that changes the configuration is applied at once, as a single changeset that is validated, checked with named-checkconf and loaded into named. To batch several changes, create a changeset and pass --changeset ID (or set JARL_CHANGESET), then apply it; or describe the changes in a file and run 'jarl apply -f FILE'. Configure the server and token once: jarl config set server https://dns.example.com:8053 jarl config set token (paste the token at the prompt) Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found, 5 conflict, 6 invalid or rejected by validation, 7 server or BIND error.`, SilenceUsage: true, SilenceErrors: true, Version: version.Version, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return checkFanOut(cmd) }, } pf := root.PersistentFlags() 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)") pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them") pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl") pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json or yaml") pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate") pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate") pf.BoolVarP(&flagQuiet, "quiet", "q", false, "only print errors (and IDs where useful)") root.AddGroup(&cobra.Group{ID: "dns", Title: "DNS commands:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"}) add := func(group string, cmds ...*cobra.Command) { for _, c := range cmds { c.GroupID = group root.AddCommand(c) } } add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), acmeCmd(), searchCmd(), statsCmd()) add("changes", applyCmd(), changesetCmd(), historyCmd()) add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd()) setupCompletion(root) return root } // ctx returns the context for API calls. The HTTP client's timeout bounds // each request. func ctx() context.Context { return context.Background() } // ---- output func output(v any, table func(w io.Writer)) error { switch flagOutput { case "json": enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(v) case "yaml": // Round-trip through JSON so field names match the API. b, err := json.Marshal(v) if err != nil { return err } var x any if err := json.Unmarshal(b, &x); err != nil { return err } return yaml.NewEncoder(os.Stdout).Encode(x) case "table", "": if table == nil { return output(v, nil) } tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) table(tw) return tw.Flush() } return fmt.Errorf("unknown output format %q (use table, json or yaml)", flagOutput) } func dot(name string) string { if name == "." || name == "" { return "." } return strings.TrimSuffix(name, ".") } // where describes the zone and view a problem is about. func where(p client.Problem) string { if p.Zone == "" { return "" } s := " [zone " + dot(p.Zone) if p.View != "" { s += ", view " + p.View } return s + "]" } func printProblems(w io.Writer, ps []client.Problem) { for _, p := range ps { fmt.Fprintf(w, " %s: %s%s\n", p.Severity, p.Message, where(p)) } } // printCheck prints named-checkconf's errors and warnings, but not its // routine "loaded serial" lines. func printCheck(w io.Writer, ms []client.CheckMessage) { for _, m := range ms { if !m.Error && (strings.HasPrefix(m.Text, "loaded serial") || m.Text == "OK") { continue } obj := "" if m.Object != "" { obj = " [" + m.Object + "]" } label := "named-checkconf" if !m.Error { label += " warning" } fmt.Fprintf(w, " %s: %s%s\n", label, m.Text, obj) } } func printError(err error) { var ce *client.Error if errors.As(err, &ce) { // The message often repeats the first problem; print it once. head, ps := ce.Info.Message, ce.Problems() for i, p := range ps { if p.Message == head { head += where(p) ps = append(ps[:i:i], ps[i+1:]...) break } if strings.HasPrefix(head, p.Message+" (and ") { head = fmt.Sprintf("the change was rejected (%d problems):", len(ps)) break } } fmt.Fprintln(os.Stderr, "error:", head) printProblems(os.Stderr, ps) printCheck(os.Stderr, ce.Check()) if r := client.ParseResult(ce.Info.Result); r != nil && r.Diff != "" && flagDryRun { fmt.Fprint(os.Stderr, r.Diff) } return } fmt.Fprintln(os.Stderr, "error:", err) } // exitCode maps errors to exit codes for scripts: 1 other errors, 3 not // authenticated or not allowed, 4 not found, 5 conflict, 6 invalid // request or rejected by validation, 7 server error. func exitCode(err error) int { var ce *client.Error if errors.As(err, &ce) { switch { case ce.Status == 401 || ce.Status == 403: return 3 case ce.Status == 404: return 4 case ce.Status == 409: return 5 case ce.Status == 400 || ce.Status == 422: return 6 case ce.Status >= 500: return 7 } } return 1 } // report prints the outcome of a change. func report(resp *api.MutationResponse) error { if flagOutput == "json" || flagOutput == "yaml" { return output(resp, nil) } w := os.Stdout r := client.ParseResult(resp.Result) switch { case resp.Staged: if !flagQuiet { for _, op := range resp.Added { fmt.Fprintf(w, "staged #%d: %s\n", op.Seq, op.Summary) } fmt.Fprintf(w, "(changeset %d: review with 'jarl changeset diff %d', apply with 'jarl changeset apply %d')\n", resp.ChangesetID, resp.ChangesetID, resp.ChangesetID) } case resp.DryRun && r != nil: for _, op := range r.Ops { fmt.Fprintf(w, "would %s\n", op.Summary) } if len(r.Problems) > 0 { fmt.Fprintln(w, "problems:") printProblems(w, r.Problems) } if r.Diff != "" { fmt.Fprint(w, r.Diff) } else { fmt.Fprintln(w, "(no file changes)") } fmt.Fprintln(w, "dry run: nothing was changed") case resp.Applied && r != nil: if flagQuiet { break } for _, op := range r.Ops { fmt.Fprintf(w, "%s\n", op.Summary) } for _, s := range r.Serials { v := "" if s.View != "" { v = " [" + s.View + "]" } fmt.Fprintf(w, "zone %s%s serial %d -> %d\n", dot(s.Zone), v, s.Old, s.New) } fmt.Fprintf(w, "applied (changeset %d)\n", r.ChangesetID) } // Warnings the change introduced (errors would have blocked it). var introduced []client.Problem switch { case resp.Applied && r != nil: introduced = r.Problems case resp.Staged && len(resp.Problems) > 0: json.Unmarshal(resp.Problems, &introduced) } printProblems(os.Stderr, introduced) for _, warn := range resp.Warnings { fmt.Fprintln(os.Stderr, "warning:", warn) } if r != nil && !resp.Applied && !resp.Staged && !resp.DryRun { return fmt.Errorf("not applied") } return nil } func q(kv ...string) url.Values { v := url.Values{} for i := 0; i+1 < len(kv); i += 2 { if kv[i+1] != "" { v.Set(kv[i], kv[i+1]) } } return v } // zonePath returns the API path for a zone given by name or ID. func zonePath(zone, view string) (string, url.Values) { z := strings.TrimSuffix(zone, ".") if zone == "." { z = "root" } return "/zones/" + url.PathEscape(z), q("view", view) } func confirm(prompt string, yes bool) bool { if yes || flagDryRun || flagChangeset > 0 { return true } fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) var answer string fmt.Scanln(&answer) return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes") }