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>
183 lines
7.5 KiB
Go
183 lines
7.5 KiB
Go
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)
|
|
}
|