From 13cbccafcb5ffa92ced1c7f54bf118e9d05c4c79 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Wed, 15 Apr 2026 19:29:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=AF=91=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/dnspod/provider.go | 332 ++++++++++++++++++++++++++++++++++++++ common/tls/acme.go | 4 +- go.mod | 1 - go.sum | 5 + service/acme/service.go | 4 +- 5 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 common/dnspod/provider.go diff --git a/common/dnspod/provider.go b/common/dnspod/provider.go new file mode 100644 index 00000000..7524a16d --- /dev/null +++ b/common/dnspod/provider.go @@ -0,0 +1,332 @@ +package dnspod + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/libdns/libdns" + E "github.com/sagernet/sing/common/exceptions" +) + +const defaultAPIEndpoint = "https://dnsapi.cn" + +type Provider struct { + APIToken string + APIEndpoint string + HTTPClient *http.Client +} + +type apiStatus struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type apiRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + TTL string `json:"ttl"` + Value string `json:"value"` +} + +type createRecordResponse struct { + Status apiStatus `json:"status"` + Record struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"record"` +} + +type listRecordsResponse struct { + Status apiStatus `json:"status"` + Records []apiRecord `json:"records"` +} + +func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { + records, err := p.listRecords(ctx, normalizeZone(zone), "", "") + if err != nil { + return nil, err + } + results := make([]libdns.Record, 0, len(records)) + for _, record := range records { + results = append(results, record.toLibdnsRecord()) + } + return results, nil +} + +func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + zone = normalizeZone(zone) + if zone == "" { + return nil, E.New("DNSPod zone is empty") + } + if strings.TrimSpace(p.APIToken) == "" { + return nil, E.New("DNSPod API token is empty") + } + created := make([]libdns.Record, 0, len(records)) + for _, record := range records { + requestRecord, err := normalizeInputRecord(record) + if err != nil { + return created, err + } + params := url.Values{ + "domain": []string{zone}, + "sub_domain": []string{requestRecord.Name}, + "record_type": []string{requestRecord.Type}, + "record_line": []string{"默认"}, + "value": []string{requestRecord.Value}, + } + if requestRecord.TTL > 0 { + params.Set("ttl", strconv.FormatInt(int64(requestRecord.TTL/time.Second), 10)) + } + var response createRecordResponse + err = p.doForm(ctx, "Record.Create", params, &response) + if err != nil { + if requestRecord.Type == "TXT" && strings.Contains(err.Error(), "104") { + existing, listErr := p.listRecords(ctx, zone, requestRecord.Name, requestRecord.Type) + if listErr != nil { + return created, err + } + for _, candidate := range existing { + if requestRecord.matches(candidate) { + created = append(created, candidate.toLibdnsRecord()) + err = nil + break + } + } + } + if err != nil { + return created, err + } + continue + } + requestRecord.ID = response.Record.ID + created = append(created, requestRecord.toLibdnsRecord()) + } + return created, nil +} + +func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) { + zone = normalizeZone(zone) + if zone == "" { + return nil, E.New("DNSPod zone is empty") + } + if strings.TrimSpace(p.APIToken) == "" { + return nil, E.New("DNSPod API token is empty") + } + deleted := make([]libdns.Record, 0, len(records)) + for _, record := range records { + requestRecord, err := normalizeInputRecord(record) + if err != nil { + return deleted, err + } + candidates, err := p.listRecords(ctx, zone, requestRecord.Name, requestRecord.Type) + if err != nil { + return deleted, err + } + for _, candidate := range candidates { + if !requestRecord.matches(candidate) { + continue + } + err = p.doForm(ctx, "Record.Remove", url.Values{ + "domain": []string{zone}, + "record_id": []string{candidate.ID}, + }, nil) + if err != nil { + return deleted, err + } + deleted = append(deleted, candidate.toLibdnsRecord()) + } + } + return deleted, nil +} + +func (p *Provider) listRecords(ctx context.Context, zone, subDomain, recordType string) ([]apiRecord, error) { + params := url.Values{ + "domain": []string{zone}, + } + if subDomain != "" { + params.Set("sub_domain", subDomain) + } + if recordType != "" { + params.Set("record_type", recordType) + } + var response listRecordsResponse + err := p.doForm(ctx, "Record.List", params, &response) + if err != nil { + return nil, err + } + return response.Records, nil +} + +func (p *Provider) doForm(ctx context.Context, action string, params url.Values, target any) error { + endpoint := strings.TrimRight(strings.TrimSpace(p.APIEndpoint), "/") + if endpoint == "" { + endpoint = defaultAPIEndpoint + } + body := url.Values{ + "login_token": []string{strings.TrimSpace(p.APIToken)}, + "format": []string{"json"}, + } + for key, values := range params { + for _, value := range values { + body.Add(key, value) + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/"+action, strings.NewReader(body.Encode())) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := p.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return E.New("DNSPod ", action, " failed: HTTP ", response.StatusCode) + } + data, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if target == nil { + var wrapper struct { + Status apiStatus `json:"status"` + } + if err = json.Unmarshal(data, &wrapper); err != nil { + return err + } + if wrapper.Status.Code != "1" { + return E.New("DNSPod ", action, " failed: ", wrapper.Status.Code, " ", strings.TrimSpace(wrapper.Status.Message)) + } + return nil + } + if err = json.Unmarshal(data, target); err != nil { + return err + } + switch result := target.(type) { + case *createRecordResponse: + if result.Status.Code != "1" { + return E.New("DNSPod ", action, " failed: ", result.Status.Code, " ", strings.TrimSpace(result.Status.Message)) + } + case *listRecordsResponse: + if result.Status.Code != "1" { + return E.New("DNSPod ", action, " failed: ", result.Status.Code, " ", strings.TrimSpace(result.Status.Message)) + } + } + return nil +} + +func normalizeZone(zone string) string { + return strings.TrimSuffix(strings.TrimSpace(zone), ".") +} + +func normalizeRecordName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "@" + } + return strings.TrimSuffix(name, ".") +} + +func normalizeRecordValue(record libdns.Record) string { + switch typed := record.(type) { + case libdns.TXT: + return typed.Text + case *libdns.TXT: + return typed.Text + default: + return record.RR().Data + } +} + +type normalizedRecord struct { + ID string + Name string + Type string + TTL time.Duration + Value string +} + +func normalizeInputRecord(record libdns.Record) (normalizedRecord, error) { + rr := record.RR() + name := normalizeRecordName(rr.Name) + if name == "" { + return normalizedRecord{}, E.New("DNSPod record name is empty") + } + recordType := strings.ToUpper(strings.TrimSpace(rr.Type)) + if recordType == "" { + return normalizedRecord{}, E.New("DNSPod record type is empty") + } + return normalizedRecord{ + Name: name, + Type: recordType, + TTL: rr.TTL, + Value: normalizeRecordValue(record), + }, nil +} + +func (r normalizedRecord) matches(candidate apiRecord) bool { + if normalizeRecordName(candidate.Name) != r.Name { + return false + } + if r.Type != "" && !strings.EqualFold(candidate.Type, r.Type) { + return false + } + if r.Value != "" && candidate.Value != r.Value { + return false + } + if r.TTL > 0 { + candidateTTL, _ := strconv.ParseInt(candidate.TTL, 10, 64) + if time.Duration(candidateTTL)*time.Second != r.TTL { + return false + } + } + return true +} + +func (r normalizedRecord) toLibdnsRecord() libdns.Record { + record := apiRecord{ + ID: r.ID, + Name: r.Name, + Type: r.Type, + TTL: strconv.FormatInt(int64(r.TTL/time.Second), 10), + Value: r.Value, + } + return record.toLibdnsRecord() +} + +func (r apiRecord) toLibdnsRecord() libdns.Record { + ttlSeconds, _ := strconv.ParseInt(strings.TrimSpace(r.TTL), 10, 64) + ttl := time.Duration(ttlSeconds) * time.Second + name := normalizeRecordName(r.Name) + recordType := strings.ToUpper(strings.TrimSpace(r.Type)) + if recordType == "TXT" { + return libdns.TXT{ + Name: name, + TTL: ttl, + Text: r.Value, + } + } + rr := libdns.RR{ + Name: name, + TTL: ttl, + Type: recordType, + Data: r.Value, + } + parsed, err := rr.Parse() + if err != nil { + return rr + } + return parsed +} diff --git a/common/tls/acme.go b/common/tls/acme.go index 02e87e5f..ebc8a470 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + boxdnspod "github.com/sagernet/sing-box/common/dnspod" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" @@ -17,7 +18,6 @@ import ( "github.com/libdns/acmedns" "github.com/libdns/alidns" "github.com/libdns/cloudflare" - "github.com/libdns/dnspod" "github.com/libdns/tencentcloud" "github.com/mholt/acmez/v3/acme" "go.uber.org/zap" @@ -106,7 +106,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound Region: dnsOptions.TencentCloudOptions.Region, } case C.DNSProviderDNSPod: - solver.DNSProvider = &dnspod.Provider{ + solver.DNSProvider = &boxdnspod.Provider{ APIToken: dnsOptions.DNSPodOptions.APIToken, } case C.DNSProviderACMEDNS: diff --git a/go.mod b/go.mod index 3b54027a..84e4dc24 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 - github.com/libdns/dnspod v0.0.3 github.com/libdns/libdns v1.1.1 github.com/libdns/tencentcloud v1.4.3 github.com/logrusorgru/aurora v2.0.3+incompatible diff --git a/go.sum b/go.sum index 9f224685..8660e4d2 100644 --- a/go.sum +++ b/go.sum @@ -124,8 +124,12 @@ github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= github.com/libdns/alidns v1.0.6/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/dnspod v0.0.3/go.mod h1:XLnqMmK7QlLPEbHwcOxbRvlzRvDgaaUlthRNFOPjXPI= +github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/tencentcloud v1.4.3 h1:xJHYLL1TdPeOtUr6Bu6dHTd1TU6/VFm7BFc2EAzAlvc= +github.com/libdns/tencentcloud v1.4.3/go.mod h1:Be9gY3tDa12DuAPU79RV9NZIcjY6qg5s7zKPsP26yAM= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= @@ -142,6 +146,7 @@ github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= diff --git a/service/acme/service.go b/service/acme/service.go index fd346e46..1df13f3f 100644 --- a/service/acme/service.go +++ b/service/acme/service.go @@ -17,6 +17,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/certificate" + boxdnspod "github.com/sagernet/sing-box/common/dnspod" "github.com/sagernet/sing-box/common/dialer" boxtls "github.com/sagernet/sing-box/common/tls" C "github.com/sagernet/sing-box/constant" @@ -30,7 +31,6 @@ import ( "github.com/caddyserver/zerossl" "github.com/libdns/alidns" "github.com/libdns/cloudflare" - "github.com/libdns/dnspod" "github.com/libdns/libdns" "github.com/libdns/tencentcloud" "github.com/mholt/acmez/v3/acme" @@ -250,7 +250,7 @@ func newDNSSolver(dnsOptions *option.ACMEProviderDNS01ChallengeOptions, logger * Region: dnsOptions.TencentCloudOptions.Region, } case C.DNSProviderDNSPod: - solver.DNSProvider = &dnspod.Provider{ + solver.DNSProvider = &boxdnspod.Provider{ APIToken: dnsOptions.DNSPodOptions.APIToken, } case C.DNSProviderACMEDNS: