Files
adamandClaude Opus 5.5 2fa9828bcf Reverse zones: list other views' addresses, clearer wording
The zone page and jarl zone show list the addresses only other views'
records use (ptr_other_views_list), with the name each would get and the
view it comes from, and say plainly that they aren't missing here. The
page's button reads 'Add PTR records for them in view X'. zone show
lists the missing PTRs too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:46:12 +01:00

566 lines
18 KiB
Go

package engine
import (
"fmt"
"net/netip"
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
"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
view string // another view's record (fillAddresses), else ""
}
// 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
}
// fillAddresses returns a view's addresses (forwardAddresses), then those
// of the other views' records that none of its own records use, marked
// with their view: a reverse zone in the internal view can name a host
// only the external view has a record for.
func fillAddresses(st *model.State, viewID int64) []addrUse {
out := forwardAddresses(st, viewID)
if len(st.Views) == 0 {
return out
}
own := map[netip.Addr]bool{}
for _, u := range out {
own[u.addr] = true
}
for _, v := range st.OrderedViews() {
if v.ID == viewID {
continue
}
for _, u := range forwardAddresses(st, v.ID) {
if !own[u.addr] {
u.view = v.Name
out = append(out, u)
}
}
}
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
// From is the view whose record names the host, when the zone's own
// view has none for the address.
From string `json:"from,omitempty"`
}
// 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. With
// otherViews, an address no record of the view uses is named from another
// view's records (fillAddresses), marked From; that is only on request,
// since it publishes one view's host names in another. existing are the
// zone's records (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record, otherViews bool) []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
addrs := forwardAddresses(st, viewID)
if otherViews {
addrs = fillAddresses(st, viewID)
}
for _, u := range addrs {
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, From: uses[0].view}
for _, u := range uses[1:] {
if u.name != f.Target {
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 f.From != "" {
out = append(out, fmt.Sprintf("%s: only view %s has a record for it, so its PTR record gives %s from there", f.Address, f.From, dnsname.Display(f.Target)))
}
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
}
// otherViewsHint says how many addresses only other views' records use in
// a reverse zone, and how to add them; "" when there are none.
func otherViewsHint(st *model.State, viewID int64, zoneName string, existing []*model.Record, own int, how string) string {
n := len(PTRFills(st, viewID, zoneName, existing, true)) - own
if n <= 0 {
return ""
}
what, has, they := "addresses", "have", "they are"
if n == 1 {
what, has, they = "address", "has", "it is"
}
return fmt.Sprintf("%d %s in %s %s A or AAAA records only in other views, so %s not missing here; %s", n, what, dnsname.Display(zoneName), has, they, how)
}
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in FillPTRInput
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), in.OtherViews)
if len(fills) == 0 {
msg := fmt.Sprintf("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))
if !in.OtherViews {
if h := otherViewsHint(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID), 0, "to add PTR records for them in this view too: --other-views"); h != "" {
msg += "; " + h
}
}
return nil, errf(KindBadRequest, "%s", msg)
}
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 {
// Local to the view in a linked zone, like the PTR records that
// follow address records (ptrChange).
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(int64(0))
}
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). With All, the view
// also gets the addresses only other views' records use, marked
// Hidden "records in VIEW": a reverse zone there can name them.
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{}
addrs := forwardAddresses(st, sc.id)
if opt.All && opt.View != "" {
addrs = fillAddresses(st, sc.id)
}
for _, u := range addrs {
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 hidden == "" && u.view != "" {
hidden = "records in " + u.view
}
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, opt.All && opt.View != "")
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
}
raw := in.Prefixes
if in.Prefix != "" {
raw = append(raw, in.Prefix)
}
if len(raw) == 0 {
return nil, errf(KindBadRequest, "give the network (prefix) of the suggestion")
}
var prefixes []string
for _, r := range raw {
p, err := netip.ParsePrefix(strings.TrimSpace(r))
if err != nil {
return nil, errf(KindBadRequest, "prefix: %v", err)
}
if s := p.Masked().String(); !slices.Contains(prefixes, s) {
prefixes = append(prefixes, s)
}
}
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 slices.Contains(prefixes, s) })
what := prefixes[0]
if len(prefixes) > 1 {
what = fmt.Sprintf("%d networks (%s, …)", len(prefixes), prefixes[0])
}
summary := "dismiss the reverse zone suggestion for " + what + " in " + where
if dismiss {
out = append(out, prefixes...)
slices.Sort(out)
} else {
if len(out) == len(list) {
return nil, errf(KindConflict, "%s isn't dismissed in %s", what, where)
}
summary = "suggest a reverse zone for " + what + " 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")
func viewName(st *model.State, id int64) string {
if v := st.Views[id]; v != nil {
return v.Name
}
return ""
}