Compare commits

..
Author SHA1 Message Date
adamandClaude Opus 5.5 33060f86ce Don't count a per-view counter going down as a restart of named
After a reconfig that changed named's views, the cache and resolver
counters (summed over views) went down, which was taken for a restart:
every counter then counted its total since named started for one minute.
Only a new boot time means a restart now; a counter that goes down
counts 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:51:24 +01:00
adamandClaude Opus 5.5 89663e366b 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>
2026-10-10 13:45:55 +00:00
26 changed files with 288 additions and 41 deletions

No files matched your search

+14
View File
@@ -8,6 +8,14 @@ All notable changes to Jarl are documented here. The format follows
### Added ### 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, - Blocking (docs/blocking.md): named answers "doesn't exist" for ads,
trackers and malware, using BIND's response policy zones. trackers and malware, using BIND's response policy zones.
- Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld - Blocklists in Adblock, hosts or plain-domain format, downloaded by jarld
@@ -58,6 +66,12 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### Fixed
- Statistics: after a reconfig that added, removed or recreated views
(creating the first view, for instance), a minute could count everything
since named started: millions of queries in an hour. A per-view counter
(cache, upstream times, validation) going down was taken for a restart
of named. Now only a new boot time means a restart, and a counter that
goes down counts 0 for that minute.
- Blocking page, checking a name: when your own block rule is for that very - Blocking page, checking a name: when your own block rule is for that very
name, it offers to remove the rule instead of an allow rule, which was name, it offers to remove the rule instead of an allow rule, which was
refused as a second rule for the same name. It also names the rule that refused as a second rule for the same name. It also names the rule that
+47 -4
View File
@@ -45,7 +45,11 @@ func zoneCmd() *cobra.Command {
if z.Type == "primary" { if z.Type == "primary" {
serial, recs = fmt.Sprint(z.SOA.Serial), fmt.Sprint(z.Records) 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, "View:\t%s\n", z.View)
} }
fmt.Fprintf(w, "Type:\t%s\n", z.Type) 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" { if z.Type == "primary" {
fmt.Fprintf(w, "Serial:\t%d\n", z.SOA.Serial) 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, "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) fmt.Fprintf(w, "Default TTL:\t%d\n", z.DefaultTTL)
if z.Disabled > 0 { if z.DisabledRecords > 0 {
fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0]) fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.DisabledRecords, args[0])
} else { } else {
fmt.Fprintf(w, "Records:\t%d\n", z.Records) 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().StringVar(&view, "view", "", "view of the zone")
del.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask for confirmation") 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{ fileCmd := &cobra.Command{
Use: "file ZONE", Use: "file ZONE",
Short: "Print the zone file Jarl generates", 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(&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") 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 return cmd
} }
+1 -1
View File
@@ -167,7 +167,7 @@ func zoneIndex(a *app) stats.ZoneIndex {
} }
out := map[string][]string{} out := map[string][]string{}
for _, z := range st.Zones { 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 continue
} }
v := st.ViewName(z) v := st.ViewName(z)
+1 -1
View File
@@ -160,7 +160,7 @@ may be the name.
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) | | GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
| POST | `/zones` | ZoneInput (below) | | POST | `/zones` | ZoneInput (below) |
| GET | `/zones/{zone}` | | | 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 | | DELETE | `/zones/{zone}` | deletes the zone with its records and options |
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) | | 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) | | GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
+9
View File
@@ -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 --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
jarl zone update adbraun.lol --move-to-view internal jarl zone update adbraun.lol --move-to-view internal
jarl zone delete lab.adbraun.lol [-y] 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 file adbraun.lol # the generated zone file
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa] 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 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? 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 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 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 covered by several zones, created together in one changeset: a /22 by four
+2
View File
@@ -57,6 +57,8 @@ showing them.
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` | | `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_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_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_records_unmanaged` | error | Only primary zones can have records in Jarl |
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` | | `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups | | `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
+4 -4
View File
@@ -22,10 +22,10 @@ type Zone struct {
Display string `json:"display_name"` Display string `json:"display_name"`
View string `json:"view,omitempty"` View string `json:"view,omitempty"`
Records int `json:"record_count"` Records int `json:"record_count"`
// Disabled is how many of the records are disabled. // DisabledRecords is how many of the records are disabled.
Disabled int `json:"disabled_count,omitempty"` DisabledRecords int `json:"disabled_count,omitempty"`
ZoneFile string `json:"zone_file,omitempty"` // the file named loads ZoneFile string `json:"zone_file,omitempty"` // the file named loads
RName string `json:"rname_email,omitempty"` RName string `json:"rname_email,omitempty"`
// Registrar names the registrar Jarl can manage the domain at // Registrar names the registrar Jarl can manage the domain at
// ("porkbun"), when one is configured for it. // ("porkbun"), when one is configured for it.
Registrar string `json:"registrar,omitempty"` Registrar string `json:"registrar,omitempty"`
+1 -1
View File
@@ -63,7 +63,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k] n := to.Zones[k]
s := n.SOA s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File, 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 { 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} in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
} }
+4 -4
View File
@@ -477,12 +477,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
changedPaths[f.Path] = true changedPaths[f.Path] = true
} }
for _, z := range work.SortedZones() { for _, z := range work.SortedZones() {
if z.Type != model.ZonePrimary { if z.Type != model.ZonePrimary || z.Disabled {
continue continue // disabled zones aren't in named: nothing to reload
} }
path := pl.out.ZoneFiles[z.ID] path := pl.out.ZoneFiles[z.ID]
old := base.Zones[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 { if !existedInNamed {
pl.newZones = true pl.newZones = true
pl.verify[z.ID] = z.SOA.Serial 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.configChanged = true
pl.reloadZones = nil pl.reloadZones = nil
for _, z := range work.SortedZones() { for _, z := range work.SortedZones() {
if z.Type == model.ZonePrimary { if z.Type == model.ZonePrimary && !z.Disabled {
pl.reloadZones = append(pl.reloadZones, z) pl.reloadZones = append(pl.reloadZones, z)
pl.verify[z.ID] = z.SOA.Serial pl.verify[z.ID] = z.SOA.Serial
} }
+34
View File
@@ -145,6 +145,12 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
if n := len(x.st.ZoneRecords(z.ID)); n > 0 { if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
verb += fmt.Sprintf(" (with its %d records)", n) 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 return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
case OpRecordCreate: case OpRecordCreate:
@@ -1029,3 +1035,31 @@ func RedactOps(ops []model.Op) []model.Op {
} }
return out 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)
}
+2
View File
@@ -108,6 +108,8 @@ type ZoneInput struct {
// finished, off (""). // finished, off ("").
DNSSECPolicy *string `json:"dnssec_policy,omitempty"` DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
Comment *string `json:"comment,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 // Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to // configured default name servers), additional records, and a zone to
+3
View File
@@ -240,6 +240,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.SerialPolicy != nil { if in.SerialPolicy != nil {
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy)) z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
} }
if in.Disabled != nil {
z.Disabled = *in.Disabled
}
if in.DNSSECPolicy != nil { if in.DNSSECPolicy != nil {
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`) p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
switch strings.ToLower(p) { switch strings.ToLower(p) {
+2 -2
View File
@@ -101,8 +101,8 @@ func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bump
continue continue
} }
old := base.Zones[z.ID] old := base.Zones[z.ID]
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID { if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID || old.Disabled || z.Disabled {
continue // new to named: nothing loaded to compare with continue // new to named (or not in it): nothing loaded to compare with
} }
path := render.ZoneFilePath(work, z, e.cfg.Paths) path := render.ZoneFilePath(work, z, e.cfg.Paths)
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) { if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
+81
View File
@@ -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)
}
}
+3
View File
@@ -128,6 +128,9 @@ type Zone struct {
// signing is being removed safely (docs/dnssec.md). // signing is being removed safely (docs/dnssec.md).
DNSSECPolicy string `json:"dnssec_policy,omitempty"` DNSSECPolicy string `json:"dnssec_policy,omitempty"`
Comment string `json:"comment"` 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 Meta
} }
+6
View File
@@ -398,6 +398,12 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
if v := s.ViewName(z); v != "" { if v := s.ViewName(z); v != "" {
label += " [view " + 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) w.begin("zone", z.ID, label)
if z.Comment != "" { if z.Comment != "" {
w.write(indentLines(commentBlock("//", z.Comment), indent)) w.write(indentLines(commentBlock("//", z.Comment), indent))
+1 -1
View File
@@ -157,7 +157,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
var out []acme.Zone var out []acme.Zone
for _, z := range st.SortedZones() { for _, z := range st.SortedZones() {
vn := st.ViewName(z) 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}) out = append(out, acme.Zone{Name: z.Name, View: vn})
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@ import (
) )
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]recordCount) api.Zone { 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) out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
if z.Type == model.ZonePrimary { if z.Type == model.ZonePrimary {
out.RName = dnsname.EmailFromMailbox(z.SOA.RName) out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
+8 -11
View File
@@ -96,23 +96,20 @@ func Flatten(body []byte) (*Snapshot, error) {
} }
// Delta returns the change from prev to cur. When named restarted (its // Delta returns the change from prev to cur. When named restarted (its
// boot time changed or a counter went down), the counters started again // boot time changed), the counters started again from zero, so the
// from zero, so the current values are the change. // current values are the change. Without a restart, a counter can still
// go down: the per-view ones (cache, upstream times, validation) are
// summed over named's views, and a reconfig that adds, removes or
// recreates views changes that sum. Such a counter counts 0 for the
// minute; it must never make every counter count from zero, which would
// add everything since named started (millions of "queries" in a minute).
func Delta(prev, cur *Snapshot) map[string]int64 { func Delta(prev, cur *Snapshot) map[string]int64 {
out := map[string]int64{} out := map[string]int64{}
restarted := prev.BootTime != cur.BootTime restarted := prev.BootTime != cur.BootTime
if !restarted {
for k, n := range cur.Values {
if n < prev.Values[k] {
restarted = true
break
}
}
}
for k, n := range cur.Values { for k, n := range cur.Values {
d := n d := n
if !restarted { if !restarted {
d = n - prev.Values[k] d = max(n-prev.Values[k], 0)
} }
if d != 0 || k == "queries" { if d != 0 || k == "queries" {
out[k] = d out[k] = d
+10 -4
View File
@@ -47,9 +47,15 @@ func TestFlattenAndDelta(t *testing.T) {
if d := Delta(s2, s3); d["queries"] != 3 || d["qtype:A"] != 3 { if d := Delta(s2, s3); d["queries"] != 3 || d["qtype:A"] != 3 {
t.Errorf("restart delta = %v", d) t.Errorf("restart delta = %v", d)
} }
// A counter going down also means a restart. // A counter going down without a restart (views changed in a
s4 := &Snapshot{BootTime: s2.BootTime, Values: map[string]int64{"queries": 2}} // reconfig) counts 0, and doesn't make the others count from zero.
if d := Delta(s2, s4); d["queries"] != 2 { s4 := &Snapshot{BootTime: s2.BootTime, Values: map[string]int64{}}
t.Errorf("reset delta = %v", d) for k, v := range s2.Values {
s4.Values[k] = v
}
s4.Values["queries"] += 4
s4.Values["cache:hits"] = 1 // was 4: a view's cache counters are gone
if d := Delta(s2, s4); d["queries"] != 4 || d["cache:hits"] != 0 || len(d) != 1 {
t.Errorf("delta with a counter going down = %v", d)
} }
} }
+4 -4
View File
@@ -92,14 +92,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
z := &model.Zone{} z := &model.Zone{}
var ca, ua string 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() rows.Close()
return nil, err return nil, err
} }
@@ -247,8 +247,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err return err
} }
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error { 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, _, 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.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy) 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 return err
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
return err return err
+4
View File
@@ -300,6 +300,10 @@ CREATE TABLE blocklist_status (
sha256 TEXT NOT NULL DEFAULT '', sha256 TEXT NOT NULL DEFAULT '',
error 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;
`, `,
} }
+5
View File
@@ -48,6 +48,9 @@ func (c *checker) checkZones() {
if err := dnsname.CheckZoneName(z.Name); err != nil { if err := dnsname.CheckZoneName(z.Name); err != nil {
c.zoneProblem(z, Error, "zone_name_invalid", "invalid zone name: %v", err) 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} k := zkey{z.ViewID, z.Name}
if seen[k] { if seen[k] {
c.zoneProblem(z, Error, "zone_duplicate", "the zone is defined more than once in the same view") 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)) 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 { } 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) 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) { for _, o := range c.s.ScopedOptions(model.ScopeZone, z.ID) {
if o.Name != "forward" && o.Name != "forwarders" { if o.Name != "forward" && o.Name != "forwarders" {
+23 -1
View File
@@ -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.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)), zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" }, 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"))); 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))); 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 // 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 // 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"); holder.classList.add("stale");
body.className = "zone-body"; body.className = "zone-body";
body.dataset.zone = id; body.dataset.zone = id;
mount(el, head, tabBar, holder); mount(el, head, banner, tabBar, holder);
if (tab === "records") await recordsTab(body, zone, query); if (tab === "records") await recordsTab(body, zone, query);
else if (tab === "settings") await settingsTab(body, zone); 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" }); 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."); 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)); 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); }
}
+2 -2
View File
@@ -23,13 +23,13 @@ export async function render(el, params, query) {
lastList.zones = hash; 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)); 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([ 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, 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: "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: "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: "Serial", render: (z) => z.type === "primary" ? String(z.soa.serial) : "", tdClass: "mono" },
{ label: "Comment", render: (z) => h("span", { class: "muted" }, z.comment || "") }, { 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); filter.addEventListener("input", draw);
viewSel.addEventListener("change", draw); viewSel.addEventListener("change", draw);
+16
View File
@@ -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 "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 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 # Blocking (docs/blocking.md). It only changes answers to recursive
# queries, so these ask with recursion and look at which policy zone (if # queries, so these ask with recursion and look at which policy zone (if
# any) the answer came from. # any) the answer came from.