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

+615
View File
@@ -0,0 +1,615 @@
package validate
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// TSIGAlgorithms are the algorithms named supports, with the minimum
// recommended secret length in bytes (the hash output size).
var TSIGAlgorithms = map[string]int{
"hmac-md5": 16,
"hmac-sha1": 20,
"hmac-sha224": 28,
"hmac-sha256": 32,
"hmac-sha384": 48,
"hmac-sha512": 64,
}
var reViewName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
func (c *checker) checkSettings() {
st := c.s.Settings
for _, ns := range st.Nameservers {
if !dnsname.IsHostname(ns, false) {
c.errf("settings_invalid", "settings", 0, "default name server %q is not a valid host name", dnsname.Display(ns))
}
}
if st.RName != "" && !dnsname.IsMailbox(st.RName) {
c.errf("settings_invalid", "settings", 0, "default SOA contact %q is not a valid mailbox name", st.RName)
}
if st.DefaultTTL > maxTTL {
c.errf("settings_invalid", "settings", 0, "default TTL %d is larger than 2147483647", st.DefaultTTL)
}
switch st.SerialPolicy {
case model.SerialDate, model.SerialIncrement, model.SerialUnixtime:
default:
c.errf("settings_invalid", "settings", 0, "unknown serial policy %q", st.SerialPolicy)
}
}
func (c *checker) checkKeys() {
seen := map[string]bool{}
for _, k := range c.s.SortedKeys() {
label := "TSIG key " + k.Name
if seen[k.Name] {
c.errf("key_duplicate", "key", k.ID, "%s is defined more than once", label)
}
seen[k.Name] = true
if k.Name == "" {
c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil {
c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err)
}
minLen, ok := TSIGAlgorithms[k.Algorithm]
if !ok {
c.errf("key_algorithm_invalid", "key", k.ID, "%s: unsupported algorithm %q (use one of hmac-sha256, hmac-sha512, ...)", label, k.Algorithm)
}
b, err := base64.StdEncoding.DecodeString(k.Secret)
if err != nil || len(b) == 0 {
c.errf("key_secret_invalid", "key", k.ID, "%s: secret is not valid base64", label)
} else if ok && len(b) < minLen {
c.warnf("key_secret_weak", "key", k.ID, "%s: secret is only %d bytes; %d or more are recommended for %s", label, len(b), minLen, k.Algorithm)
}
if k.Algorithm == "hmac-md5" || k.Algorithm == "hmac-sha1" {
c.warnf("key_algorithm_weak", "key", k.ID, "%s: %s is outdated; prefer hmac-sha256", label, k.Algorithm)
}
}
}
// parseElements parses ACL-style element strings, reporting errors.
func (c *checker) parseElements(elems []string, report func(err error, elem string)) []namedconf.AMElement {
var out []namedconf.AMElement
for _, s := range elems {
e, err := namedconf.ParseAMElement(s)
if err != nil {
report(err, s)
continue
}
out = append(out, e)
}
return out
}
// checkRefs reports references to ACLs and keys that don't exist.
func (c *checker) checkRefs(elems []namedconf.AMElement, where string, objType string, id int64) {
acls, keys := namedconf.AMRefs(elems)
for _, a := range acls {
if c.s.ACLByName(a) == nil {
c.errf("acl_ref_missing", objType, id, "%s refers to ACL %q, which does not exist", where, a)
}
}
for _, k := range keys {
if !c.keyNames[model.NormalizeKeyName(k)] && !c.opts.ExternalKeysUnknown {
c.errf("key_ref_missing", objType, id, "%s refers to TSIG key %q, which does not exist", where, k)
}
}
}
func (c *checker) checkACLs() {
seen := map[string]bool{}
for _, a := range c.s.SortedACLs() {
label := "ACL " + a.Name
lname := strings.ToLower(a.Name)
if seen[lname] {
c.errf("acl_duplicate", "acl", a.ID, "%s is defined more than once (ACL names are case-insensitive)", label)
}
seen[lname] = true
if namedconf.BuiltinACLs[lname] {
c.errf("acl_name_invalid", "acl", a.ID, "%q is a built-in ACL name and can't be redefined", a.Name)
} else if err := namedconf.CheckACLName(a.Name); err != nil {
c.errf("acl_name_invalid", "acl", a.ID, "%v", err)
}
elems := c.parseElements(a.Elements, func(err error, s string) {
c.errf("acl_element_invalid", "acl", a.ID, "%s: element %q: %v", label, s, err)
})
if len(a.Elements) == 0 {
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
}
c.checkRefs(elems, label, "acl", a.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
}
}
// Cycles between ACLs.
state := map[string]int{} // 0 unvisited, 1 visiting, 2 done
var visit func(a *model.ACL, path []string)
visit = func(a *model.ACL, path []string) {
n := strings.ToLower(a.Name)
switch state[n] {
case 1:
c.errf("acl_cycle", "acl", a.ID, "ACLs refer to each other in a loop: %s", strings.Join(append(path, a.Name), " -> "))
return
case 2:
return
}
state[n] = 1
var elems []namedconf.AMElement
for _, s := range a.Elements {
if e, err := namedconf.ParseAMElement(s); err == nil {
elems = append(elems, e)
}
}
refs, _ := namedconf.AMRefs(elems)
for _, r := range refs {
if next := c.s.ACLByName(r); next != nil {
visit(next, append(path, a.Name))
}
}
state[n] = 2
}
for _, a := range c.s.SortedACLs() {
visit(a, nil)
}
}
func (c *checker) checkViews() {
seen := map[string]bool{}
for _, v := range c.s.OrderedViews() {
label := "view " + v.Name
ln := strings.ToLower(v.Name)
if seen[ln] {
c.errf("view_duplicate", "view", v.ID, "%s is defined more than once", label)
}
seen[ln] = true
if !reViewName.MatchString(v.Name) {
c.errf("view_name_invalid", "view", v.ID, "view name %q must start with a letter or digit and contain only letters, digits, '_', '-' and '.'", v.Name)
}
for field, list := range map[string][]string{"match-clients": v.MatchClients, "match-destinations": v.MatchDestinations} {
elems := c.parseElements(list, func(err error, s string) {
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
})
c.checkRefs(elems, label+" "+field, "view", v.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
}
}
}
// Views that can never match because an earlier view matches every
// client.
views := c.s.OrderedViews()
for i, v := range views {
if !c.viewMatchesEveryone(v) {
continue
}
for _, later := range views[i+1:] {
c.warnf("view_unreachable", "view", later.ID, "view %s is never used: view %s before it matches every client", later.Name, v.Name)
}
break
}
}
// viewMatchesEveryone reports whether a view matches all clients and
// destinations (and not just recursive queries).
func (c *checker) viewMatchesEveryone(v *model.View) bool {
if v.MatchRecursiveOnly != nil && *v.MatchRecursiveOnly {
return false
}
return c.listMatchesEveryone(v.MatchClients, true) && c.listMatchesEveryone(v.MatchDestinations, true)
}
// listMatchesEveryone reports whether an element list matches every
// address. emptyIsAny is true for lists that default to "any".
func (c *checker) listMatchesEveryone(list []string, emptyIsAny bool) bool {
if len(list) == 0 {
return emptyIsAny
}
return namedconf.ContainsAny(c.flatten(list, 0))
}
// flatten parses elements and expands references to user ACLs.
func (c *checker) flatten(list []string, depth int) []namedconf.AMElement {
var out []namedconf.AMElement
for _, s := range list {
e, err := namedconf.ParseAMElement(s)
if err != nil {
continue
}
if e.Kind == namedconf.AMName && !e.IsBuiltin() && depth < 10 {
if a := c.s.ACLByName(e.Name); a != nil {
inner := c.flatten(a.Elements, depth+1)
if e.Negated {
// A negated ACL reference: treat as a single
// negated element (restrictive).
out = append(out, namedconf.AMElement{Negated: true, Kind: namedconf.AMOther, Raw: "!" + e.Name})
continue
}
out = append(out, inner...)
continue
}
}
out = append(out, e)
}
return out
}
// optionScope maps an option to its catalog scope.
func (c *checker) optionScope(o *model.Option) (string, string, bool) {
switch o.Scope {
case model.ScopeOptions:
return catalog.ScopeOptions, "server options", true
case model.ScopeTopLevel:
return catalog.ScopeTop, "top-level statements", true
case model.ScopeView:
v := c.s.Views[o.ScopeID]
if v == nil {
return "", "", false
}
return catalog.ScopeView, "view " + v.Name, true
case model.ScopeZone:
z := c.s.Zones[o.ScopeID]
if z == nil {
return "", "", false
}
return catalog.ZoneScope(z.Type), "zone " + c.zoneLabel(z), true
}
return "", "", false
}
func (c *checker) checkOptions() {
cat := catalog.Default()
type key struct {
scope string
id int64
name string
}
counts := map[key]int{}
var opts []*model.Option
for _, o := range c.s.Options {
opts = append(opts, o)
}
sortOptions(opts)
for _, o := range opts {
scope, where, ok := c.optionScope(o)
if !ok {
c.errf("option_scope_invalid", "option", o.ID, "option %s belongs to a %s that does not exist", o.Name, o.Scope)
continue
}
name := strings.ToLower(o.Name)
st, known := cat.Lookup(scope, name)
if !known {
if o.Scope == model.ScopeZone {
z := c.s.Zones[o.ScopeID]
c.errf("option_unknown", "option", o.ID, "%s: %q is not a valid option for %s zones", where, o.Name, z.Type)
} else {
c.errf("option_unknown", "option", o.ID, "%s: unknown option %q", where, o.Name)
}
continue
}
if st.Managed {
c.errf("option_managed", "option", o.ID, "%s: %q can't be set as an option; it is managed through %s", where, o.Name, st.ManagedBy)
continue
}
switch st.Status {
case "deprecated":
c.warnf("option_deprecated", "option", o.ID, "%s: %q is deprecated in BIND 9.20", where, o.Name)
case "obsolete":
c.warnf("option_obsolete", "option", o.ID, "%s: %q is obsolete and has no effect (or is rejected) in BIND 9.20", where, o.Name)
case "experimental":
c.infof("option_experimental", "option", o.ID, "%s: %q is experimental", where, o.Name)
case "test only", "not configured":
c.warnf("option_unavailable", "option", o.ID, "%s: %q is %s in this BIND build", where, o.Name, st.Status)
}
k := key{o.Scope, o.ScopeID, st.Name}
counts[k]++
if counts[k] == 2 && !st.Multiple {
c.errf("option_duplicate", "option", o.ID, "%s: %q may only be set once", where, o.Name)
}
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
if strings.TrimSpace(o.Value) == "" && (name == "null" || name == "stderr") {
continue
}
c.errf("option_syntax", "option", o.ID, "%s: %s: %v", where, o.Name, err)
continue
}
if err := checkKind(st, stmt); err != nil {
c.errf("option_value_invalid", "option", o.ID, "%s: %s: %v (expected: %s)", where, o.Name, err, st.Grammar)
continue
}
label := where + " " + o.Name
if strings.Contains(st.Grammar, "<address_match_element>") && !strings.Contains(st.Grammar, "\n") {
if b := stmt.FirstBlock(); b != nil {
elems, err := namedconf.ParseAMList(b)
if err != nil {
c.errf("option_value_invalid", "option", o.ID, "%s: %v", label, err)
} else {
c.checkRefs(elems, label, "option", o.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "option", o.ID, "%s: %s", label, w)
}
}
}
}
for _, kn := range keyRefs(stmt) {
if !c.keyNames[model.NormalizeKeyName(kn)] && !c.opts.ExternalKeysUnknown {
c.errf("key_ref_missing", "option", o.ID, "%s refers to TSIG key %q, which does not exist", label, kn)
}
}
}
}
func sortOptions(opts []*model.Option) {
for i := 1; i < len(opts); i++ {
for j := i; j > 0 && optLess(opts[j], opts[j-1]); j-- {
opts[j], opts[j-1] = opts[j-1], opts[j]
}
}
}
func optLess(a, b *model.Option) bool {
if a.Scope != b.Scope {
return a.Scope < b.Scope
}
if a.ScopeID != b.ScopeID {
return a.ScopeID < b.ScopeID
}
if a.Position != b.Position {
return a.Position < b.Position
}
return a.ID < b.ID
}
var (
reDuration = regexp.MustCompile(`(?i)^(\d+|(\d+[wdhms])+|P(\d+[YMWD])*(T(\d+[HMS])+)?)$`)
reInteger = regexp.MustCompile(`^\d+$`)
)
// checkKind does quick checks on simple option values. Anything it does
// not understand is left to named-checkconf.
func checkKind(st *catalog.Statement, stmt *namedconf.Stmt) error {
args := stmt.Items[1:]
single := func() (*namedconf.Value, error) {
if len(args) != 1 || args[0].Value == nil {
return nil, fmt.Errorf("expected a single value")
}
return args[0].Value, nil
}
switch st.Kind {
case catalog.KindBool:
v, err := single()
if err != nil {
return err
}
switch strings.ToLower(v.Text) {
case "yes", "no", "true", "false", "1", "0":
return nil
}
return fmt.Errorf("%q is not a boolean (use yes or no)", v.Text)
case catalog.KindInt:
v, err := single()
if err != nil {
return err
}
if !reInteger.MatchString(v.Text) {
return fmt.Errorf("%q is not a number", v.Text)
}
case catalog.KindDuration:
v, err := single()
if err != nil {
return err
}
if !reDuration.MatchString(v.Text) {
return fmt.Errorf("%q is not a duration (e.g. 3600, 1h30m or PT1H)", v.Text)
}
case catalog.KindString, catalog.KindWord:
if _, err := single(); err != nil {
return err
}
case catalog.KindEnum:
v, err := single()
if err != nil {
return err
}
for _, ch := range st.Choices {
if strings.EqualFold(ch, v.Text) {
return nil
}
}
if lv := strings.ToLower(v.Text); contains(st.Choices, "yes") && (lv == "true" || lv == "false" || lv == "1" || lv == "0") {
return nil
}
return fmt.Errorf("%q is not one of %s", v.Text, strings.Join(st.Choices, ", "))
case catalog.KindACL:
if len(args) != 1 || args[0].Block == nil {
return fmt.Errorf("expected an address match list in braces, e.g. { 192.168.0.0/16; localhost; }")
}
}
return nil
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// keyRefs finds TSIG key references in a statement: "key NAME" and
// "keys { NAME; ... }" / "keys NAME" anywhere in it.
func keyRefs(st *namedconf.Stmt) []string {
var out []string
var walk func(items []namedconf.Item)
walk = func(items []namedconf.Item) {
for i, it := range items {
if it.Value != nil && !it.Value.Quoted && i+1 < len(items) {
switch strings.ToLower(it.Value.Text) {
case "key":
if n := items[i+1].Value; n != nil {
out = append(out, n.Text)
}
case "keys":
if n := items[i+1].Value; n != nil {
out = append(out, n.Text)
} else if b := items[i+1].Block; b != nil {
for _, s := range b.Stmts {
if len(s.Items) > 0 && s.Items[0].Value != nil {
out = append(out, s.Items[0].Value.Text)
}
}
}
}
}
if it.Block != nil {
for _, s := range it.Block.Stmts {
walk(s.Items)
}
}
}
}
if st.Keyword() == "key" { // a key definition, not a reference
return nil
}
walk(st.Items)
return out
}
// effectiveAML finds the first of names set in the view (if any) or in the
// global options, returning its elements (with ACL references expanded).
func (c *checker) effectiveAML(v *model.View, names ...string) ([]namedconf.AMElement, string, bool) {
for _, n := range names {
if v != nil {
if o := c.s.FindOption(model.ScopeView, v.ID, n); o != nil {
return c.amlOf(o), n, true
}
}
if o := c.s.FindOption(model.ScopeOptions, 0, n); o != nil {
return c.amlOf(o), n, true
}
}
return nil, "", false
}
func (c *checker) amlOf(o *model.Option) []namedconf.AMElement {
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || st.FirstBlock() == nil {
return nil
}
var list []string
for _, s := range st.FirstBlock().Stmts {
list = append(list, namedconf.Compact(s.Items))
}
return c.flatten(list, 0)
}
func (c *checker) effectiveBool(v *model.View, name string, def bool) bool {
var o *model.Option
if v != nil {
o = c.s.FindOption(model.ScopeView, v.ID, name)
}
if o == nil {
o = c.s.FindOption(model.ScopeOptions, 0, name)
}
if o == nil {
return def
}
switch strings.ToLower(strings.TrimSpace(o.Value)) {
case "yes", "true", "1":
return true
case "no", "false", "0":
return false
}
return def
}
// checkSecurity looks for configurations that are dangerous on an
// internet-facing server.
func (c *checker) checkSecurity() {
views := c.s.OrderedViews()
type ctx struct {
v *model.View
label string
}
var ctxs []ctx
if len(views) == 0 {
ctxs = append(ctxs, ctx{nil, "the server"})
}
for _, v := range views {
ctxs = append(ctxs, ctx{v, "view " + v.Name})
}
for _, x := range ctxs {
// A view only reachable from some clients (e.g. "internal") can
// recurse for everyone inside it without being an open resolver.
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) {
continue
}
if c.effectiveBool(x.v, "recursion", true) {
aml, from, set := c.effectiveAML(x.v, "allow-recursion", "allow-query-cache", "allow-query")
if set && namedconf.ContainsAny(aml) {
p := c.warnf("security_open_resolver", "view", viewID(x.v),
"%s offers recursion to any client (via %s), making it an open resolver that can be abused for DDoS amplification; set allow-recursion to your own networks", x.label, from)
if x.v != nil {
p.View = x.v.Name
}
}
}
if aml, _, set := c.effectiveAML(x.v, "allow-transfer"); set && namedconf.ContainsAny(aml) {
p := c.warnf("security_open_transfer", "view", viewID(x.v), "%s allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key", x.label)
if x.v != nil {
p.View = x.v.Name
}
}
}
// Dynamic updates would be lost when Jarl rewrites zone files.
for _, z := range c.s.SortedZones() {
if z.Type != model.ZonePrimary {
continue
}
v := c.s.Views[z.ViewID]
if o := c.s.FindOption(model.ScopeZone, z.ID, "update-policy"); o != nil {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "update-policy makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; manage records through Jarl instead")
p.Zone, p.View = z.Name, c.s.ViewName(z)
continue
}
var aml []namedconf.AMElement
set := false
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-update"); o != nil {
aml, set = c.amlOf(o), true
} else {
aml, _, set = c.effectiveAML(v, "allow-update")
}
if set && !onlyNone(aml) {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
p.Zone, p.View = z.Name, c.s.ViewName(z)
}
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
p.Zone, p.View = z.Name, c.s.ViewName(z)
}
}
}
func onlyNone(elems []namedconf.AMElement) bool {
for _, e := range elems {
if !(e.Kind == namedconf.AMName && e.Name == "none" && !e.Negated) {
return false
}
}
return true
}
func viewID(v *model.View) int64 {
if v == nil {
return 0
}
return v.ID
}