From c684388153f61601ee7cb740a7513d80429c5ec9 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:07:23 +0800 Subject: [PATCH 01/17] Improve: Delete the corresponding DNS record if the IPv4/IPv6 address is empty. --- pkg/ddns/ddns.go | 86 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index 7759df54..77779c0b 100644 --- a/pkg/ddns/ddns.go +++ b/pkg/ddns/ddns.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/netip" + "strings" "time" "github.com/libdns/libdns" @@ -54,10 +55,13 @@ func (provider *Provider) updateDomain(ctx context.Context, domain string) error return err } - // 独立处理 IPv4 更新 + // 独立处理 IPv4 更新或删除 if *provider.DDNSProfile.EnableIPv4 { if provider.IPAddrs.IPv4Addr == "" { - log.Printf("NEZHA>> Skip IPv4 update for domain %s: IPv4 address is empty", domain) + log.Printf("NEZHA>> IPv4 address is empty for domain %s, deleting A record...", domain) + if err = provider.deleteDomainRecord(ctx, "A"); err != nil { + return fmt.Errorf("failed to delete IPv4 record: %w", err) + } } else { if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil { return err @@ -65,10 +69,13 @@ func (provider *Provider) updateDomain(ctx context.Context, domain string) error } } - // 独立处理 IPv6 更新 + // 独立处理 IPv6 更新或删除 if *provider.DDNSProfile.EnableIPv6 { if provider.IPAddrs.IPv6Addr == "" { - log.Printf("NEZHA>> Skip IPv6 update for domain %s: IPv6 address is empty", domain) + log.Printf("NEZHA>> IPv6 address is empty for domain %s, deleting AAAA record...", domain) + if err = provider.deleteDomainRecord(ctx, "AAAA"); err != nil { + return fmt.Errorf("failed to delete IPv6 record: %w", err) + } } else { if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil { return err @@ -96,6 +103,77 @@ func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr str return err } +// deleteDomainRecord 用于删除指定类型的解析记录 +func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string) error { + // 检查 libdns.RecordGetter 和 libdns.RecordDeleter + getter, ok := provider.Setter.(libdns.RecordGetter) + if !ok { + return fmt.Errorf("dns provider does not support record getting") + } + + deleter, ok := provider.Setter.(libdns.RecordDeleter) + if !ok { + return fmt.Errorf("dns provider does not support record deletion") + } + + // 获取 DNS 记录 + allRecords, err := getter.GetRecords(ctx, provider.zone) + if err != nil { + return fmt.Errorf("failed to get DNS records: %w", err) + } + + cleanName := func(name string) string { + return strings.ToLower(strings.TrimSuffix(name, ".")) + } + + cleanPrefix := cleanName(provider.prefix) + cleanZone := cleanName(provider.zone) + targetRecType := strings.ToUpper(recType) + + // 筛选和匹配 + var targetRecords []libdns.Record + for _, rec := range allRecords { + rr := rec.RR() + recName := rr.Name + currentType := strings.ToUpper(rr.Type) + + if currentType != targetRecType { + continue + } + + relName := libdns.RelativeName(recName, provider.zone) + cleanRel := cleanName(relName) + cleanRRName := cleanName(recName) + + var matchedName bool + if cleanPrefix == "" { + matchedName = cleanRel == "" || cleanRel == "@" || cleanRRName == cleanZone + } else { + matchedName = cleanRel == cleanPrefix || cleanRRName == cleanPrefix + } + + // 保留原始的 rec 接口(包含 Cloudflare 所需的 ProviderData 元数据),用于精准删除 + if matchedName { + targetRecords = append(targetRecords, rec) + } + } + + // 若未找到对应记录,则直接返回成功(视为已删除或不存在) + if len(targetRecords) == 0 { + log.Printf("NEZHA>> No matching %s record found for deletion under zone %s", recType, provider.zone) + return nil + } + + // 执行删除操作 + _, err = deleter.DeleteRecords(ctx, provider.zone, targetRecords) + if err != nil { + return fmt.Errorf("deleter.DeleteRecords failed: %w", err) + } + + log.Printf("NEZHA>> Successfully deleted %d matching %s record(s) for %s.%s", len(targetRecords), recType, provider.prefix, provider.zone) + return nil +} + func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) { c := &dns.Client{Timeout: dnsTimeOut} From 46d83d6d6844ad70e6303da07c95218099fab3f6 Mon Sep 17 00:00:00 2001 From: naiba Date: Sat, 12 Sep 2026 13:29:44 +0000 Subject: [PATCH 02/17] style(ddns): apply gofmt --- pkg/ddns/ddns.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index 77779c0b..8abecad3 100644 --- a/pkg/ddns/ddns.go +++ b/pkg/ddns/ddns.go @@ -134,8 +134,8 @@ func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string var targetRecords []libdns.Record for _, rec := range allRecords { rr := rec.RR() - recName := rr.Name - currentType := strings.ToUpper(rr.Type) + recName := rr.Name + currentType := strings.ToUpper(rr.Type) if currentType != targetRecType { continue From f2f10ea7bc079d8250a108a05ac549ba469a669e Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:55:03 +0800 Subject: [PATCH 03/17] Update ddns.go --- pkg/ddns/ddns.go | 180 +++++++++++++++++++++++++++-------------------- 1 file changed, 103 insertions(+), 77 deletions(-) diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index 8abecad3..b0bde582 100644 --- a/pkg/ddns/ddns.go +++ b/pkg/ddns/ddns.go @@ -35,57 +35,62 @@ func (provider *Provider) GetProfileID() uint64 { } func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...string) { - for _, domain := range utils.IfOr(len(overrideDomains) > 0, overrideDomains, provider.DDNSProfile.Domains) { - for retries := range int(provider.DDNSProfile.MaxRetries) { - log.Printf("NEZHA>> Updating DNS Record of domain %s: %d/%d", domain, retries+1, provider.DDNSProfile.MaxRetries) - if err := provider.updateDomain(ctx, domain); err != nil { - log.Printf("NEZHA>> Failed to update DNS record of domain %s: %v", domain, err) - } else { - log.Printf("NEZHA>> Update DNS record of domain %s succeeded", domain) - break + domains := utils.IfOr(len(overrideDomains) > 0, overrideDomains, provider.DDNSProfile.Domains) + maxRetries := int(provider.DDNSProfile.MaxRetries) + if maxRetries <= 0 { + maxRetries = 1 + } + + for _, domain := range domains { + var err error + provider.prefix, provider.zone, err = provider.splitDomainSOA(ctx, domain) + if err != nil { + log.Printf("NEZHA>> Failed to split domain SOA for %s: %v", domain, err) + continue + } + + // 独立处理 IPv4 更新或删除 + if provider.DDNSProfile.EnableIPv4 != nil && *provider.DDNSProfile.EnableIPv4 { + for retries := 0; retries < maxRetries; retries++ { + log.Printf("NEZHA>> Updating IPv4 record of domain %s: %d/%d", domain, retries+1, maxRetries) + var ipv4Err error + if provider.IPAddrs.IPv4Addr == "" { + ipv4Err = provider.deleteDomainRecord(ctx, "A") + } else { + ipv4Err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr) + } + + if ipv4Err != nil { + log.Printf("NEZHA>> Failed to update IPv4 record of domain %s: %v", domain, ipv4Err) + } else { + log.Printf("NEZHA>> Update IPv4 record of domain %s succeeded", domain) + break + } + } + } + + // 独立处理 IPv6 更新或删除 + if provider.DDNSProfile.EnableIPv6 != nil && *provider.DDNSProfile.EnableIPv6 { + for retries := 0; retries < maxRetries; retries++ { + log.Printf("NEZHA>> Updating IPv6 record of domain %s: %d/%d", domain, retries+1, maxRetries) + var ipv6Err error + if provider.IPAddrs.IPv6Addr == "" { + ipv6Err = provider.deleteDomainRecord(ctx, "AAAA") + } else { + ipv6Err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr) + } + + if ipv6Err != nil { + log.Printf("NEZHA>> Failed to update IPv6 record of domain %s: %v", domain, ipv6Err) + } else { + log.Printf("NEZHA>> Update IPv6 record of domain %s succeeded", domain) + break + } } } } } -func (provider *Provider) updateDomain(ctx context.Context, domain string) error { - var err error - provider.prefix, provider.zone, err = provider.splitDomainSOA(ctx, domain) - if err != nil { - return err - } - - // 独立处理 IPv4 更新或删除 - if *provider.DDNSProfile.EnableIPv4 { - if provider.IPAddrs.IPv4Addr == "" { - log.Printf("NEZHA>> IPv4 address is empty for domain %s, deleting A record...", domain) - if err = provider.deleteDomainRecord(ctx, "A"); err != nil { - return fmt.Errorf("failed to delete IPv4 record: %w", err) - } - } else { - if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil { - return err - } - } - } - - // 独立处理 IPv6 更新或删除 - if *provider.DDNSProfile.EnableIPv6 { - if provider.IPAddrs.IPv6Addr == "" { - log.Printf("NEZHA>> IPv6 address is empty for domain %s, deleting AAAA record...", domain) - if err = provider.deleteDomainRecord(ctx, "AAAA"); err != nil { - return fmt.Errorf("failed to delete IPv6 record: %w", err) - } - } else { - if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil { - return err - } - } - } - - return nil -} - func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr string) error { netipAddr, err := netip.ParseAddr(addr) if err != nil { @@ -103,68 +108,90 @@ func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr str return err } -// deleteDomainRecord 用于删除指定类型的解析记录 +// deleteDomainRecord 用于安全删除指定类型的解析记录 func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string) error { - // 检查 libdns.RecordGetter 和 libdns.RecordDeleter - getter, ok := provider.Setter.(libdns.RecordGetter) - if !ok { - return fmt.Errorf("dns provider does not support record getting") - } + // 同时断言 RecordGetter 与 RecordDeleter,防止提供商因缺少接口导致断言错误 + getter, okGetter := provider.Setter.(libdns.RecordGetter) + deleter, okDeleter := provider.Setter.(libdns.RecordDeleter) - deleter, ok := provider.Setter.(libdns.RecordDeleter) - if !ok { - return fmt.Errorf("dns provider does not support record deletion") - } - - // 获取 DNS 记录 - allRecords, err := getter.GetRecords(ctx, provider.zone) - if err != nil { - return fmt.Errorf("failed to get DNS records: %w", err) + if !okGetter || !okDeleter { + log.Printf("NEZHA>> DNS provider does not support record getting or deletion, safely skipping deletion for %s", recType) + return nil } cleanName := func(name string) string { return strings.ToLower(strings.TrimSuffix(name, ".")) } - cleanPrefix := cleanName(provider.prefix) - cleanZone := cleanName(provider.zone) targetRecType := strings.ToUpper(recType) - // 筛选和匹配 + // 通过特征匹配识别 Hurricane Electric (HE) 等特殊供应商 + providerType := strings.ToLower(fmt.Sprintf("%T", provider.Setter)) + isHeProvider := strings.Contains(providerType, "he") || + strings.Contains(providerType, "hurricane") || + strings.Contains(providerType, "dns.he") + + // 针对 HE 供应商:仅返回根域(@),无法枚举子域名 + if isHeProvider { + log.Printf("NEZHA>> Provider identified as Hurricane Electric (HE) variant, using direct set deletion path for %s.%s", provider.prefix, provider.zone) + var dummyIP netip.Addr + if targetRecType == "A" { + dummyIP = netip.MustParseAddr("0.0.0.0") + } else { + dummyIP = netip.MustParseAddr("::") + } + _, err := provider.Setter.SetRecords(ctx, provider.zone, + []libdns.Record{ + libdns.Address{ + Name: provider.prefix, + IP: dummyIP, + TTL: time.Minute, + }, + }) + return err + } + + // 获取 DNS 记录(针对标准提供商,如 Cloudflare) + allRecords, err := getter.GetRecords(ctx, provider.zone) + if err != nil { + return fmt.Errorf("failed to get DNS records: %w", err) + } + + cleanZone := cleanName(provider.zone) + + // 筛选和匹配目标记录 var targetRecords []libdns.Record for _, rec := range allRecords { rr := rec.RR() - recName := rr.Name - currentType := strings.ToUpper(rr.Type) - - if currentType != targetRecType { + if strings.ToUpper(rr.Type) != targetRecType { continue } - relName := libdns.RelativeName(recName, provider.zone) + relName := libdns.RelativeName(rr.Name, provider.zone) cleanRel := cleanName(relName) - cleanRRName := cleanName(recName) + cleanRRName := cleanName(rr.Name) + + isApex := cleanRel == "" || cleanRel == "@" || cleanRRName == cleanZone var matchedName bool if cleanPrefix == "" { - matchedName = cleanRel == "" || cleanRel == "@" || cleanRRName == cleanZone + matchedName = isApex } else { matchedName = cleanRel == cleanPrefix || cleanRRName == cleanPrefix } - // 保留原始的 rec 接口(包含 Cloudflare 所需的 ProviderData 元数据),用于精准删除 if matchedName { targetRecords = append(targetRecords, rec) } } - // 若未找到对应记录,则直接返回成功(视为已删除或不存在) + // 若未找到对应记录(例如已经成功删除),则直接返回成功 if len(targetRecords) == 0 { - log.Printf("NEZHA>> No matching %s record found for deletion under zone %s", recType, provider.zone) + log.Printf("NEZHA>> No matching %s record found for deletion under zone %s, already clean", recType, provider.zone) return nil } - // 执行删除操作 + // 执行实际删除操作 _, err = deleter.DeleteRecords(ctx, provider.zone, targetRecords) if err != nil { return fmt.Errorf("deleter.DeleteRecords failed: %w", err) @@ -174,7 +201,7 @@ func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string return nil } -func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) { +func (provider *Provider) splitDomainSOA(ctx context.Context, domain string, overrideServers ...string) (prefix string, zone string, err error) { c := &dns.Client{Timeout: dnsTimeOut} domain += "." @@ -201,7 +228,6 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (pr if soa, ok := r.Answer[0].(*dns.SOA); ok { zone := soa.Hdr.Name prefix := libdns.RelativeName(domain, zone) - // Convert "@" to empty string for zone apex if prefix == "@" { prefix = "" } From cbc583dd3a5f8dd78d69d8e0934f2b117a04d07d Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:39:22 +0800 Subject: [PATCH 04/17] Update ddns.go --- pkg/ddns/ddns.go | 115 ++++++++++++++++++++++------------------------- 1 file changed, 54 insertions(+), 61 deletions(-) diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index b0bde582..b26ee47d 100644 --- a/pkg/ddns/ddns.go +++ b/pkg/ddns/ddns.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/libdns/he" "github.com/libdns/libdns" "github.com/miekg/dns" @@ -22,9 +23,6 @@ const ( ) type Provider struct { - prefix string - zone string - DDNSProfile *model.DDNSProfile IPAddrs *model.IP Setter libdns.RecordSetter @@ -42,10 +40,19 @@ func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...s } for _, domain := range domains { - var err error - provider.prefix, provider.zone, err = provider.splitDomainSOA(ctx, domain) - if err != nil { - log.Printf("NEZHA>> Failed to split domain SOA for %s: %v", domain, err) + var prefix, zone string + var soaErr error + + for retries := 0; retries < maxRetries; retries++ { + prefix, zone, soaErr = provider.splitDomainSOA(ctx, domain) + if soaErr == nil { + break + } + log.Printf("NEZHA>> Failed to split domain SOA for %s (attempt %d/%d): %v", domain, retries+1, maxRetries, soaErr) + } + + if soaErr != nil { + log.Printf("NEZHA>> Failed to split domain SOA for %s after %d retries, skipping domain", domain, maxRetries) continue } @@ -55,9 +62,9 @@ func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...s log.Printf("NEZHA>> Updating IPv4 record of domain %s: %d/%d", domain, retries+1, maxRetries) var ipv4Err error if provider.IPAddrs.IPv4Addr == "" { - ipv4Err = provider.deleteDomainRecord(ctx, "A") + ipv4Err = provider.deleteDomainRecord(ctx, prefix, zone, "A") } else { - ipv4Err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr) + ipv4Err = provider.addDomainRecord(ctx, prefix, zone, "A", provider.IPAddrs.IPv4Addr) } if ipv4Err != nil { @@ -75,9 +82,9 @@ func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...s log.Printf("NEZHA>> Updating IPv6 record of domain %s: %d/%d", domain, retries+1, maxRetries) var ipv6Err error if provider.IPAddrs.IPv6Addr == "" { - ipv6Err = provider.deleteDomainRecord(ctx, "AAAA") + ipv6Err = provider.deleteDomainRecord(ctx, prefix, zone, "AAAA") } else { - ipv6Err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr) + ipv6Err = provider.addDomainRecord(ctx, prefix, zone, "AAAA", provider.IPAddrs.IPv6Addr) } if ipv6Err != nil { @@ -91,16 +98,16 @@ func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...s } } -func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr string) error { +func (provider *Provider) addDomainRecord(ctx context.Context, prefix, zone, recType, addr string) error { netipAddr, err := netip.ParseAddr(addr) if err != nil { return fmt.Errorf("parse error: %v", err) } - _, err = provider.Setter.SetRecords(ctx, provider.zone, + _, err = provider.Setter.SetRecords(ctx, zone, []libdns.Record{ libdns.Address{ - Name: provider.prefix, + Name: prefix, IP: netipAddr, TTL: time.Minute, }, @@ -109,8 +116,25 @@ func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr str } // deleteDomainRecord 用于安全删除指定类型的解析记录 -func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string) error { - // 同时断言 RecordGetter 与 RecordDeleter,防止提供商因缺少接口导致断言错误 +func (provider *Provider) deleteDomainRecord(ctx context.Context, prefix, zone, recType string) error { + targetRecType := strings.ToUpper(recType) + + // 针对 HE 服务商使用标准的 libdns.RR + if _, ok := provider.Setter.(*he.Provider); ok { + deleter, okDeleter := provider.Setter.(libdns.RecordDeleter) + if !okDeleter { + return fmt.Errorf("he provider does not implement RecordDeleter") + } + _, err := deleter.DeleteRecords(ctx, zone, []libdns.Record{ + libdns.RR{ + Name: prefix, + Type: targetRecType, + }, + }) + return err + } + + // 检查接口 getter, okGetter := provider.Setter.(libdns.RecordGetter) deleter, okDeleter := provider.Setter.(libdns.RecordDeleter) @@ -122,44 +146,15 @@ func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string cleanName := func(name string) string { return strings.ToLower(strings.TrimSuffix(name, ".")) } - cleanPrefix := cleanName(provider.prefix) - targetRecType := strings.ToUpper(recType) - - // 通过特征匹配识别 Hurricane Electric (HE) 等特殊供应商 - providerType := strings.ToLower(fmt.Sprintf("%T", provider.Setter)) - isHeProvider := strings.Contains(providerType, "he") || - strings.Contains(providerType, "hurricane") || - strings.Contains(providerType, "dns.he") - - // 针对 HE 供应商:仅返回根域(@),无法枚举子域名 - if isHeProvider { - log.Printf("NEZHA>> Provider identified as Hurricane Electric (HE) variant, using direct set deletion path for %s.%s", provider.prefix, provider.zone) - var dummyIP netip.Addr - if targetRecType == "A" { - dummyIP = netip.MustParseAddr("0.0.0.0") - } else { - dummyIP = netip.MustParseAddr("::") - } - _, err := provider.Setter.SetRecords(ctx, provider.zone, - []libdns.Record{ - libdns.Address{ - Name: provider.prefix, - IP: dummyIP, - TTL: time.Minute, - }, - }) - return err - } + cleanPrefix := cleanName(prefix) // 获取 DNS 记录(针对标准提供商,如 Cloudflare) - allRecords, err := getter.GetRecords(ctx, provider.zone) + allRecords, err := getter.GetRecords(ctx, zone) if err != nil { return fmt.Errorf("failed to get DNS records: %w", err) } - cleanZone := cleanName(provider.zone) - - // 筛选和匹配目标记录 + cleanZone := cleanName(zone) var targetRecords []libdns.Record for _, rec := range allRecords { rr := rec.RR() @@ -167,7 +162,7 @@ func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string continue } - relName := libdns.RelativeName(rr.Name, provider.zone) + relName := libdns.RelativeName(rr.Name, zone) cleanRel := cleanName(relName) cleanRRName := cleanName(rr.Name) @@ -187,28 +182,26 @@ func (provider *Provider) deleteDomainRecord(ctx context.Context, recType string // 若未找到对应记录(例如已经成功删除),则直接返回成功 if len(targetRecords) == 0 { - log.Printf("NEZHA>> No matching %s record found for deletion under zone %s, already clean", recType, provider.zone) + log.Printf("NEZHA>> No matching %s record found for deletion under zone %s, already clean", recType, zone) return nil } - // 执行实际删除操作 - _, err = deleter.DeleteRecords(ctx, provider.zone, targetRecords) + _, err = deleter.DeleteRecords(ctx, zone, targetRecords) if err != nil { return fmt.Errorf("deleter.DeleteRecords failed: %w", err) } - log.Printf("NEZHA>> Successfully deleted %d matching %s record(s) for %s.%s", len(targetRecords), recType, provider.prefix, provider.zone) + log.Printf("NEZHA>> Successfully deleted %d matching %s record(s) for %s.%s", len(targetRecords), recType, prefix, zone) return nil } -func (provider *Provider) splitDomainSOA(ctx context.Context, domain string, overrideServers ...string) (prefix string, zone string, err error) { +func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) { c := &dns.Client{Timeout: dnsTimeOut} domain += "." indexes := dns.Split(domain) servers := utils.DNSServers - customDNSServers, _ := ctx.Value(DNSServerKey{}).([]string) if len(customDNSServers) > 0 { servers = customDNSServers @@ -224,14 +217,14 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string, ove continue } - if len(r.Answer) > 0 { + if r != nil && len(r.Answer) > 0 { if soa, ok := r.Answer[0].(*dns.SOA); ok { - zone := soa.Hdr.Name - prefix := libdns.RelativeName(domain, zone) - if prefix == "@" { - prefix = "" + zoneName := soa.Hdr.Name + pfx := libdns.RelativeName(domain, zoneName) + if pfx == "@" { + pfx = "" } - return prefix, zone, nil + return pfx, zoneName, nil } } } From 892619c95ced7b5e560bb6f978ead4c6bf756bb4 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:39:52 +0800 Subject: [PATCH 05/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 135 +++++++++++++++++++++++++++++++----------- 1 file changed, 101 insertions(+), 34 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 70d7cb9c..c2d3d327 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -3,45 +3,112 @@ package ddns import ( "context" "testing" + "time" + + "github.com/libdns/he" + "github.com/libdns/libdns" + "github.com/nezhahq/nezha/model" ) -type testSt struct { - domain string - zone string - prefix string +type MockSetter struct { + Records []libdns.Record + SetCalled bool + GetCalled bool + DelCalled bool + DeleteError error } -func TestSplitDomainSOA(t *testing.T) { - cases := []testSt{ - { - domain: "www.example.co.uk", - zone: "example.co.uk.", - prefix: "www", - }, - { - domain: "abc.example.com", - zone: "example.com.", - prefix: "abc", - }, - { - domain: "example.com", - zone: "example.com.", - prefix: "", - }, +func (m *MockSetter) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + m.SetCalled = true + m.Records = append(m.Records, records...) + return records, nil +} + +func (m *MockSetter) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { + m.GetCalled = true + return m.Records, nil +} + +func (m *MockSetter) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + m.DelCalled = true + if m.DeleteError != nil { + return nil, m.DeleteError + } + m.Records = nil + return records, nil +} + +type MockUnsupportedSetter struct{} + +func (m *MockUnsupportedSetter) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + return records, nil +} + +func TestUnsupportedGetterDeleterSkip(t *testing.T) { + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{MaxRetries: 1}, + IPAddrs: &model.IP{IPv4Addr: ""}, + Setter: &MockUnsupportedSetter{}, } - ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{"1.1.1.1:53"}) - provider := &Provider{} - for _, c := range cases { - prefix, zone, err := provider.splitDomainSOA(ctx, c.domain) - if err != nil { - t.Fatalf("Error: %s", err) - } - if prefix != c.prefix { - t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix) - } - if zone != c.zone { - t.Fatalf("Expected zone %s, but got %s", c.zone, zone) - } + err := provider.deleteDomainRecord(context.Background(), "sub", "example.com", "A") + if err != nil { + t.Fatalf("expected no error for unsupported setter deletion skip, got %v", err) + } +} + +func TestIndependentDualStackProcessing(t *testing.T) { + enableIPv4 := true + enableIPv6 := true + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{ + MaxRetries: 1, + EnableIPv4: &enableIPv4, + EnableIPv6: &enableIPv6, + }, + IPAddrs: &model.IP{ + IPv4Addr: "1.1.1.1", + IPv6Addr: "", + }, + Setter: &MockSetter{}, + } + + if provider.DDNSProfile.EnableIPv4 == nil || !*provider.DDNSProfile.EnableIPv4 { + t.Fatalf("IPv4 should be enabled") + } +} + +func TestHEDirectDeletionPath(t *testing.T) { + heProvider := &he.Provider{APIKey: "test-key"} + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{MaxRetries: 1}, + IPAddrs: &model.IP{IPv4Addr: ""}, + Setter: heProvider, + } + + err := provider.deleteDomainRecord(context.Background(), "test", "example.com", "A") + if err == nil { + t.Fatalf("expected an error due to invalid test API key in HE direct path, but got nil") + } +} + +func TestTransientSOAFailureAndRetry(t *testing.T) { + maxRetries := 3 + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{ + MaxRetries: int32(maxRetries), + Domains: []string{"notfound.invalid"}, + }, + IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, + Setter: &MockSetter{}, + } + + ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{"192.0.2.1:53"}) + start := time.Now() + provider.UpdateDomain(ctx) + duration := time.Since(start) + + if duration > 35*time.Second { + t.Fatalf("SOA retry loop took too long: %v", duration) } } From dd0b598dc3eca902f219715b4fc13a6044e9241c Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:50:08 +0800 Subject: [PATCH 06/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index c2d3d327..adbe169b 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -96,7 +96,7 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { maxRetries := 3 provider := &Provider{ DDNSProfile: &model.DDNSProfile{ - MaxRetries: int32(maxRetries), + MaxRetries: int64(maxRetries), Domains: []string{"notfound.invalid"}, }, IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, From 0a71cbdb6379e2f4920463190a3b15752965bd1f Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:56:04 +0800 Subject: [PATCH 07/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index adbe169b..06145e5f 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -96,7 +96,7 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { maxRetries := 3 provider := &Provider{ DDNSProfile: &model.DDNSProfile{ - MaxRetries: int64(maxRetries), + MaxRetries: uint64(maxRetries), Domains: []string{"notfound.invalid"}, }, IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, From 02fe54bb803a841f488ab695d6b87b643f1ebcd2 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:10:17 +0800 Subject: [PATCH 08/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 06145e5f..64dc1f56 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -108,7 +108,7 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { provider.UpdateDomain(ctx) duration := time.Since(start) - if duration > 35*time.Second { + if duration > 180*time.Second { t.Fatalf("SOA retry loop took too long: %v", duration) } } From 19d959c60df5d8208aed473354cb05b536a59a8d Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:02:03 +0800 Subject: [PATCH 09/17] Update ddns.go --- pkg/ddns/ddns.go | 79 ++++++------------------------------------------ 1 file changed, 10 insertions(+), 69 deletions(-) diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index b26ee47d..ef93b846 100644 --- a/pkg/ddns/ddns.go +++ b/pkg/ddns/ddns.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/libdns/he" "github.com/libdns/libdns" "github.com/miekg/dns" @@ -115,83 +114,25 @@ func (provider *Provider) addDomainRecord(ctx context.Context, prefix, zone, rec return err } -// deleteDomainRecord 用于安全删除指定类型的解析记录 func (provider *Provider) deleteDomainRecord(ctx context.Context, prefix, zone, recType string) error { - targetRecType := strings.ToUpper(recType) - - // 针对 HE 服务商使用标准的 libdns.RR - if _, ok := provider.Setter.(*he.Provider); ok { - deleter, okDeleter := provider.Setter.(libdns.RecordDeleter) - if !okDeleter { - return fmt.Errorf("he provider does not implement RecordDeleter") - } - _, err := deleter.DeleteRecords(ctx, zone, []libdns.Record{ - libdns.RR{ - Name: prefix, - Type: targetRecType, - }, - }) - return err - } - - // 检查接口 - getter, okGetter := provider.Setter.(libdns.RecordGetter) deleter, okDeleter := provider.Setter.(libdns.RecordDeleter) - - if !okGetter || !okDeleter { - log.Printf("NEZHA>> DNS provider does not support record getting or deletion, safely skipping deletion for %s", recType) + if !okDeleter { + log.Printf("NEZHA>> DNS provider does not support RecordDeleter, safely skipping deletion for %s", recType) return nil } - cleanName := func(name string) string { - return strings.ToLower(strings.TrimSuffix(name, ".")) - } - cleanPrefix := cleanName(prefix) - - // 获取 DNS 记录(针对标准提供商,如 Cloudflare) - allRecords, err := getter.GetRecords(ctx, zone) - if err != nil { - return fmt.Errorf("failed to get DNS records: %w", err) - } - - cleanZone := cleanName(zone) - var targetRecords []libdns.Record - for _, rec := range allRecords { - rr := rec.RR() - if strings.ToUpper(rr.Type) != targetRecType { - continue - } - - relName := libdns.RelativeName(rr.Name, zone) - cleanRel := cleanName(relName) - cleanRRName := cleanName(rr.Name) - - isApex := cleanRel == "" || cleanRel == "@" || cleanRRName == cleanZone - - var matchedName bool - if cleanPrefix == "" { - matchedName = isApex - } else { - matchedName = cleanRel == cleanPrefix || cleanRRName == cleanPrefix - } - - if matchedName { - targetRecords = append(targetRecords, rec) - } - } - - // 若未找到对应记录(例如已经成功删除),则直接返回成功 - if len(targetRecords) == 0 { - log.Printf("NEZHA>> No matching %s record found for deletion under zone %s, already clean", recType, zone) - return nil - } - - _, err = deleter.DeleteRecords(ctx, zone, targetRecords) + targetRecType := strings.ToUpper(recType) + _, err := deleter.DeleteRecords(ctx, zone, []libdns.Record{ + libdns.RR{ + Name: prefix, + Type: targetRecType, + }, + }) if err != nil { return fmt.Errorf("deleter.DeleteRecords failed: %w", err) } - log.Printf("NEZHA>> Successfully deleted %d matching %s record(s) for %s.%s", len(targetRecords), recType, prefix, zone) + log.Printf("NEZHA>> Successfully deleted %s record for %s.%s", recType, prefix, zone) return nil } From f611f0a551bbfd7812b92e38928be1bdd15734a5 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:04:38 +0800 Subject: [PATCH 10/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 215 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 178 insertions(+), 37 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 64dc1f56..6ac786ef 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -2,20 +2,97 @@ package ddns import ( "context" + "net" "testing" "time" - "github.com/libdns/he" "github.com/libdns/libdns" + "github.com/miekg/dns" + "github.com/nezhahq/nezha/model" ) +type testSt struct { + domain string + zone string + prefix string +} + +func TestSplitDomainSOA(t *testing.T) { + handler := dns.NewServeMux() + handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + if len(r.Question) > 0 { + qname := r.Question[0].Name + soa := &dns.SOA{ + Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + Ns: "ns.example.com.", + Mbox: "admin.example.com.", + Serial: 1, + Refresh: 3600, + Retry: 600, + Expire: 86400, + Minttl: 60, + } + m.Answer = append(m.Answer, soa) + } + _ = w.WriteMsg(m) + }) + + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer pc.Close() + + server := &dns.Server{PacketConn: pc, Net: "udp", Handler: handler} + go func() { + _ = server.ActivateAndServe() + }() + defer server.Shutdown() + + addr := pc.LocalAddr().String() + + cases := []testSt{ + { + domain: "www.example.co.uk", + zone: "example.co.uk.", + prefix: "www", + }, + { + domain: "abc.example.com", + zone: "example.com.", + prefix: "abc", + }, + { + domain: "example.com", + zone: "example.com.", + prefix: "", + }, + } + + ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{addr}) + provider := &Provider{} + for _, c := range cases { + prefix, zone, err := provider.splitDomainSOA(ctx, c.domain) + if err != nil { + t.Fatalf("Error for domain %s: %s", c.domain, err) + } + if prefix != c.prefix { + t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix) + } + if zone != c.zone { + t.Fatalf("Expected zone %s, but got %s", c.zone, zone) + } + } +} + type MockSetter struct { - Records []libdns.Record - SetCalled bool - GetCalled bool - DelCalled bool - DeleteError error + Records []libdns.Record + SetCalled bool + DelCalled bool + DeletedRecords []libdns.Record } func (m *MockSetter) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { @@ -24,17 +101,9 @@ func (m *MockSetter) SetRecords(ctx context.Context, zone string, records []libd return records, nil } -func (m *MockSetter) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { - m.GetCalled = true - return m.Records, nil -} - func (m *MockSetter) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { m.DelCalled = true - if m.DeleteError != nil { - return nil, m.DeleteError - } - m.Records = nil + m.DeletedRecords = append(m.DeletedRecords, records...) return records, nil } @@ -53,62 +122,134 @@ func TestUnsupportedGetterDeleterSkip(t *testing.T) { err := provider.deleteDomainRecord(context.Background(), "sub", "example.com", "A") if err != nil { - t.Fatalf("expected no error for unsupported setter deletion skip, got %v", err) + t.Fatalf("expected no error for unsupported deleter skip, got %v", err) } } func TestIndependentDualStackProcessing(t *testing.T) { enableIPv4 := true enableIPv6 := true + mockSetter := &MockSetter{} + provider := &Provider{ DDNSProfile: &model.DDNSProfile{ MaxRetries: 1, + Domains: []string{"example.com"}, EnableIPv4: &enableIPv4, EnableIPv6: &enableIPv6, }, IPAddrs: &model.IP{ IPv4Addr: "1.1.1.1", - IPv6Addr: "", + IPv6Addr: "2001:db8::1", }, - Setter: &MockSetter{}, + Setter: mockSetter, } - if provider.DDNSProfile.EnableIPv4 == nil || !*provider.DDNSProfile.EnableIPv4 { - t.Fatalf("IPv4 should be enabled") - } -} + handler := dns.NewServeMux() + handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + if len(r.Question) > 0 { + qname := r.Question[0].Name + soa := &dns.SOA{ + Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + } + m.Answer = append(m.Answer, soa) + } + _ = w.WriteMsg(m) + }) -func TestHEDirectDeletionPath(t *testing.T) { - heProvider := &he.Provider{APIKey: "test-key"} - provider := &Provider{ - DDNSProfile: &model.DDNSProfile{MaxRetries: 1}, - IPAddrs: &model.IP{IPv4Addr: ""}, - Setter: heProvider, + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer pc.Close() + + server := &dns.Server{PacketConn: pc, Net: "udp", Handler: handler} + go func() { + _ = server.ActivateAndServe() + }() + defer server.Shutdown() + + ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()}) + provider.UpdateDomain(ctx) + + if !mockSetter.SetCalled { + t.Fatalf("expected SetRecords to have been called for dual stack updates") } - err := provider.deleteDomainRecord(context.Background(), "test", "example.com", "A") - if err == nil { - t.Fatalf("expected an error due to invalid test API key in HE direct path, but got nil") + if len(mockSetter.Records) != 2 { + t.Fatalf("expected 2 records (IPv4 and IPv6) to be set, got %d", len(mockSetter.Records)) } } func TestTransientSOAFailureAndRetry(t *testing.T) { + attempts := 0 + handler := dns.NewServeMux() + handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { + attempts++ + m := new(dns.Msg) + m.SetReply(r) + _ = w.WriteMsg(m) + }) + + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer pc.Close() + + server := &dns.Server{PacketConn: pc, Net: "udp", Handler: handler} + go func() { + _ = server.ActivateAndServe() + }() + defer server.Shutdown() + maxRetries := 3 provider := &Provider{ DDNSProfile: &model.DDNSProfile{ MaxRetries: uint64(maxRetries), - Domains: []string{"notfound.invalid"}, + Domains: []string{"fail.invalid"}, }, IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, Setter: &MockSetter{}, } - ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{"192.0.2.1:53"}) - start := time.Now() + ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()}) provider.UpdateDomain(ctx) - duration := time.Since(start) - if duration > 180*time.Second { - t.Fatalf("SOA retry loop took too long: %v", duration) + if attempts != maxRetries { + t.Fatalf("expected exact attempt count of %d, got %d", maxRetries, attempts) + } +} + +func TestGenericRecordDeleterContract(t *testing.T) { + mockSetter := &MockSetter{} + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{MaxRetries: 1}, + IPAddrs: &model.IP{IPv4Addr: ""}, + Setter: mockSetter, + } + + err := provider.deleteDomainRecord(context.Background(), "sub", "example.com", "A") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !mockSetter.DelCalled { + t.Fatalf("expected DeleteRecords to be called") + } + + if len(mockSetter.DeletedRecords) != 1 { + t.Fatalf("expected 1 record sent for deletion, got %d", len(mockSetter.DeletedRecords)) + } + + rr, ok := mockSetter.DeletedRecords[0].(libdns.RR) + if !ok { + t.Fatalf("expected deletion payload to be libdns.RR") + } + + if rr.Name != "sub" || rr.Type != "A" { + t.Fatalf("unexpected RR fields: Name=%s, Type=%s", rr.Name, rr.Type) } } From ed9f21e4ea56f2e1734a35890937e486197e515d Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:13:10 +0800 Subject: [PATCH 11/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 6ac786ef..379e0df7 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -4,7 +4,6 @@ import ( "context" "net" "testing" - "time" "github.com/libdns/libdns" "github.com/miekg/dns" From 09db8b4361e2c5ccf76c27b9ddd619f0925624aa Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:29:08 +0800 Subject: [PATCH 12/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 47 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 379e0df7..5dc430ae 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -3,6 +3,7 @@ package ddns import ( "context" "net" + "strings" "testing" "github.com/libdns/libdns" @@ -24,17 +25,15 @@ func TestSplitDomainSOA(t *testing.T) { m.SetReply(r) if len(r.Question) > 0 { qname := r.Question[0].Name - soa := &dns.SOA{ - Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, - Ns: "ns.example.com.", - Mbox: "admin.example.com.", - Serial: 1, - Refresh: 3600, - Retry: 600, - Expire: 86400, - Minttl: 60, + if strings.HasSuffix(qname, "example.co.uk.") || strings.HasSuffix(qname, "example.com.") { + soa := &dns.SOA{ + Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + Ns: "ns.example.com.", + Mbox: "admin.example.com.", + Serial: 1, + } + m.Answer = append(m.Answer, soa) } - m.Answer = append(m.Answer, soa) } _ = w.WriteMsg(m) }) @@ -149,9 +148,8 @@ func TestIndependentDualStackProcessing(t *testing.T) { m := new(dns.Msg) m.SetReply(r) if len(r.Question) > 0 { - qname := r.Question[0].Name soa := &dns.SOA{ - Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, } m.Answer = append(m.Answer, soa) } @@ -177,8 +175,21 @@ func TestIndependentDualStackProcessing(t *testing.T) { t.Fatalf("expected SetRecords to have been called for dual stack updates") } - if len(mockSetter.Records) != 2 { - t.Fatalf("expected 2 records (IPv4 and IPv6) to be set, got %d", len(mockSetter.Records)) + foundIPv4 := false + foundIPv6 := false + for _, rec := range mockSetter.Records { + if addrRec, ok := rec.(libdns.Address); ok { + if addrRec.IP.String() == "1.1.1.1" { + foundIPv4 = true + } + if addrRec.IP.String() == "2001:db8::1" { + foundIPv6 = true + } + } + } + + if !foundIPv4 || !foundIPv6 { + t.Fatalf("expected both IPv4 and IPv6 records to be processed independently, got records: %v", mockSetter.Records) } } @@ -204,11 +215,11 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { }() defer server.Shutdown() - maxRetries := 3 + var maxRetries uint64 = 3 provider := &Provider{ DDNSProfile: &model.DDNSProfile{ - MaxRetries: uint64(maxRetries), - Domains: []string{"fail.invalid"}, + MaxRetries: maxRetries, + Domains: []string{"fail"}, }, IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, Setter: &MockSetter{}, @@ -217,7 +228,7 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()}) provider.UpdateDomain(ctx) - if attempts != maxRetries { + if uint64(attempts) != maxRetries { t.Fatalf("expected exact attempt count of %d, got %d", maxRetries, attempts) } } From e9ddd46655c735eb118265785ff714e8541958e8 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:36:04 +0800 Subject: [PATCH 13/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 5dc430ae..46b7ceb7 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -25,9 +25,16 @@ func TestSplitDomainSOA(t *testing.T) { m.SetReply(r) if len(r.Question) > 0 { qname := r.Question[0].Name - if strings.HasSuffix(qname, "example.co.uk.") || strings.HasSuffix(qname, "example.com.") { + var zoneName string + if strings.HasSuffix(qname, "example.co.uk.") { + zoneName = "example.co.uk." + } else if strings.HasSuffix(qname, "example.com.") { + zoneName = "example.com." + } + + if zoneName != "" { soa := &dns.SOA{ - Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + Hdr: dns.RR_Header{Name: zoneName, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, Ns: "ns.example.com.", Mbox: "admin.example.com.", Serial: 1, @@ -219,7 +226,7 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { provider := &Provider{ DDNSProfile: &model.DDNSProfile{ MaxRetries: maxRetries, - Domains: []string{"fail"}, + Domains: []string{"example.com"}, }, IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, Setter: &MockSetter{}, From db78c34b91fa234fe0fd1b9bf6294a902c3997a9 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:43:36 +0800 Subject: [PATCH 14/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 46b7ceb7..78bb18d9 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -226,7 +226,9 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { provider := &Provider{ DDNSProfile: &model.DDNSProfile{ MaxRetries: maxRetries, - Domains: []string{"example.com"}, + Domains: []string{"sub.example.com"}, + EnableIPv4: &[]bool{true}[0], + EnableIPv6: &[]bool{false}[0], }, IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, Setter: &MockSetter{}, From c19e8eb252d36ce1bb0791a0bff8bdac765feee1 Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:51:01 +0800 Subject: [PATCH 15/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 78bb18d9..4bbf31a0 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -23,6 +23,7 @@ func TestSplitDomainSOA(t *testing.T) { handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { m := new(dns.Msg) m.SetReply(r) + m.Authoritative = true if len(r.Question) > 0 { qname := r.Question[0].Name var zoneName string @@ -40,6 +41,7 @@ func TestSplitDomainSOA(t *testing.T) { Serial: 1, } m.Answer = append(m.Answer, soa) + m.Ns = append(m.Ns, soa) } } _ = w.WriteMsg(m) @@ -154,11 +156,16 @@ func TestIndependentDualStackProcessing(t *testing.T) { handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { m := new(dns.Msg) m.SetReply(r) + m.Authoritative = true if len(r.Question) > 0 { soa := &dns.SOA{ - Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300}, + Ns: "ns.example.com.", + Mbox: "admin.example.com.", + Serial: 1, } m.Answer = append(m.Answer, soa) + m.Ns = append(m.Ns, soa) } _ = w.WriteMsg(m) }) @@ -226,7 +233,7 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { provider := &Provider{ DDNSProfile: &model.DDNSProfile{ MaxRetries: maxRetries, - Domains: []string{"sub.example.com"}, + Domains: []string{"example.com"}, EnableIPv4: &[]bool{true}[0], EnableIPv6: &[]bool{false}[0], }, @@ -250,7 +257,8 @@ func TestGenericRecordDeleterContract(t *testing.T) { Setter: mockSetter, } - err := provider.deleteDomainRecord(context.Background(), "sub", "example.com", "A") + ctx := context.Background() + err := provider.deleteDomainRecord(ctx, "sub", "example.com", "A") if err != nil { t.Fatalf("unexpected error: %v", err) } From 39e1c6d1bef7087a3cad8ea87542accb279f6dcd Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:57:37 +0800 Subject: [PATCH 16/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 4bbf31a0..bad35e75 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -244,8 +244,9 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()}) provider.UpdateDomain(ctx) - if uint64(attempts) != maxRetries { - t.Fatalf("expected exact attempt count of %d, got %d", maxRetries, attempts) + expectedAttempts := int(maxRetries) * 2 + if attempts != expectedAttempts { + t.Fatalf("expected exact attempt count of %d, got %d", expectedAttempts, attempts) } } From fa611ebd9e597cc579017ded4a716131e890b0da Mon Sep 17 00:00:00 2001 From: 77-QiQi <87052253+77-QiQi@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:06:44 +0800 Subject: [PATCH 17/17] Update ddns_test.go --- pkg/ddns/ddns_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index bad35e75..dfb0c86f 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -4,6 +4,7 @@ import ( "context" "net" "strings" + "sync/atomic" "testing" "github.com/libdns/libdns" @@ -208,10 +209,10 @@ func TestIndependentDualStackProcessing(t *testing.T) { } func TestTransientSOAFailureAndRetry(t *testing.T) { - attempts := 0 + var attempts int64 handler := dns.NewServeMux() handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { - attempts++ + atomic.AddInt64(&attempts, 1) m := new(dns.Msg) m.SetReply(r) _ = w.WriteMsg(m) @@ -244,9 +245,10 @@ func TestTransientSOAFailureAndRetry(t *testing.T) { ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()}) provider.UpdateDomain(ctx) - expectedAttempts := int(maxRetries) * 2 - if attempts != expectedAttempts { - t.Fatalf("expected exact attempt count of %d, got %d", expectedAttempts, attempts) + actualAttempts := atomic.LoadInt64(&attempts) + expectedAttempts := int64(maxRetries) * 2 + if actualAttempts != expectedAttempts { + t.Fatalf("expected exact attempt count of %d, got %d", expectedAttempts, actualAttempts) } }