538 lines
14 KiB
Go
538 lines
14 KiB
Go
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 的 key(route@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[:]))
|
||
}
|