The version moves from the header to a "Powered by Jarl" footer. A build of a release tag (HEAD tagged v<VERSION>, no tracked changes; the release workflow sets RELEASE_TAG) shows and links the release; any other build shows and links its commit, marked -modified with uncommitted changes. jarld version, jarl --version, /status and /auth/whoami report the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
480 lines
13 KiB
Go
480 lines
13 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"
|
|
"sync"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
sock, isSocket := client.SocketPath(t.URL)
|
|
if isSocket {
|
|
if err := checkSocket(sock, t.Name == localName && flagServer == "" && os.Getenv("JARL_SERVER") == ""); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if token == "" && !isSocket {
|
|
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
|
|
if !c.Preflight {
|
|
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// tokenExpiryWarning returns a function that warns, once, that the token
|
|
// used for server expires soon. Scripts (certbot hooks, cron jobs) show it
|
|
// in their logs well before the token stops working.
|
|
func tokenExpiryWarning(server string) func(time.Time) {
|
|
var once sync.Once
|
|
return func(t time.Time) {
|
|
once.Do(func() {
|
|
where := ""
|
|
if server != "" && server != localName {
|
|
where = " for server " + server
|
|
}
|
|
when := "expires " + fmtTime(&t) + " (in " + fmtUntil(time.Until(t)) + ")"
|
|
if time.Now().After(t) {
|
|
when = "has expired"
|
|
}
|
|
fmt.Fprintf(os.Stderr, "warning: the API token%s %s; give it a new lifetime with 'jarl token rotate --current' (see 'jarl token rotate --help')\n", where, when)
|
|
})
|
|
}
|
|
}
|
|
|
|
// fmtUntil is a short, rounded-down duration: "5 days", "3 hours".
|
|
func fmtUntil(d time.Duration) string {
|
|
switch {
|
|
case d >= 48*time.Hour:
|
|
return fmt.Sprintf("%d days", int(d/(24*time.Hour)))
|
|
case d >= 2*time.Hour:
|
|
return fmt.Sprintf("%d hours", int(d/time.Hour))
|
|
case d >= time.Minute:
|
|
return fmt.Sprintf("%d minutes", int(d/time.Minute))
|
|
}
|
|
return "under a minute"
|
|
}
|
|
|
|
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.String(),
|
|
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 cf *checkFailed
|
|
if errors.As(err, &cf) {
|
|
return 2
|
|
}
|
|
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")
|
|
}
|