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:
adamandClaude Opus 5.5 committed 2026-10-09 22:33:02 +00:00
1 parent 5f04ab523b
commit 295c549479
29 files changed
+815 -60

No files matched your search

+7 -4
View File
@@ -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"`
}
+1 -1
View File
@@ -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 {
+176
View File
@@ -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")
}
}
+139 -6
View File
@@ -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 {
+10 -2
View File
@@ -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"`
+22 -4
View File
@@ -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
+26
View File
@@ -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
+5 -1
View File
@@ -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))
+61
View File
@@ -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)
}
}
+3 -3
View File
@@ -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)
}
+1 -1
View File
@@ -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))
}
+72 -3
View File
@@ -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 {
+2 -2
View File
@@ -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)
+4
View File
@@ -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))
+4 -4
View File
@@ -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
+4
View File
@@ -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;
`,
}
+25
View File
@@ -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,
+3 -2
View File
@@ -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
}
+3 -2
View File
@@ -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".
+25 -3
View File
@@ -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));
+2 -2
View File
@@ -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");
}
+3 -1
View File
@@ -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 */