Reverse zone suggestions: yours and public, dismiss, per-view switch; child zones take their records

Suggestions split into networks that are yours (private IPv4 /24, ULA /48, an IPv6 /48 this server has an address in), which Create all creates, and public addresses (/32 or /128) to create only if they are yours or dismiss. A view can turn suggestions off, which also keeps it out of choosing a reverse zone's view (database version 13). A zone created inside another in the same view takes the parent's records under it; a delegation and its glue stay, a linked parent keeps them with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 22:01:18 +01:00
1 parent aa4b004a14
commit b24c730b97
21 files changed
+543 -107

No files matched your search

+3 -2
View File
@@ -25,7 +25,7 @@ func DiffOps(from, to *model.State) []model.Op {
s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged))}, "restore settings")
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse))}, "restore settings")
}
for _, k := range sortedKeys(to.Keys) {
@@ -50,7 +50,8 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Views[k]
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged))}
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
if n.MatchRecursiveOnly != nil {
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
} else {
+77 -2
View File
@@ -157,6 +157,8 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
return x.zoneCreate(req)
case OpZoneFillPTR:
return x.zoneFillPTR(req)
case OpReverseDismiss, OpReverseUndismiss:
return x.reverseDismiss(req, req.Op == OpReverseDismiss)
case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID]
if z == nil {
@@ -403,6 +405,41 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
in.ViewID = &v.ID
}
}
var moveOps []model.Op
var moved []*model.Record
if zt == model.ZonePrimary {
// The records a parent zone in the view has under the new zone
// move into it: named answers those names from the new zone, so
// left behind they would never be served again.
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
if parent := x.parentZone(name, vid); parent != nil {
moved = x.recordsUnder(parent, name)
switch {
case len(moved) == 0:
case parent.LinkGroup != 0:
x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name)))
moved = nil
default:
for _, r := range moved {
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)}
if r.TTL != nil {
ri.TTL = model.Some(*r.TTL)
}
if r.Comment != "" {
ri.Comment = model.Ptr(r.Comment)
}
if r.Disabled {
ri.Disabled = model.Ptr(true)
}
in.Records = append(in.Records, ri)
moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent))))
}
}
}
}
fill := in.Fill
in.Fill = false
if fill {
@@ -416,7 +453,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
if in.ViewID != nil {
vid = *in.ViewID
}
fills := PTRFills(x.st, vid, name, nil)
fills := PTRFills(x.st, vid, name, moved)
if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
}
@@ -642,7 +679,45 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
r.in.ZoneID = ids[0]
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
}
return ops, nil
return append(ops, moveOps...), nil
}
// parentZone returns the most specific primary zone in a view that
// contains name (not name itself).
func (x *expander) parentZone(name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range x.st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best
}
// recordsUnder returns a zone's records at or below name, except a
// delegation's NS and DS records and its glue, which belong to the parent.
func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record {
all := x.st.ZoneRecords(z.ID)
var ns []string
for _, r := range all {
if r.Name == name && r.Type == "NS" {
ns = append(ns, strings.ToLower(r.Data))
}
}
var out []*model.Record
for _, r := range all {
if !dnsname.IsSubdomain(r.Name, name) {
continue
}
if r.Name == name && (r.Type == "NS" || r.Type == "DS") {
continue
}
if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) {
continue // glue
}
out = append(out, r)
}
return out
}
func deref(s *string) string {
+20
View File
@@ -62,8 +62,20 @@ const (
// Filling a reverse zone with the PTR records of its view's A and
// AAAA records (docs/reverse.md) becomes record.create operations.
OpZoneFillPTR = "zone.fill_ptr"
// Dismissing a reverse zone suggestion, and taking that back, become
// view.update or settings.update operations.
OpReverseDismiss = "reverse.dismiss"
OpReverseUndismiss = "reverse.undismiss"
)
// DismissInput dismisses a reverse zone suggestion: a network in a view
// (by ID or name), or the server's (neither given).
type DismissInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
}
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given).
type AckInput struct {
@@ -246,6 +258,11 @@ type ViewInput struct {
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// ReverseSuggestions turns the reverse zone suggestions for the
// view's addresses on or off (docs/reverse.md).
ReverseSuggestions *bool `json:"reverse_suggestions,omitempty"`
// DismissedReverse are the networks whose suggestion was dismissed.
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
}
// ReorderInput sets the order of views.
@@ -292,6 +309,9 @@ type SettingsInput struct {
// Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views).
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
}
// DeleteInput carries options for delete operations.
+10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"slices"
"strings"
"time"
@@ -808,6 +809,12 @@ func setViewFields(v *model.View, in *ViewInput) error {
if in.Acknowledged != nil {
v.Acknowledged = cleanAcks(*in.Acknowledged)
}
if in.ReverseSuggestions != nil {
v.ReverseSuggestionsOff = !*in.ReverseSuggestions
}
if in.DismissedReverse != nil {
v.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
return nil
}
@@ -1404,6 +1411,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
if in.Acknowledged != nil {
s.Acknowledged = cleanAcks(*in.Acknowledged)
}
if in.DismissedReverse != nil {
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
}
s.Version++
return nil
}
+121 -36
View File
@@ -219,7 +219,8 @@ func (x *expander) publicViews() map[int64]bool {
// 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.
// 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 {
@@ -227,7 +228,7 @@ func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*m
}
var private, using []*model.View
for _, v := range st.OrderedViews() {
if public[v.ID] {
if public[v.ID] || v.ReverseSuggestionsOff {
continue
}
private = append(private, v)
@@ -253,62 +254,68 @@ func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*m
// 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"`
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:
// 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 {
//
// - 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
}
viewIDs := []int64{0}
type scope struct {
id int64
dismissed []string
}
var scopes []scope
if len(st.Views) > 0 {
viewIDs = viewIDs[:0]
for _, v := range st.OrderedViews() {
if !public[v.ID] {
viewIDs = append(viewIDs, v.ID)
if !public[v.ID] && !v.ReverseSuggestionsOff {
scopes = append(scopes, scope{v.ID, v.DismissedReverse})
}
}
} else if public[0] {
return []ReverseSuggestion{}
} else if !public[0] {
scopes = append(scopes, scope{0, st.Settings.DismissedReverse})
}
out := []ReverseSuggestion{}
for _, vid := range viewIDs {
for _, sc := range scopes {
seen := map[netip.Prefix]bool{}
for _, u := range forwardAddresses(st, vid) {
for _, u := range forwardAddresses(st, sc.id) {
a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue
}
if rz, _ := reverseZoneFor(st, vid, a); rz != nil {
if rz, _ := reverseZoneFor(st, sc.id, 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] {
p, group, why := suggestedPrefix(a, local)
if seen[p] || slices.Contains(sc.dismissed, p.String()) {
continue
}
seen[p] = true
@@ -316,8 +323,8 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers
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 := 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 {
@@ -329,6 +336,9 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers
}
}
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)
}
@@ -337,6 +347,81 @@ func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []Revers
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
+106 -7
View File
@@ -1,6 +1,7 @@
package engine
import (
"net/netip"
"slices"
"strings"
"testing"
@@ -40,16 +41,16 @@ func TestReverseFillAndSuggest(t *testing.T) {
recIn("internal", "*.dev", "A", "10.0.10.99"),
recIn("internal", "lo", "A", "127.0.0.1"))
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()))
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil)
var got []string
for _, s := range sugg {
got = append(got, s.View+" "+s.Prefix+" "+s.Zone)
got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
}
want(t, "suggestions", got,
"internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"internal 192.0.2.25/32 25.2.0.192.in-addr.arpa",
"internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa")
"yours internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"yours internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa",
"public internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"public internal 192.0.2.25/32 25.2.0.192.in-addr.arpa")
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
t.Errorf("10.0.10.0/24: %+v", sugg[0])
}
@@ -77,7 +78,7 @@ func TestReverseFillAndSuggest(t *testing.T) {
}
got = got[:0]
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state())) {
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
got = append(got, s.Prefix)
}
want(t, "suggestions left", got, "192.0.2.10/32")
@@ -136,3 +137,101 @@ func TestAutoPTR(t *testing.T) {
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
want(t, "off", env.ptrs("internal", rz))
}
func suggested(env *testEnv, local ...netip.Addr) []string {
var out []string
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), local) {
out = append(out, s.Group+" "+s.View+" "+s.Prefix)
}
return out
}
// Public IPv6: the /48 only when this server has an address in it, else
// the address alone, as a public suggestion. Dismissed networks and views
// with suggestions off get none.
func TestReverseSuggestionGroups(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "home", "AAAA", "2a02:8013:7f3d:10::5"),
recIn("internal", "gh", "AAAA", "2606:50c0:8000::153"))
want(t, "no local addresses", suggested(env),
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32",
"public internal 2606:50c0:8000::153/128", "public internal 2a02:8013:7f3d:10::5/128")
want(t, "this server is in 2a02:8013:7f3d::/48", suggested(env, netip.MustParseAddr("2a02:8013:7f3d:10:1266:6aff:fef9:f5b1")),
"yours internal 2a02:8013:7f3d::/48",
"public internal 192.0.2.10/32", "public internal 192.0.2.25/32", "public internal 2606:50c0:8000::153/128")
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
if got := suggested(env); slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("dismissed still suggested: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"})); e.Kind != KindConflict {
t.Errorf("undismiss of one not dismissed: %v", e)
}
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefix: "2606:50c0:8000::153/128"}))
if got := suggested(env); !slices.Contains(got, "public internal 2606:50c0:8000::153/128") {
t.Errorf("undismissed not suggested: %q", got)
}
// A second private view using the same address (a VPS's view): with
// its suggestions off it gets none, and doesn't count when choosing
// the view of a new reverse zone.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("vps"), MatchClients: &[]string{"198.51.100.7"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("vps"), LinkWith: "internal"}))
if got := suggested(env); !slices.Contains(got, "public vps 192.0.2.25/32") {
t.Errorf("vps view: %q", got)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32")})); !strings.Contains(e.Message, "views internal and vps") {
t.Errorf("two views: %v", e)
}
vps := env.state().ViewByName("vps")
env.submit(env.admin, NewRequest(OpViewUpdate, vps.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, " vps ") }) {
t.Errorf("vps view with suggestions off: %q", got)
}
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "went to internal", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
}
// A zone created inside another takes the parent's records under it.
func TestChildZoneTakesRecords(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.0.0/8"), View: model.Ptr("internal")}))
ptr := func(name, data string) Request {
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "10.in-addr.arpa", View: "internal", Name: &name, Type: model.Ptr("PTR"), Data: &data})
}
env.submit(env.admin, ptr("20.10.0.10.in-addr.arpa.", "printer.example.com."), ptr("5.20.0.10.in-addr.arpa.", "other.example.com."))
env.submit(env.admin, recIn("internal", "nas", "A", "10.0.10.30"))
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal"), Fill: true}))
want(t, "/24", env.ptrs("internal", "10.0.10.in-addr.arpa."),
"20.10.0.10.in-addr.arpa. printer.example.com.", "30.10.0.10.in-addr.arpa. nas.example.com.")
want(t, "/8 keeps the rest", env.ptrs("internal", "10.in-addr.arpa."), "5.20.0.10.in-addr.arpa. other.example.com.")
for _, p := range res.Problems {
if p.Code == "shadowed_by_zone" {
t.Errorf("still hidden records: %v", p)
}
}
// A forward child: its delegation and glue stay in the parent.
env.submit(env.admin,
recIn("internal", "lab", "NS", "ns.lab.example.com."),
recIn("internal", "ns.lab", "A", "10.0.30.1"),
recIn("internal", "www.lab", "A", "10.0.30.2"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.example.com"), View: model.Ptr("internal")}))
st := env.state()
lab := st.FindZone("lab.example.com.", st.ViewByName("internal").ID)
var labRecs []string
for _, r := range st.ZoneRecords(lab.ID) {
if r.Type != "NS" {
labRecs = append(labRecs, r.Name+" "+r.Type)
}
}
want(t, "lab", labRecs, "www.lab.example.com. A")
v := env.view("internal") // without NS records
if !slices.Contains(v, "ns.lab A 10.0.30.1 L") || slices.ContainsFunc(v, func(s string) bool { return strings.HasPrefix(s, "www.lab ") }) {
t.Errorf("parent after: %q", v)
}
if env.recordID("internal", "lab", "NS") == 0 {
t.Error("the delegation left the parent")
}
}
+9 -1
View File
@@ -54,6 +54,11 @@ type View struct {
// internet (docs/exposure.md): "" works it out, ExposurePublic or
// ExposurePrivate say so.
Exposure string `json:"exposure,omitempty"`
// ReverseSuggestionsOff stops suggesting reverse zones for the view's
// addresses (docs/reverse.md), e.g. for a view only a few servers use;
// DismissedReverse are the networks whose suggestion was dismissed.
ReverseSuggestionsOff bool `json:"reverse_suggestions_off,omitempty"`
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
// Acknowledged are exposure findings accepted on purpose; they are
// shown as info instead of warnings.
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
@@ -267,7 +272,10 @@ type Settings struct {
// for server-wide findings such as a visible version): see View.
Exposure string `json:"exposure,omitempty"`
Acknowledged []Acknowledgement `json:"acknowledged,omitempty"`
Version int `json:"version"`
// DismissedReverse are dismissed reverse zone suggestions (without
// views; see View).
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
Version int `json:"version"`
}
// BlockingPaused reports whether blocking is paused, for a time or until
+18 -1
View File
@@ -29,7 +29,7 @@ func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *a
return err
}
out := []engine.ReverseSuggestion{}
for _, sg := range engine.ReverseSuggestions(st, s.eng.Exposures(st)) {
for _, sg := range s.eng.ReverseSuggestions(st) {
if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg)
}
@@ -37,3 +37,20 @@ func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *a
writeJSON(w, http.StatusOK, out)
return nil
}
// reverseDismiss: POST /reverse-zones/dismiss {"view": "internal",
// "prefix": "185.199.108.153/32"} hides a suggestion; /undismiss shows it
// again.
func (s *Server) reverseDismiss(dismiss bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.DismissInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpReverseUndismiss
if dismiss {
op = engine.OpReverseDismiss
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+2
View File
@@ -116,6 +116,8 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/fill-ptr", s.authed(s.zoneFillPTR))
m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions))
m.HandleFunc("POST "+p+"/reverse-zones/dismiss", s.authed(s.reverseDismiss(true)))
m.HandleFunc("POST "+p+"/reverse-zones/undismiss", s.authed(s.reverseDismiss(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
+8 -5
View File
@@ -31,15 +31,15 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
}
}
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by FROM views`)
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by FROM views`)
if err != nil {
return nil, err
}
for rows.Next() {
v := &model.View{}
var mc, md, acks, ca, ua string
var mc, md, acks, dismissed, ca, ua string
var mro sql.NullInt64
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Exposure, &acks, &v.ReverseSuggestionsOff, &dismissed, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
@@ -48,6 +48,9 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
if acks != "[]" && acks != "" {
json.Unmarshal([]byte(acks), &v.Acknowledged)
}
if dismissed != "[]" && dismissed != "" {
json.Unmarshal([]byte(dismissed), &v.DismissedReverse)
}
if mro.Valid {
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
}
@@ -267,8 +270,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
if v.MatchRecursiveOnly != nil {
mro = boolInt(*v.MatchRecursiveOnly)
}
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, exposure, acknowledged, reverse_suggestions_off, dismissed_reverse, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Exposure, jsonText(nonNilAcks(v.Acknowledged)), v.ReverseSuggestionsOff, jsonText(nonNil(v.DismissedReverse)), v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
return err
+5
View File
@@ -370,6 +370,11 @@ ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
// 12: PTR records kept in step per zone (docs/reverse.md).
`
ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
`,
// 13: reverse zone suggestions per view (docs/reverse.md).
`
ALTER TABLE views ADD COLUMN reverse_suggestions_off INTEGER NOT NULL DEFAULT 0;
ALTER TABLE views ADD COLUMN dismissed_reverse TEXT NOT NULL DEFAULT '[]';
`,
}
+4 -1
View File
@@ -125,6 +125,7 @@ async function viewForm(v, viewless) {
const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : "");
const comment = input({ value: v ? v.comment : "" });
const color = colorPicker(v ? v.color : "");
const revSuggest = checkbox("Suggest reverse zones for the addresses this view's records use", !(v && v.reverse_suggestions_off));
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name),
@@ -132,6 +133,7 @@ async function viewForm(v, viewless) {
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment),
field("Reverse zones", revSuggest, "Turn it off for a view only some servers use (a VPS's view, say): its addresses then get no suggestions under Zones, and it doesn't count when Jarl picks the view of a new reverse zone. Public views never get suggestions."),
field("Colour", color, "How the web UI shows this view, so zones of the same view are easy to spot. Only changes the web UI: it applies at once, with no deploy.")),
!v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null,
err);
@@ -139,6 +141,7 @@ async function viewForm(v, viewless) {
mount(err);
const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value };
data.match_recursive_only = mro.value === "" ? null : mro.value === "true";
data.reverse_suggestions = revSuggest.input.checked;
if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true;
const colorChanged = color.value !== ((v && v.color) || "");
try {
@@ -170,7 +173,7 @@ function configChanged(v, data) {
const same = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
const mro = v.match_recursive_only === undefined ? null : v.match_recursive_only;
return data.name !== v.name || !same(data.match_clients, v.match_clients) || !same(data.match_destinations, v.match_destinations) ||
data.match_recursive_only !== mro || data.comment !== (v.comment || "");
data.match_recursive_only !== mro || data.comment !== (v.comment || "") || data.reverse_suggestions === !!v.reverse_suggestions_off;
}
async function deleteView(v) {
+24 -9
View File
@@ -281,7 +281,9 @@ export function pendingBadge(kind) {
}
// suggestPanel lists the reverse zones the private views lack for their
// records' addresses, to create filled with PTR records (docs/reverse.md).
// records' addresses, to create filled with PTR records (docs/reverse.md):
// networks that are yours, and public addresses, which may be a web
// host's, to create one by one or dismiss.
function suggestPanel(sugg, views) {
const create = async (list) => {
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
@@ -292,15 +294,28 @@ function suggestPanel(sugg, views) {
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const dismiss = async (s) => {
try {
const r = await change("POST", "/reverse-zones/dismiss", { view_id: s.view_id || undefined, prefix: s.prefix });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const cols = (pub) => [
views.length ? { label: "View", render: (s) => s.view } : null,
{ label: pub ? "Address" : "Network", render: (s) => [h("span", { class: "mono" }, pub ? s.prefix.replace(/\/(32|128)$/, "") : s.prefix), pub ? null : h("div", { class: "muted small" }, s.why)] },
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
{ label: pub ? "Name" : "PTR records", render: (s) => pub ? h("span", { class: "mono small" }, s.samples.map((x) => x.split(" ")[1]).join(", ")) : [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => [h("button", { class: "small", onclick: () => create([s]) }, "Create"), pub ? [" ", h("button", { class: "small", title: "Not yours (a web host's, say): don't suggest it again", onclick: () => dismiss(s) }, "Dismiss")] : null], tdClass: "actions-cell" },
].filter(Boolean);
const yours = sugg.filter((s) => s.group === "yours");
const pub = sugg.filter((s) => s.group !== "yours");
return h("div", { class: "panel" },
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
sugg.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(sugg) }, "Create all " + sugg.length)) : null),
yours.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(yours) }, "Create all " + yours.length)) : null),
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
table([
views.length ? { label: "View", render: (s) => s.view } : null,
{ label: "Network", render: (s) => [h("span", { class: "mono" }, s.prefix), h("div", { class: "muted small" }, s.why)] },
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
{ label: "PTR records", render: (s) => [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => h("button", { class: "small", onclick: () => create([s]) }, "Create"), tdClass: "actions-cell" },
].filter(Boolean), sugg));
yours.length ? table(cols(false), yours) : null,
pub.length ? [h("h3", null, "Public addresses: only if they're yours"),
h("p", { class: "muted small" }, "Jarl can't tell your own servers from someone else's (a web host's). Create a zone for an address that is yours; dismiss the others. Each gets a zone of its own: the rest of its network belongs to others."),
table(cols(true), pub)] : null);
}