This commit is contained in:
unknown
2026-02-04 20:27:13 +08:00
commit 3b042d1dad
9410 changed files with 1488147 additions and 0 deletions

View File

@@ -0,0 +1,537 @@
package dnsclients
import (
"crypto/md5"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
teaconst "github.com/TeaOSLab/EdgeAPI/internal/const"
"github.com/TeaOSLab/EdgeAPI/internal/dnsclients/dnstypes"
"github.com/TeaOSLab/EdgeAPI/internal/dnsclients/gname"
"github.com/TeaOSLab/EdgeAPI/internal/errors"
"github.com/iwind/TeaGo/maps"
"github.com/iwind/TeaGo/types"
)
const (
GnameDefaultRoute = "0" // 默认线路代码,根据文档为 "0"
GnameAPIEndpoint = "https://api.gname.com" // Gname API 基础地址
)
// gnameNormalizeRouteCode 将 Gname 列表接口返回的线路(可能是名称或代码)统一为 AddRecord 使用的代码,
// 避免 oldRecordsMap 的 keyroute@value与任务生成的 key 不一致导致重复添加、触发「相同主机记录及相同记录值」错误。
func gnameNormalizeRouteCode(xl string) string {
if xl == "" {
return GnameDefaultRoute
}
// Gname 列表可能返回线路名称或代码,与 GetRoutes 中的 Name/Code 对应
switch xl {
case "中国大陆":
return "asia-cn"
case "非中国大陆":
return "non-cn"
case "默认":
return "0"
case "搜索引擎":
return "seo"
case "搜索引擎-Google":
return "seo-google"
case "搜索引擎-Baidu":
return "seo-baidu"
case "搜索引擎-Bing":
return "seo-bing"
case "亚洲":
return "asia"
case "新加坡":
return "asia-sg"
case "欧洲":
return "eu"
case "美洲":
return "na-usa"
}
// 已是代码或未知值,原样返回
return xl
}
var gnameHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
// GnameProvider Gname DNS 提供商
// 参考文档https://www.gname.vip/domain/api/jiexi/add
type GnameProvider struct {
BaseProvider
ProviderId int64
// Gname API 认证信息
appid string // APPID (Application Client Id)
secret string // API Secret (用于签名)
}
// Auth 认证
func (this *GnameProvider) Auth(params maps.Map) error {
this.appid = params.GetString("appid")
if len(this.appid) == 0 {
// 兼容旧参数名
this.appid = params.GetString("apiKey")
}
if len(this.appid) == 0 {
return errors.New("'appid' or 'apiKey' should not be empty")
}
this.secret = params.GetString("secret")
if len(this.secret) == 0 {
// 兼容旧参数名
this.secret = params.GetString("apiSecret")
}
if len(this.secret) == 0 {
return errors.New("'secret' or 'apiSecret' should not be empty")
}
return nil
}
// MaskParams 对参数进行掩码
func (this *GnameProvider) MaskParams(params maps.Map) {
if params == nil {
return
}
if params.Has("secret") {
params["secret"] = MaskString(params.GetString("secret"))
}
if params.Has("apiSecret") {
params["apiSecret"] = MaskString(params.GetString("apiSecret"))
}
}
// GetDomains 获取所有域名列表
func (this *GnameProvider) GetDomains() (domains []string, err error) {
var resp = &gname.BaseResponse{}
err = this.doAPI(http.MethodPost, "/api/domain/list", map[string]string{}, resp)
if err != nil {
return
}
if resp.Code != 1 {
return nil, errors.New("Failed: " + resp.Msg)
}
// 解析响应数据 - 支持多种可能的数据格式
if resp.Data != nil {
// 方式1: 直接是字符串数组
if dataArray, ok := resp.Data.([]interface{}); ok {
for _, item := range dataArray {
switch v := item.(type) {
case string:
// 直接是域名字符串
if len(v) > 0 {
domains = append(domains, v)
}
case map[string]interface{}:
// 是对象,尝试多个可能的字段名
if domainName, ok := v["domain"].(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
} else if domainName, ok := v["name"].(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
} else if domainName, ok := v["ym"].(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
}
}
}
} else if dataMap, ok := resp.Data.(map[string]interface{}); ok {
// 方式2: 数据在某个字段中(如 data.list, data.domains 等)
if list, ok := dataMap["list"].([]interface{}); ok {
for _, item := range list {
if domainName, ok := item.(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
} else if itemMap, ok := item.(map[string]interface{}); ok {
if domainName, ok := itemMap["domain"].(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
} else if domainName, ok := itemMap["name"].(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
}
}
}
}
if domainsList, ok := dataMap["domains"].([]interface{}); ok {
for _, item := range domainsList {
if domainName, ok := item.(string); ok && len(domainName) > 0 {
domains = append(domains, domainName)
}
}
}
}
}
return
}
// GetRecords 获取域名解析记录列表
func (this *GnameProvider) GetRecords(domain string) (records []*dnstypes.Record, err error) {
var resp = &gname.BaseResponse{}
err = this.doAPI(http.MethodPost, "/api/resolution/list", map[string]string{
"ym": domain, // 域名
}, resp)
if err != nil {
return
}
if resp.Code != 1 {
return nil, errors.New("Failed: " + resp.Msg)
}
// 解析响应数据
if resp.Data != nil {
if dataList, ok := resp.Data.([]interface{}); ok {
for _, item := range dataList {
if itemMap, ok := item.(map[string]interface{}); ok {
record := &dnstypes.Record{}
if id, ok := itemMap["id"].(float64); ok {
record.Id = types.String(int64(id))
}
if zj, ok := itemMap["zj"].(string); ok {
record.Name = zj // 主机记录
}
if lx, ok := itemMap["lx"].(string); ok {
record.Type = lx // 记录类型
}
if jlz, ok := itemMap["jlz"].(string); ok {
record.Value = jlz // 记录值
}
if ttl, ok := itemMap["ttl"].(float64); ok {
record.TTL = int32(ttl)
}
if xl, ok := itemMap["xl"].(string); ok {
record.Route = gnameNormalizeRouteCode(xl) // 线路:统一为代码,与 AddRecord 一致
} else {
record.Route = GnameDefaultRoute
}
// 修正 CNAME 记录值
if record.Type == dnstypes.RecordTypeCNAME && !strings.HasSuffix(record.Value, ".") {
record.Value += "."
}
records = append(records, record)
}
}
}
}
return
}
// GetRoutes 读取域名支持的线路数据
func (this *GnameProvider) GetRoutes(domain string) (routes []*dnstypes.Route, err error) {
// 根据 Gname API 文档,线路列表
routes = []*dnstypes.Route{
{Name: "默认", Code: "0"},
{Name: "搜索引擎", Code: "seo"},
{Name: "搜索引擎-Google", Code: "seo-google"},
{Name: "搜索引擎-Baidu", Code: "seo-baidu"},
{Name: "搜索引擎-Bing", Code: "seo-bing"},
{Name: "亚洲", Code: "asia"},
{Name: "中国大陆", Code: "asia-cn"},
{Name: "非中国大陆", Code: "non-cn"},
{Name: "新加坡", Code: "asia-sg"},
{Name: "欧洲", Code: "eu"},
{Name: "美洲", Code: "na-usa"},
}
return
}
// QueryRecord 查询单个记录
func (this *GnameProvider) QueryRecord(domain string, name string, recordType dnstypes.RecordType) (*dnstypes.Record, error) {
records, err := this.QueryRecords(domain, name, recordType)
if err != nil {
return nil, err
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// QueryRecords 查询多个记录
func (this *GnameProvider) QueryRecords(domain string, name string, recordType dnstypes.RecordType) ([]*dnstypes.Record, error) {
// 先获取所有记录,然后过滤
allRecords, err := this.GetRecords(domain)
if err != nil {
return nil, err
}
var result = []*dnstypes.Record{}
for _, record := range allRecords {
if record.Name == name && record.Type == recordType {
result = append(result, record)
}
}
return result, nil
}
// AddRecord 设置记录
// 参考文档https://www.gname.vip/domain/api/jiexi/add
func (this *GnameProvider) AddRecord(domain string, newRecord *dnstypes.Record) error {
// 修正 CNAME 记录
if newRecord.Type == dnstypes.RecordTypeCNAME && !strings.HasSuffix(newRecord.Value, ".") {
newRecord.Value += "."
}
// 设置默认 TTL
var ttl = newRecord.TTL
if ttl <= 0 {
ttl = 600
}
// 使用 Gname API 文档中的参数名
params := map[string]string{
"ym": domain, // 域名
"lx": newRecord.Type, // 记录类型A,CNAME,MX,URL,TXT
"zj": newRecord.Name, // 主机记录
"jlz": newRecord.Value, // 记录值
"ttl": types.String(ttl), // TTL值
"xl": GnameDefaultRoute, // 线路,默认使用 "0"
}
// 如果有线路支持
if len(newRecord.Route) > 0 && newRecord.Route != this.DefaultRoute() {
params["xl"] = newRecord.Route
}
// MX 记录需要 MX 值
if newRecord.Type == "MX" {
params["mx"] = "10" // 默认 MX 优先级
}
var resp = &gname.BaseResponse{}
err := this.doAPI(http.MethodPost, "/api/resolution/add", params, resp)
if err != nil {
return err
}
// 检查响应状态code=1 表示成功,-1 表示失败
if resp.Code != 1 {
return errors.New("Failed: " + resp.Msg)
}
// 如果返回了记录ID可以保存到 newRecord.Id
if resp.Data != nil {
if id, ok := resp.Data.(float64); ok {
newRecord.Id = types.String(int64(id))
} else if idStr, ok := resp.Data.(string); ok {
newRecord.Id = idStr
}
}
return nil
}
// UpdateRecord 修改记录
func (this *GnameProvider) UpdateRecord(domain string, record *dnstypes.Record, newRecord *dnstypes.Record) error {
// 修正 CNAME 记录
if newRecord.Type == dnstypes.RecordTypeCNAME && !strings.HasSuffix(newRecord.Value, ".") {
newRecord.Value += "."
}
// 设置默认 TTL
var ttl = newRecord.TTL
if ttl <= 0 {
ttl = 600
}
// 使用 Gname API 文档中的参数名
params := map[string]string{
"ym": domain, // 域名
"id": record.Id, // 记录ID
"lx": newRecord.Type, // 记录类型
"zj": newRecord.Name, // 主机记录
"jlz": newRecord.Value, // 记录值
"ttl": types.String(ttl), // TTL值
"xl": GnameDefaultRoute, // 线路
}
// 如果有线路支持
if len(newRecord.Route) > 0 && newRecord.Route != this.DefaultRoute() {
params["xl"] = newRecord.Route
}
// MX 记录需要 MX 值
if newRecord.Type == "MX" {
params["mx"] = "10" // 默认 MX 优先级
}
var resp = &gname.BaseResponse{}
err := this.doAPI(http.MethodPost, "/api/resolution/modify", params, resp)
if err != nil {
return err
}
// 检查响应状态code=1 表示成功,-1 表示失败
if resp.Code != 1 {
return errors.New("Failed: " + resp.Msg)
}
return nil
}
// DeleteRecord 删除记录
func (this *GnameProvider) DeleteRecord(domain string, record *dnstypes.Record) error {
params := map[string]string{
"ym": domain, // 域名
"id": record.Id, // 记录ID
}
var resp = &gname.BaseResponse{}
err := this.doAPI(http.MethodPost, "/api/resolution/delete", params, resp)
if err != nil {
return err
}
// 检查响应状态code=1 表示成功,-1 表示失败
if resp.Code != 1 {
return errors.New("Failed: " + resp.Msg)
}
return nil
}
// DefaultRoute 默认线路
func (this *GnameProvider) DefaultRoute() string {
return "0" // 根据文档,默认线路代码为 "0"
}
// doAPI 发送 API 请求
// 根据 Gname API 文档,所有请求都需要包含 appid, gntime, gntoken 参数
func (this *GnameProvider) doAPI(method string, apiPath string, params map[string]string, respPtr interface{}) error {
apiURL := GnameAPIEndpoint + apiPath
method = strings.ToUpper(method)
// 添加公共参数
if params == nil {
params = map[string]string{}
}
params["appid"] = this.appid
params["gntime"] = types.String(time.Now().Unix())
// 生成签名
params["gntoken"] = this.generateSignature(params)
// 构建请求体POST 方式)
var bodyReader io.Reader = nil
if method == http.MethodPost {
var formData = url.Values{}
for k, v := range params {
formData.Set(k, v)
}
bodyReader = strings.NewReader(formData.Encode())
} else {
// GET 方式使用查询参数
var query = url.Values{}
for k, v := range params {
query.Set(k, v)
}
apiURL += "?" + query.Encode()
}
req, err := http.NewRequest(method, apiURL, bodyReader)
if err != nil {
return err
}
// 设置请求头
req.Header.Set("User-Agent", teaconst.ProductName+"/"+teaconst.Version)
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := gnameHTTPClient.Do(req)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// 检查 HTTP 状态码
if resp.StatusCode == 0 {
return errors.New("invalid response status '" + strconv.Itoa(resp.StatusCode) + "', response '" + string(data) + "'")
}
if resp.StatusCode != http.StatusOK {
return errors.New("response error: " + string(data))
}
// 解析响应
if respPtr != nil {
err = json.Unmarshal(data, respPtr)
if err != nil {
return fmt.Errorf("decode json failed: %w: %s", err, string(data))
}
}
return nil
}
// generateSignature 生成签名
// 根据 Gname API 文档的签名方法:
// 1. 将所有参数(除了 gntoken按字母顺序排序
// 2. 拼接成字符串key1=value1&key2=value2&...
// 3. 加上密钥:字符串 + secret
// 4. MD5 加密并转大写
func (this *GnameProvider) generateSignature(params map[string]string) string {
// 复制参数(排除 gntoken
var signParams = map[string]string{}
for k, v := range params {
if k != "gntoken" {
signParams[k] = v
}
}
// 按字母顺序排序
var keys = []string{}
for k := range signParams {
keys = append(keys, k)
}
sort.Strings(keys)
// 拼接字符串
var source strings.Builder
for i, key := range keys {
if i > 0 {
source.WriteString("&")
}
source.WriteString(key)
source.WriteString("=")
source.WriteString(url.QueryEscape(signParams[key]))
}
// 加上密钥
source.WriteString(this.secret)
// MD5 加密并转大写
md := md5.Sum([]byte(source.String()))
return strings.ToUpper(hex.EncodeToString(md[:]))
}