Reapply SingboxForPanel integration on upstream stable

This commit is contained in:
CN-JS-HuiBai
2026-04-16 10:29:41 +08:00
parent d5adb54bc6
commit 66c252d6ef
29 changed files with 5280 additions and 41 deletions

332
common/dnspod/provider.go Normal file
View 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
}

View File

@@ -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,