Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,382 @@
|
||||
// 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()
|
||||
if err := root.Execute(); 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.
|
||||
type cliConfig struct {
|
||||
Server string `yaml:"server"`
|
||||
Token string `yaml:"token"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
CACert string `yaml:"ca_cert,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)
|
||||
}
|
||||
server := firstNonEmpty(flagServer, os.Getenv("JARL_SERVER"), cfg.Server)
|
||||
token := firstNonEmpty(flagToken, os.Getenv("JARL_TOKEN"), cfg.Token)
|
||||
if token == "" {
|
||||
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: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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, 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,
|
||||
}
|
||||
pf := root.PersistentFlags()
|
||||
pf.StringVar(&flagServer, "server", "", "Jarl server 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())
|
||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
||||
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func printCheck(w io.Writer, ms []client.CheckMessage) {
|
||||
for _, m := range ms {
|
||||
if m.Error {
|
||||
obj := ""
|
||||
if m.Object != "" {
|
||||
obj = " [" + m.Object + "]"
|
||||
}
|
||||
fmt.Fprintf(w, " named-checkconf: %s%s\n", 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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user