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

+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))