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) }