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; 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"` 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 { public := map[int64]bool{} for _, e := range exposures { public[e.ViewID] = e.Public } type scope struct { id int64 dismissed []string } var scopes []scope if len(st.Views) > 0 { for _, v := range st.OrderedViews() { if !public[v.ID] && !v.ReverseSuggestionsOff { scopes = append(scopes, scope{v.ID, v.DismissedReverse}) } } } else if !public[0] { scopes = append(scopes, scope{0, st.Settings.DismissedReverse}) } out := []ReverseSuggestion{} for _, sc := range scopes { seen := map[netip.Prefix]bool{} for _, u := range forwardAddresses(st, sc.id) { 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) if seen[p] || slices.Contains(sc.dismissed, p.String()) { continue } 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, Why: why, Samples: []string{}} fills := PTRFills(st, sc.id, 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.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 private views lack. func (e *Engine) ReverseSuggestions(st *model.State) []ReverseSuggestion { var local []netip.Addr if e.cfg.LocalAddrs != nil { local = e.cfg.LocalAddrs() } return ReverseSuggestions(st, e.Exposures(st), local) } // 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 } p, err := netip.ParsePrefix(strings.TrimSpace(in.Prefix)) if err != nil { return nil, errf(KindBadRequest, "prefix: %v", err) } p = p.Masked() 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 s == p.String() }) summary := "dismiss the reverse zone suggestion for " + p.String() + " in " + where if dismiss { out = append(out, p.String()) slices.Sort(out) } else { if len(out) == len(list) { return nil, errf(KindConflict, "%s isn't dismissed in %s", p, where) } summary = "suggest a reverse zone for " + p.String() + " 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") // 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 "" }