Files
jarl/cmd/jarl/main.go
T
adamandClaude Opus 5.5 66dcf53684 DNS over TLS and HTTPS set up in one step
jarl encrypted-dns setup (and the Encrypted DNS page) finds or obtains a
certificate for the host name, checks that nothing else listens on the
DoT and DoH ports at the chosen addresses (offering a free DoH port), and
stages named's tls statement and listeners as one change, keeping port 53
where it was when there was no listen-on, plus the host name's records in
the private views (local to the view in a linked zone). Status shows what
to give Android, Linux, Firefox, Chrome and Windows clients. A tls
statement with missing files is refused (tls_file_missing), and a
certificate named uses can't be deleted. docs/encrypted-dns.md, e2e with
real DoT and DoH queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:14 +01:00

589 lines
17 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/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/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)
}
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 {
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(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.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.
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")
}