Reverse zones made easy: suggested, filled, in the right view, PTRs kept in step
Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3b054cc86
commit
aa4b004a14
27 files changed
+1173
-38
No files matched your search
@@ -0,0 +1,357 @@
|
||||
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. "" 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] {
|
||||
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"`
|
||||
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"
|
||||
}
|
||||
|
||||
// ReverseSuggestions returns the reverse zones the private views (or the
|
||||
// server, without views) lack for their A and AAAA records' addresses:
|
||||
// the /24 of a private IPv4 address, a /32 for a public one (the /24
|
||||
// belongs to someone else, and a zone for it here would hide the
|
||||
// neighbours' names from your clients), the /48 of an IPv6 address.
|
||||
// Public views get none: the internet asks the addresses' owners.
|
||||
func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []ReverseSuggestion {
|
||||
public := map[int64]bool{}
|
||||
for _, e := range exposures {
|
||||
public[e.ViewID] = e.Public
|
||||
}
|
||||
viewIDs := []int64{0}
|
||||
if len(st.Views) > 0 {
|
||||
viewIDs = viewIDs[:0]
|
||||
for _, v := range st.OrderedViews() {
|
||||
if !public[v.ID] {
|
||||
viewIDs = append(viewIDs, v.ID)
|
||||
}
|
||||
}
|
||||
} else if public[0] {
|
||||
return []ReverseSuggestion{}
|
||||
}
|
||||
out := []ReverseSuggestion{}
|
||||
for _, vid := range viewIDs {
|
||||
seen := map[netip.Prefix]bool{}
|
||||
for _, u := range forwardAddresses(st, vid) {
|
||||
a := u.addr
|
||||
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
|
||||
continue
|
||||
}
|
||||
if rz, _ := reverseZoneFor(st, vid, a); rz != nil {
|
||||
continue
|
||||
}
|
||||
var p netip.Prefix
|
||||
why := ""
|
||||
switch {
|
||||
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
|
||||
p, why = netip.PrefixFrom(a, 24).Masked(), "private network"
|
||||
case a.Is4():
|
||||
p, why = netip.PrefixFrom(a, 32), "a public address: its own zone, since the rest of its /24 belongs to others"
|
||||
default:
|
||||
p, why = netip.PrefixFrom(a, 48).Masked(), "the /48 of your IPv6 addresses (one zone for all their subnets)"
|
||||
if a.IsPrivate() {
|
||||
why = "your ULA /48 (one zone for all its subnets)"
|
||||
}
|
||||
}
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
name, err := dnsname.ReverseZone(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := ReverseSuggestion{ViewID: vid, View: viewName(st, vid), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Why: why, Samples: []string{}}
|
||||
fills := PTRFills(st, vid, 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.View != b.View {
|
||||
return strings.Compare(a.View, b.View)
|
||||
}
|
||||
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
Reference in new issue
Block a user