Files
waf-platform/EdgeAPI/internal/dnsclients/provider_gname.go
2026-02-04 20:27:13 +08:00

538 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[:]))
}