jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
624 lines
18 KiB
Go
624 lines
18 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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.yaml.in/yaml/v3"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/client"
|
|
"jarl/internal/dnsname"
|
|
"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
|
|
flagRisk 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)
|
|
}
|
|
|
|
// connection is the server a command talks to and how: Token may be
|
|
// "@FILE" (read by open).
|
|
type connection struct {
|
|
Server string `json:"server"`
|
|
Token string `json:"token,omitempty"`
|
|
Insecure bool `json:"insecure,omitempty"`
|
|
CACert string `json:"ca_cert,omitempty"`
|
|
// Name is the named server's name ("" for one given by address).
|
|
Name string `json:"-"`
|
|
}
|
|
|
|
// resolveConnection works out the connection from the flags, the
|
|
// environment and the CLI configuration.
|
|
func resolveConnection() (connection, error) {
|
|
cfg, err := loadCLIConfig()
|
|
if err != nil {
|
|
return connection{}, fmt.Errorf("reading %s: %w", flagConfig, err)
|
|
}
|
|
ts, err := resolveTargets(cfg, targetSpec(cfg))
|
|
if err != nil {
|
|
return connection{}, err
|
|
}
|
|
if len(ts) > 1 {
|
|
return connection{}, 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 connection{}, err
|
|
}
|
|
}
|
|
if token == "" && !isSocket {
|
|
if t.Name != "" {
|
|
return connection{}, fmt.Errorf("no API token for server %s: use 'jarl --server %s config set token'", t.Name, t.Name)
|
|
}
|
|
return connection{}, fmt.Errorf("no API token: use --token, JARL_TOKEN or 'jarl config set token'")
|
|
}
|
|
return connection{Server: t.URL, Token: token, Insecure: flagInsecure || t.Insecure, CACert: firstNonEmpty(flagCACert, t.CACert), Name: t.Name}, nil
|
|
}
|
|
|
|
// open returns a client for the connection.
|
|
func (cn connection) open() (*client.Client, error) {
|
|
token := cn.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))
|
|
}
|
|
return client.New(client.Options{Server: cn.Server, Token: token, Insecure: cn.Insecure, CACert: cn.CACert})
|
|
}
|
|
|
|
func newClient() (*client.Client, error) {
|
|
cn, err := resolveConnection()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c, err := cn.open()
|
|
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 {
|
|
n, err := envChangeset()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n > 0 {
|
|
// Checked when a change is first staged into it
|
|
// (envchangeset.go).
|
|
c.Changeset = n
|
|
c.CheckChangeset = func(context.Context) error {
|
|
_, err := checkEnvChangeset(c, n, true)
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
|
if !c.Preflight {
|
|
c.OnTokenExpiry = tokenExpiryWarning(cn.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.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
|
|
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
|
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 (with show and list: just the IDs, like -o id)")
|
|
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(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
|
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
|
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), 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 {
|
|
format := flagOutput
|
|
quietIDs := flagQuiet && (format == "table" || format == "")
|
|
if quietIDs {
|
|
format = "id"
|
|
}
|
|
if fieldFormat(format) {
|
|
return outputFields(v, format, quietIDs)
|
|
}
|
|
switch format {
|
|
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, yaml, id or field=NAME)", flagOutput)
|
|
}
|
|
|
|
// outputFields prints fields of each object (or of the one object), one
|
|
// line per object with the fields separated by tabs, for scripts:
|
|
// "id" prints the IDs, "field=view,soa.serial" those fields. Names are the
|
|
// JSON field names (see -o json); a dot reaches into nested objects. A
|
|
// field an object doesn't have prints as empty (the API leaves out empty
|
|
// fields).
|
|
func outputFields(v any, format string, quiet bool) error {
|
|
fields := []string{"id"}
|
|
if f, ok := strings.CutPrefix(format, "field="); ok {
|
|
fields = strings.Split(f, ",")
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(b))
|
|
dec.UseNumber()
|
|
var x any
|
|
if err := dec.Decode(&x); err != nil {
|
|
return err
|
|
}
|
|
items, ok := x.([]any)
|
|
if !ok {
|
|
items = []any{x}
|
|
}
|
|
if quiet && !slices.ContainsFunc(items, func(it any) bool { _, ok := lookupField(it, "id"); return ok }) {
|
|
return nil // -q on something without IDs: only errors
|
|
}
|
|
var out strings.Builder
|
|
for _, it := range items {
|
|
var vals []string
|
|
for _, f := range fields {
|
|
val, _ := lookupField(it, strings.TrimSpace(f))
|
|
vals = append(vals, fieldText(val))
|
|
}
|
|
out.WriteString(strings.Join(vals, "\t") + "\n")
|
|
}
|
|
_, err = io.WriteString(os.Stdout, out.String())
|
|
return err
|
|
}
|
|
|
|
// fieldFormat reports whether an -o format prints fields (id, field=...).
|
|
func fieldFormat(format string) bool {
|
|
return format == "id" || strings.HasPrefix(format, "field=")
|
|
}
|
|
|
|
func lookupField(v any, path string) (any, bool) {
|
|
for _, part := range strings.Split(path, ".") {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if v, ok = m[part]; !ok {
|
|
return nil, false
|
|
}
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func fieldText(v any) string {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return x
|
|
case json.Number, bool:
|
|
return fmt.Sprint(x)
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
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)
|
|
}
|
|
if fieldFormat(flagOutput) {
|
|
// The changed object, if the response has it ("jarl record add
|
|
// ... -o id" prints the new record's ID).
|
|
if len(resp.Object) > 0 {
|
|
return outputFields(resp.Object, flagOutput, false)
|
|
}
|
|
return outputFields(resp, flagOutput, false)
|
|
}
|
|
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)
|
|
// named-checkconf's own warnings about the new configuration.
|
|
if r != nil && (resp.Applied || resp.DryRun) {
|
|
for _, m := range r.Check {
|
|
if !m.Error && !strings.HasPrefix(m.Text, "loaded serial") && m.Text != "OK" {
|
|
where := ""
|
|
if m.Object != "" {
|
|
where = " (" + m.Object + ")"
|
|
}
|
|
fmt.Fprintf(os.Stderr, "warning: named-checkconf: %s%s\n", m.Text, where)
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
// A network names its reverse zone: 10.0.10.0/24 is 10.0.10.in-addr.arpa.
|
|
func zonePath(zone, view string) (string, url.Values) {
|
|
if p, err := netip.ParsePrefix(strings.TrimSpace(zone)); err == nil {
|
|
if name, err := dnsname.ReverseZone(p); err == nil {
|
|
zone = name
|
|
}
|
|
}
|
|
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")
|
|
}
|