333 lines
8.2 KiB
Go
333 lines
8.2 KiB
Go
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
|
|
}
|