Files
jarl/internal/validate/zones.go
T
abrown38andClaude Opus 5.5 0795a1001e Fix zone list filtering and BIND warning display; add a views guide
- jarl zone list showed only primary zones. zone create binds its --type
  flag (default "primary") to the same variable as list's --type, so the
  default leaked into the list filter and hid hint, secondary and forward
  zones. list now has its own variable. TestListQueries runs list
  commands against a fake server and checks the filters they send.
- named-checkconf/named-checkzone messages are classified by the tool's
  exit code: on success, everything printed is a warning. Warnings such
  as 'both "recursion no;" and "allow-recursion" active' were shown as
  errors. The CLI, jarld check and the web UI now label them as warnings
  and skip the routine "loaded serial" lines.
- The zone_outside_view error points at --adopt-zones.
- docs/views.md: step-by-step split DNS for a server that has no views
  yet, tested on a copy of a stock Ubuntu 26.04 setup. Linked from the
  README, concepts and validation docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:36:24 +00:00

208 lines
8.6 KiB
Go

package validate
import (
"fmt"
"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, or create the first view with adopt_zones (jarl view create NAME --adopt-zones) to move all zones at once")
}
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)
}
// Resolvers cache "no such name/type" answers for the smaller of the
// SOA minimum and the SOA record's own TTL (RFC 2308 section 5); the
// SOA record uses the zone's default TTL.
if neg := min(soa.Minimum, z.DefaultTTL); neg > 10800 {
cmd := "jarl zone update " + dnsname.Display(z.Name)
if v := c.s.ViewName(z); v != "" {
cmd += " --view " + v
}
c.zoneProblem(z, Warning, "soa_warning", "resolvers may cache negative answers (\"no such name\") from this zone for %s (the smaller of the SOA minimum, %d, and the zone's default TTL, %d), so a name you add can stay invisible that long to anyone who looked it up before; RFC 2308 recommends 1 to 3 hours (lower it with '%s --minimum 3600')",
humanSeconds(neg), soa.Minimum, z.DefaultTTL, cmd)
}
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)
}
// humanSeconds formats a duration in seconds as e.g. "1 day" or "12 hours".
func humanSeconds(n uint32) string {
for _, u := range []struct {
secs uint32
name string
}{{604800, "week"}, {86400, "day"}, {3600, "hour"}, {60, "minute"}} {
if n >= u.secs && n%u.secs == 0 {
if v := n / u.secs; v != 1 {
return fmt.Sprintf("%d %ss", v, u.name)
}
return "1 " + u.name
}
}
return fmt.Sprintf("%d seconds", n)
}