Zone settings: no empty changes, keep an outside name server absolute
- A zone update that changes nothing stages nothing: saving a zone's settings unchanged says "Nothing changed" instead of staging an empty change. Staging operations that change nothing is a 400 "nothing to change". - The settings form showed the SOA MNAME without its final dot, which the server reads as relative to the zone, so saving wg.adbraun.lol turned ns1.adbraun.lol. into ns1.adbraun.lol.wg.adbraun.lol. Names outside the zone keep the dot, and MNAME is only sent when edited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e663ab36c
commit
e38390df7d
6 files changed
+94
-6
No files matched your search
@@ -330,6 +330,9 @@ func (e *Engine) Stage(ctx context.Context, csID int64, reqs []Request, p *auth.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil, errf(KindBadRequest, "nothing to change")
|
||||
}
|
||||
intro := validate.Introduced(before, e.validate(work))
|
||||
if validate.HasErrors(intro) {
|
||||
return nil, &Error{Kind: KindValidation, Message: describeErrors(intro), Problems: validate.Errors(intro)}
|
||||
|
||||
@@ -462,3 +462,39 @@ func TestParseWarningsReachTheResult(t *testing.T) {
|
||||
t.Errorf("ops = %+v", res.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedZoneUpdateStagesNothing(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("wg.example.com")}))
|
||||
z := env.state().FindZone("wg.example.com.", 0)
|
||||
if z.SOA.MName != "ns1.example.com." {
|
||||
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{MName: model.Ptr("ns1.example.com.")}}))
|
||||
z = env.state().FindZone("wg.example.com.", 0)
|
||||
}
|
||||
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The settings form saved as it was.
|
||||
same := ZoneInput{Comment: model.Ptr(z.Comment), DefaultTTL: model.Ptr(z.DefaultTTL), SerialPolicy: model.Ptr(z.SerialPolicy),
|
||||
SOA: &SOAInput{RName: model.Ptr("hostmaster@example.com"), Refresh: model.Ptr(z.SOA.Refresh), Retry: model.Ptr(z.SOA.Retry),
|
||||
Expire: model.Ptr(z.SOA.Expire), Minimum: model.Ptr(z.SOA.Minimum)}}
|
||||
if z.SOA.RName != "hostmaster.example.com." {
|
||||
same.SOA.RName = nil
|
||||
}
|
||||
_, err = env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, same)}, env.admin)
|
||||
if e, ok := AsError(err); !ok || e.Kind != KindBadRequest || e.Message != "nothing to change" {
|
||||
t.Fatalf("unchanged update: %v", err)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, same)); e.Message != "nothing to do" {
|
||||
t.Errorf("unchanged update applied: %+v", e)
|
||||
}
|
||||
// A real change still stages, and an absolute MNAME stays absolute.
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{Comment: model.Ptr("the VPN")})}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := env.state().FindZone("wg.example.com.", 0).SOA.MName; got != "ns1.example.com." {
|
||||
t.Errorf("mname = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -193,6 +194,11 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||
if in.LinkWith != "" {
|
||||
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||
}
|
||||
// An update that leaves the zone as it is stages nothing (a
|
||||
// settings form saved without changes).
|
||||
if zoneUnchanged(x.st, z, &in) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
case OpRecordCreate:
|
||||
@@ -1555,3 +1561,17 @@ func firstNonEmptyStr(a, b string) string {
|
||||
func notLinked(st *model.State, z *model.Zone) error {
|
||||
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||
}
|
||||
|
||||
// zoneUnchanged reports whether an update would leave the zone as it is.
|
||||
// Errors are left for applying the update to report.
|
||||
func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool {
|
||||
if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 {
|
||||
return false
|
||||
}
|
||||
c := st.Clone()
|
||||
n := c.Zones[z.ID]
|
||||
if err := setZoneFields(c, n, in, false); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(n, z)
|
||||
}
|
||||
Reference in new issue
Block a user