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 := "addresses" if n == 1 { what = "address" } return fmt.Sprintf("%d %s in %s only other views' records use; %s", n, what, dnsname.Display(zoneName), 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 name them from there, fill with other views (--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 "" }