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 }