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
+815
-60
No files matched your search
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user