Can delete a record in a service

This commit is contained in:
nemunaire 2025-07-13 22:57:48 +02:00
commit f5911cb256
10 changed files with 468 additions and 17 deletions

View file

@ -29,6 +29,7 @@ import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/model"
)
@ -208,3 +209,58 @@ func (zc *ZoneController) ExportZone(c *gin.Context) {
c.JSON(http.StatusOK, ret)
}
//
// @Summary Delete a given record in the zone.
// @Schemes
// @Description Delete a given record in the zone.
// @Tags zones
// @Accept json
// @Produce json
// @Security securitydefinitions.basic
// @Param domainId path string true "Domain identifier"
// @Param zoneId path string true "Zone identifier"
// @Param body body []string true "Records to delete as text, one record per line array"
// @Success 200 {object} happydns.Zone "The updated zone"
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
// @Router /domains/{domainId}/zone/{zoneId}/records/delete [post]
func (zc *ZoneController) DeleteRecords(c *gin.Context) {
domain := c.MustGet("domain").(*happydns.Domain)
zone := c.MustGet("zone").(*happydns.Zone)
var records []string
err := c.ShouldBindJSON(&records)
if err != nil {
log.Printf("%s sends invalid JSON record: %s", c.ClientIP(), err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Sprintf("Something is wrong in received data: %s", err.Error())})
return
}
for _, record := range records {
rr, err := helpers.ParseRecord(record, domain.DomainName)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Make record relative
rr = helpers.RRRelative(rr, domain.DomainName)
err = zc.zoneService.DeleteRecord(zone, domain.DomainName, rr)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
}
err = zc.zoneService.UpdateZone(zone.Id, func(z *happydns.Zone) {
z.Services = zone.Services
})
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, zone)
}

View file

@ -50,4 +50,6 @@ func DeclareZoneRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDep
apiZonesSubdomainRoutes.GET("", zc.GetZoneSubdomain)
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, dependancies)
apiZonesRoutes.POST("/records/delete", zc.DeleteRecords)
}

View file

