Files
jarl/cmd/jarl/main.go
T
abrown38andClaude Opus 5.5 352c9cb394 Add a Debian package and bash completion
make deb builds dist/jarl_<version>_<arch>.deb with dpkg-deb alone (no
root), for the host or another architecture (DEB_ARCH=arm64). The package
creates the jarl user and directories and an example jarl.yaml on the
first install, restarts a running jarld on upgrades, never touches
/etc/jarl/jarl.yaml, stops jarld on removal, and keeps Jarl's data even
on purge.

make install and the package install bash completion for jarl and jarld.
Besides commands and flags it completes server and group names from the
CLI configuration, zone, view, ACL, TSIG key and user names from the
server, and record types. make dist includes the completion scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:10:57 +00:00

432 lines
12 KiB
Go

// 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")
}