Files
jarl/internal/validate/zones.go
T
abrown38andClaude Opus 5.5 3c73afe0e1 Fix the negative-caching warning and show warnings in the CLI
The soa_warning for negative caching fired on the SOA minimum alone and
blamed BIND's resolver cap (max-ncache-ttl), which doesn't apply to an
authoritative zone. Resolvers cache negative answers for the smaller of
the SOA minimum and the SOA record's TTL (RFC 2308 section 5), and the
SOA record uses the zone's default TTL. The warning now uses that value,
explains the effect, and suggests the fix command (with --view when the
zone is in a view).

Also:
- Problem.String shows zone names without the trailing dot (jarld check).
- jarl prints the warnings a change introduces when it is applied or
  staged, not only on dry runs.
- jarl check/resync print only named-checkconf errors, not its
  "loaded serial" lines.

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

208 lines
8.5 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")
}
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)
}