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>
566 lines
18 KiB
Go
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 ""
|
|
}
|