Fix "update PTR": add a missing PTR, also in linked views

Ticking update PTR alone did nothing: the web UI didn't send it, and the engine only acted when the name or address changed. Alone it now adds a missing PTR record (or enables it); for linked zones every linked view's reverse zone counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 20:06:18 +01:00
1 parent 921bdd6c95
commit 1cec6cc393
6 files changed
+141 -17

No files matched your search

+69 -13
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"slices"
"strings"
"time"
@@ -642,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
return nil, nil
var ops []model.Op
found := false
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
found = true
more, err := x.ptrCreateIn(rz, ptrName, owner)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
if !found {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
}
return ops, nil
}
// ptrViews are the views whose reverse zones get the PTR records of zone
// z's address records: z's own, and, for a linked zone, the other views
// of the link (the record is in all of them).
func ptrViews(st *model.State, z *model.Zone) []int64 {
out := []int64{z.ViewID}
for _, o := range st.LinkedWith(z) {
if !slices.Contains(out, o.ViewID) {
out = append(out, o.ViewID)
}
}
return out
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) {
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
@@ -674,10 +706,18 @@ func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, dis
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
}
return ops
}
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
var ops []model.Op
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
@@ -700,12 +740,16 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
return ops
}
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
@@ -885,7 +929,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if len(pre) > 0 {
base = 0 // the record was just updated by pre
}
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
ops := pre
if string(mustJSON(in)) != "{}" || !ptr {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
}
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
@@ -909,6 +956,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
}
if ptr && !moved && !toggled && !disabled {
// Nothing else changes: make sure the PTR record is there (the
// reverse zone may be newer than the record).
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
+3 -1
View File
@@ -174,7 +174,9 @@ type RecordInput struct {
// Disabled keeps the record in Jarl but out of the zone.
Disabled *bool `json:"disabled,omitempty"`
// PTR also creates (or deletes, disables or enables) the matching PTR
// record in the reverse zone, for A and AAAA records.
// record in the reverse zone, for A and AAAA records. On an update that
// changes nothing else it adds the PTR record if it is missing. In a
// linked zone, every linked view's reverse zone counts.
PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"`
+53
View File
@@ -0,0 +1,53 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/model"
)
// Ticking "update PTR" on an unchanged address record adds its PTR when
// the reverse zone is there (it was created after the record).
func TestUpdatePTRAddsMissing(t *testing.T) {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
var id int64
for _, r := range env.state().Records {
if r.Name == "ldn.example.com." {
id = r.ID
}
}
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
got := env.records("154.144.144.139.in-addr.arpa.")
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
t.Errorf("reverse zone = %q", got)
}
}
// In linked zones the PTR goes to the reverse zone of whichever linked
// view has one.
func TestUpdatePTRLinked(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, recIn("internal", "ldn", "A", "139.144.144.154"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
st := env.state()
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
ok := false
for _, r := range st.ZoneRecords(z.ID) {
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
}
if !ok {
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
}
// Again: already there, nothing to do.
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
t.Errorf("second time: %v", e)
}
}