A disabled zone stays in Jarl with its records, options and history, still editable, but named doesn't load it; enabling it serves it as it is then. Signed zones, and zones another view shares, can't be disabled. jarl zone disable|enable, the zone page's Disable/Enable button. Database schema 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
9.1 KiB
Go
214 lines
9.1 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)
|
|
}
|
|
if z.Disabled && z.Signed() {
|
|
c.zoneProblem(z, Error, "zone_disabled_signed", "a signed zone can't be disabled: validating resolvers would lose its signed answers while the parent may still have its DS record; turn signing off first")
|
|
}
|
|
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.allRecords[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.allRecords[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)
|
|
} else if src.Disabled && !z.Disabled {
|
|
c.zoneProblem(z, Error, "zone_inview_disabled", "%s in view %q is disabled, so this zone can't share it; disable this one too, or enable that one", dnsname.Display(z.Name), target.Name)
|
|
}
|
|
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)
|
|
c.checkDisabledRecords(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)
|
|
}
|