A reverse zone's fill (and fill-ptr, the missing-PTR counts) names an address no record of its own view uses from another view's record, so an internal reverse zone can give the PTR of a host only external has an AAAA for; New reverse zone with a view chosen lists those addresses, marked. Automatic suggestions stay per view. For hundreds of addresses: the New reverse zone list has a search; on the Zones page the public addresses fold away, have a search, show 25 at a time and Dismiss these N dismisses everything a search found in one change (reverse.dismiss takes prefixes; --dismiss takes several). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
16 KiB
Go
306 lines
16 KiB
Go
package engine
|
|
|
|
import (
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// ptrs returns a reverse zone's PTR records in a view as "owner target",
|
|
// with " disabled" when so.
|
|
func (env *testEnv) ptrs(view, zone string) []string {
|
|
env.t.Helper()
|
|
s := env.state()
|
|
z := s.FindZone(zone, s.ViewByName(view).ID)
|
|
if z == nil {
|
|
env.t.Fatalf("no zone %s in %s", zone, view)
|
|
}
|
|
var out []string
|
|
for _, r := range s.ZoneRecords(z.ID) {
|
|
if r.Type == "PTR" {
|
|
d := r.Name + " " + r.Data
|
|
if r.Disabled {
|
|
d += " disabled"
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestReverseFillAndSuggest(t *testing.T) {
|
|
env := linkedEnv(t)
|
|
env.submit(env.admin,
|
|
recIn("internal", "@", "A", "192.0.2.25"),
|
|
recIn("internal", "nas", "A", "10.0.10.20"),
|
|
recIn("internal", "files", "A", "10.0.10.20"),
|
|
recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"),
|
|
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()), nil)
|
|
var got []string
|
|
for _, s := range sugg {
|
|
got = append(got, s.Group+" "+s.View+" "+s.Prefix+" "+s.Zone)
|
|
}
|
|
want(t, "suggestions", got,
|
|
"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])
|
|
}
|
|
|
|
// No view given: the private view whose records use the addresses.
|
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true}))
|
|
want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.")
|
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) {
|
|
t.Errorf("warnings %q", res.Warnings)
|
|
}
|
|
// The mail server's name beats the domain's own.
|
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
|
|
want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
|
|
|
|
// Filling an existing zone; then there is nothing left to add.
|
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
|
|
st := env.state()
|
|
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
|
|
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{}))
|
|
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
|
|
t.Errorf("ip6 fill: %q", p)
|
|
}
|
|
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") {
|
|
t.Errorf("second fill: %v", e)
|
|
}
|
|
|
|
got = got[:0]
|
|
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")
|
|
|
|
// Addresses that two private views use: the view must be given.
|
|
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}}))
|
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}),
|
|
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")}))
|
|
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") {
|
|
t.Errorf("two views: %v", e)
|
|
}
|
|
}
|
|
|
|
func TestAutoPTR(t *testing.T) {
|
|
env := linkedEnv(t)
|
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
|
|
st := env.state()
|
|
zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID
|
|
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)}))
|
|
rz := "10.0.10.in-addr.arpa."
|
|
|
|
res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5"))
|
|
want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
|
|
if len(res.Warnings) > 0 {
|
|
t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings)
|
|
}
|
|
// A second name for the address: its PTR stays.
|
|
res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30"))
|
|
want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
|
|
if len(res.Warnings) > 0 {
|
|
t.Errorf("warnings %q", res.Warnings)
|
|
}
|
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil))
|
|
|
|
// Moved, disabled, enabled, deleted.
|
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")}))
|
|
want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
|
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)}))
|
|
want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled")
|
|
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)}))
|
|
want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
|
|
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil))
|
|
want(t, "deleted", env.ptrs("internal", rz))
|
|
|
|
// A whole set replaced.
|
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}}))
|
|
want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.")
|
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}}))
|
|
want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.")
|
|
// Deleting by name and type.
|
|
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")}))
|
|
want(t, "deleted by match", env.ptrs("internal", rz))
|
|
|
|
// Off again: records come and go alone.
|
|
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)}))
|
|
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"}))
|
|
// Several at once.
|
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
|
if got := suggested(env); slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "192.0.2.") }) {
|
|
t.Errorf("dismissed together, still suggested: %q", got)
|
|
}
|
|
env.submit(env.admin, NewRequest(OpReverseUndismiss, 0, DismissInput{View: "internal", Prefixes: []string{"192.0.2.10/32", "192.0.2.25/32"}}))
|
|
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")
|
|
}
|
|
}
|
|
|
|
// With All, the networks no suggestion is made for unasked are listed too,
|
|
// marked: views with suggestions off, dismissed ones, public views.
|
|
func TestReverseCandidatesAll(t *testing.T) {
|
|
env := linkedEnv(t)
|
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "nas", Type: "AAAA", Data: []string{"2a02:8013:7f3d:10::5"}, Local: model.Ptr(true)}))
|
|
env.submit(env.admin, NewRequest(OpReverseDismiss, 0, DismissInput{View: "internal", Prefix: "192.0.2.10/32"}))
|
|
internal := env.state().ViewByName("internal")
|
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(false)}))
|
|
list := func(opt ReverseOptions) []string {
|
|
var out []string
|
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, opt) {
|
|
out = append(out, s.View+" "+s.Prefix+" "+s.Hidden)
|
|
}
|
|
return out
|
|
}
|
|
want(t, "unasked", list(ReverseOptions{}))
|
|
want(t, "internal, all", list(ReverseOptions{All: true, View: "internal"}),
|
|
"internal 192.0.2.10/32 suggestions off", "internal 192.0.2.25/32 suggestions off", "internal 2a02:8013:7f3d:10::5/128 suggestions off")
|
|
got := list(ReverseOptions{All: true, View: "external"})
|
|
want(t, "external, all", got, "external 192.0.2.10/32 public view", "external 192.0.2.25/32 public view", "external 2a02:8013:7f3d:10::5/128 public view")
|
|
|
|
env.submit(env.admin, NewRequest(OpViewUpdate, internal.ID, ViewInput{ReverseSuggestions: model.Ptr(true)}))
|
|
want(t, "dismissed", list(ReverseOptions{All: true, View: "internal"}),
|
|
"internal 192.0.2.10/32 dismissed", "internal 192.0.2.25/32 ", "internal 2a02:8013:7f3d:10::5/128 ")
|
|
}
|
|
|
|
// An address only another view has a record for: a reverse zone in the
|
|
// internal view is offered for it, and filled from that view's record.
|
|
func TestReverseFromOtherView(t *testing.T) {
|
|
env := linkedEnv(t)
|
|
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
|
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "vps", Type: "AAAA", Data: []string{"2600:3c01::f03c:93ff:fe2b:1"}, Local: model.Ptr(true)}))
|
|
var got []string
|
|
for _, s := range ReverseCandidates(env.state(), env.e.Exposures(env.state()), nil, ReverseOptions{All: true, View: "internal"}) {
|
|
got = append(got, s.Prefix+" "+s.Hidden)
|
|
}
|
|
if !slices.Contains(got, "2600:3c01::f03c:93ff:fe2b:1/128 records in external") {
|
|
t.Errorf("internal candidates %q", got)
|
|
}
|
|
// Not suggested unasked: the internal view's own records don't use it.
|
|
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state()), nil) {
|
|
if strings.HasPrefix(s.Prefix, "2600:") {
|
|
t.Errorf("suggested unasked: %+v", s)
|
|
}
|
|
}
|
|
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("2600:3c01::f03c:93ff:fe2b:1/128"), View: model.Ptr("internal"), Fill: true}))
|
|
st := env.state()
|
|
var zone string
|
|
for _, z := range st.ZonesInView(st.ViewByName("internal").ID) {
|
|
if strings.HasSuffix(z.Name, ".ip6.arpa.") {
|
|
zone = z.Name
|
|
}
|
|
}
|
|
if p := env.ptrs("internal", zone); len(p) != 1 || !strings.HasSuffix(p[0], " vps.example.com.") {
|
|
t.Errorf("filled %q", p)
|
|
}
|
|
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "only view external has a record for it") }) {
|
|
t.Errorf("warnings %q", res.Warnings)
|
|
}
|
|
}
|