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>
358 lines
10 KiB
Go
358 lines
10 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. "" 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 ""
|
|
}
|