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:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+408
View File
@@ -0,0 +1,408 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"jarl/internal/api"
"jarl/internal/client"
)
// batchFile is the format of 'jarl apply -f'.
type batchFile struct {
Description string `yaml:"description" json:"description"`
Changes []batchEntry `yaml:"changes" json:"changes"`
}
type batchEntry struct {
Add map[string]any `yaml:"add,omitempty" json:"add,omitempty"`
Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"`
Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"`
Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"`
// Any API operation: op, id, data.
Op string `yaml:"op,omitempty" json:"op,omitempty"`
ID int64 `yaml:"id,omitempty" json:"id,omitempty"`
Data map[string]any `yaml:"data,omitempty" json:"data,omitempty"`
}
func toInt64(v any) (int64, bool) {
switch n := v.(type) {
case int:
return int64(n), true
case int64:
return n, true
case float64:
return int64(n), true
case string:
i, err := strconv.ParseInt(n, 10, 64)
return i, err == nil
}
return 0, false
}
func rawJSON(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
// requests converts the file's entries into API change requests.
func (b *batchFile) requests() ([]api.ChangeRequest, error) {
var out []api.ChangeRequest
for i, e := range b.Changes {
n := 0
for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Op != ""} {
if set {
n++
}
}
if n != 1 {
return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update or op", i+1)
}
switch {
case e.Add != nil:
out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)})
case e.Delete != nil:
d := e.Delete
if id, ok := toInt64(d["id"]); ok {
delete(d, "id")
out = append(out, api.ChangeRequest{Op: "record.delete", ID: id, Data: rawJSON(d)})
} else {
out = append(out, api.ChangeRequest{Op: "record.delete", Data: rawJSON(d)})
}
case e.Set != nil:
s := e.Set
switch d := s["data"].(type) {
case string:
s["data"] = []string{d}
case nil:
s["data"] = []string{}
}
out = append(out, api.ChangeRequest{Op: "rrset.replace", Data: rawJSON(s)})
case e.Update != nil:
u := e.Update
id, ok := toInt64(u["id"])
if !ok {
return nil, fmt.Errorf("change %d: update needs the record id", i+1)
}
delete(u, "id")
out = append(out, api.ChangeRequest{Op: "record.update", ID: id, Data: rawJSON(u)})
default:
var data json.RawMessage
if e.Data != nil {
data = rawJSON(e.Data)
}
out = append(out, api.ChangeRequest{Op: e.Op, ID: e.ID, Data: data})
}
}
return out, nil
}
// submitBatch applies requests as one changeset, or stages them into the
// changeset given with --changeset.
func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error {
var resp api.MutationResponse
if c.Changeset > 0 {
if err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/ops", c.Changeset), nil, map[string]any{"ops": reqs}, &resp); err != nil {
return err
}
return report(&resp)
}
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil {
return err
}
return report(&resp)
}
func applyCmd() *cobra.Command {
var file, desc string
cmd := &cobra.Command{
Use: "apply -f FILE",
Short: "Apply a batch of changes from a YAML or JSON file, all or nothing",
Long: `Applies the changes in FILE ('-' for standard input) as one changeset: they
are validated together and either all are applied or none.
description: move the web server
changes:
- add: {zone: example.com, name: www2, type: A, data: 192.0.2.20, ttl: 300, ptr: true}
- delete: {zone: example.com, name: www, type: A} # every A record of www
- delete: {zone: example.com, name: old, type: CNAME, data: www} # just this record
- delete: {id: 1234}
- set: {zone: example.com, name: api, type: A, data: [192.0.2.30, 192.0.2.31], ttl: 60}
- update: {id: 1235, data: 192.0.2.40}
- op: zone.create # any API operation
data: {name: lab.example.com, nameservers: [ns1.example.com.]}
Records name zones by name (add "view: NAME" when the zone exists in several
views). With --changeset the changes are staged instead; with --dry-run they
are only validated.`,
RunE: func(cmd *cobra.Command, args []string) error {
if file == "" {
return fmt.Errorf("-f FILE is required")
}
var data []byte
var err error
if file == "-" {
data, err = io.ReadAll(os.Stdin)
} else {
data, err = os.ReadFile(file)
}
if err != nil {
return err
}
var b batchFile
if err := yaml.Unmarshal(data, &b); err != nil {
return fmt.Errorf("%s: %w", file, err)
}
if desc != "" {
b.Description = desc
}
reqs, err := b.requests()
if err != nil {
return err
}
if len(reqs) == 0 {
return fmt.Errorf("%s has no changes", file)
}
c, err := newClient()
if err != nil {
return err
}
return submitBatch(c, b.Description, reqs)
},
}
cmd.Flags().StringVarP(&file, "file", "f", "", "file with the changes ('-' for standard input)")
cmd.Flags().StringVar(&desc, "description", "", "description for the history")
return cmd
}
func changesetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "changeset", Aliases: []string{"cs"}, Short: "Stage changes and apply them together"}
var status string
var mine bool
list := &cobra.Command{
Use: "list",
Short: "List changesets (open ones by default)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
qv := q("status", status)
if mine {
qv.Set("mine", "1")
}
var list []api.Changeset
if err := c.Get(ctx(), "/changesets", qv, &list); err != nil {
return err
}
return output(list, func(w io.Writer) {
fmt.Fprintln(w, "ID\tSTATUS\tUSER\tVIA\tOPS\tDESCRIPTION")
for _, cs := range list {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.Description)
}
})
},
}
list.Flags().StringVar(&status, "status", "open", "open, applied or discarded (empty for all)")
list.Flags().BoolVar(&mine, "mine", false, "only my changesets")
create := &cobra.Command{
Use: "create [DESCRIPTION]",
Short: "Start a changeset; prints its ID",
Example: ` export JARL_CHANGESET=$(jarl changeset create "move web servers" -q)
jarl record add example.com www2 A 192.0.2.20
jarl record delete example.com www A
jarl changeset diff $JARL_CHANGESET
jarl changeset apply $JARL_CHANGESET`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": strings.Join(args, " ")}, &cs); err != nil {
return err
}
if flagQuiet {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d\nstage changes with --changeset %d (or export JARL_CHANGESET=%d)\n", cs.ID, cs.ID, cs.ID)
return nil
},
}
show := &cobra.Command{
Use: "show ID",
Short: "Show a changeset's operations",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Get(ctx(), "/changesets/"+args[0], nil, &cs); err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via)
if cs.AppliedAt != nil {
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy)
}
fmt.Fprintln(w, "\n#\tOPERATION")
for _, op := range cs.Ops {
fmt.Fprintf(w, "%d\t%s\n", op.Seq, op.Summary)
}
if cs.Diff != "" {
w.(interface{ Flush() error }).Flush()
fmt.Println()
fmt.Print(cs.Diff)
}
})
},
}
diff := &cobra.Command{
Use: "diff ID",
Short: "Validate an open changeset and show what applying it would change",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var raw json.RawMessage
if err := c.Get(ctx(), "/changesets/"+args[0]+"/preview", nil, &raw); err != nil {
return err
}
if flagOutput != "table" {
var v any
json.Unmarshal(raw, &v)
return output(v, nil)
}
r := client.ParseResult(raw)
for _, op := range r.Ops {
fmt.Printf("#%d %s\n", op.Seq, op.Summary)
}
if r.Error != nil {
fmt.Println("error:", r.Error.Message)
}
printProblems(os.Stdout, r.Problems)
printCheck(os.Stdout, r.Check)
for _, w := range r.Warnings {
fmt.Println("warning:", w)
}
fmt.Println()
fmt.Print(r.Diff)
if r.Valid {
fmt.Println("\nvalid: apply with 'jarl changeset apply " + args[0] + "'")
return nil
}
return fmt.Errorf("the changeset can't be applied as it is")
},
}
apply := &cobra.Command{
Use: "apply ID",
Short: "Apply an open changeset",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
return err
}
return report(&resp)
},
}
discard := &cobra.Command{
Use: "discard ID",
Short: "Throw away an open changeset",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/discard", nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("discarded changeset", args[0])
}
return nil
},
}
remove := &cobra.Command{
Use: "remove ID SEQ",
Short: "Remove one operation from an open changeset",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", "/changesets/"+args[0]+"/ops/"+args[1], nil, nil, nil)
},
}
revert := &cobra.Command{
Use: "revert ID",
Short: "Create a changeset that undoes an applied changeset (review it, then apply)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets/"+args[0]+"/revert", nil, nil, &cs); err != nil {
return err
}
if flagQuiet {
fmt.Println(cs.ID)
return nil
}
fmt.Printf("created changeset %d with %d operations; review with 'jarl changeset diff %d', apply with 'jarl changeset apply %d'\n", cs.ID, len(cs.Ops), cs.ID, cs.ID)
return nil
},
}
cmd.AddCommand(list, create, show, diff, apply, discard, remove, revert)
return cmd
}
func historyCmd() *cobra.Command {
var limit int
cmd := &cobra.Command{
Use: "history",
Short: "List applied changesets",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var list []api.Changeset
if err := c.Get(ctx(), "/changesets", q("status", "applied", "limit", fmt.Sprint(limit)), &list); err != nil {
return err
}
return output(list, func(w io.Writer) {
fmt.Fprintln(w, "ID\tAPPLIED\tBY\tVIA\tOPS\tDESCRIPTION")
for _, cs := range list {
at := ""
if cs.AppliedAt != nil {
at = cs.AppliedAt.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", cs.ID, at, cs.AppliedBy, cs.Via, len(cs.Ops), cs.Description)
}
})
},
}
cmd.Flags().IntVar(&limit, "limit", 30, "how many")
return cmd
}