The Zones page's New reverse zone lists every network the records use without a reverse zone, in every view, also where nothing is suggested (suggestions off, dismissed, public views), marked. Picking one fills in the network and view; size buttons (/48 /56 /64 /128, /16 /24 /32) re-mask it around a record's address, so a /48 becomes the records' own /64. Fill stays on. jarl zone suggest-reverse --all [--view V]; GET /reverse-zones/suggestions?all=1&view=. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
487 lines
15 KiB
Go
487 lines
15 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
"jarl/internal/rr"
|
|
"jarl/internal/validate"
|
|
)
|
|
|
|
// Reverse zones made easy (docs/reverse.md): filling a reverse zone from
|
|
// the A and AAAA records of its view, choosing its view, and suggesting
|
|
// the reverse zones a view lacks.
|
|
|
|
// addrUse is an address a forward record gives a name.
|
|
type addrUse struct {
|
|
addr netip.Addr
|
|
name string // the owner name, absolute
|
|
apex bool
|
|
}
|
|
|
|
// forwardAddresses returns the addresses of the enabled A and AAAA
|
|
// records of a view's primary forward zones (wildcards aside: a PTR record
|
|
// can't name them).
|
|
func forwardAddresses(st *model.State, viewID int64) []addrUse {
|
|
var out []addrUse
|
|
for _, z := range st.ZonesInView(viewID) {
|
|
if z.Type != model.ZonePrimary || z.Disabled || dnsname.IsReverse(z.Name) {
|
|
continue
|
|
}
|
|
for _, r := range st.ZoneRecords(z.ID) {
|
|
if r.Disabled || (r.Type != "A" && r.Type != "AAAA") || strings.HasPrefix(r.Name, "*.") {
|
|
continue
|
|
}
|
|
a, err := netip.ParseAddr(r.Data)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, addrUse{addr: a.Unmap(), name: r.Name, apex: r.Name == z.Name})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PTRFill is a PTR record filling a reverse zone would add.
|
|
type PTRFill struct {
|
|
Address string `json:"address"`
|
|
Name string `json:"name"` // the PTR owner name
|
|
Target string `json:"target"` // the host name it gives
|
|
Others []string `json:"others,omitempty"` // other names with this address
|
|
}
|
|
|
|
// PTRFills returns the PTR records a reverse zone named zoneName in a view
|
|
// lacks for the addresses of the view's A and AAAA records: one per
|
|
// address, naming its mail or name server host when it is one, otherwise
|
|
// a host rather than the domain itself, then the shortest name.
|
|
// existing are the zone's records (none for a new zone).
|
|
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
|
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
has := map[string]bool{}
|
|
for _, r := range existing {
|
|
if r.Type == "PTR" {
|
|
has[r.Name] = true
|
|
}
|
|
}
|
|
servers := serverNames(st, viewID)
|
|
byAddr := map[netip.Addr][]addrUse{}
|
|
var order []netip.Addr
|
|
for _, u := range forwardAddresses(st, viewID) {
|
|
if !prefix.Contains(u.addr) {
|
|
continue
|
|
}
|
|
if _, seen := byAddr[u.addr]; !seen {
|
|
order = append(order, u.addr)
|
|
}
|
|
if !slices.ContainsFunc(byAddr[u.addr], func(o addrUse) bool { return o.name == u.name }) {
|
|
byAddr[u.addr] = append(byAddr[u.addr], u)
|
|
}
|
|
}
|
|
slices.SortFunc(order, func(a, b netip.Addr) int { return a.Compare(b) })
|
|
var out []PTRFill
|
|
for _, a := range order {
|
|
owner := dnsname.ReverseName(a)
|
|
if has[owner] {
|
|
continue
|
|
}
|
|
uses := byAddr[a]
|
|
slices.SortStableFunc(uses, func(x, y addrUse) int {
|
|
rank := func(u addrUse) int {
|
|
switch {
|
|
case servers[u.name]:
|
|
return 0
|
|
case !u.apex:
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
if d := rank(x) - rank(y); d != 0 {
|
|
return d
|
|
}
|
|
if d := strings.Count(x.name, ".") - strings.Count(y.name, "."); d != 0 {
|
|
return d
|
|
}
|
|
return strings.Compare(x.name, y.name)
|
|
})
|
|
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name}
|
|
for _, u := range uses[1:] {
|
|
f.Others = append(f.Others, u.name)
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// serverNames are the hosts the view's MX and NS records name.
|
|
func serverNames(st *model.State, viewID int64) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, z := range st.ZonesInView(viewID) {
|
|
if z.Type != model.ZonePrimary {
|
|
continue
|
|
}
|
|
for _, r := range st.ZoneRecords(z.ID) {
|
|
switch r.Type {
|
|
case "MX":
|
|
if f := strings.Fields(r.Data); len(f) == 2 {
|
|
out[strings.ToLower(f[1])] = true
|
|
}
|
|
case "NS":
|
|
out[strings.ToLower(r.Data)] = true
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fillWarnings(fills []PTRFill) []string {
|
|
var out []string
|
|
for _, f := range fills {
|
|
if len(f.Others) > 0 {
|
|
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func displayAll(names []string) []string {
|
|
out := make([]string, len(names))
|
|
for i, n := range names {
|
|
out[i] = dnsname.Display(n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
|
|
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
|
|
var in LinkInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
id := in.ZoneID
|
|
if id == 0 {
|
|
id = req.ID
|
|
}
|
|
z := x.st.Zones[id]
|
|
if z == nil {
|
|
return nil, errf(KindNotFound, "zone %d does not exist", id)
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return nil, errf(KindBadRequest, "zone %s isn't a primary zone", zoneDesc(x.st, z))
|
|
}
|
|
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
|
|
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
|
|
}
|
|
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID))
|
|
if len(fills) == 0 {
|
|
return nil, errf(KindBadRequest, "nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
|
|
}
|
|
x.warnings = append(x.warnings, fillWarnings(fills)...)
|
|
ids, err := x.ids(len(fills))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ops []model.Op
|
|
for i, f := range fills {
|
|
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
|
|
if z.LinkGroup != 0 {
|
|
ri.SharedID = model.Ptr(ids[i])
|
|
}
|
|
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
func viewOrServer(st *model.State, viewID int64) string {
|
|
if v := st.Views[viewID]; v != nil {
|
|
return "view " + v.Name
|
|
}
|
|
return "the server"
|
|
}
|
|
|
|
// publicViews returns which views the internet reaches.
|
|
func (x *expander) publicViews() map[int64]bool {
|
|
out := map[int64]bool{}
|
|
if x.exposures == nil {
|
|
return out
|
|
}
|
|
for _, e := range x.exposures(x.st) {
|
|
out[e.ViewID] = e.Public
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reverseViewFor chooses the view of a new reverse zone given without one:
|
|
// the private view whose records use addresses in its range, or the only
|
|
// private view; views with reverse suggestions off don't count. nil with
|
|
// no error leaves the choice to the usual rules.
|
|
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
|
|
prefix, ok := dnsname.PrefixFromReverse(zoneName)
|
|
if !ok || len(st.Views) == 0 {
|
|
return nil, nil
|
|
}
|
|
var private, using []*model.View
|
|
for _, v := range st.OrderedViews() {
|
|
if public[v.ID] || v.ReverseSuggestionsOff {
|
|
continue
|
|
}
|
|
private = append(private, v)
|
|
if slices.ContainsFunc(forwardAddresses(st, v.ID), func(u addrUse) bool { return prefix.Contains(u.addr) }) {
|
|
using = append(using, v)
|
|
}
|
|
}
|
|
switch {
|
|
case len(using) == 1:
|
|
return using[0], nil
|
|
case len(using) > 1:
|
|
var names []string
|
|
for _, v := range using {
|
|
names = append(names, v.Name)
|
|
}
|
|
return nil, errf(KindBadRequest, "views %s all have addresses in %s: give the view (or create the zone in each)", strings.Join(names, " and "), prefix)
|
|
case len(private) == 1:
|
|
return private[0], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// ReverseSuggestion is a reverse zone a view lacks for addresses its
|
|
// records use.
|
|
type ReverseSuggestion struct {
|
|
ViewID int64 `json:"view_id"`
|
|
View string `json:"view,omitempty"`
|
|
Prefix string `json:"prefix"`
|
|
Zone string `json:"zone"`
|
|
// Group is "yours" for networks that are yours for sure (private
|
|
// ranges, IPv6 prefixes this server has an address in), "public" for
|
|
// a public address that may be someone else's (a web host's).
|
|
Group string `json:"group"`
|
|
// Hidden says why the suggestion isn't made unasked (with All only):
|
|
// "public view", "suggestions off" or "dismissed".
|
|
Hidden string `json:"hidden,omitempty"`
|
|
Why string `json:"why"`
|
|
Fill int `json:"fill"` // PTR records creating it with fill adds
|
|
Samples []string `json:"samples"` // a few of them, "address name"
|
|
}
|
|
|
|
// Suggestion groups.
|
|
const (
|
|
SuggestYours = "yours"
|
|
SuggestPublic = "public"
|
|
)
|
|
|
|
// ReverseSuggestions returns the reverse zones the private views (or the
|
|
// server, without views) lack for their A and AAAA records' addresses:
|
|
//
|
|
// - yours: the /24 of a private IPv4 address, the /48 of a ULA or of a
|
|
// public IPv6 prefix this server has an address in (local);
|
|
// - public: a /32 (or /128) for any other public address. It may be a
|
|
// web host's; the rest of its /24 surely belongs to others, and a
|
|
// zone for it here would hide their names from your clients.
|
|
//
|
|
// Public views, views with suggestions turned off and dismissed networks
|
|
// get none: the internet asks the addresses' owners.
|
|
func ReverseSuggestions(st *model.State, exposures []validate.Exposure, local []netip.Addr) []ReverseSuggestion {
|
|
return ReverseCandidates(st, exposures, local, ReverseOptions{})
|
|
}
|
|
|
|
// ReverseOptions widen or narrow ReverseCandidates.
|
|
type ReverseOptions struct {
|
|
// All includes what isn't suggested unasked, marked Hidden: public
|
|
// views, views with suggestions off, dismissed networks.
|
|
All bool
|
|
// View narrows it to one view (by name; "" all).
|
|
View string
|
|
}
|
|
|
|
// ReverseCandidates is ReverseSuggestions with options: with All, every
|
|
// network the views' records use that no reverse zone in their view
|
|
// covers, for creating one by hand where suggestions are off.
|
|
func ReverseCandidates(st *model.State, exposures []validate.Exposure, local []netip.Addr, opt ReverseOptions) []ReverseSuggestion {
|
|
public := map[int64]bool{}
|
|
for _, e := range exposures {
|
|
public[e.ViewID] = e.Public
|
|
}
|
|
type scope struct {
|
|
id int64
|
|
dismissed []string
|
|
hidden string
|
|
}
|
|
var scopes []scope
|
|
if len(st.Views) > 0 {
|
|
for _, v := range st.OrderedViews() {
|
|
if opt.View != "" && !strings.EqualFold(v.Name, opt.View) {
|
|
continue
|
|
}
|
|
hidden := ""
|
|
switch {
|
|
case public[v.ID]:
|
|
hidden = "public view"
|
|
case v.ReverseSuggestionsOff:
|
|
hidden = "suggestions off"
|
|
}
|
|
if hidden == "" || opt.All {
|
|
scopes = append(scopes, scope{v.ID, v.DismissedReverse, hidden})
|
|
}
|
|
}
|
|
} else if !public[0] || opt.All {
|
|
hidden := ""
|
|
if public[0] {
|
|
hidden = "public server"
|
|
}
|
|
scopes = append(scopes, scope{0, st.Settings.DismissedReverse, hidden})
|
|
}
|
|
out := []ReverseSuggestion{}
|
|
for _, sc := range scopes {
|
|
seen := map[netip.Prefix]bool{}
|
|
for _, u := range forwardAddresses(st, sc.id) {
|
|
a := u.addr
|
|
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
|
|
continue
|
|
}
|
|
if rz, _ := reverseZoneFor(st, sc.id, a); rz != nil {
|
|
continue
|
|
}
|
|
p, group, why := suggestedPrefix(a, local)
|
|
hidden := sc.hidden
|
|
if seen[p] {
|
|
continue
|
|
}
|
|
if slices.Contains(sc.dismissed, p.String()) {
|
|
if !opt.All {
|
|
continue
|
|
}
|
|
if hidden == "" {
|
|
hidden = "dismissed"
|
|
}
|
|
}
|
|
seen[p] = true
|
|
name, err := dnsname.ReverseZone(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s := ReverseSuggestion{ViewID: sc.id, View: viewName(st, sc.id), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Group: group, Hidden: hidden, Why: why, Samples: []string{}}
|
|
fills := PTRFills(st, sc.id, name, nil)
|
|
s.Fill = len(fills)
|
|
for i, f := range fills {
|
|
if i == 3 {
|
|
break
|
|
}
|
|
s.Samples = append(s.Samples, f.Address+" "+dnsname.Display(f.Target))
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
|
|
if a.Group != b.Group {
|
|
return strings.Compare(b.Group, a.Group) // yours first
|
|
}
|
|
if a.View != b.View {
|
|
return strings.Compare(a.View, b.View)
|
|
}
|
|
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
|
|
})
|
|
return out
|
|
}
|
|
|
|
// suggestedPrefix returns the reverse zone network for an address, its
|
|
// group and why.
|
|
func suggestedPrefix(a netip.Addr, local []netip.Addr) (netip.Prefix, string, string) {
|
|
switch {
|
|
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
|
|
return netip.PrefixFrom(a, 24).Masked(), SuggestYours, "private network"
|
|
case a.Is4():
|
|
return netip.PrefixFrom(a, 32), SuggestPublic, "a public address: create it only if it is yours (not a web host's); the rest of its /24 belongs to others"
|
|
case a.IsPrivate():
|
|
return netip.PrefixFrom(a, 48).Masked(), SuggestYours, "your ULA /48 (one zone for all its subnets)"
|
|
}
|
|
p := netip.PrefixFrom(a, 48).Masked()
|
|
if slices.ContainsFunc(local, func(l netip.Addr) bool { return p.Contains(l.Unmap()) }) {
|
|
return p, SuggestYours, "your IPv6 /48 (this server has an address in it): one zone for all its subnets"
|
|
}
|
|
return netip.PrefixFrom(a, 128), SuggestPublic, "a public address outside this server's networks: create it only if it is yours"
|
|
}
|
|
|
|
// ReverseSuggestions lists the reverse zones the views lack (opt.All:
|
|
// also where suggestions aren't made unasked).
|
|
func (e *Engine) ReverseSuggestions(st *model.State, opt ReverseOptions) []ReverseSuggestion {
|
|
var local []netip.Addr
|
|
if e.cfg.LocalAddrs != nil {
|
|
local = e.cfg.LocalAddrs()
|
|
}
|
|
return ReverseCandidates(st, e.Exposures(st), local, opt)
|
|
}
|
|
|
|
// reverseDismiss expands reverse.dismiss and reverse.undismiss.
|
|
func (x *expander) reverseDismiss(req Request, dismiss bool) ([]model.Op, error) {
|
|
var in DismissInput
|
|
if err := decode(req.Data, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix))
|
|
if err != nil {
|
|
return nil, errf(KindBadRequest, "prefix: %v", err)
|
|
}
|
|
p = p.Masked()
|
|
var v *model.View
|
|
switch {
|
|
case in.ViewID != 0:
|
|
v = x.st.Views[in.ViewID]
|
|
case in.View != "":
|
|
v = x.st.ViewByName(in.View)
|
|
}
|
|
if (in.ViewID != 0 || in.View != "") && v == nil {
|
|
return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID)))
|
|
}
|
|
if v == nil && len(x.st.Views) > 0 {
|
|
return nil, errf(KindBadRequest, "give the view of the suggestion")
|
|
}
|
|
list, where := x.st.Settings.DismissedReverse, "the server"
|
|
if v != nil {
|
|
list, where = v.DismissedReverse, "view "+v.Name
|
|
}
|
|
out := slices.DeleteFunc(slices.Clone(list), func(s string) bool { return s == p.String() })
|
|
summary := "dismiss the reverse zone suggestion for " + p.String() + " in " + where
|
|
if dismiss {
|
|
out = append(out, p.String())
|
|
slices.Sort(out)
|
|
} else {
|
|
if len(out) == len(list) {
|
|
return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where)
|
|
}
|
|
summary = "suggest a reverse zone for " + p.String() + " in " + where + " again"
|
|
}
|
|
if out == nil {
|
|
out = []string{}
|
|
}
|
|
if v != nil {
|
|
return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{DismissedReverse: &out}, summary)}, nil
|
|
}
|
|
return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{DismissedReverse: &out}, summary)}, nil
|
|
}
|
|
|
|
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
|
|
|
|
// autoPTR reports whether a record's PTR is kept in step by its zone's
|
|
// auto_ptr setting.
|
|
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
|
|
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
|
|
return false
|
|
}
|
|
_, ok := addrOf(p)
|
|
return ok
|
|
}
|
|
|
|
func viewName(st *model.State, id int64) string {
|
|
if v := st.Views[id]; v != nil {
|
|
return v.Name
|
|
}
|
|
return ""
|
|
}
|