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

+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".