Scert/backend/internal/dnsprovider/aliyun_authorization.go

371 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 dnsprovider
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/go-acme/alidns-20150109/v5/client"
)
const (
aliyunDefaultRegion = "cn-hangzhou"
aliyunAuthorizationTTL = 600
aliyunAuthorizationTimeout = 30 * time.Second
)
// AuthorizationDomain 描述一个阿里云 DNS 根域名的授权探测结果。
type AuthorizationDomain struct {
Domain string `json:"domain"`
Listed bool `json:"listed"`
Writable bool `json:"writable"`
Cleanup bool `json:"cleanup"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// AuthorizationReport 区分 API 可见范围和经过实际写入证明的范围。
type AuthorizationReport struct {
Provider string `json:"provider"`
RequestedDomain string `json:"requested_domain,omitempty"`
MatchedDomain string `json:"matched_domain,omitempty"`
Domains []AuthorizationDomain `json:"domains"`
Verified bool `json:"verified"`
Diagnosis string `json:"diagnosis,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
// AliyunAuthorizationChecker 负责发现并证明 RAM 账号对阿里云 DNS 根域名的实际写权限。
type AliyunAuthorizationChecker struct {
now func() time.Time
verifyDomainFunc func(context.Context, string, map[string]string) error
}
func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker {
return &AliyunAuthorizationChecker{now: time.Now}
}
func (c *AliyunAuthorizationChecker) Supports(providerID string) bool {
return strings.EqualFold(strings.TrimSpace(providerID), Aliyun)
}
// Discover 只读取阿里云账号可见的根域名,不把读取权限误判为写入权限。
func (c *AliyunAuthorizationChecker) Discover(ctx context.Context, credentials map[string]string) (AuthorizationReport, error) {
report := AuthorizationReport{Provider: Aliyun, CheckedAt: c.currentTime().UTC()}
client, err := newAliyunClient(credentials)
if err != nil {
return report, err
}
domains, err := listAliyunDomains(ctx, client)
if err != nil {
return report, fmt.Errorf("读取阿里云 DNS 授权域名失败:%w", err)
}
for _, name := range domains {
report.Domains = append(report.Domains, AuthorizationDomain{
Domain: name, Listed: true, Status: "可见但尚未证明可写",
})
}
return report, nil
}
// Inspect 只查询显式指定托管区的读取权限,不逐级探测父域、不写入 DNS,也不证明可写。
func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
requestedDomain = strings.TrimPrefix(normalizeAuthorizationDomain(requestedDomain), "*.")
report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
if !domain.IsValidName(requestedDomain) {
return report, fmt.Errorf("待检查托管区格式无效:%s", requestedDomain)
}
report.Domains = []AuthorizationDomain{{Domain: requestedDomain, Status: "等待读取权限检查"}}
dnsClient, err := newAliyunClient(credentials)
if err != nil {
return report, err
}
request := new(client.DescribeDomainRecordsRequest).SetDomainName(requestedDomain).SetPageNumber(1).SetPageSize(1)
response, err := client.DescribeDomainRecordsWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
if err != nil {
report.Domains[0].Status = "读取未通过,可写性未验证"
report.Domains[0].Error = err.Error()
report.Diagnosis = DiagnoseAliyunError(err, requestedDomain)
return report, fmt.Errorf("阿里云域名读取检查失败:%w", err)
}
if response == nil || response.Body == nil {
return report, errors.New("阿里云未返回有效的域名读取结果")
}
report.Domains[0].Listed = true
report.Domains[0].Status = "可读取,未验证写入和清理权限"
// 读取成功必须保持 Verified=false,也不能写入授权缓存。
return report, nil
}
// Verify 从目标域名到父级托管区逐级执行最小写入和精确清理。
func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
if !domain.IsValidName(requestedDomain) {
return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
}
for _, candidate := range authorizationZoneCandidates(requestedDomain) {
report.Domains = append(report.Domains, AuthorizationDomain{
Domain: candidate, Status: "待验证",
})
}
client, err := newAliyunClient(credentials)
if err != nil {
return report, err
}
var lastErr error
for index, candidate := range authorizationZoneCandidates(requestedDomain) {
report.Domains[index].Status = "正在证明写入权限"
recordID, probeErr := addAuthorizationRecord(ctx, client, candidate)
if probeErr != nil {
lastErr = probeErr
report.Domains[index].Status = "不可操作"
report.Domains[index].Error = probeErr.Error()
continue
}
report.Domains[index].Writable = true
report.MatchedDomain = candidate
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
cleanupErr := deleteAuthorizationRecord(cleanupContext, client, recordID)
cancel()
if cleanupErr != nil {
markAuthorizationFailure(&report, candidate, fmt.Errorf("清理授权探测 TXT 记录失败:%w", cleanupErr))
return report, cleanupErr
}
report.Domains[index].Cleanup = true
report.Domains[index].Status = "可操作"
report.Verified = true
return report, nil
}
if lastErr == nil {
lastErr = errors.New("未找到可验证的阿里云 DNS 托管根域名")
}
report.Diagnosis = DiagnoseAliyunError(lastErr, requestedDomain)
return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis)
}
// VerifyDomain 每次重新证明写入和清理能力,不使用跨请求缓存掩盖撤权。
func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error {
if !c.Supports(providerID) {
return nil
}
if c == nil {
return errors.New("阿里云权限探测器未初始化")
}
if err := ctx.Err(); err != nil {
return err
}
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
if !domain.IsValidName(requestedDomain) {
return fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
}
if _, err := ValidateCredentials(Aliyun, credentials); err != nil {
return err
}
if c.verifyDomainFunc != nil {
return c.verifyDomainFunc(ctx, requestedDomain, credentials)
}
report, err := c.Verify(ctx, requestedDomain, credentials)
if err != nil {
return err
}
if !report.Verified {
return errors.New("阿里云 DNS 域名授权未通过")
}
return nil
}
// InvalidateDomain 保留旧调用方接口;授权检查已不再缓存,无需额外失效操作。
func (c *AliyunAuthorizationChecker) InvalidateDomain(providerID, requestedDomain string, credentials map[string]string) {
}
func (c *AliyunAuthorizationChecker) currentTime() time.Time {
if c != nil && c.now != nil {
return c.now()
}
return time.Now()
}
func newAliyunClient(credentials map[string]string) (*client.Client, error) {
cleaned, err := ValidateCredentials(Aliyun, credentials)
if err != nil {
return nil, err
}
config := new(openapi.Config).SetRegionId(aliyunDefaultRegion).
SetAccessKeyId(cleaned["access_key_id"]).
SetAccessKeySecret(cleaned["access_key_secret"])
if token := cleaned["security_token"]; token != "" {
config.SetSecurityToken(token)
}
result, err := client.NewClient(config)
if err != nil {
return nil, fmt.Errorf("创建阿里云 DNS 客户端失败:%w", err)
}
return result, nil
}
func listAliyunDomains(ctx context.Context, dnsClient *client.Client) ([]string, error) {
if dnsClient == nil {
return nil, errors.New("阿里云 DNS 客户端未初始化")
}
var result []string
for page := int64(1); ; page++ {
request := new(client.DescribeDomainsRequest).SetPageNumber(page).SetPageSize(100)
response, err := client.DescribeDomainsWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
if err != nil {
return nil, err
}
if response == nil || response.Body == nil {
return nil, errors.New("阿里云 DNS 返回空的域名列表")
}
if response.Body.Domains != nil {
for _, item := range response.Body.Domains.Domain {
if item == nil || item.DomainName == nil {
continue
}
name := normalizeAuthorizationDomain(*item.DomainName)
if name != "" {
result = append(result, name)
}
}
}
pageSize := dereferenceInt64(response.Body.PageSize)
total := dereferenceInt64(response.Body.TotalCount)
if pageSize <= 0 || page*pageSize >= total {
break
}
}
sort.Strings(result)
return uniqueStrings(result), nil
}
func addAuthorizationRecord(ctx context.Context, dnsClient *client.Client, zone string) (string, error) {
suffix, err := randomAuthorizationValue(18)
if err != nil {
return "", fmt.Errorf("生成授权探测标识失败:%w", err)
}
value, err := randomAuthorizationValue(32)
if err != nil {
return "", fmt.Errorf("生成授权探测值失败:%w", err)
}
request := new(client.AddDomainRecordRequest).
SetDomainName(zone).
SetRR("_scert-authorization-" + suffix).
SetType("TXT").
SetValue(value).
SetTTL(aliyunAuthorizationTTL)
response, err := client.AddDomainRecordWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
if err != nil {
return "", err
}
if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" {
return "", errors.New("阿里云未返回授权探测记录 ID")
}
return strings.TrimSpace(*response.Body.RecordId), nil
}
func deleteAuthorizationRecord(ctx context.Context, dnsClient *client.Client, recordID string) error {
if strings.TrimSpace(recordID) == "" {
return errors.New("授权探测记录 ID 为空")
}
_, err := client.DeleteDomainRecordWithContext(ctx, dnsClient, new(client.DeleteDomainRecordRequest).SetRecordId(recordID), &dara.RuntimeOptions{})
return err
}
func matchManagedDomain(requested string, domains []AuthorizationDomain) string {
requested = normalizeAuthorizationDomain(requested)
best := ""
for _, item := range domains {
candidate := normalizeAuthorizationDomain(item.Domain)
if candidate == "" || (requested != candidate && !strings.HasSuffix(requested, "."+candidate)) {
continue
}
if len(candidate) > len(best) {
best = candidate
}
}
return best
}
func authorizationZoneCandidates(requested string) []string {
requested = strings.TrimPrefix(normalizeAuthorizationDomain(requested), "*.")
labels := strings.Split(requested, ".")
result := make([]string, 0, len(labels)-1)
for index := 0; index < len(labels)-1; index++ {
candidate := strings.Join(labels[index:], ".")
if domain.IsValidName(candidate) {
result = append(result, candidate)
}
}
return result
}
func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) {
for index := range report.Domains {
if report.Domains[index].Domain == domainName {
report.Domains[index].Status = "不可操作"
report.Domains[index].Error = err.Error()
}
}
}
func normalizeAuthorizationDomain(value string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), "."))
}
func randomAuthorizationValue(size int) (string, error) {
data := make([]byte, size)
if _, err := rand.Read(data); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func dereferenceInt64(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func uniqueStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
// DiagnoseAliyunError 对阿里云 API 返回的错误进行分类并提供明确的修复指导。
func DiagnoseAliyunError(err error, domainName string) string {
if err == nil {
return ""
}
msg := err.Error()
lowered := strings.ToLower(msg)
if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "forbidden.ram") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") {
return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/%s 的操作权限(Action: alidns:*)", domainName, domainName)
}
if strings.Contains(lowered, "nosuchdomain") || strings.Contains(lowered, "invaliddomainname.noexist") {
return fmt.Sprintf("域名 %s 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名", domainName)
}
if strings.Contains(lowered, "invalidaccesskeyid.notfound") || strings.Contains(lowered, "signaturedoesnotmatch") {
return "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置"
}
return fmt.Sprintf("阿里云 RAM 权限验证未通过:%s", msg)
}