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