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

+316
View File
@@ -0,0 +1,316 @@
// Package bind runs BIND's own tools: named-checkconf and named-checkzone
// to validate generated configuration, and rndc to reload named and query
// its state.
package bind
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// Tools locates and runs the BIND utilities.
type Tools struct {
NamedCheckConf string // path to named-checkconf
NamedCheckZone string // path to named-checkzone
RNDC string // path to rndc
RNDCArgs []string // extra rndc arguments, e.g. -k /etc/bind/rndc.key -s 127.0.0.1 -p 953
Env []string // extra environment variables
Timeout time.Duration
}
// Message is one line of tool output.
type Message struct {
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Zone string `json:"zone,omitempty"` // zone name, when the message is about a zone
View string `json:"view,omitempty"`
Text string `json:"text"`
Error bool `json:"error"`
}
// Result is the outcome of a check.
type Result struct {
OK bool `json:"ok"`
Messages []Message `json:"messages"`
Output string `json:"output"`
}
func (t *Tools) timeout() time.Duration {
if t.Timeout > 0 {
return t.Timeout
}
return 60 * time.Second
}
func (t *Tools) run(ctx context.Context, bin string, args ...string) (string, int, error) {
ctx, cancel := context.WithTimeout(ctx, t.timeout())
defer cancel()
cmd := exec.CommandContext(ctx, bin, args...)
if len(t.Env) > 0 {
cmd.Env = append(cmd.Environ(), t.Env...)
}
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
code := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
code = ee.ExitCode()
err = nil
} else {
return out.String(), -1, fmt.Errorf("running %s: %w", bin, err)
}
}
if ctx.Err() != nil {
return out.String(), -1, fmt.Errorf("%s timed out after %s", bin, t.timeout())
}
return out.String(), code, nil
}
var (
// /path/file:12: message
reFileLine = regexp.MustCompile(`^(?:dns_master_load: |dns_rdata_fromtext: )?(/[^:]+|[^:\s]+\.[A-Za-z0-9]+):(\d+): (.*)$`)
// zone example.com/IN: message or zone example.com/IN/internal: message
reZoneMsg = regexp.MustCompile(`^zone ([^/\s]+)/([A-Z]+)(?:/(\S+))?: (.*)$`)
// _default/example.com/IN: message (checkconf summary)
reViewZone = regexp.MustCompile(`^([^/\s]+)/([^/\s]+)/([A-Z]+): (.*)$`)
)
func isErrorText(s string) bool {
l := strings.ToLower(s)
switch {
case strings.HasPrefix(l, "warning"), strings.Contains(l, ": warning:"):
return false
case strings.HasPrefix(l, "loaded serial"), l == "ok":
return false
}
return true
}
// ParseOutput splits tool output into messages.
func ParseOutput(out string) []Message {
var msgs []Message
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := Message{Text: line}
if sm := reFileLine.FindStringSubmatch(line); sm != nil {
m.File = sm[1]
m.Line, _ = strconv.Atoi(sm[2])
m.Text = sm[3]
m.Error = isErrorText(sm[3])
} else if sm := reZoneMsg.FindStringSubmatch(line); sm != nil {
m.Zone, m.View, m.Text = sm[1], sm[3], sm[4]
m.Error = isErrorText(sm[4]) && !strings.HasPrefix(sm[4], "loaded serial")
if strings.Contains(sm[4], "is a CNAME (illegal)") || strings.Contains(sm[4], "has no address records") || strings.Contains(sm[4], "has no REQUIRED GLUE") {
// named loads the zone anyway; Jarl's own checks
// report these with more context.
m.Error = strings.Contains(sm[4], "NS '") && !strings.Contains(sm[4], "/NS '")
}
} else if sm := reViewZone.FindStringSubmatch(line); sm != nil {
m.View, m.Zone, m.Text = sm[1], sm[2], sm[4]
m.Error = true
} else {
m.Error = isErrorText(line) && line != "OK"
}
msgs = append(msgs, m)
}
return msgs
}
// CheckConfig runs named-checkconf on path. With loadZones, primary zones
// are test-loaded too (-z).
func (t *Tools) CheckConfig(ctx context.Context, path string, loadZones bool) (*Result, error) {
args := []string{}
if loadZones {
args = append(args, "-z")
}
args = append(args, path)
out, code, err := t.run(ctx, t.NamedCheckConf, args...)
if err != nil {
return nil, err
}
return &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, nil
}
// CheckZone runs named-checkzone on a zone file with the same strictness
// named applies to primary zones (check-names fail, local integrity
// checks).
func (t *Tools) CheckZone(ctx context.Context, zone, file string) (*Result, error) {
out, code, err := t.run(ctx, t.NamedCheckZone, "-k", "fail", "-i", "local", zone, file)
if err != nil {
return nil, err
}
return &Result{OK: code == 0, Messages: ParseOutput(out), Output: out}, nil
}
// DumpZone loads a zone file and prints it in canonical form (used by the
// importer for files with $INCLUDE/$GENERATE).
func (t *Tools) DumpZone(ctx context.Context, zone, file string) (string, error) {
out, code, err := t.run(ctx, t.NamedCheckZone, "-q", "-D", "-s", "full", "-o", "-", zone, file)
if err != nil {
return "", err
}
if code != 0 {
return "", fmt.Errorf("named-checkzone failed for %s", zone)
}
return out, nil
}
// Version returns the version printed by named-checkconf -v.
func (t *Tools) Version(ctx context.Context) (string, error) {
out, _, err := t.run(ctx, t.NamedCheckConf, "-v")
return strings.TrimSpace(out), err
}
// RNDCError is returned when rndc reports a failure.
type RNDCError struct {
Command string
Output string
}
func (e *RNDCError) Error() string {
return fmt.Sprintf("rndc %s failed: %s", e.Command, strings.TrimSpace(e.Output))
}
// Run runs an rndc command.
func (t *Tools) RNDCRun(ctx context.Context, args ...string) (string, error) {
full := append(append([]string{}, t.RNDCArgs...), args...)
out, code, err := t.run(ctx, t.RNDC, full...)
if err != nil {
return out, err
}
if code != 0 {
return out, &RNDCError{Command: strings.Join(args, " "), Output: out}
}
return out, nil
}
// Reconfig makes named reread its configuration and load new zones.
func (t *Tools) Reconfig(ctx context.Context) error {
_, err := t.RNDCRun(ctx, "reconfig")
return err
}
// ReloadZone asks named to reload one zone. named queues the reload and
// does not report load errors; check the result with ZoneStatus.
func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error {
_, err := t.RNDCRun(ctx, zoneArgs("reload", zone, view)...)
return err
}
func zoneArgs(cmd, zone, view string) []string {
z := strings.TrimSuffix(zone, ".")
if z == "" {
z = "."
}
if view == "" {
return []string{cmd, z}
}
return []string{cmd, z, "IN", view}
}
// ZoneStatus is the result of rndc zonestatus.
type ZoneStatus struct {
Name string `json:"name"`
Type string `json:"type"`
Files string `json:"files"`
Serial uint32 `json:"serial"`
HasSerial bool `json:"-"`
Nodes int `json:"nodes"`
LastLoaded string `json:"last_loaded"`
Secure bool `json:"secure"`
Dynamic bool `json:"dynamic"`
Raw string `json:"raw"`
}
// ZoneStatus returns named's view of a zone.
func (t *Tools) ZoneStatus(ctx context.Context, zone, view string) (*ZoneStatus, error) {
out, err := t.RNDCRun(ctx, zoneArgs("zonestatus", zone, view)...)
if err != nil {
return nil, err
}
return ParseZoneStatus(out), nil
}
// ParseZoneStatus parses rndc zonestatus output.
func ParseZoneStatus(out string) *ZoneStatus {
zs := &ZoneStatus{Raw: out}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, ": ")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.TrimSpace(k) {
case "name":
zs.Name = v
case "type":
zs.Type = v
case "files":
zs.Files = v
case "serial":
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
zs.Serial, zs.HasSerial = uint32(n), true
}
case "nodes":
zs.Nodes, _ = strconv.Atoi(v)
case "last loaded":
zs.LastLoaded = v
case "secure":
zs.Secure = v == "yes"
case "dynamic":
zs.Dynamic = v == "yes"
}
}
return zs
}
// ServerStatus is the parsed output of rndc status.
type ServerStatus struct {
Version string `json:"version"`
BootTime string `json:"boot_time"`
LastConfigured string `json:"last_configured"`
ConfigFile string `json:"config_file"`
Zones string `json:"zones"`
Running bool `json:"running"`
Raw string `json:"raw"`
}
// Status returns named's status.
func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
out, err := t.RNDCRun(ctx, "status")
if err != nil {
return nil, err
}
st := &ServerStatus{Raw: out}
for _, line := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(line, ": ")
switch {
case ok && k == "version":
st.Version = v
case ok && k == "boot time":
st.BootTime = v
case ok && k == "last configured":
st.LastConfigured = v
case ok && k == "configuration file":
st.ConfigFile = v
case ok && k == "number of zones":
st.Zones = v
case strings.Contains(line, "server is up and running"):
st.Running = true
}
}
return st, nil
}