diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbbf4a..b8592fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to Jarl are documented here. The format follows ## [Unreleased] +### Added + +- Disabled records: a record can be disabled instead of deleted. It stays + in Jarl with its ID, TTL and comment but is left out of the zone (the + zone file lists it as a comment), and enabling it puts it back exactly + as it was. Web UI: a Disable/Enable button on each record, disabled + records greyed out with a badge, a filter for them, and a count in the + zone list. CLI: `jarl record disable|enable` (by ID or by name, type and + data, `--ptr` for the PTR record, `--missing-ok`), `record list + --disabled`, and `disable:`/`enable:` entries for `jarl apply -f`. API: + `POST /records/{id}/disable|enable`, `POST + /zones/{zone}/records/disable|enable`, a `disabled` field on records, + `?disabled=` on record lists and `record.disable|enable` operations. + Validation checks the zone as it is served: the last NS record can't be + disabled, enabling a record is checked like adding it, and a disabled + record that a CNAME now blocks gets a warning. Database schema 5. + ### Changed - Records table: names are no longer greyed out when they repeat the row diff --git a/cmd/jarl/changes.go b/cmd/jarl/changes.go index 8f30321..c6a3e09 100644 --- a/cmd/jarl/changes.go +++ b/cmd/jarl/changes.go @@ -26,6 +26,10 @@ type batchEntry struct { Delete map[string]any `yaml:"delete,omitempty" json:"delete,omitempty"` Set map[string]any `yaml:"set,omitempty" json:"set,omitempty"` Update map[string]any `yaml:"update,omitempty" json:"update,omitempty"` + // Disable and Enable find records like Delete: by id, or by zone, + // name, type and optionally data. + Disable map[string]any `yaml:"disable,omitempty" json:"disable,omitempty"` + Enable map[string]any `yaml:"enable,omitempty" json:"enable,omitempty"` // Any API operation: op, id, data. Op string `yaml:"op,omitempty" json:"op,omitempty"` ID int64 `yaml:"id,omitempty" json:"id,omitempty"` @@ -57,25 +61,23 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) { var out []api.ChangeRequest for i, e := range b.Changes { n := 0 - for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Op != ""} { + for _, set := range []bool{e.Add != nil, e.Delete != nil, e.Set != nil, e.Update != nil, e.Disable != nil, e.Enable != nil, e.Op != ""} { if set { n++ } } if n != 1 { - return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update or op", i+1) + return nil, fmt.Errorf("change %d: use exactly one of add, delete, set, update, disable, enable or op", i+1) } switch { case e.Add != nil: out = append(out, api.ChangeRequest{Op: "record.create", Data: rawJSON(e.Add)}) case e.Delete != nil: - d := e.Delete - if id, ok := toInt64(d["id"]); ok { - delete(d, "id") - out = append(out, api.ChangeRequest{Op: "record.delete", ID: id, Data: rawJSON(d)}) - } else { - out = append(out, api.ChangeRequest{Op: "record.delete", Data: rawJSON(d)}) - } + out = append(out, byIDOrMatch("record.delete", e.Delete)) + case e.Disable != nil: + out = append(out, byIDOrMatch("record.disable", e.Disable)) + case e.Enable != nil: + out = append(out, byIDOrMatch("record.enable", e.Enable)) case e.Set != nil: s := e.Set switch d := s["data"].(type) { @@ -104,6 +106,16 @@ func (b *batchFile) requests() ([]api.ChangeRequest, error) { return out, nil } +// byIDOrMatch is a request for the record with d's id, or for the records +// d matches. +func byIDOrMatch(op string, d map[string]any) api.ChangeRequest { + if id, ok := toInt64(d["id"]); ok { + delete(d, "id") + return api.ChangeRequest{Op: op, ID: id, Data: rawJSON(d)} + } + return api.ChangeRequest{Op: op, Data: rawJSON(d)} +} + // submitBatch applies requests as one changeset, or stages them into the // changeset given with --changeset. func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest) error { diff --git a/cmd/jarl/main_test.go b/cmd/jarl/main_test.go index e9eb9f3..28f5eb0 100644 --- a/cmd/jarl/main_test.go +++ b/cmd/jarl/main_test.go @@ -22,6 +22,8 @@ changes: - set: {zone: example.com, name: api, type: A, data: 192.0.2.5} - set: {zone: example.com, name: gone, type: TXT} - update: {id: 13, data: 192.0.2.9} + - disable: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true} + - enable: {id: 14} - op: zone.create data: {name: lab.example.com} ` @@ -44,6 +46,8 @@ changes: {"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`}, {"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`}, {"record.update", 13, `{"data":"192.0.2.9"}`}, + {"record.disable", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`}, + {"record.enable", 14, `{}`}, {"zone.create", 0, `{"name":"lab.example.com"}`}, } if len(reqs) != len(want) { @@ -112,6 +116,38 @@ func TestListValue(t *testing.T) { // filters they send: flags bound to variables shared between subcommands // must not leak defaults (zone create's --type primary once hid every // non-primary zone from 'zone list'). +func TestDisableCommands(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Method + " " + r.URL.Path + "?" + r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"applied":true,"changeset_id":1}`)) + })) + defer srv.Close() + t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml")) + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"record", "disable", "example.com", "www", "A"}, "POST /api/v1/zones/example.com/records/disable?name=www&type=A"}, + {[]string{"record", "disable", "example.com", "www", "A", "192.0.2.1", "--ptr", "--view", "internal"}, "POST /api/v1/zones/example.com/records/disable?data=192.0.2.1&name=www&ptr=1&type=A&view=internal"}, + {[]string{"record", "enable", "--id", "12", "--ptr"}, "POST /api/v1/records/12/enable?ptr=1"}, + {[]string{"record", "enable", "example.com", "old", "CNAME", "--missing-ok"}, "POST /api/v1/zones/example.com/records/enable?missing_ok=1&name=old&type=CNAME"}, + } { + got = "" + root := rootCmd() + root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json")) + root.SetOut(io.Discard) + if err := root.Execute(); err != nil { + t.Errorf("%v: %v", tc.args, err) + continue + } + if got != tc.want { + t.Errorf("%v sent %q, want %q", tc.args, got, tc.want) + } + } +} + func TestListQueries(t *testing.T) { var got string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/jarl/zones.go b/cmd/jarl/zones.go index ee7b81b..36439db 100644 --- a/cmd/jarl/zones.go +++ b/cmd/jarl/zones.go @@ -75,7 +75,11 @@ func zoneCmd() *cobra.Command { 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) - fmt.Fprintf(w, "Records:\t%d\n", z.Records) + if z.Disabled > 0 { + fmt.Fprintf(w, "Records:\t%d (%d disabled: jarl record list %s --disabled)\n", z.Records, z.Disabled, args[0]) + } else { + fmt.Fprintf(w, "Records:\t%d\n", z.Records) + } } if z.InView != "" { fmt.Fprintf(w, "In view:\t%s\n", z.InView) @@ -403,7 +407,7 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) } func recordCmd() *cobra.Command { cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"} var view, name, typ, search, comment, ttlArg string - var ptr, missingOK, yes bool + var ptr, missingOK, yes, onlyDisabled bool var id int64 list := &cobra.Command{ @@ -419,6 +423,9 @@ func recordCmd() *cobra.Command { for k, v := range q("name", name, "type", strings.ToUpper(typ), "q", search) { qv[k] = v } + if onlyDisabled { + qv.Set("disabled", "1") + } var recs []api.Record if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil { return err @@ -430,7 +437,11 @@ func recordCmd() *cobra.Command { if r.TTL == nil { ttl += "*" } - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, r.Comment) + comment := r.Comment + if r.Disabled { + comment = strings.TrimSpace("[disabled] " + comment) + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.RelativeName, ttl, r.Type, r.Data, comment) } }) }, @@ -439,6 +450,7 @@ func recordCmd() *cobra.Command { list.Flags().StringVar(&name, "name", "", "only this name") list.Flags().StringVarP(&typ, "type", "t", "", "only this type") list.Flags().StringVar(&search, "search", "", "only records whose name, data or comment contains this") + list.Flags().BoolVar(&onlyDisabled, "disabled", false, "only disabled records") add := &cobra.Command{ Use: "add ZONE NAME TYPE DATA...", @@ -618,7 +630,83 @@ spaces. set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset") set.Flags().StringVar(&comment, "comment", "", "comment for new records") - cmd.AddCommand(list, add, update, del, set) + toggle := func(disable bool) *cobra.Command { + verb := "enable" + long := `Enable disabled records again: by name and type (all of them, or only the +one with DATA), or one record by ID. The record is checked as if it were +added, so a CNAME added in the meantime at its name stops it. + + jarl record enable example.com www A 192.0.2.10 + jarl record enable --id 1234 --ptr` + if disable { + verb = "disable" + long = `Disable records: they stay in Jarl, with their TTL, comment and ID, but are +left out of the zone (the zone file lists them as comments) until they are +enabled again. Select them by name and type (all of them, or only the one +with DATA), or one record by ID. + + jarl record disable example.com www A 192.0.2.10 --ptr # and its PTR record + jarl record disable example.com old CNAME + jarl record disable --id 1234 + jarl record list example.com --disabled` + } + var tview string + var tid int64 + var tptr, tmissing bool + c := &cobra.Command{ + Use: verb + " ZONE NAME TYPE [DATA...] | " + verb + " --id ID", + Short: strings.ToUpper(verb[:1]) + verb[1:] + " records", + Long: long, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + qv := q() + if tptr { + qv.Set("ptr", "1") + } + if tid > 0 { + resp, err := c.Mutate(ctx(), "POST", "/records/"+strconv.FormatInt(tid, 10)+"/"+verb, qv, nil) + if err != nil { + return err + } + return report(resp) + } + if len(args) < 3 { + return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id") + } + path, zq := zonePath(args[0], tview) + for k, v := range zq { + qv[k] = v + } + qv.Set("name", args[1]) + qv.Set("type", args[2]) + if len(args) > 3 { + qv.Set("data", strings.Join(args[3:], " ")) + } + if tmissing { + qv.Set("missing_ok", "1") + } + resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil) + if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") { + fmt.Println("nothing to do") + return nil + } + if err != nil { + return err + } + return report(resp) + }, + } + c.Flags().StringVar(&tview, "view", "", "view of the zone") + c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID") + c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d") + c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)") + return c + } + + cmd.AddCommand(list, add, update, del, set, toggle(true), toggle(false)) return cmd } diff --git a/docs/api.md b/docs/api.md index 8587d85..cead89b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,7 +110,8 @@ on the resources below, except users and tokens) works the same way: Reads accept `?changeset=ID` too. They then show the configuration as it will be after the changeset, with `"pending": "add" | "change" | "delete"` -on affected zones and records. +on affected zones and records (`"disable"` or `"enable"` for a record +that is only being disabled or enabled). The response of an applied change: @@ -333,14 +334,18 @@ registrar_error`. | Method | Path | Body / query | |--------|------|--------------| -| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=` | +| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) | | POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) | | DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match | +| POST | `/zones/{zone}/records/disable` | same query as delete by match: disable the matching records | +| POST | `/zones/{zone}/records/enable` | same query: enable them again | | PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this | | DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset | | GET | `/records/{id}` | | | PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) | | DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR | +| POST | `/records/{id}/disable` | `?ptr=1` also disables the matching PTR | +| POST | `/records/{id}/enable` | `?ptr=1` also enables the matching PTR | RecordInput: @@ -351,12 +356,26 @@ RecordInput: "data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text "ttl": 300, // null or absent: the zone's default TTL "comment": "web server", - "ptr": true // A/AAAA: also create/move/delete the PTR in the reverse zone + "disabled": false, // true: keep the record in Jarl but out of the zone + "ptr": true // A/AAAA: also create/move/delete (or disable/enable) the PTR in the reverse zone } ``` Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text` -(the decoded value of TXT records). +(the decoded value of TXT records). Zones have `disabled_count` when +some of their records are disabled. + +**Disabled records** stay in Jarl, with their ID, TTL and comment, but are +left out of the zone: the zone file lists them as comments (`; disabled: +www IN A 192.0.2.10`). Disabling and enabling are ordinary changes (staged, +reviewed, reverted like any other). Validation checks the zone as it is +served, so disabling the zone's last NS record is refused, and enabling a +record is checked as if it were added: a CNAME added at its name in the +meantime stops it (and gives a `disabled_conflict` warning until then). +`PUT /rrsets` makes the RRset exactly the given data: a disabled record +with one of the values is enabled, other disabled records are left alone. +Disabling a record that is already disabled is a 409; with `missing_ok=1` +(by match) it's "nothing to do". ### Views @@ -436,6 +455,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`: | `record.create` | | RecordInput + `"zone": "adbraun.lol"` (or `zone_id`) and `"view"` | | `record.update` | record | RecordInput | | `record.delete` | record, or 0 with `zone`, `name`, `type`, optional `data`, `missing_ok`, `ptr` | | +| `record.disable` / `record.enable` | record, or 0 with the same match fields as `record.delete` | stored as `record.update` operations | | `rrset.replace` | | `{"zone", "view", "name", "type", "ttl", "data": [...]}` (empty data deletes the RRset) | | `view.create` / `view.update` / `view.delete` / `view.reorder` | view (update/delete) | ViewInput / `{"ids": [...]}` | | `acl.create` / `acl.update` / `acl.delete` | ACL | ACL fields | diff --git a/docs/cli.md b/docs/cli.md index 9e0acc3..a30821b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -245,7 +245,7 @@ Jarl.) ## Records ```sh -jarl record list adbraun.lol [--name www] [-t A] [--search nas] +jarl record list adbraun.lol [--name www] [-t A] [--search nas] [--disabled] jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology" jarl record add adbraun.lol @ MX 10 mail jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic @@ -257,6 +257,9 @@ jarl record update 1234 --ttl default jarl record delete adbraun.lol www A # every A record of www jarl record delete adbraun.lol www A 192.168.1.10 # just this one jarl record delete --id 1234 [--ptr] +jarl record disable adbraun.lol www A 192.168.1.10 --ptr # out of the zone, kept in Jarl +jarl record enable adbraun.lol www A 192.168.1.10 --ptr # back again +jarl record disable --id 1234 # by ID; same for enable # reverse zones: name records by address; the host name needs no final dot jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol @@ -273,6 +276,16 @@ by Jarl). Every record type BIND supports can be used. Jarl knows the syntax of the common ones and checks them strictly, and the rest are validated by parsing. +**Disabling** a record takes it out of the zone without deleting it: it +keeps its ID, TTL and comment, the zone file lists it as a comment +(`; disabled: www IN A ...`), and `enable` puts it back exactly as it +was. `record list` marks disabled records `[disabled]`, and `zone show` +counts them. The zone is validated as it is served: you can't disable its +last NS record, and enabling a record is checked like adding it (a CNAME +added at the same name in the meantime stops it). `record set` enables a +disabled record whose value it is given, and leaves other disabled +records alone. + ## Batches: `jarl apply -f` Several changes that must succeed or fail together: @@ -287,6 +300,7 @@ changes: - set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60} - update: {id: 1235, comment: "old web server, remove in November"} - delete: {id: 1240} + - disable: {zone: adbraun.lol, name: old, type: A} # or {id: ...}; enable: likewise - op: acl.update # any API operation (see api.md) id: 3 data: {elements: [192.168.1.0/24, 10.10.0.0/16]} @@ -430,4 +444,5 @@ See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone. * `-q` with `create` commands prints just the ID or token. * Check `$?` against the exit code table. * `jarl record delete ... --missing-ok` succeeds when nothing matches, which - keeps cleanup scripts idempotent. `jarl record set` is idempotent by nature. + keeps cleanup scripts idempotent. So does `record disable|enable + --missing-ok`, also when the records are already disabled (enabled). `jarl record set` is idempotent by nature. diff --git a/internal/api/types.go b/internal/api/types.go index 59a0026..65d08fc 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -19,9 +19,11 @@ const Prefix = "/api/v1" // Zone is a zone as returned by the API. type Zone struct { model.Zone - Display string `json:"display_name"` - View string `json:"view,omitempty"` - Records int `json:"record_count"` + 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"` // Registrar names the registrar Jarl can manage the domain at @@ -40,7 +42,8 @@ type Record struct { EffectiveTTL uint32 `json:"effective_ttl"` // Text is the decoded value of TXT and SPF records. Text *string `json:"text,omitempty"` - // Pending is set when listing with a changeset: add, change or delete. + // Pending is set when listing with a changeset: add, change, delete, + // disable or enable. Pending string `json:"pending,omitempty"` } diff --git a/internal/engine/diffops.go b/internal/engine/diffops.go index 02a049a..e10189f 100644 --- a/internal/engine/diffops.go +++ b/internal/engine/diffops.go @@ -88,7 +88,7 @@ func DiffOps(from, to *model.State) []model.Op { } for _, k := range sortedKeys(to.Records) { n := to.Records[k] - in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment} + in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled} if n.TTL != nil { in.TTL = model.Some(*n.TTL) } else { diff --git a/internal/engine/disable_test.go b/internal/engine/disable_test.go new file mode 100644 index 0000000..17e7173 --- /dev/null +++ b/internal/engine/disable_test.go @@ -0,0 +1,176 @@ +package engine + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "jarl/internal/model" +) + +func disableReq(id int64, ptr bool) Request { + return NewRequest(OpRecordDisable, id, RecordInput{PTR: ptr}) +} + +func enableReq(id int64, ptr bool) Request { + return NewRequest(OpRecordEnable, id, RecordInput{PTR: ptr}) +} + +func (env *testEnv) zoneFile(name string) string { + env.t.Helper() + b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default", name+".zone")) + if err != nil { + env.t.Fatal(err) + } + return string(b) +} + +func serialOf(env *testEnv, zone string) uint32 { + return env.state().ZonesNamed(zone)[0].SOA.Serial +} + +func TestDisableRecord(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + www := findRec(env.state(), "www.example.com.", "A") + serial := serialOf(env, "example.com") + + res := env.submit(env.admin, disableReq(www.ID, false)) + if len(res.Ops) != 1 || res.Ops[0].Summary != "disable www.example.com. A 192.0.2.10" { + t.Fatalf("ops = %+v", res.Ops) + } + if r := env.state().Records[www.ID]; r == nil || !r.Disabled { + t.Fatalf("record = %+v", r) + } + zf := env.zoneFile("example.com") + if !strings.Contains(zf, "; disabled: www\t\tIN\tA\t192.0.2.10\n") || strings.Contains(zf, "\nwww\t") { + t.Errorf("zone file:\n%s", zf) + } + if s := serialOf(env, "example.com"); s != serial+1 { + t.Errorf("serial after disable = %d, want %d", s, serial+1) + } + // Editing a disabled record changes nothing that is served. + env.submit(env.admin, NewRequest(OpRecordUpdate, www.ID, RecordInput{Comment: model.Ptr("old web server"), Data: model.Ptr("192.0.2.11")})) + if s := serialOf(env, "example.com"); s != serial+1 { + t.Errorf("serial after editing a disabled record = %d", s) + } + // Disabling it again says so. + if e := env.submitErr(env.admin, disableReq(www.ID, false)); e.Kind != KindConflict || !strings.Contains(e.Message, "already disabled") { + t.Errorf("second disable: %+v", e) + } + + env.submit(env.admin, enableReq(www.ID, false)) + r := env.state().Records[www.ID] + if r.Disabled || r.Data != "192.0.2.11" || r.Comment != "old web server" { + t.Errorf("enabled record = %+v", r) + } + if zf := env.zoneFile("example.com"); !strings.Contains(zf, "\nwww\t\tIN\tA\t192.0.2.11\t; old web server\n") || strings.Contains(zf, "disabled") { + t.Errorf("zone file after enable:\n%s", zf) + } +} + +func TestDisableValidation(t *testing.T) { + env := newEnv(t) + setupZone(env) + // The zone must keep a name server. + ns := findRec(env.state(), "example.com.", "NS") + if e := env.submitErr(env.admin, disableReq(ns.ID, false)); e.Kind != KindValidation { + t.Errorf("disabling the only NS: %+v", e) + } + + // A disabled record doesn't count for the CNAME rule... + env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10")) + www := findRec(env.state(), "www.example.com.", "A") + env.submit(env.admin, disableReq(www.ID, false)) + res := env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1")) + // ...but there's a warning that it can't come back, + if len(res.Problems) != 1 || res.Problems[0].Code != "disabled_conflict" || res.Problems[0].ObjectID != www.ID { + t.Errorf("problems = %+v", res.Problems) + } + // and enabling it is checked like adding it. + if e := env.submitErr(env.admin, enableReq(www.ID, false)); e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") { + t.Errorf("enabling next to a CNAME: %+v", e) + } +} + +func TestDisableByMatchAndPTR(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")})) + env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true})) + ptrDisabled := func() bool { + return findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR").Disabled + } + + // By match, with the PTR record. + match := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), PTR: true} + res := env.submit(env.admin, NewRequest(OpRecordDisable, 0, match)) + if len(res.Ops) != 2 || res.Ops[1].Summary != "disable 20.2.0.192.in-addr.arpa. PTR nas.example.com." { + t.Fatalf("ops = %+v", res.Ops) + } + if !findRec(env.state(), "nas.example.com.", "A").Disabled || !ptrDisabled() { + t.Fatal("A or PTR not disabled") + } + env.submit(env.admin, NewRequest(OpRecordEnable, 0, match)) + if findRec(env.state(), "nas.example.com.", "A").Disabled || ptrDisabled() { + t.Fatal("A or PTR not enabled") + } + // Without ptr, the PTR record stays as it is. + a := findRec(env.state(), "nas.example.com.", "A") + env.submit(env.admin, disableReq(a.ID, false)) + if ptrDisabled() { + t.Error("PTR disabled without ptr") + } + + // Data narrows the match; nothing matching is an error unless missing_ok. + env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31")) + one := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.31")} + env.submit(env.admin, NewRequest(OpRecordDisable, 0, one)) + if findRecData(env.state(), "api.example.com.", "192.0.2.30").Disabled || !findRecData(env.state(), "api.example.com.", "192.0.2.31").Disabled { + t.Error("data match wrong") + } + none := RecordInput{Zone: "example.com", Name: model.Ptr("nope"), Type: model.Ptr("A")} + if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Kind != KindNotFound { + t.Errorf("no match: %+v", e) + } + // missing_ok: nothing to do (an error the CLI's --missing-ok accepts). + none.MissingOK = true + if e := env.submitErr(env.admin, NewRequest(OpRecordDisable, 0, none)); e.Message != "nothing to do" { + t.Errorf("missing_ok: %+v", e) + } +} + +func TestDisableRRSetAndRevert(t *testing.T) { + env := newEnv(t) + setupZone(env) + env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.30"), rec("example.com", "api", "A", "192.0.2.31")) + r30 := findRecData(env.state(), "api.example.com.", "192.0.2.30") + r31 := findRecData(env.state(), "api.example.com.", "192.0.2.31") + env.submit(env.admin, disableReq(r30.ID, false), disableReq(r31.ID, false)) + + // Setting the RRset enables a disabled record with a given value and + // leaves the other disabled one alone. + env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.40"}})) + s := env.state() + if r := s.Records[r30.ID]; r == nil || r.Disabled { + t.Errorf(".30 = %+v, want kept and enabled", r) + } + if r := s.Records[r31.ID]; r == nil || !r.Disabled { + t.Errorf(".31 = %+v, want kept and disabled", r) + } + + // Reverting a disabling changeset enables the record again. + res := env.submit(env.admin, disableReq(r30.ID, false)) + 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 { + t.Fatal(err) + } + if env.state().Records[r30.ID].Disabled { + t.Error("revert did not enable the record") + } +} diff --git a/internal/engine/expand.go b/internal/engine/expand.go index 037d4c7..0aa9c01 100644 --- a/internal/engine/expand.go +++ b/internal/engine/expand.go @@ -155,6 +155,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) { return x.recordDelete(req) case OpRRSetReplace: return x.rrsetReplace(req) + case OpRecordDisable, OpRecordEnable: + return x.recordSetDisabled(req, req.Op == OpRecordDisable) case OpViewCreate: return x.viewCreate(req) case OpViewUpdate, OpViewDelete: @@ -413,6 +415,9 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) { if src != nil { for _, r := range x.st.ZoneRecords(src.ID) { ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)} + if r.Disabled { + ri.Disabled = model.Ptr(true) + } if r.TTL != nil { ri.TTL = model.Some(*r.TTL) } else { @@ -502,6 +507,10 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model for _, r := range x.st.ZoneRecords(rz.ID) { if r.Name == ptrName && r.Type == "PTR" { if strings.EqualFold(r.Data, owner) { + if r.Disabled { + in := RecordInput{Disabled: model.Ptr(false)} + return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil + } return nil, nil // already there } x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data)) @@ -515,6 +524,34 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil } +// ptrSetDisabled returns operations disabling or enabling the PTR record +// of an address record. +func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, disabled bool) []model.Op { + addr, ok := addrOf(p) + if !ok { + return nil + } + rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr) + if rz == nil { + return nil + } + var ops []model.Op + for _, r := range x.st.ZoneRecords(rz.ID) { + if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled { + in := RecordInput{Disabled: model.Ptr(disabled)} + ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s PTR %s", enableWord(disabled), r.Name, r.Data))) + } + } + return ops +} + +func enableWord(disabled bool) string { + if disabled { + return "disable" + } + return "enable" +} + func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op { addr, ok := addrOf(p) if !ok { @@ -635,8 +672,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) { if in.Comment != nil && *in.Comment != r.Comment { changes = append(changes, "comment") } + toggled := in.Disabled != nil && *in.Disabled != r.Disabled summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data) - if len(changes) > 0 { + switch { + case toggled && len(changes) == 0: + summary = fmt.Sprintf("%s %s %s %s", enableWord(*in.Disabled), r.Name, r.Type, r.Data) + case toggled: + changes = append(changes, enableWord(*in.Disabled)+"d") + fallthrough + case len(changes) > 0: summary += ": " + strings.Join(changes, ", ") } base := req.BaseVersion @@ -644,15 +688,28 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) { base = r.Version } ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)} - if ptr && (p.Data != r.Data || owner != r.Name) { + disabled := r.Disabled + if in.Disabled != nil { + disabled = *in.Disabled + } + moved := p.Data != r.Data || owner != r.Name + if ptr && moved { if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { ops = append(ops, x.ptrDelete(z, r.Name, old)...) } - more, err := x.ptrCreate(z, owner, p) - if err != nil { - return nil, err + if !disabled { + more, err := x.ptrCreate(z, owner, p) + if err != nil { + return nil, err + } + ops = append(ops, more...) } - ops = append(ops, more...) + } + if ptr && !moved && toggled { + if _, ok := addrOf(p); !ok { + return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records") + } + ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...) } return ops, nil } @@ -725,6 +782,82 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) { return ops, nil } +// recordSetDisabled expands record.disable and record.enable: the record +// with the request's ID, or the records matching zone, name, type and +// (optionally) data. +func (x *expander) recordSetDisabled(req Request, disabled bool) ([]model.Op, error) { + var in RecordInput + if err := decode(req.Data, &in); err != nil { + return nil, err + } + var recs []*model.Record + if req.ID != 0 { + r := x.st.Records[req.ID] + if r == nil { + return nil, errf(KindNotFound, "record %d does not exist", req.ID) + } + recs = append(recs, r) + } else { + z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) + if err != nil { + return nil, err + } + if in.Name == nil || in.Type == nil { + return nil, errf(KindBadRequest, "finding records needs a name and a type (or give the record id)") + } + owner, err := dnsname.Owner(*in.Name, z.Name) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + typ, _, err := rr.NormalizeType(*in.Type) + if err != nil { + return nil, errf(KindBadRequest, "%v", err) + } + data, what := "", owner+" "+typ + if in.Data != nil && strings.TrimSpace(*in.Data) != "" { + _, p, err := parseRecord(z, owner, typ, *in.Data) + if err != nil { + return nil, err + } + data, what = p.Data, what+" "+p.Data + } + for _, r := range x.st.ZoneRecords(z.ID) { + if r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) { + recs = append(recs, r) + } + } + if len(recs) == 0 { + if in.MissingOK { + return nil, nil + } + return nil, errf(KindNotFound, "no record %s in zone %s", dnsname.Display(what), dnsname.Display(z.Name)) + } + } + var ops []model.Op + for _, r := range recs { + if r.Disabled == disabled { + continue // already so + } + base := 0 + if req.ID != 0 { + base = req.BaseVersion + } + more, err := x.recordUpdate(Request{Op: OpRecordUpdate, ID: r.ID, BaseVersion: base, Data: mustJSON(RecordInput{Disabled: model.Ptr(disabled), PTR: in.PTR})}) + if err != nil { + return nil, err + } + ops = append(ops, more...) + } + if len(ops) == 0 && !in.MissingOK { + what := fmt.Sprintf("%s %s %s is", recs[0].Name, recs[0].Type, recs[0].Data) + if len(recs) > 1 { + what = fmt.Sprintf("all %d %s %s records are", len(recs), recs[0].Name, recs[0].Type) + } + return nil, errf(KindConflict, "%s already %sd", what, enableWord(disabled)) + } + return ops, nil +} + func (x *expander) rrsetReplace(req Request) ([]model.Op, error) { var in RRSetInput if err := decode(req.Data, &in); err != nil { diff --git a/internal/engine/inputs.go b/internal/engine/inputs.go index 35b32fd..8bffb51 100644 --- a/internal/engine/inputs.go +++ b/internal/engine/inputs.go @@ -29,6 +29,12 @@ const ( OpOptionUpdate = "option.update" OpOptionDelete = "option.delete" OpSettingsUpdate = "settings.update" + + // Requests only: disabling and enabling records, by ID or by match + // (zone, name, type and optionally data), become record.update + // operations. + OpRecordDisable = "record.disable" + OpRecordEnable = "record.enable" ) // OpKinds lists all operation kinds. @@ -121,8 +127,10 @@ type RecordInput struct { TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default Data *string `json:"data,omitempty"` Comment *string `json:"comment,omitempty"` - // PTR also creates (or deletes) the matching PTR record in the - // reverse zone, for A and AAAA records. + // Disabled keeps the record in Jarl but out of the zone. + Disabled *bool `json:"disabled,omitempty"` + // PTR also creates (or deletes, disables or enables) the matching PTR + // record in the reverse zone, for A and AAAA records. PTR bool `json:"ptr,omitempty"` // MissingOK makes a delete-by-match succeed when nothing matches. MissingOK bool `json:"missing_ok,omitempty"` diff --git a/internal/engine/ops.go b/internal/engine/ops.go index 8d48c89..76bb1cb 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -373,7 +373,7 @@ func parseRecord(z *model.Zone, name, typ, data string) (string, *rr.Parsed, err func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) { for _, r := range st.Records { - if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID { + if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID && !r.Disabled { if r.TTL == nil { return nil, true } @@ -422,6 +422,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error { if in.Comment != nil { r.Comment = *in.Comment } + if in.Disabled != nil { + r.Disabled = *in.Disabled + } for _, w := range p.Warnings { c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w) } @@ -498,6 +501,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error { if in.Comment != nil { r.Comment = *in.Comment } + if in.Disabled != nil { + r.Disabled = *in.Disabled + } r.Touch(c.user(), c.now) return nil } @@ -617,20 +623,32 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error { } else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok { ttl, ttlKnown = t, true } + // The RRset becomes exactly the given data. A disabled record with + // one of the values is enabled again; other disabled records are left + // alone. keep := map[string]bool{} - for id, r := range st.Records { + // (In ID order, so which of two identical records is kept doesn't + // depend on map order.) + for _, id := range sortedKeys(st.Records) { + r := st.Records[id] if r.ZoneID != z.ID || r.Name != owner || r.Type != typ { continue } k := strings.ToLower(r.Data) if seen[k] && !keep[k] { keep[k] = true - if ttlKnown && !sameTTL(r.TTL, ttl) { - r.TTL = copyTTL(ttl) + if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled { + if ttlKnown { + r.TTL = copyTTL(ttl) + } + r.Disabled = false r.Touch(c.user(), c.now) } continue } + if r.Disabled { + continue + } delete(st.Records, id) } ids := in.NewIDs diff --git a/internal/model/model.go b/internal/model/model.go index 369e138..03043bc 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -154,6 +154,9 @@ type Record struct { TTL *uint32 `json:"ttl,omitempty"` Data string `json:"data"` // canonical presentation format Comment string `json:"comment"` + // Disabled records stay in Jarl but are left out of the zone (the + // zone file lists them as comments). + Disabled bool `json:"disabled,omitempty"` Meta } @@ -446,6 +449,18 @@ func (s *State) ZoneRecords(zoneID int64) []*Record { return out } +// ServedRecords returns the records of a zone that are in the zone file +// (not disabled), in display order. +func (s *State) ServedRecords(zoneID int64) []*Record { + var out []*Record + for _, r := range s.ZoneRecords(zoneID) { + if !r.Disabled { + out = append(out, r) + } + } + return out +} + // RecordsByZone groups all records by zone ID (unsorted). func (s *State) RecordsByZone() map[int64][]*Record { out := map[int64][]*Record{} @@ -455,6 +470,17 @@ func (s *State) RecordsByZone() map[int64][]*Record { return out } +// ServedRecordsByZone is RecordsByZone without disabled records. +func (s *State) ServedRecordsByZone() map[int64][]*Record { + out := map[int64][]*Record{} + for _, r := range s.Records { + if !r.Disabled { + out[r.ZoneID] = append(out[r.ZoneID], r) + } + } + return out +} + // ScopedOptions returns the options of a scope, in position order. func (s *State) ScopedOptions(scope string, scopeID int64) []*Option { var out []*Option diff --git a/internal/render/render.go b/internal/render/render.go index e30dd82..e3fab7f 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -461,6 +461,10 @@ func ZoneFile(s *model.State, z *model.Zone) string { if r.Comment != "" { line += "\t; " + oneLine(r.Comment) } + if r.Disabled { + // Kept in Jarl, left out of the zone. + line = "; disabled: " + line + } sb.WriteString(line + "\n") } return sb.String() @@ -478,7 +482,7 @@ func ZoneContentHash(s *model.State, z *model.Zone) string { h := sha256.New() soa := z.SOA fmt.Fprintf(h, "%s|%d|%s|%s|%d|%d|%d|%d\n", z.Name, z.DefaultTTL, soa.MName, soa.RName, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum) - for _, r := range s.ZoneRecords(z.ID) { + for _, r := range s.ServedRecords(z.ID) { fmt.Fprintf(h, "%s|%d|%s|%s\n", r.Name, r.EffectiveTTL(z), r.Type, r.Data) } return hex.EncodeToString(h.Sum(nil)) diff --git a/internal/server/disable_test.go b/internal/server/disable_test.go new file mode 100644 index 0000000..93080f0 --- /dev/null +++ b/internal/server/disable_test.go @@ -0,0 +1,61 @@ +package server + +import ( + "strings" + "testing" +) + +func TestDisableRecords(t *testing.T) { + e := newAPIEnv(t) + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201) + www := e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10"}, 201)["object"].(map[string]any) + id := itoa(int64(www["id"].(float64))) + + // By ID: the record comes back disabled. + if r := e.do("POST", "/records/"+id+"/disable", e.token, nil, 201)["object"].(map[string]any); r["disabled"] != true { + t.Errorf("disabled record = %v", r) + } + if _, status := e.req("POST", "/records/"+id+"/disable", e.token, nil); status != 409 { + t.Errorf("disabling twice = %d, want 409", status) + } + if z := e.do("GET", "/zones/example.com", e.token, nil, 200); z["disabled_count"] != 1.0 || z["record_count"] != 3.0 { + t.Errorf("zone counts = %v / %v", z["record_count"], z["disabled_count"]) + } + off := e.list("/zones/example.com/records?disabled=1", e.token) + on := e.list("/zones/example.com/records?disabled=0", e.token) + if len(off) != 1 || off[0]["name"] != "www.example.com." || len(on) != 2 { + t.Errorf("filtered: %d disabled, %d enabled", len(off), len(on)) + } + file := e.do("GET", "/zones/example.com/file", e.token, nil, 200) + if s, _ := file["_raw"].(string); !strings.Contains(s, "; disabled: www") { + t.Errorf("zone file = %v", file) + } + + // Staged: listing with the changeset marks the record. + cs := itoa(int64(e.do("POST", "/changesets", e.token, map[string]any{"description": "back"}, 201)["id"].(float64))) + e.do("POST", "/records/"+id+"/enable?changeset="+cs, e.token, nil, 202) + for _, r := range e.list("/zones/example.com/records?changeset="+cs, e.token) { + if r["name"] == "www.example.com." && (r["pending"] != "enable" || r["disabled"] == true) { + t.Errorf("staged record = %v", r) + } + } + e.do("POST", "/changesets/"+cs+"/discard", e.token, nil, 204) + + // By match. + e.do("POST", "/zones/example.com/records/enable?name=www&type=A", e.token, nil, 201) + if _, status := e.req("POST", "/zones/example.com/records/disable?name=nope&type=A", e.token, nil); status != 404 { + t.Errorf("no match = %d", status) + } + if _, status := e.req("POST", "/zones/example.com/records/disable?name=www", e.token, nil); status != 400 { + t.Errorf("no type = %d", status) + } + e.do("POST", "/zones/example.com/records/disable?name=www&type=A&data=192.0.2.10", e.token, nil, 201) + if r := e.do("GET", "/records/"+id, e.token, nil, 200); r["disabled"] != true { + t.Errorf("after match disable = %v", r) + } + // And in a batch. + e.do("POST", "/apply", e.token, map[string]any{"ops": []map[string]any{{"op": "record.enable", "id": www["id"]}}}, 201) + if r := e.do("GET", "/records/"+id, e.token, nil, 200); r["disabled"] == true { + t.Errorf("after batch enable = %v", r) + } +} diff --git a/internal/server/h_nscheck.go b/internal/server/h_nscheck.go index 9dae2fd..792c57d 100644 --- a/internal/server/h_nscheck.go +++ b/internal/server/h_nscheck.go @@ -169,7 +169,7 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth. c.Resolver = net.JoinHostPort(a.String(), "53") } var ns []string - for _, rec := range st.ZoneRecords(z.ID) { + for _, rec := range st.ServedRecords(z.ID) { if rec.Type == "NS" && rec.Name == z.Name { ns = append(ns, rec.Data) } @@ -226,7 +226,7 @@ func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone // addresses Jarl has for them in the same view (or nil, to be looked up). func nsTargets(st *model.State, z *model.Zone) []nscheck.Target { var out []nscheck.Target - for _, rec := range st.ZoneRecords(z.ID) { + for _, rec := range st.ServedRecords(z.ID) { if rec.Type != "NS" || rec.Name != z.Name { continue } @@ -244,7 +244,7 @@ func jarlAddresses(st *model.State, viewID int64, name string) []string { if !z.HasRecords() || !dnsname.IsSubdomain(name, z.Name) { continue } - for _, rec := range st.ZoneRecords(z.ID) { + for _, rec := range st.ServedRecords(z.ID) { if rec.Name == name && (rec.Type == "A" || rec.Type == "AAAA") { out = append(out, rec.Data) } diff --git a/internal/server/h_registrar.go b/internal/server/h_registrar.go index 48d7fbb..750ff88 100644 --- a/internal/server/h_registrar.go +++ b/internal/server/h_registrar.go @@ -73,7 +73,7 @@ func lowerNoDot(n string) string { return strings.ToLower(strings.TrimSuffix(n, // trailing dots. func zoneNS(st *model.State, z *model.Zone) []string { var out []string - for _, rec := range st.ZoneRecords(z.ID) { + for _, rec := range st.ServedRecords(z.ID) { if rec.Type == "NS" && rec.Name == z.Name { out = append(out, lowerNoDot(rec.Data)) } diff --git a/internal/server/h_zones.go b/internal/server/h_zones.go index b6d7af3..475688c 100644 --- a/internal/server/h_zones.go +++ b/internal/server/h_zones.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "reflect" "sort" "strconv" "strings" @@ -44,10 +45,17 @@ func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal) return z, nil } -func recordCounts(st *model.State) map[int64]int { - counts := map[int64]int{} +type recordCount struct{ all, disabled int } + +func recordCounts(st *model.State) map[int64]recordCount { + counts := map[int64]recordCount{} for _, r := range st.Records { - counts[r.ZoneID]++ + c := counts[r.ZoneID] + c.all++ + if r.Disabled { + c.disabled++ + } + counts[r.ZoneID] = c } return counts } @@ -190,10 +198,14 @@ func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Pri } typ := strings.ToUpper(q.Get("type")) search := strings.ToLower(q.Get("q")) + disabled := q.Get("disabled") // "1": only disabled records, "0": only enabled ones match := func(rec *model.Record) bool { if name != "" && rec.Name != name || typ != "" && rec.Type != typ { return false } + if disabled != "" && rec.Disabled != (disabled == "1" || disabled == "true") { + return false + } return search == "" || strings.Contains(rec.Name, search) || strings.Contains(strings.ToLower(rec.Data), search) || strings.Contains(strings.ToLower(rec.Comment), search) } recs := st.ZoneRecords(z.ID) @@ -229,6 +241,13 @@ func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Pri ro.Pending = "add" case old.Version != rec.Version: ro.Pending = "change" + if only := *old; only.Disabled != rec.Disabled { + // Just disabled or enabled: say which. + only.Disabled, only.Meta = rec.Disabled, rec.Meta + if reflect.DeepEqual(only, *rec) { + ro.Pending = map[bool]string{true: "disable", false: "enable"}[rec.Disabled] + } + } } } out = append(out, ro) @@ -380,6 +399,56 @@ func (s *Server) deleteRecordsByMatch(w http.ResponseWriter, r *http.Request, p return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpRecordDelete, 0, in)}, nil) } +// setRecordDisabled: POST /records/{id}/disable or /enable [?ptr=1] +func (s *Server) setRecordDisabled(disabled bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error { + op := disableOp(disabled) + return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + rec, err := s.recordByPath(r, st, p) + if err != nil { + return err + } + req := engine.NewRequest(op, rec.ID, engine.RecordInput{PTR: queryBool(r, "ptr")}) + id := rec.ID + return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { return recordObject(st, id) }) + } +} + +// setRecordsDisabled: POST /zones/{zone}/records/disable or /enable +// ?name=&type=[&data=][&missing_ok=1][&ptr=1] +func (s *Server) setRecordsDisabled(disabled bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error { + op := disableOp(disabled) + return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + st, err := s.state(r) + if err != nil { + return err + } + z, err := s.zoneByPath(r, st, p) + if err != nil { + return err + } + q := r.URL.Query() + if q.Get("name") == "" || q.Get("type") == "" { + return badRequest("give name and type (and optionally data) of the records") + } + in := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(q.Get("name")), Type: model.Ptr(q.Get("type")), MissingOK: queryBool(r, "missing_ok"), PTR: queryBool(r, "ptr")} + if d := q.Get("data"); d != "" { + in.Data = &d + } + return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil) + } +} + +func disableOp(disabled bool) string { + if disabled { + return engine.OpRecordDisable + } + return engine.OpRecordEnable +} + func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { diff --git a/internal/server/present.go b/internal/server/present.go index 92c19a3..69dab3a 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -14,8 +14,8 @@ import ( "jarl/internal/version" ) -func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone { - out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID]} +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.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/server/routes.go b/internal/server/routes.go index 3a9fe93..9327370 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -88,11 +88,15 @@ func (s *Server) routes() { m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords)) m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord)) m.HandleFunc("DELETE "+p+"/zones/{zone}/records", s.authed(s.deleteRecordsByMatch)) + m.HandleFunc("POST "+p+"/zones/{zone}/records/disable", s.authed(s.setRecordsDisabled(true))) + m.HandleFunc("POST "+p+"/zones/{zone}/records/enable", s.authed(s.setRecordsDisabled(false))) m.HandleFunc("PUT "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.putRRSet)) m.HandleFunc("DELETE "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.deleteRRSet)) m.HandleFunc("GET "+p+"/records/{id}", s.authed(s.getRecord)) m.HandleFunc("PATCH "+p+"/records/{id}", s.authed(s.updateRecord)) m.HandleFunc("DELETE "+p+"/records/{id}", s.authed(s.deleteRecord)) + m.HandleFunc("POST "+p+"/records/{id}/disable", s.authed(s.setRecordDisabled(true))) + m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false))) // Options and settings m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions)) m.HandleFunc("POST "+p+"/options", s.authed(s.createOption)) diff --git a/internal/store/state.go b/internal/store/state.go index d3fabe2..595c14b 100644 --- a/internal/store/state.go +++ b/internal/store/state.go @@ -110,7 +110,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) { return nil, err } - rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by FROM records`) + rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM records`) if err != nil { return nil, err } @@ -118,7 +118,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) { r := &model.Record{} var ttl sql.NullInt64 var ca, ua string - if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil { + if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil { rows.Close() return nil, err } @@ -220,8 +220,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error { if r.TTL != nil { ttl = int64(*r.TTL) } - _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy) + _, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, + r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy) return err }, func(id int64) error { return del(ctx, e, "records", id) }); err != nil { return err diff --git a/internal/store/store.go b/internal/store/store.go index 7ffd2b5..683054b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -259,6 +259,10 @@ CREATE TABLE recovery_codes ( used_at TEXT, PRIMARY KEY (user_id, code_hash) ); +`, + // 5: disabled records, kept but left out of the zone. + ` +ALTER TABLE records ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0; `, } diff --git a/internal/validate/records.go b/internal/validate/records.go index cfa9db4..03bcd08 100644 --- a/internal/validate/records.go +++ b/internal/validate/records.go @@ -103,6 +103,31 @@ func (c *checker) zoneLimit(z *model.Zone, name string, def int) int { return n } +// checkDisabledRecords warns about disabled records that couldn't be +// enabled again as the zone is now: the CNAME rules are the usual way a +// name changes while one of its records is disabled. +func (c *checker) checkDisabledRecords(z *model.Zone) { + served := map[string]map[string]bool{} + for _, r := range c.zoneRecords[z.ID] { + if served[r.Name] == nil { + served[r.Name] = map[string]bool{} + } + served[r.Name][r.Type] = true + } + for _, r := range c.allRecords[z.ID] { + if !r.Disabled { + continue + } + types := served[r.Name] + switch { + case r.Type != "CNAME" && types["CNAME"]: + c.recProblem(z, r, Warning, "disabled_conflict", "the disabled record %s %s %s can't be enabled again while %s has a CNAME (a CNAME must be the only record at its name)", disp(r.Name), r.Type, r.Data, disp(r.Name)) + case r.Type == "CNAME" && len(types) > 0: + c.recProblem(z, r, Warning, "disabled_conflict", "the disabled CNAME %s → %s can't be enabled again while %s has other records (a CNAME must be the only record at its name)", disp(r.Name), r.Data, disp(r.Name)) + } + } +} + func (c *checker) checkZoneRecords(z *model.Zone) { ix := &zoneIndex{ z: z, diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 7e94eab..05d8dd4 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -115,13 +115,14 @@ type checker struct { opts Options problems []Problem // indexes - zoneRecords map[int64][]*model.Record + zoneRecords map[int64][]*model.Record // served records (not disabled) + allRecords map[int64][]*model.Record keyNames map[string]bool } // State validates the complete state. func State(s *model.State, opts Options) []Problem { - c := &checker{s: s, opts: opts, zoneRecords: s.RecordsByZone(), keyNames: map[string]bool{}} + c := &checker{s: s, opts: opts, zoneRecords: s.ServedRecordsByZone(), allRecords: s.RecordsByZone(), keyNames: map[string]bool{}} for _, k := range s.Keys { c.keyNames[k.Name] = true } diff --git a/internal/validate/zones.go b/internal/validate/zones.go index 290121d..f79ee47 100644 --- a/internal/validate/zones.go +++ b/internal/validate/zones.go @@ -105,8 +105,8 @@ func (c *checker) checkZones() { } } } - if !z.HasRecords() && len(c.zoneRecords[z.ID]) > 0 { - c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.zoneRecords[z.ID])) + if !z.HasRecords() && len(c.allRecords[z.ID]) > 0 { + c.zoneProblem(z, Error, "zone_records_unmanaged", "%s zones can't have records managed by Jarl (they get their data from elsewhere); delete the %d records or change the type to primary", z.Type, len(c.allRecords[z.ID])) } } } @@ -188,6 +188,7 @@ func (c *checker) checkPrimary(z *model.Zone) { c.zoneProblem(z, Info, "zone_name_slash", "zone names with '/' (RFC 2317) are supported, but the file name is escaped") } c.checkZoneRecords(z) + c.checkDisabledRecords(z) } // humanSeconds formats a duration in seconds as e.g. "1 day" or "12 hours". diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index efe5add..7910b6d 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -436,6 +436,8 @@ async function recordsTab(el, zone, query) { // Filters and table const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" }); const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], ""); + // Shown once the zone has disabled records. + const stateFilter = select([["", "Enabled and disabled"], ["on", "Enabled only"], ["off", "Disabled only"]], ""); const list = h("div"); let editing = null; let current = records; @@ -445,18 +447,26 @@ async function recordsTab(el, zone, query) { }; const draw = () => { const q = filter.value.trim().toLowerCase(); - const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value)); + const off = current.filter((r) => r.disabled).length; + stateFilter.style.display = off || stateFilter.value ? "" : "none"; + stateFilter.options[2].textContent = "Disabled only (" + off + ")"; + const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value) && + (!stateFilter.value || !!r.disabled === (stateFilter.value === "off"))); mount(list, table([ { label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: "name" }, { label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" }, { label: "Type", render: (r) => h("strong", null, r.type) }, { label: "Data", render: (r) => r.data, tdClass: "data" }, - { label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] }, + { label: "Comment", render: (r) => [r.disabled && r.pending !== "disable" ? [badge("disabled"), " "] : null, r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] }, { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", + r.disabled + ? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable") + : h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ", h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" }, ], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [ r.pending ? "pending-" + r.pending : "", + r.disabled ? "disabled" : "", // A record followed by more of the same name: a faint line, so the // full line marks where the next name starts. rows[i + 1] && rows[i + 1].name === r.name ? "same-name-next" : "", @@ -478,6 +488,17 @@ async function recordsTab(el, zone, query) { await redraw(); } catch (e) { toast(e.message, "err", 8000); } }; + // toggle disables or enables a record, and the PTR record that goes + // with an address record. + const toggle = async (r) => { + const verb = r.disabled ? "enable" : "disable"; + const ptr = r.type === "A" || r.type === "AAAA"; + try { + const res = await change("POST", "/records/" + r.id + "/" + verb, undefined, { query: ptr ? { ptr: 1 } : undefined }); + toast(describeResult(res)); + await redraw(); + } catch (e) { toast(e.message, "err", 8000); } + }; const edit = (r) => { editing = r; draw(); }; const mountEdit = () => { const r = editing; @@ -525,12 +546,13 @@ async function recordsTab(el, zone, query) { }; filter.addEventListener("input", draw); typeFilter.addEventListener("change", draw); + stateFilter.addEventListener("change", draw); mount(el, h("div", { class: "panel" }, reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null, form, willAdd, suggest, hint, err), h("div", { class: "panel" }, - h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter, + h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter, stateFilter, h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). " + (reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))), list)); diff --git a/internal/web/static/assets/pages/zones.js b/internal/web/static/assets/pages/zones.js index acf8c31..39b79b1 100644 --- a/internal/web/static/assets/pages/zones.js +++ b/internal/web/static/assets/pages/zones.js @@ -26,7 +26,7 @@ export async function render(el, params, query) { { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), 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) : "", 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: "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 : "" })); @@ -237,5 +237,5 @@ export function emailOf(rname) { } export function pendingBadge(kind) { - return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete" }[kind] || kind, kind === "delete" ? "err" : "warn"); + return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn"); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 00f405c..9640d6e 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -145,8 +145,10 @@ tr.same-name-next td { border-bottom-color: color-mix(in srgb, var(--border) 40% td.actions-cell { white-space: nowrap; text-align: right; width: 1%; } tr.editing td { background: var(--accent-soft); } tr.pending-add td { background: var(--add); } -tr.pending-change td { background: var(--warn-soft); } +tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); } tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); } +tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); } +button.toggle { min-width: 5.2em; } .empty { color: var(--muted); padding: 18px; text-align: center; } /* Forms */ diff --git a/scripts/e2e.sh b/scripts/e2e.sh index c3e54b3..2ec8314 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -162,6 +162,16 @@ contains "adbraun.test" run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300 expect_dig "web A" "192.168.1.30" web.adbraun.test A expect_dig "PTR 30" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR +run "record disable with PTR" 0 $J record disable adbraun.test web A --ptr +expect_dig "disabled web A gone" "" web.adbraun.test A +expect_dig "disabled PTR 30 gone" "" 30.1.168.192.in-addr.arpa PTR +run "record list marks it" 0 $J record list adbraun.test --disabled +contains "\[disabled\]" +run "disabling twice says so (exit 5)" 5 $J record disable adbraun.test web A +contains "already disabled" +run "record enable with PTR" 0 $J record enable adbraun.test web A --ptr +expect_dig "enabled web A back" "192.168.1.30" web.adbraun.test A +expect_dig "enabled PTR 30 back" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99 contains "CNAME" run "record add TXT with spaces" 0 $J record add adbraun.test @ TXT "v=spf1 mx -all"