Reapply SingboxForPanel integration on upstream stable
This commit is contained in:
332
common/dnspod/provider.go
Normal file
332
common/dnspod/provider.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,6 +18,7 @@ import (
|
||||
"github.com/libdns/acmedns"
|
||||
"github.com/libdns/alidns"
|
||||
"github.com/libdns/cloudflare"
|
||||
"github.com/libdns/tencentcloud"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
@@ -112,7 +114,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
|
||||
}
|
||||
if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" {
|
||||
var solver certmagic.DNS01Solver
|
||||
switch dnsOptions.Provider {
|
||||
switch C.NormalizeACMEDNSProvider(dnsOptions.Provider) {
|
||||
case C.DNSProviderAliDNS:
|
||||
solver.DNSProvider = &alidns.Provider{
|
||||
CredentialInfo: alidns.CredentialInfo{
|
||||
@@ -127,6 +129,17 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
|
||||
APIToken: dnsOptions.CloudflareOptions.APIToken,
|
||||
ZoneToken: dnsOptions.CloudflareOptions.ZoneToken,
|
||||
}
|
||||
case C.DNSProviderTencentCloud:
|
||||
solver.DNSProvider = &tencentcloud.Provider{
|
||||
SecretId: dnsOptions.TencentCloudOptions.SecretID,
|
||||
SecretKey: dnsOptions.TencentCloudOptions.SecretKey,
|
||||
SessionToken: dnsOptions.TencentCloudOptions.SessionToken,
|
||||
Region: dnsOptions.TencentCloudOptions.Region,
|
||||
}
|
||||
case C.DNSProviderDNSPod:
|
||||
solver.DNSProvider = &boxdnspod.Provider{
|
||||
APIToken: dnsOptions.DNSPodOptions.APIToken,
|
||||
}
|
||||
case C.DNSProviderACMEDNS:
|
||||
solver.DNSProvider = &acmedns.Provider{
|
||||
Username: dnsOptions.ACMEDNSOptions.Username,
|
||||
|
||||
Reference in New Issue
Block a user