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

+182
View File
@@ -0,0 +1,182 @@
package validate
import (
"path/filepath"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
)
const maxTTL = 2147483647
func (c *checker) zoneProblem(z *model.Zone, sev Severity, code, format string, args ...any) *Problem {
var p *Problem
switch sev {
case Error:
p = c.errf(code, "zone", z.ID, format, args...)
case Warning:
p = c.warnf(code, "zone", z.ID, format, args...)
default:
p = c.infof(code, "zone", z.ID, format, args...)
}
p.Zone = z.Name
p.View = c.s.ViewName(z)
return p
}
func (c *checker) checkZones() {
hasViews := len(c.s.Views) > 0
type zkey struct {
view int64
name string
}
seen := map[zkey]bool{}
viewPos := map[int64]int{}
for i, v := range c.s.OrderedViews() {
viewPos[v.ID] = i
}
for _, z := range c.s.SortedZones() {
if z.ViewID != 0 && c.s.Views[z.ViewID] == nil {
c.zoneProblem(z, Error, "zone_view_missing", "the zone's view (id %d) does not exist", z.ViewID)
continue
}
if hasViews && z.ViewID == 0 {
c.zoneProblem(z, Error, "zone_outside_view", "when views are used, every zone must be inside a view (named requires this); move the zone into a view")
}
if err := dnsname.CheckZoneName(z.Name); err != nil {
c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err)
}
k := zkey{z.ViewID, z.Name}
if seen[k] {
c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view")
}
seen[k] = true
switch z.Type {
case model.ZonePrimary:
c.checkPrimary(z)
case model.ZoneSecondary, model.ZoneStub:
if c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil {
c.zoneProblem(z, Error, "zone_missing_option", "%s zones need a primaries option listing the primary servers", z.Type)
}
case model.ZoneMirror:
if z.Name != dnsname.Root && c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil {
c.zoneProblem(z, Error, "zone_missing_option", "mirror zones other than the root zone need a primaries option")
}
case model.ZoneForward:
if c.s.FindOption(model.ScopeZone, z.ID, "forwarders") == nil {
c.zoneProblem(z, Info, "zone_no_forwarders", "the forward zone has no forwarders option, so names in it are resolved normally (forwarding is disabled for this domain)")
}
case model.ZoneHint:
if z.File == "" {
c.zoneProblem(z, Error, "zone_file_required", "hint zones need a file (for example /usr/share/dns/root.hints)")
}
if z.Name != dnsname.Root {
c.zoneProblem(z, Warning, "zone_hint_name", "hint zones are normally the root zone \".\"")
}
case model.ZoneRedirect:
if z.Name != dnsname.Root {
c.zoneProblem(z, Error, "zone_name_invalid", "redirect zones must be named \".\"")
}
if z.File == "" && c.s.FindOption(model.ScopeZone, z.ID, "primaries") == nil {
c.zoneProblem(z, Error, "zone_file_required", "redirect zones need a file or a primaries option")
}
case model.ZoneStaticStub:
if c.s.FindOption(model.ScopeZone, z.ID, "server-addresses") == nil && c.s.FindOption(model.ScopeZone, z.ID, "server-names") == nil {
c.zoneProblem(z, Error, "zone_missing_option", "static-stub zones need server-addresses or server-names")
}
case model.ZoneInView:
c.checkInView(z, viewPos)
default:
c.zoneProblem(z, Error, "zone_type_invalid", "unknown zone type %q", z.Type)
}
if z.File != "" {
switch z.Type {
case model.ZonePrimary:
c.zoneProblem(z, Error, "zone_file_managed", "the file of a primary zone is generated by Jarl and can't be set")
case model.ZoneForward, model.ZoneStaticStub, model.ZoneInView:
c.zoneProblem(z, Error, "zone_file_invalid", "%s zones have no file", z.Type)
default:
if !filepath.IsAbs(z.File) {
c.zoneProblem(z, Error, "zone_file_invalid", "zone file %q must be an absolute path", z.File)
}
}
}
if !z.HasRecords() && len(c.zoneRecords[z.ID]) > 0 {
c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.zoneRecords[z.ID]))
}
}
}
func (c *checker) checkInView(z *model.Zone, viewPos map[int64]int) {
if z.ViewID == 0 {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view zones can only be used inside views")
return
}
target := c.s.ViewByName(z.InView)
if target == nil {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view refers to view %q, which does not exist", z.InView)
return
}
if target.ID == z.ViewID {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can't refer to the zone's own view")
return
}
if viewPos[target.ID] > viewPos[z.ViewID] {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view refers to view %q, which comes after this view; named only allows references to earlier views (reorder the views)", target.Name)
}
src := c.s.FindZone(z.Name, target.ID)
if src == nil {
c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name))
} else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary {
c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type)
}
for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) {
if o.Name != "forward" && o.Name != "forwarders" {
c.zoneProblem(z, Error, "zone_inview_option", "in-view zones may only set forward and forwarders, not %s (set it on the zone in view %q)", o.Name, z.InView)
}
}
}
func (c *checker) checkPrimary(z *model.Zone) {
soa := z.SOA
if soa.MName == "" || !dnsname.IsHostname(soa.MName, false) {
c.zoneProblem(z, Error, "soa_invalid", "SOA primary name server (mname) %q is not a valid host name", dnsname.Display(soa.MName))
}
if soa.RName == "" || !dnsname.IsMailbox(soa.RName) {
c.zoneProblem(z, Error, "soa_invalid", "SOA contact (rname) %q is not a valid mailbox (use hostmaster.example.com. or an email address)", soa.RName)
}
for name, v := range map[string]uint32{"refresh": soa.Refresh, "retry": soa.Retry, "expire": soa.Expire, "minimum": soa.Minimum} {
if v > maxTTL {
c.zoneProblem(z, Error, "soa_invalid", "SOA %s %d is larger than 2147483647", name, v)
}
}
if soa.Refresh < 60 {
c.zoneProblem(z, Warning, "soa_warning", "SOA refresh %ds is very short; secondaries will poll constantly", soa.Refresh)
}
if soa.Retry >= soa.Refresh && soa.Refresh > 0 {
c.zoneProblem(z, Warning, "soa_warning", "SOA retry (%d) should be shorter than refresh (%d)", soa.Retry, soa.Refresh)
}
if soa.Expire < soa.Refresh+soa.Retry {
c.zoneProblem(z, Warning, "soa_warning", "SOA expire (%d) should be longer than refresh + retry", soa.Expire)
} else if soa.Expire < 604800 {
c.zoneProblem(z, Warning, "soa_warning", "SOA expire is under a week (%d); 2-4 weeks is recommended so secondaries survive a primary outage", soa.Expire)
}
if soa.Minimum > 10800 {
c.zoneProblem(z, Warning, "soa_warning", "SOA minimum (negative caching TTL) %d is more than 3 hours; BIND caps negative caching at 3 hours", soa.Minimum)
}
if z.DefaultTTL > maxTTL {
c.zoneProblem(z, Error, "ttl_invalid", "default TTL %d is larger than 2147483647", z.DefaultTTL)
}
switch z.SerialPolicy {
case "", model.SerialDate, model.SerialIncrement, model.SerialUnixtime:
default:
c.zoneProblem(z, Error, "zone_serial_policy", "unknown serial policy %q (use date, increment or unixtime)", z.SerialPolicy)
}
if strings.Contains(z.Name, "/") {
c.zoneProblem(z, Info, "zone_name_slash", "zone names with '/' (RFC 2317) are supported, but the file name is escaped")
}
c.checkZoneRecords(z)
}