Disable records instead of deleting them
A disabled record stays in Jarl with its ID, TTL and comment but is left out of the zone; the zone file lists it as a comment. Web UI toggle, jarl record disable|enable, record.disable|enable operations and API endpoints. Database schema 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f04ab523b
commit
295c549479
29 files changed
+806
-51
No files matched your search
@@ -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
|
||||
|
||||
+21
-9
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+91
-3
@@ -75,8 +75,12 @@ 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)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+24
-4
@@ -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 |
|
||||
|
||||
+17
-2
@@ -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.
|
||||
@@ -22,6 +22,8 @@ type Zone struct {
|
||||
Display string `json:"display_name"`
|
||||
View string `json:"view,omitempty"`
|
||||
Records int `json:"record_count"`
|
||||
// Disabled is how many of the records are disabled.
|
||||
Disabled int `json:"disabled_count,omitempty"`
|
||||
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
||||
RName string `json:"rname_email,omitempty"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+135
-2
@@ -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,16 +688,29 @@ 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)...)
|
||||
}
|
||||
if !disabled {
|
||||
more, err := x.ptrCreate(z, owner, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+21
-3
@@ -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) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user