diff --git a/pkg/ddns/ddns.go b/pkg/ddns/ddns.go index 7759df54..ef93b846 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" @@ -21,9 +22,6 @@ const ( ) type Provider struct { - prefix string - zone string - DDNSProfile *model.DDNSProfile IPAddrs *model.IP Setter libdns.RecordSetter @@ -34,61 +32,81 @@ 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) + 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 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 + } + + // 独立处理 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, prefix, zone, "A") + } else { + ipv4Err = provider.addDomainRecord(ctx, prefix, zone, "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, prefix, zone, "AAAA") + } else { + ipv6Err = provider.addDomainRecord(ctx, prefix, zone, "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>> Skip IPv4 update for domain %s: IPv4 address is empty", domain) - } 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>> Skip IPv6 update for domain %s: IPv6 address is empty", domain) - } 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 { +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, }, @@ -96,6 +114,28 @@ func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr str return err } +func (provider *Provider) deleteDomainRecord(ctx context.Context, prefix, zone, recType string) error { + deleter, okDeleter := provider.Setter.(libdns.RecordDeleter) + if !okDeleter { + log.Printf("NEZHA>> DNS provider does not support RecordDeleter, safely skipping deletion for %s", recType) + return nil + } + + 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 %s record for %s.%s", recType, prefix, zone) + return nil +} + func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) { c := &dns.Client{Timeout: dnsTimeOut} @@ -103,7 +143,6 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (pr indexes := dns.Split(domain) servers := utils.DNSServers - customDNSServers, _ := ctx.Value(DNSServerKey{}).([]string) if len(customDNSServers) > 0 { servers = customDNSServers @@ -119,15 +158,14 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (pr 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) - // Convert "@" to empty string for zone apex - if prefix == "@" { - prefix = "" + zoneName := soa.Hdr.Name + pfx := libdns.RelativeName(domain, zoneName) + if pfx == "@" { + pfx = "" } - return prefix, zone, nil + return pfx, zoneName, nil } } } diff --git a/pkg/ddns/ddns_test.go b/pkg/ddns/ddns_test.go index 70d7cb9c..dfb0c86f 100644 --- a/pkg/ddns/ddns_test.go +++ b/pkg/ddns/ddns_test.go @@ -2,7 +2,15 @@ package ddns import ( "context" + "net" + "strings" + "sync/atomic" "testing" + + "github.com/libdns/libdns" + "github.com/miekg/dns" + + "github.com/nezhahq/nezha/model" ) type testSt struct { @@ -12,6 +20,48 @@ type testSt struct { } func TestSplitDomainSOA(t *testing.T) { + handler := dns.NewServeMux() + 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 + 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: zoneName, 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) + }) + + 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", @@ -30,12 +80,12 @@ func TestSplitDomainSOA(t *testing.T) { }, } - ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{"1.1.1.1:53"}) + 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: %s", err) + 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) @@ -45,3 +95,191 @@ func TestSplitDomainSOA(t *testing.T) { } } } + +type MockSetter struct { + 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) { + m.SetCalled = true + m.Records = append(m.Records, records...) + return records, nil +} + +func (m *MockSetter) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + m.DelCalled = true + m.DeletedRecords = append(m.DeletedRecords, records...) + 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{}, + } + + err := provider.deleteDomainRecord(context.Background(), "sub", "example.com", "A") + if err != nil { + 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: "2001:db8::1", + }, + Setter: mockSetter, + } + + handler := dns.NewServeMux() + 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}, + 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) + }) + + 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") + } + + 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) + } +} + +func TestTransientSOAFailureAndRetry(t *testing.T) { + var attempts int64 + handler := dns.NewServeMux() + handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { + atomic.AddInt64(&attempts, 1) + 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() + + var maxRetries uint64 = 3 + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{ + MaxRetries: maxRetries, + Domains: []string{"example.com"}, + EnableIPv4: &[]bool{true}[0], + EnableIPv6: &[]bool{false}[0], + }, + IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"}, + Setter: &MockSetter{}, + } + + ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()}) + provider.UpdateDomain(ctx) + + actualAttempts := atomic.LoadInt64(&attempts) + expectedAttempts := int64(maxRetries) * 2 + if actualAttempts != expectedAttempts { + t.Fatalf("expected exact attempt count of %d, got %d", expectedAttempts, actualAttempts) + } +} + +func TestGenericRecordDeleterContract(t *testing.T) { + mockSetter := &MockSetter{} + provider := &Provider{ + DDNSProfile: &model.DDNSProfile{MaxRetries: 1}, + IPAddrs: &model.IP{IPv4Addr: ""}, + Setter: mockSetter, + } + + ctx := context.Background() + err := provider.deleteDomainRecord(ctx, "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) + } +}