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 "" }