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,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
|
||||
}
|
||||
Reference in new issue
Block a user