Disable zones instead of deleting them
A disabled zone stays in Jarl with its records, options and history, still editable, but named doesn't load it; enabling it serves it as it is then. Signed zones, and zones another view shares, can't be disabled. jarl zone disable|enable, the zone page's Disable/Enable button. Database schema 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
59baccd636
commit
89663e366b
24 files changed
+264
-26
No files matched your search
@@ -63,7 +63,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
n := to.Zones[k]
|
||||
s := n.SOA
|
||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
||||
if n.Type == model.ZonePrimary {
|
||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||
}
|
||||
|
||||
@@ -477,12 +477,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
changedPaths[f.Path] = true
|
||||
}
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
if z.Type != model.ZonePrimary || z.Disabled {
|
||||
continue // disabled zones aren't in named: nothing to reload
|
||||
}
|
||||
path := pl.out.ZoneFiles[z.ID]
|
||||
old := base.Zones[z.ID]
|
||||
existedInNamed := old != nil && old.Type == model.ZonePrimary && baseOut.ZoneFiles[old.ID] == path
|
||||
existedInNamed := old != nil && old.Type == model.ZonePrimary && !old.Disabled && baseOut.ZoneFiles[old.ID] == path
|
||||
if !existedInNamed {
|
||||
pl.newZones = true
|
||||
pl.verify[z.ID] = z.SOA.Serial
|
||||
@@ -1054,7 +1054,7 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
pl.configChanged = true
|
||||
pl.reloadZones = nil
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type == model.ZonePrimary {
|
||||
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||
pl.reloadZones = append(pl.reloadZones, z)
|
||||
pl.verify[z.ID] = z.SOA.Serial
|
||||
}
|
||||
|
||||
@@ -145,6 +145,12 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
|
||||
verb += fmt.Sprintf(" (with its %d records)", n)
|
||||
}
|
||||
} else if d := zoneToggle(req, z); d != nil {
|
||||
if *d {
|
||||
x.warnings = append(x.warnings, disabledZoneEffect(x.st, z))
|
||||
return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
||||
case OpRecordCreate:
|
||||
@@ -1029,3 +1035,31 @@ func RedactOps(ops []model.Op) []model.Op {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zoneToggle returns the new disabled state when a zone update only
|
||||
// disables or enables the zone (nil otherwise).
|
||||
func zoneToggle(req Request, z *model.Zone) *bool {
|
||||
var in ZoneInput
|
||||
if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled {
|
||||
return nil
|
||||
}
|
||||
only := ZoneInput{Disabled: in.Disabled}
|
||||
if string(mustJSON(in)) != string(mustJSON(only)) {
|
||||
return nil
|
||||
}
|
||||
return in.Disabled
|
||||
}
|
||||
|
||||
// disabledZoneEffect says what clients get once named no longer loads a
|
||||
// zone: in a view that offers recursion, answers from recursion (the
|
||||
// internet's version of the zone, if it has one); elsewhere, REFUSED.
|
||||
func disabledZoneEffect(st *model.State, z *model.Zone) string {
|
||||
where := "this server"
|
||||
if v := st.ViewName(z); v != "" {
|
||||
where = "view " + v
|
||||
}
|
||||
if st.RecursionOn(z.ViewID) {
|
||||
return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where)
|
||||
}
|
||||
return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where)
|
||||
}
|
||||
@@ -108,6 +108,8 @@ type ZoneInput struct {
|
||||
// finished, off ("").
|
||||
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
|
||||
@@ -240,6 +240,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
||||
if in.SerialPolicy != nil {
|
||||
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
||||
}
|
||||
if in.Disabled != nil {
|
||||
z.Disabled = *in.Disabled
|
||||
}
|
||||
if in.DNSSECPolicy != nil {
|
||||
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
||||
switch strings.ToLower(p) {
|
||||
|
||||
@@ -101,8 +101,8 @@ func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bump
|
||||
continue
|
||||
}
|
||||
old := base.Zones[z.ID]
|
||||
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
||||
continue // new to named: nothing loaded to compare with
|
||||
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID || old.Disabled || z.Disabled {
|
||||
continue // new to named (or not in it): nothing loaded to compare with
|
||||
}
|
||||
path := render.ZoneFilePath(work, z, e.cfg.Paths)
|
||||
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func TestZoneDisable(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
z := env.state().ZonesNamed("example.com")[0]
|
||||
confPath := filepath.Join(env.dir, "etc/named.conf")
|
||||
zonePath := filepath.Join(env.dir, "zones/_default/example.com.zone")
|
||||
|
||||
res := env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||
if res.Ops[0].Summary != "disable zone example.com" || len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "recursion") {
|
||||
t.Errorf("disable: %q, warnings %v", res.Ops[0].Summary, res.Warnings)
|
||||
}
|
||||
conf, _ := os.ReadFile(confPath)
|
||||
if strings.Contains(string(conf), "zone \"example.com\" {") || !strings.Contains(string(conf), `// zone "example.com" is disabled in Jarl`) {
|
||||
t.Errorf("named.conf with the zone disabled:\n%s", conf)
|
||||
}
|
||||
// Still edited, and its file kept up to date.
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if f, err := os.ReadFile(zonePath); err != nil || !strings.Contains(string(f), "www\t\tIN\tA\t192.0.2.10") {
|
||||
t.Errorf("zone file while disabled: %v\n%s", err, f)
|
||||
}
|
||||
|
||||
// Enabling brings it back.
|
||||
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(false)}))
|
||||
if res.Ops[0].Summary != "enable zone example.com" {
|
||||
t.Errorf("enable: %q", res.Ops[0].Summary)
|
||||
}
|
||||
if conf, _ := os.ReadFile(confPath); !strings.Contains(string(conf), "zone \"example.com\" {") {
|
||||
t.Errorf("named.conf after enabling:\n%s", conf)
|
||||
}
|
||||
// Reverting a disable enables the zone again.
|
||||
res = env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Zones[z.ID].Disabled {
|
||||
t.Errorf("revert of a disable: %v, disabled=%v", err, env.state().Zones[z.ID].Disabled)
|
||||
}
|
||||
|
||||
// Signed zones can't be disabled.
|
||||
env.submit(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "signed zone can't be disabled") {
|
||||
t.Errorf("disabling a signed zone: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneDisableInView(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}, AdoptZones: true}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external")}),
|
||||
)
|
||||
st := env.state()
|
||||
ext := st.ViewByName("external")
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), ViewID: &ext.ID, Type: model.Ptr(model.ZoneInView), InView: model.Ptr("internal")}))
|
||||
in := st.ZonesNamed("example.com")[0]
|
||||
// The zone another view shares can't be disabled alone.
|
||||
if e := env.submitErr(env.admin, NewRequest(OpZoneUpdate, in.ID, ZoneInput{Disabled: model.Ptr(true)})); e.Kind != KindValidation || !strings.Contains(e.Message, "is disabled, so this zone can't share it") {
|
||||
t.Errorf("disabling a shared zone: %+v", e)
|
||||
}
|
||||
// Without recursion, the warning says REFUSED.
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &ext.ID, Name: model.Ptr("recursion"), Value: model.Ptr("no")}))
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("public.example"), ViewID: &ext.ID}))
|
||||
pub := env.state().FindZone("public.example", ext.ID)
|
||||
res := env.submit(env.admin, NewRequest(OpZoneUpdate, pub.ID, ZoneInput{Disabled: model.Ptr(true)}))
|
||||
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "view external answers REFUSED") {
|
||||
t.Errorf("warnings = %v", res.Warnings)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user