diff --git a/CHANGELOG.md b/CHANGELOG.md index f75e0ef..80ba676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to Jarl are documented here. The format follows ### Added +- Disabled zones: a zone can be disabled instead of deleted. named stops + loading it (its zone statement is left out of named.conf), while Jarl + keeps it with its records, options and history, still editable; + enabling it serves it as it is then. Disabling says what clients get + instead (recursion, or REFUSED). Signed zones can't be disabled. Web + UI: Disable/Enable on the zone page, a badge in the zone list. CLI: + `jarl zone disable|enable`. API: `PATCH /zones/{zone}` with + `{"disabled": true}`. Database schema 7. - Blocking (docs/blocking.md): named answers "doesn't exist" for ads, trackers and malware, using BIND's response policy zones. - Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index ba5125a..e780110 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -45,7 +45,11 @@ func zoneCmd() *cobra.Command { if z.Type == "primary" { serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records) } - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, z.Type, recs, serial) + typ := z.Type + if z.Disabled { + typ += " (disabled)" + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", z.ID, z.Display, z.View, typ, recs, serial) } }) }, @@ -73,12 +77,15 @@ func zoneCmd() *cobra.Command { fmt.Fprintf(w, "View:\t%s\n", z.View) } fmt.Fprintf(w, "Type:\t%s\n", z.Type) + if z.Disabled { + fmt.Fprintf(w, "Disabled:\tyes: named doesn't load it ('jarl zone enable %s')\n", args[0]) + } if z.Type == "primary" { fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial) fmt.Fprintf(w, "SOA:\t%s %s refresh %d retry %d expire %d minimum %d\n", z.SOA.MName, z.SOA.RName, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum) fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL) - if z.Disabled > 0 { - fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0]) + if z.DisabledRecords > 0 { + fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0]) } else { fmt.Fprintf(w, "Records:\t%d\n", z.Records) } @@ -306,6 +313,42 @@ and a network between octet boundaries, like a /22, gets one zone per /24 del.Flags().StringVar(&view, "view", "", "view of the zone") del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") + toggle := func(disable bool) *cobra.Command { + verb := map[bool]string{true: "disable", false: "enable"}[disable] + long := `Load a disabled zone in named again, as it is in Jarl now (changes made +while it was disabled included).` + if disable { + long = `Disable a zone: it stays in Jarl with its records, options and history, +and can still be edited, but named no longer loads it. Its names are then +answered by recursion where the view offers it (the internet's version of +the zone, if any), and REFUSED elsewhere. Signed zones can't be disabled; +turn signing off first. + + jarl zone disable old.adbraun.lol + jarl zone enable old.adbraun.lol` + } + c := &cobra.Command{ + Use: verb + " ZONE", + Short: map[bool]string{true: "Stop serving a zone, keeping it in Jarl", false: "Serve a disabled zone again"}[disable], + Long: long, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + path, qv := zonePath(args[0], view) + resp, err := c.Mutate(ctx(), "PATCH", path, qv, map[string]any{"disabled": disable}) + if err != nil { + return err + } + return report(resp) + }, + } + c.Flags().StringVar(&view, "view", "", "view of the zone") + return c + } + fileCmd := &cobra.Command{ Use: "file ZONE", Short: "Print the zone file Jarl generates", @@ -373,7 +416,7 @@ equal to the file: records not in the file are deleted.`, importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file") importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file") - cmd.AddCommand(list, show, create, update, del, fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd()) + cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd()) return cmd } diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index 9f18f83..9b049be 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -167,7 +167,7 @@ func zoneIndex(a *app) stats.ZoneIndex { } out := map[string][]string{} for _, z := range st.Zones { - if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror { + if z.Type != model.ZonePrimary && z.Type != model.ZoneSecondary && z.Type != model.ZoneMirror || z.Disabled { continue } v := st.ViewName(z) diff --git a/docs/api.md b/docs/api.md index 0159f8f..47637b9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -160,7 +160,7 @@ may be the name. | GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) | | POST | `/zones` | ZoneInput (below) | | GET | `/zones/{zone}` | | -| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) | +| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change); `{"disabled": true}` disables the zone: named stops loading it, Jarl keeps it | | DELETE | `/zones/{zone}` | deletes the zone with its records and options | | GET | `/zones/{zone}/file` | the generated zone file (text/plain) | | GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) | diff --git a/docs/cli.md b/docs/cli.md index f9808b6..1159b25 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -182,12 +182,21 @@ jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600" jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200 jarl zone update adbraun.lol --move-to-view internal jarl zone delete lab.adbraun.lol [-y] +jarl zone disable lab.adbraun.lol [--view V] # named stops serving it; kept (and editable) in Jarl +jarl zone enable lab.adbraun.lol [--view V] jarl zone file adbraun.lol # the generated zone file jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa] jarl zone serials adbraun.lol # are the secondaries (or primaries) on the current serial? jarl zone delegation adbraun.lol [--resolver 9.9.9.9] # does the parent zone delegate it correctly? ``` +**Disabling a zone** leaves it out of named's configuration but keeps it in +Jarl: records, options and history, which can still be edited. Its names +are then answered by recursion where the view offers it (the internet's +version of the zone, if any), and REFUSED elsewhere; `disable` says which. +Enabling it serves it as it is then. A signed zone can't be disabled (turn +signing off first), nor a zone another view shares with `in-view`. + Reverse names follow the dots (the nibbles in IPv6), so a reverse zone covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is covered by several zones, created together in one changeset: a /22 by four diff --git a/docs/validation.md b/docs/validation.md index 23efa1b..d2db0a3 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -57,6 +57,8 @@ showing them. | `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` | | `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's | | `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` | +| `zone_inview_disabled` | error | An `in-view` zone can't share a disabled zone | +| `zone_disabled_signed` | error | A signed zone can't be disabled: turn signing off first | | `zone_records_unmanaged` | error | Only primary zones can have records in Jarl | | `zone_serial_policy` | error | `date`, `increment` or `unixtime` | | `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups | diff --git a/internal/api/types.go b/internal/api/types.go index 4438cf5..afe3433 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -22,10 +22,10 @@ type Zone struct { Display string `json:"display_name"` View string `json:"view,omitempty"` Records int `json:"record_count"` - // Disabled is how many of the records are disabled. - Disabled int `json:"disabled_count,omitempty"` - ZoneFile string `json:"zone_file,omitempty"` // the file named loads - RName string `json:"rname_email,omitempty"` + // DisabledRecords is how many of the records are disabled. + DisabledRecords int `json:"disabled_count,omitempty"` + ZoneFile string `json:"zone_file,omitempty"` // the file named loads + RName string `json:"rname_email,omitempty"` // Registrar names the registrar Jarl can manage the domain at // ("porkbun"), when one is configured for it. Registrar string `json:"registrar,omitempty"` diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index 219553d..7cbccc0 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -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} } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 0cb9214..65ccc5b 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 } diff --git a/internal/engine/expand.go b/internal/engine/expand.go index 63fe00a..bb2cb58 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -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) +} diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index 72916b7..12c0f92 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -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 diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 0bc4a93..6078dcc 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -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) { diff --git a/internal/engine/serial.go b/internal/engine/serial.go index 63b754d..922fb10 100644 --- a/internal/engine/serial.go +++ b/internal/engine/serial.go @@ -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) { diff --git a/internal/engine/zonedisable_test.go b/internal/engine/zonedisable_test.go new file mode 100644 index 0000000..e331f8e --- /dev/null +++ b/internal/engine/zonedisable_test.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 64a01ed..d2392c2 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -128,6 +128,9 @@ type Zone struct { // signing is being removed safely (docs/dnssec.md). DNSSECPolicy string `json:"dnssec_policy,omitempty"` Comment string `json:"comment"` + // Disabled zones stay in Jarl, records and all, but named doesn't + // load them: their zone statement is left out of named.conf. + Disabled bool `json:"disabled,omitempty"` Meta } diff --git a/internal/render/render.go b/internal/render/render.go index 41dc819..ccbd199 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -398,6 +398,12 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp if v := s.ViewName(z); v != "" { label += " [view " + v + "]" } + if z.Disabled { + // Kept in Jarl (its zone file too, so enabling it is quick), not + // loaded by named. + w.write(fmt.Sprintf("%s// zone %s is disabled in Jarl: named doesn't load it.\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name)))) + return + } w.begin("zone", z.ID, label) if z.Comment != "" { w.write(indentLines(commentBlock("//", z.Comment), indent)) diff --git a/internal/server/h_acme.go b/internal/server/h_acme.go index 7c4bb69..ee85afe 100644 --- a/internal/server/h_acme.go +++ b/internal/server/h_acme.go @@ -157,7 +157,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone { var out []acme.Zone for _, z := range st.SortedZones() { vn := st.ViewName(z) - if z.Type == model.ZonePrimary && p.CanSeeZone(z.Name, vn) { + if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) { out = append(out, acme.Zone{Name: z.Name, View: vn}) } } diff --git a/internal/server/present.go b/internal/server/present.go index 467434a..34589ff 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -16,7 +16,7 @@ import ( ) func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]recordCount) api.Zone { - out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, Disabled: counts[z.ID].disabled} + out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID].all, DisabledRecords: counts[z.ID].disabled} out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths) if z.Type == model.ZonePrimary { out.RName = dnsname.EmailFromMailbox(z.SOA.RName) diff --git a/internal/store/state.go b/internal/store/state.go index 8c628db..7649b3e 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -92,14 +92,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) { return nil, err } - rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`) + rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM zones`) if err != nil { return nil, err } for rows.Next() { z := &model.Zone{} var ca, ua string - if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil { + if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil { rows.Close() return nil, err } @@ -247,8 +247,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error { return err } if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error { - _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy) + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy) return err }, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil { return err diff --git a/internal/store/store.go b/internal/store/store.go index 8690c4f..f4d4dee 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -300,6 +300,10 @@ CREATE TABLE blocklist_status ( sha256 TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '' ); +`, + // 7: disabled zones, kept but not loaded by named. + ` +ALTER TABLE zones ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; `, } diff --git a/internal/validate/zones.go b/internal/validate/zones.go index f79ee47..b62828c 100644 --- a/internal/validate/zones.go +++ b/internal/validate/zones.go @@ -48,6 +48,9 @@ func (c *checker) checkZones() { if err := dnsname.CheckZoneName(z.Name); err != nil { c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err) } + if z.Disabled && z.Signed() { + c.zoneProblem(z, Error, "zone_disabled_signed", "a signed zone can't be disabled: validating resolvers would lose its signed answers while the parent may still have its DS record; turn signing off first") + } k := zkey{z.ViewID, z.Name} if seen[k] { c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view") @@ -133,6 +136,8 @@ func (c *checker) checkInView(z *model.Zone, viewPos map[int64]int) { c.zoneProblem(z, Error, "zone_inview_invalid", "view %q has no zone %s to share", target.Name, dnsname.Display(z.Name)) } else if src.Type != model.ZonePrimary && src.Type != model.ZoneSecondary { c.zoneProblem(z, Error, "zone_inview_invalid", "in-view can only share primary or secondary zones, but %s in view %q is %s", dnsname.Display(z.Name), target.Name, src.Type) + } else if src.Disabled && !z.Disabled { + c.zoneProblem(z, Error, "zone_inview_disabled", "%s in view %q is disabled, so this zone can't share it; disable this one too, or enable that one", dnsname.Display(z.Name), target.Name) } for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) { if o.Name != "forward" && o.Name != "forwarders" { diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index b341846..a5c582d 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -36,7 +36,13 @@ export async function render(el, params, query) { zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null, zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), h("div", { class: "actions" }, + zone.disabled + ? h("button", { onclick: () => toggleZone(zone) }, "Enable zone") + : h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"), + " ", h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))); + const banner = zone.disabled ? h("div", { class: "msg warn" }, + h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null; const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))); // The header and tabs show at once. Below them, switching tabs keeps the // previous tab's content (faded) until the new one is loaded, rather @@ -47,7 +53,7 @@ export async function render(el, params, query) { holder.classList.add("stale"); body.className = "zone-body"; body.dataset.zone = id; - mount(el, head, tabBar, holder); + mount(el, head, banner, tabBar, holder); if (tab === "records") await recordsTab(body, zone, query); else if (tab === "settings") await settingsTab(body, zone); else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" }); @@ -653,3 +659,19 @@ function diffViewOf(r) { if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import."); return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff)); } + +// toggleZone disables or enables a zone (asking first when disabling). +async function toggleZone(zone) { + if (!zone.disabled) { + const where = zone.view ? "view " + zone.view : "this server"; + const ok = await confirmDialog("Disable zone " + zone.display_name, + "named stops serving " + zone.display_name + " in " + where + ". Its names are then answered by recursion where the view offers it (the internet's version of the zone, if any), and REFUSED elsewhere. The zone, its records and options stay in Jarl.", "Disable zone", "danger"); + if (!ok) return; + } + try { + const r = await change("PATCH", "/zones/" + zone.id, { disabled: !zone.disabled }); + toast(describeResult(r)); + (r.warnings || []).forEach((w) => toast(w, "warn", 10000)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index 9baf949..a9e6010 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -23,13 +23,13 @@ export async function render(el, params, query) { lastList.zones = hash; const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value)); mount(list, table([ - { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, + { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null, { label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") }, { label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" }, { label: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" }, { label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") }, - ].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => z.pending ? "pending-" + z.pending : "" })); + ].filter(Boolean), rows, { empty: zones.length ? "No zones match." : "No zones yet. Create one, or import your existing BIND configuration with 'jarld import'.", rowClass: (z) => [z.pending ? "pending-" + z.pending : "", z.disabled ? "disabled" : ""].filter(Boolean).join(" ") })); }; filter.addEventListener("input", draw); viewSel.addEventListener("change", draw); diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 5ecd7ea..7223288 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -410,6 +410,22 @@ run "wipe the child" 0 $J zone dnssec wipe sub.adbraun.test --view external --ye run "delete the child" 0 $J zone delete sub.adbraun.test --view external --yes run "remove the delegation" 0 $J record delete adbraun.test sub NS --view external --yes +# Disabled zones: kept in Jarl, not loaded by named. +run "zone for disabling" 0 $J zone create parked.test --view internal --ns ns1.adbraun.test. +run "record in it" 0 $J record add parked.test www A 192.0.2.70 --view internal +expect_dig "parked www served" "192.0.2.70" www.parked.test A +run "zone disable" 0 $J zone disable parked.test --view internal +contains "recursion" +expect_dig "disabled zone not served" "" www.parked.test A +run "named.conf says it's disabled" 0 grep -q 'zone "parked.test" is disabled in Jarl' $E/etc-jarl/named.conf +run "zone list marks it" 0 $J zone list +contains "parked.test.*primary (disabled)" +run "editing a disabled zone works (no reload of a zone named lacks)" 0 $J record add parked.test api A 192.0.2.71 --view internal +run "zone enable" 0 $J zone enable parked.test --view internal +expect_dig "enabled zone served again" "192.0.2.70" www.parked.test A +expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A +run "delete it" 0 $J zone delete parked.test --view internal --yes + # Blocking (docs/blocking.md). It only changes answers to recursive # queries, so these ask with recursion and look at which policy zone (if # any) the answer came from.