@ -117,6 +117,25 @@ func NewRecord(domain string, rrtype string, ttl uint32, origin string) happydns
return rr
}
func ParseRecord(input, origin string) (happydns.Record, error) {
zp := dns.NewZoneParser(strings.NewReader(input), origin, "")
zp.SetDefaultTTL(0)
zp.SetIncludeAllowed(false)
rr, _ := zp.Next()
if err := zp.Err(); err != nil {
return nil, err
}
if rr.Header().Rrtype == dns.TypeTXT {
return happydns.NewTXT(rr.(*dns.TXT)), nil
} else if rr.Header().Rrtype == dns.TypeSPF {
return happydns.NewSPF(rr.(*dns.SPF)), nil
}
return rr, nil
}
// RRRelative strips the end of the given RR if it is relative to origin.
func RRRelative(rr happydns.Record, origin string) happydns.Record {
if !strings.HasSuffix(origin, ".") {

View file

@ -332,6 +332,185 @@ func TestNewRecord(t *testing.T) {
}
}
func TestParseRecord(t *testing.T) {
tests := []struct {
name string
input string
origin string
wantError bool
validate func(t *testing.T, rr dns.RR)
}{
{
name: "SOA record",
input: "@ 3600 IN SOA a.misconfigured.dns.server.invalid. hostmaster 2025091001 10800 3600 604800 3600",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
soa, ok := rr.(*dns.SOA)
if !ok {
t.Fatal("Expected *dns.SOA type")
}
if soa.Ns != "a.misconfigured.dns.server.invalid." {
t.Errorf("Expected Ns a.misconfigured.dns.server.invalid., got %s", soa.Ns)
}
if soa.Mbox != "hostmaster.example.com." {
t.Errorf("Expected Mbox hostmaster.example.com., got %s", soa.Mbox)
}
if soa.Header().Ttl != 3600 {
t.Errorf("Expected TTL 3600, got %d", soa.Header().Ttl)
}
},
},
{
name: "A record",
input: "www 3600 IN A 192.0.2.1",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
a, ok := rr.(*dns.A)
if !ok {
t.Fatal("Expected *dns.A type")
}
if a.A.String() != "192.0.2.1" {
t.Errorf("Expected IP 192.0.2.1, got %s", a.A.String())
}
if a.Header().Ttl != 3600 {
t.Errorf("Expected TTL 3600, got %d", a.Header().Ttl)
}
},
},
{
name: "AAAA record",
input: "www 7200 IN AAAA 2001:db8::1",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
aaaa, ok := rr.(*dns.AAAA)
if !ok {
t.Fatal("Expected *dns.AAAA type")
}
if aaaa.AAAA.String() != "2001:db8::1" {
t.Errorf("Expected IP 2001:db8::1, got %s", aaaa.AAAA.String())
}
},
},
{
name: "MX record",
input: "@ 1800 IN MX 10 mail.example.com.",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
mx, ok := rr.(*dns.MX)
if !ok {
t.Fatal("Expected *dns.MX type")
}
if mx.Preference != 10 {
t.Errorf("Expected preference 10, got %d", mx.Preference)
}
if mx.Mx != "mail.example.com." {
t.Errorf("Expected Mx 'mail.example.com.', got %q", mx.Mx)
}
},
},
{
name: "CNAME record",
input: "www 600 IN CNAME target.example.com.",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
cname, ok := rr.(*dns.CNAME)
if !ok {
t.Fatal("Expected *dns.CNAME type")
}
if cname.Target != "target.example.com." {
t.Errorf("Expected Target 'target.example.com.', got %q", cname.Target)
}
},
},
{
name: "TXT record with single value",
input: "_dmarc 300 IN TXT \"v=DMARC1; p=none\"",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
// ParseRecord returns happydns.TXT for TXT records
if rr.Header().Rrtype != dns.TypeTXT {
t.Errorf("Expected TypeTXT, got %d", rr.Header().Rrtype)
}
},
},
{
name: "NS record",
input: "@ 86400 IN NS ns1.example.com.",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
ns, ok := rr.(*dns.NS)
if !ok {
t.Fatal("Expected *dns.NS type")
}
if ns.Ns != "ns1.example.com." {
t.Errorf("Expected Ns 'ns1.example.com.', got %q", ns.Ns)
}
},
},
{
name: "SRV record",
input: "_http._tcp 3600 IN SRV 10 60 80 target.example.com.",
origin: "example.com.",
wantError: false,
validate: func(t *testing.T, rr dns.RR) {
srv, ok := rr.(*dns.SRV)
if !ok {
t.Fatal("Expected *dns.SRV type")
}
if srv.Priority != 10 {
t.Errorf("Expected Priority 10, got %d", srv.Priority)
}
if srv.Weight != 60 {
t.Errorf("Expected Weight 60, got %d", srv.Weight)
}
if srv.Port != 80 {
t.Errorf("Expected Port 80, got %d", srv.Port)
}
if srv.Target != "target.example.com." {
t.Errorf("Expected Target 'target.example.com.', got %q", srv.Target)
}
},
},
{
name: "invalid record",
input: "invalid record format",
origin: "example.com.",
wantError: true,
validate: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseRecord(tt.input, tt.origin)
if tt.wantError {
if err == nil {
t.Error("Expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if result == nil {
t.Fatal("ParseRecord returned nil")
}
if tt.validate != nil {
if dnsrr, ok := result.(dns.RR); ok {
tt.validate(t, dnsrr)
}
}
})
}
}
func TestRRRelative(t *testing.T) {
origin := "example.com."

View file

@ -0,0 +1,162 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package zone
import (
"fmt"
"reflect"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/internal/usecase/service"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services"
)
type DeleteRecordUsecase struct {
serviceListRecordsUC *service.ListRecordsUsecase
serviceSearchRecordUC *service.SearchRecordUsecase
}
func NewDeleteRecordUsecase(serviceListRecordsUC *service.ListRecordsUsecase, serviceSearchRecordUC *service.SearchRecordUsecase) *DeleteRecordUsecase {
return &DeleteRecordUsecase{
serviceListRecordsUC: serviceListRecordsUC,
serviceSearchRecordUC: serviceSearchRecordUC,
}
}
func (uc *DeleteRecordUsecase) delete(zone *happydns.Zone, origin string, record happydns.Record, svc *happydns.Service, dn happydns.Subdomain) error {
// Export service related records
svc_rrs, err := uc.serviceListRecordsUC.List(svc, origin, 0)
if err != nil {
return err
}
record = helpers.RRAbsolute(record, origin)
// Drop given record
rr_found := false
for i, svc_rr := range svc_rrs {
if svc_rr.String() == record.String() {
svc_rrs = append(svc_rrs[:i], svc_rrs[i+1:]...)
rr_found = true
break
}
}
if !rr_found {
return fmt.Errorf("unable to find record")
}
var newsvc map[happydns.Subdomain][]*happydns.Service
if len(svc_rrs) > 0 {
// Recreate the service
newsvc, _, err = svcs.AnalyzeZone(origin, svc_rrs)
if err != nil {
return err
}
}
// Register in zone
for i, s := range zone.Services[dn] {
if s.Id.Equals(svc.Id) {
nextsvcs := zone.Services[dn][i+1:]
zone.Services[dn] = append(zone.Services[dn][:i], newsvc[dn]...)
zone.Services[dn] = append(zone.Services[dn], nextsvcs...)
break
}
}
return nil
}
func (uc *DeleteRecordUsecase) Delete(zone *happydns.Zone, origin string, record happydns.Record) error {
dn, svc, err := uc.serviceSearchRecordUC.Search(zone, record)
if err != nil {
return err
}
if svc == nil {
return fmt.Errorf("unable to delete record: record not found")
}
err = uc.delete(zone, origin, record, svc, dn)
if err != nil {
return err
}
if svc.Type == "svcs.Orphan" {
err = uc.ReanalyzeOrphan(zone, origin, dn)
if err != nil {
return err
}
}
return nil
}
func (uc *DeleteRecordUsecase) ReanalyzeOrphan(zone *happydns.Zone, origin string, dn happydns.Subdomain) error {
var records []happydns.Record
// Found all orphan records
for _, svc := range zone.Services[dn] {
if svc.Type == "svcs.Orphan" {
svc_rrs, err := uc.serviceListRecordsUC.List(svc, origin, 0)
if err != nil {
return err
}
records = append(records, svc_rrs...)
}
}
if len(records) == 0 {
return nil
}
// Redo analysis
newsvcs, _, err := svcs.AnalyzeZone(origin, records)
if err != nil {
return err
}
for dn, nsvcs := range newsvcs {
for _, svc := range nsvcs {
if reflect.Indirect(reflect.ValueOf(svc)).Type().String() != reflect.ValueOf(svcs.Orphan{}).Type().String() {
svc_rrs, err := uc.serviceListRecordsUC.List(svc, origin, 0)
if err != nil {
return err
}
for _, record := range svc_rrs {
err = uc.delete(zone, origin, record, svc, dn)
if err != nil {
return err
}
}
zone.Services[dn] = append(zone.Services[dn], svc)
}
}
}
return nil
}

View file

@ -27,12 +27,13 @@ import (
)
type Service struct {
CreateZoneUC *CreateZoneUsecase
DeleteZoneUC *DeleteZoneUsecase
DiffZoneUC *ZoneDifferUsecase
GetZoneUC *GetZoneUsecase
ListRecordsUC *ListRecordsUsecase
UpdateZoneUC *UpdateZoneUsecase
CreateZoneUC *CreateZoneUsecase
DeleteRecordUC *DeleteRecordUsecase
DeleteZoneUC *DeleteZoneUsecase
DiffZoneUC *ZoneDifferUsecase
GetZoneUC *GetZoneUsecase
ListRecordsUC *ListRecordsUsecase
UpdateZoneUC *UpdateZoneUsecase
}
func NewZoneUsecases(store ZoneStorage, serviceUC *service.Service) *Service {
@ -40,12 +41,13 @@ func NewZoneUsecases(store ZoneStorage, serviceUC *service.Service) *Service {
listRecords := NewListRecordsUsecase(serviceUC.ListRecordsUC)
return &Service{
CreateZoneUC: NewCreateZoneUsecase(store),
DeleteZoneUC: NewDeleteZoneUsecase(store),
DiffZoneUC: NewZoneDifferUsecase(getZone, listRecords),
GetZoneUC: getZone,
ListRecordsUC: listRecords,
UpdateZoneUC: NewUpdateZoneUsease(store, getZone),
CreateZoneUC: NewCreateZoneUsecase(store),
DeleteRecordUC: NewDeleteRecordUsecase(serviceUC.ListRecordsUC, serviceUC.SearchRecordUC),
DeleteZoneUC: NewDeleteZoneUsecase(store),
DiffZoneUC: NewZoneDifferUsecase(getZone, listRecords),
GetZoneUC: getZone,
ListRecordsUC: listRecords,
UpdateZoneUC: NewUpdateZoneUsease(store, getZone),
}
}
@ -57,6 +59,10 @@ func (s *Service) DeleteZone(zoneid happydns.Identifier) error {
return s.DeleteZoneUC.Delete(zoneid)
}
func (s *Service) DeleteRecord(zone *happydns.Zone, origin string, record happydns.Record) error {
return s.DeleteRecordUC.Delete(zone, origin, record)
}
func (s *Service) DiffZones(domain *happydns.Domain, newZone *happydns.Zone, oldZoneID happydns.Identifier) ([]*happydns.Correction, error) {
return s.DiffZoneUC.Diff(domain, newZone, oldZoneID)
}

View file

@ -158,6 +158,7 @@ type ZoneServices struct {
type ZoneUsecase interface {
CreateZone(*Zone) error
DeleteRecord(*Zone, string, Record) error
DeleteZone(Identifier) error
DiffZones(*Domain, *Zone, Identifier) ([]*Correction, error)
FlattenZoneFile(*Domain, *Zone) (string, error)

View file

@ -20,6 +20,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { handleApiResponse } from "$lib/errors";
import { printRR } from "$lib/dns";
import type { dnsRR } from "$lib/dns_rr";
import type { Correction } from "$lib/model/correction";
import type { Domain } from "$lib/model/domain";
import type { ServiceCombined, ServiceMeta } from "$lib/model/service";
@ -154,3 +156,20 @@ export async function deleteZoneService(
});
return await handleApiResponse<Zone>(res);
}
export async function deleteZoneRecord(
domain: Domain,
id: string,
subdomain: string,
record: dnsRR,
): Promise<Zone> {
const dnid = encodeURIComponent(domain.id);
id = encodeURIComponent(id);
const res = await fetch(`/api/domains/${dnid}/zone/${id}/records/delete`, {
method: "POST",
headers: { Accept: "application/json" },
body: JSON.stringify([printRR(record, subdomain)]),
});
return await handleApiResponse<Zone>(res);
}

View file

@ -47,12 +47,14 @@
Spinner,
} from "@sveltestrap/sveltestrap";
import { addServiceRecord, deleteServiceRecord, updateServiceRecord } from "$lib/api/service";
import { addServiceRecord, updateServiceRecord } from "$lib/api/service";
import { deleteZoneRecord } from "$lib/api/zone";
import { fqdn, nsclass, nsrrtype } from "$lib/dns";
import { rdatafields } from "$lib/dns_rr";
import type { Domain } from "$lib/model/domain";
import type { ServiceCombined } from "$lib/model/service";
import type { Zone } from "$lib/model/zone";
import { thisZone } from "$lib/stores/thiszone";
import { t } from "$lib/translations";
const dispatch = createEventDispatcher();
@ -76,9 +78,9 @@
function deleteRecord() {
if (!record) return;
deleteRecordInProgress = true;
deleteServiceRecord(origin, zone.id, record).then(
deleteZoneRecord(origin, zone.id, service._domain, record).then(
(z: Zone) => {
dispatch("update-zone-records", z);
thisZone.set(z);
deleteRecordInProgress = false;
toggle();
},

View file

@ -240,6 +240,11 @@ export function unreverseDomain(dn: string) {
return ip.replace(/:(0000:)+/, "::").replace(/:0{1,3}/g, ":").replace(/^0+/, "").replace(/0+$/, "");
}
export function printRR(rr: dnsRR, dn: string, origin: string): string {
return fqdn(rr.Hdr.Name, fqdn(dn, origin)) + "\t" + rr.Hdr.Ttl + "\t" + nsclass(rr.Hdr.Class) + "\t" + nsrrtype(rr.Hdr.Rrtype) + "\t" + rdatatostr(rr);
export function printRR(rr: dnsRR, dn?: string, origin?: string): string {
let domain = rr.Hdr.Name || '@';
if (dn && origin) domain = fqdn(domain, fqdn(dn, origin));
else if (dn) domain = fqdn(domain, dn);
else if (origin) domain = fqdn(domain, origin);
return domain + "\t" + rr.Hdr.Ttl + "\t" + nsclass(rr.Hdr.Class) + "\t" + nsrrtype(rr.Hdr.Rrtype) + "\t" + rdatatostr(rr);
}