推进阿里云授权边界与验证加固
This commit is contained in:
parent
6e7720b53d
commit
e9c31312d4
@ -128,6 +128,10 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro
|
||||
if providerID == "" || strings.TrimSpace(s.config.DNS.CredentialRef) == "" {
|
||||
return IssueResult{}, errors.New("DNS Provider 或凭据引用未配置")
|
||||
}
|
||||
// 必须在读取凭据、构造 Provider 或访问 ACME 前拒绝受保护的任意 SAN。
|
||||
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
|
||||
return IssueResult{}, err
|
||||
}
|
||||
if configured := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider)); configured != "" && providerID != configured {
|
||||
return IssueResult{}, fmt.Errorf("域名配置的 DNS Provider %s 尚未绑定独立凭据,当前初始化凭据属于 %s", providerID, configured)
|
||||
}
|
||||
@ -214,6 +218,10 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
|
||||
if providerID == "" || credentialRef == "" {
|
||||
return SavedResource{}, errors.New("初始化证书的 DNS Provider 或凭据引用未配置")
|
||||
}
|
||||
// 初始化流程同样必须先拒绝整单保护 SAN,不能触碰凭据或云端。
|
||||
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
environment := strings.ToLower(strings.TrimSpace(request.Environment))
|
||||
if environment == "" {
|
||||
environment = s.config.ACME.Environment
|
||||
@ -273,7 +281,7 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
|
||||
|
||||
func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error {
|
||||
// 必须先校验全部 SAN;后续域名越界时也不能先写入前面的域名。
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(providerID, domains, credentials); err != nil {
|
||||
if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.authorization == nil {
|
||||
|
||||
@ -20,20 +20,16 @@ type dnsPermissionVerifier func(context.Context, string, map[string]string) (dns
|
||||
func newDNSCommand() *cobra.Command {
|
||||
command := &cobra.Command{Use: "dns", Short: "检查指定域名的阿里云 RAM 权限"}
|
||||
checker := dnsprovider.NewAliyunAuthorizationChecker()
|
||||
command.AddCommand(newDNSCheckCommand(checker.Verify), newDNSInspectCommand(checker.Inspect))
|
||||
command.AddCommand(newDNSCheckCommand(checker.Verify))
|
||||
return command
|
||||
}
|
||||
|
||||
func newDNSInspectCommand(inspect dnsPermissionVerifier) *cobra.Command {
|
||||
return newDNSPermissionCommand(inspect, true)
|
||||
}
|
||||
|
||||
// 权限检查不启动应用、续期调度或数据库,也不调用全局域名发现。
|
||||
func newDNSCheckCommand(verify dnsPermissionVerifier) *cobra.Command {
|
||||
return newDNSPermissionCommand(verify, false)
|
||||
return newDNSPermissionCommand(verify)
|
||||
}
|
||||
|
||||
func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra.Command {
|
||||
func newDNSPermissionCommand(verify dnsPermissionVerifier) *cobra.Command {
|
||||
var requestedDomain string
|
||||
var confirmWrite bool
|
||||
command := &cobra.Command{
|
||||
@ -45,7 +41,7 @@ func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return errors.New("必须提供有效的待检查域名")
|
||||
}
|
||||
if !readOnly && !confirmWrite {
|
||||
if !confirmWrite {
|
||||
return errors.New("此检查会写入并删除专用临时 TXT 记录;确认已获目标域名测试授权后,请添加 --confirm-dns-write")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
@ -70,10 +66,8 @@ func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取阿里云 DNS 凭据失败:%w", err)
|
||||
}
|
||||
if !readOnly {
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(dnsprovider.Aliyun, []string{requestedDomain}, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnsprovider.ValidateAliyunTargetDomains(dnsprovider.Aliyun, []string{requestedDomain}); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), dnsprovider.PermissionVerificationTimeoutFor(dnsprovider.Aliyun))
|
||||
defer cancel()
|
||||
@ -84,19 +78,14 @@ func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("域名权限检查未通过:%w", verifyErr)
|
||||
}
|
||||
if !readOnly && !report.Verified {
|
||||
if !report.Verified {
|
||||
return errors.New("域名权限检查未通过:未获得写入及清理均成功的证据")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&requestedDomain, "domain", "", "明确授权检查的单个域名,不枚举全账号域名")
|
||||
if readOnly {
|
||||
command.Use = "inspect"
|
||||
command.Short = "只读检查指定托管区,不写入 DNS、不证明可写"
|
||||
} else {
|
||||
command.Flags().BoolVar(&confirmWrite, "confirm-dns-write", false, "确认允许专用临时 TXT 记录写入和精确清理")
|
||||
}
|
||||
command.Flags().BoolVar(&confirmWrite, "confirm-dns-write", false, "确认允许专用临时 TXT 记录写入和精确清理")
|
||||
_ = command.MarkFlagRequired("domain")
|
||||
return command
|
||||
}
|
||||
|
||||
@ -143,8 +143,8 @@ func Default(baseDir string) Config {
|
||||
RenewalUrgentThresholdDays: 3,
|
||||
RenewalMaxRetries: 3,
|
||||
RenewalRetryIntervalMins: 30,
|
||||
PropagationTimeoutSecs: 900,
|
||||
PropagationPollingIntervalSecs: 10,
|
||||
PropagationTimeoutSecs: 300,
|
||||
PropagationPollingIntervalSecs: 5,
|
||||
},
|
||||
DNS: DNSConfig{VerificationState: "not_configured"},
|
||||
Notification: NotificationConfig{
|
||||
@ -336,7 +336,7 @@ func (c *Config) Normalize() {
|
||||
c.DNS.VerificationState = "not_configured"
|
||||
}
|
||||
if c.ACME.PropagationTimeoutSecs == 0 {
|
||||
c.ACME.PropagationTimeoutSecs = 900
|
||||
c.ACME.PropagationTimeoutSecs = 300
|
||||
}
|
||||
if c.ACME.RenewalCheckIntervalDays == 0 {
|
||||
c.ACME.RenewalCheckIntervalDays = 3
|
||||
@ -357,7 +357,7 @@ func (c *Config) Normalize() {
|
||||
c.ACME.RenewalRetryIntervalMins = 30
|
||||
}
|
||||
if c.ACME.PropagationPollingIntervalSecs == 0 {
|
||||
c.ACME.PropagationPollingIntervalSecs = 10
|
||||
c.ACME.PropagationPollingIntervalSecs = 5
|
||||
}
|
||||
if c.Notification.HTTPPath == "" {
|
||||
c.Notification.HTTPPath = "/api/email/send"
|
||||
|
||||
@ -6,11 +6,9 @@ import (
|
||||
"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"
|
||||
@ -46,7 +44,7 @@ type AuthorizationReport struct {
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
// AliyunAuthorizationChecker 负责发现并证明 RAM 账号对阿里云 DNS 根域名的实际写权限。
|
||||
// AliyunAuthorizationChecker 负责证明 RAM 账号对用户指定域名的实际写权限。
|
||||
type AliyunAuthorizationChecker struct {
|
||||
now func() time.Time
|
||||
verifyDomainFunc func(context.Context, string, map[string]string) error
|
||||
@ -61,79 +59,11 @@ 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)
|
||||
}
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
explicit := false
|
||||
for _, zone := range scope.ManagedZones {
|
||||
if zone == requestedDomain {
|
||||
explicit = true
|
||||
}
|
||||
}
|
||||
if !explicit {
|
||||
return report, errors.New("只允许读取显式配置的托管区,未调用阿里云 API")
|
||||
}
|
||||
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 只在用户显式允许的域名及托管区中执行一次写入和精确清理。
|
||||
// Verify 仅针对本次输入所推导的唯一注册根域执行一次写入和精确清理。
|
||||
func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (report AuthorizationReport, resultErr error) {
|
||||
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
|
||||
report = AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
|
||||
}
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
zone, err := scope.Resolve(requestedDomain)
|
||||
zone, err := ResolveAliyunTargetDomain(requestedDomain)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
@ -146,7 +76,11 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
|
||||
if c == nil || c.recordAPI == nil {
|
||||
return report, errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
api, err := c.recordAPI(credentials)
|
||||
cleaned, err := ValidateCredentials(Aliyun, credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
api, err := c.recordAPI(cleaned)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
@ -236,13 +170,10 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
|
||||
return err
|
||||
}
|
||||
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
|
||||
}
|
||||
if _, err := ValidateCredentials(Aliyun, credentials); err != nil {
|
||||
if _, err := ResolveAliyunTargetDomain(requestedDomain); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateAliyunDomainScope(Aliyun, []string{requestedDomain}, credentials); err != nil {
|
||||
if _, err := ValidateCredentials(Aliyun, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.verifyDomainFunc != nil {
|
||||
@ -303,41 +234,6 @@ func newAliyunClientWithRetryOptions(credentials map[string]string, retryOptions
|
||||
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 deleteAuthorizationRecord(ctx context.Context, dnsClient *client.Client, recordID string) error {
|
||||
if strings.TrimSpace(recordID) == "" {
|
||||
return errors.New("授权探测记录 ID 为空")
|
||||
@ -346,21 +242,6 @@ func deleteAuthorizationRecord(ctx context.Context, dnsClient *client.Client, re
|
||||
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 markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) {
|
||||
for index := range report.Domains {
|
||||
if report.Domains[index].Domain == domainName {
|
||||
@ -382,13 +263,6 @@ func randomAuthorizationValue(size int) (string, error) {
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func dereferenceInt64(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func dereferenceString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
@ -396,19 +270,6 @@ func dereferenceString(value *string) string {
|
||||
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 {
|
||||
|
||||
@ -89,7 +89,8 @@ func newAliyunAuthorizationRecordAPI(credentials map[string]string) (aliyunRecor
|
||||
|
||||
// 不使用自动发现托管区的上游适配器,也不沿 CNAME 自动扩大写入范围。
|
||||
type scopedAliyunProvider struct {
|
||||
scope AliyunScope
|
||||
credentials map[string]string
|
||||
newAPI func(map[string]string) (aliyunRecordAPI, error)
|
||||
api aliyunRecordAPI
|
||||
mu sync.Mutex
|
||||
records map[[32]byte]string
|
||||
@ -97,15 +98,14 @@ type scopedAliyunProvider struct {
|
||||
}
|
||||
|
||||
func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvider, error) {
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
cleaned, err := ValidateCredentials(Aliyun, credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
api, err := newAliyunRecordAPI(credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: aliyunChallengeInfo}, nil
|
||||
return &scopedAliyunProvider{
|
||||
credentials: cleaned, newAPI: newAliyunRecordAPI,
|
||||
records: make(map[[32]byte]string), challengeInfo: aliyunChallengeInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func aliyunChallengeInfo(ctx context.Context, domainName, keyAuth string) (string, string, string) {
|
||||
@ -115,11 +115,7 @@ func aliyunChallengeInfo(ctx context.Context, domainName, keyAuth string) (strin
|
||||
|
||||
func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth string) error {
|
||||
name = normalizeAuthorizationDomain(name)
|
||||
zone, err := p.scope.Resolve(name)
|
||||
// ACME 的通配符授权以去掉通配符的标识符进入 Provider;业务层已逐项验证 SAN。
|
||||
if err != nil && !strings.HasPrefix(name, "*.") {
|
||||
zone, err = p.scope.Resolve("*." + name)
|
||||
}
|
||||
zone, err := ResolveAliyunTargetDomain(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -142,6 +138,16 @@ func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth
|
||||
if _, ok := p.records[key]; ok {
|
||||
return errors.New("同一 DNS 挑战已有待清理记录,拒绝重复新增")
|
||||
}
|
||||
if p.api == nil {
|
||||
if p.newAPI == nil {
|
||||
return errors.New("阿里云 DNS 客户端工厂未初始化")
|
||||
}
|
||||
api, err := p.newAPI(p.credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.api = api
|
||||
}
|
||||
id, err := p.api.Add(ctx, zone, rr, value)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -151,6 +157,9 @@ func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth
|
||||
}
|
||||
|
||||
func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth string) error {
|
||||
if _, err := ResolveAliyunTargetDomain(name); err != nil {
|
||||
return err
|
||||
}
|
||||
key := sha256.Sum256([]byte(normalizeAuthorizationDomain(name) + "\x00" + token + "\x00" + keyAuth))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@ -158,6 +167,9 @@ func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if p.api == nil {
|
||||
return errors.New("阿里云 DNS 临时记录状态不完整,已拒绝清理请求")
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
|
||||
defer cancel()
|
||||
if err := p.api.Delete(cleanupCtx, id); err != nil {
|
||||
|
||||
@ -9,106 +9,42 @@ import (
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// AliyunScope 是用户授权的上限,不是从 RAM 读取结果自动推导的权限。
|
||||
// 范围随凭据引用不可变地保存;修改范围必须重新在线验证并获得新的引用。
|
||||
type AliyunScope struct {
|
||||
ManagedZones []string `json:"managed_zones"`
|
||||
AllowedDomains []string `json:"allowed_domains"`
|
||||
}
|
||||
const protectedAliyunDomain = "pcux.cn"
|
||||
|
||||
func ParseAliyunScope(credentials map[string]string) (AliyunScope, error) {
|
||||
var scope AliyunScope
|
||||
var err error
|
||||
scope.ManagedZones, err = parseScopeNames(credentials["managed_zones"], false)
|
||||
if err != nil {
|
||||
return scope, fmt.Errorf("请显式配置阿里云托管区:%w", err)
|
||||
}
|
||||
scope.AllowedDomains, err = parseScopeNames(credentials["allowed_domains"], true)
|
||||
if err != nil {
|
||||
return scope, fmt.Errorf("请显式配置允许操作的域名:%w", err)
|
||||
}
|
||||
for _, name := range scope.AllowedDomains {
|
||||
if scope.zoneFor(strings.TrimPrefix(name, "*.")) == "" {
|
||||
return AliyunScope{}, fmt.Errorf("允许域名 %s 不属于任何显式托管区", name)
|
||||
}
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func parseScopeNames(value string, wildcard bool) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, errors.New("旧配置或空范围不允许 DNS 操作,请补充范围并重新验证")
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) > 128 {
|
||||
return nil, errors.New("每个范围最多允许 128 项")
|
||||
}
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
name := normalizeAuthorizationDomain(part)
|
||||
base := strings.TrimPrefix(name, "*.")
|
||||
if !domain.IsValidName(name) || (!wildcard && name != base) {
|
||||
return nil, errors.New("范围应为逗号分隔的有效域名,托管区不能含通配符")
|
||||
}
|
||||
if _, err := publicsuffix.EffectiveTLDPlusOne(base); err != nil {
|
||||
return nil, fmt.Errorf("公共后缀不能作为操作范围:%s", name)
|
||||
}
|
||||
result = append(result, name)
|
||||
}
|
||||
return uniqueStrings(result), nil
|
||||
}
|
||||
|
||||
func (s AliyunScope) zoneFor(name string) string {
|
||||
best := ""
|
||||
for _, zone := range s.ManagedZones {
|
||||
if (name == zone || strings.HasSuffix(name, "."+zone)) && len(zone) > len(best) {
|
||||
best = zone
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Resolve 只选择最具体的显式托管区;失败后绝不回退到父区。
|
||||
// 通配授权只包含一层子域和对应通配证书,不隐含根域或多层子域。
|
||||
func (s AliyunScope) Resolve(requested string) (string, error) {
|
||||
// ResolveAliyunTargetDomain 从本次输入中得到唯一可写入的注册根域。
|
||||
// 它不枚举账号域名、不猜测托管区,也绝不因一次失败改写为父域或其他候选域名。
|
||||
func ResolveAliyunTargetDomain(requested string) (string, error) {
|
||||
requested = normalizeAuthorizationDomain(requested)
|
||||
if !domain.IsValidName(requested) {
|
||||
base := strings.TrimPrefix(requested, "*.")
|
||||
if !domain.IsValidName(requested) || base == "" {
|
||||
return "", errors.New("待操作域名格式无效")
|
||||
}
|
||||
allowed := false
|
||||
for _, name := range s.AllowedDomains {
|
||||
if requested == name {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(name, "*.") && !strings.HasPrefix(requested, "*.") {
|
||||
suffix := name[1:]
|
||||
if strings.HasSuffix(requested, suffix) {
|
||||
label := strings.TrimSuffix(requested, suffix)
|
||||
allowed = label != "" && !strings.Contains(label, ".")
|
||||
if allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if isProtectedAliyunDomain(base) {
|
||||
return "", fmt.Errorf("域名 %s 受本机保护策略限制,未构造阿里云客户端或调用云 API", requested)
|
||||
}
|
||||
zone := s.zoneFor(strings.TrimPrefix(requested, "*."))
|
||||
if !allowed || zone == "" {
|
||||
return "", fmt.Errorf("域名 %s 不在用户显式允许范围内,未调用阿里云 API", requested)
|
||||
zone, err := publicsuffix.EffectiveTLDPlusOne(base)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("待操作域名必须包含可注册根域:%s", requested)
|
||||
}
|
||||
return zone, nil
|
||||
return normalizeAuthorizationDomain(zone), nil
|
||||
}
|
||||
|
||||
func ValidateAliyunDomainScope(providerID string, domains []string, credentials map[string]string) error {
|
||||
func isProtectedAliyunDomain(name string) bool {
|
||||
name = normalizeAuthorizationDomain(strings.TrimPrefix(name, "*."))
|
||||
return name == protectedAliyunDomain || strings.HasSuffix(name, "."+protectedAliyunDomain)
|
||||
}
|
||||
|
||||
// ValidateAliyunTargetDomains 在任何凭据读取、客户端构造或云 API 调用前完成整单本地校验。
|
||||
// SAN 中只要有一个保护域,整单都会被拒绝。
|
||||
func ValidateAliyunTargetDomains(providerID string, domains []string) error {
|
||||
if !strings.EqualFold(strings.TrimSpace(providerID), Aliyun) {
|
||||
return nil
|
||||
}
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(domains) == 0 {
|
||||
return errors.New("至少需要一个待操作域名")
|
||||
}
|
||||
for _, name := range domains {
|
||||
if _, err := scope.Resolve(name); err != nil {
|
||||
if _, err := ResolveAliyunTargetDomain(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,13 +48,11 @@ var descriptors = map[string]Descriptor{
|
||||
},
|
||||
Aliyun: {
|
||||
ID: Aliyun, Name: "阿里云 DNS", Available: true,
|
||||
Description: "仅操作显式托管区内的允许域名。范围用英文逗号分隔;通配符只允许一层子域,不包含根域。旧配置需补充范围后重新验证。",
|
||||
Description: "输入目标域名后,系统立即使用当前账号创建、查询并清理临时 TXT 记录,以验证该域名的实际 DNS 操作权限;不枚举账号域名,也不尝试其他托管区。",
|
||||
CredentialFields: []CredentialField{
|
||||
{Name: "access_key_id", Label: "AccessKey ID", Required: true},
|
||||
{Name: "access_key_secret", Label: "AccessKey Secret", Secret: true, Required: true},
|
||||
{Name: "security_token", Label: "Security Token", Secret: true},
|
||||
{Name: "managed_zones", Label: "明确托管区", Required: true, Placeholder: "例如 example.com,example.net;不自动尝试父域"},
|
||||
{Name: "allowed_domains", Label: "允许操作的域名", Required: true, Placeholder: "例如 www.example.com,*.example.net;根域须单独填写"},
|
||||
},
|
||||
},
|
||||
DNSPod: {
|
||||
@ -160,6 +158,11 @@ func ValidateCredentials(providerID string, credentials map[string]string) (map[
|
||||
}
|
||||
for name := range credentials {
|
||||
if _, ok := allowed[name]; !ok {
|
||||
// 旧版本曾把用户填写的范围随凭据保存。保留读取兼容,
|
||||
// 但绝不将它们带入新凭据或作为授权依据。
|
||||
if providerID == Aliyun && (name == "managed_zones" || name == "allowed_domains") {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("DNS 凭据字段无效:%s", name)
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err := ValidateAliyunDomainScope(providerID, []string{testDomain}, cleaned); err != nil {
|
||||
if err := ValidateAliyunTargetDomains(providerID, []string{testDomain}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(providerID), Aliyun) {
|
||||
|
||||
@ -14,7 +14,6 @@ import (
|
||||
)
|
||||
|
||||
type aliyunScopeChecker interface {
|
||||
Discover(ctx context.Context, credentials map[string]string) (dnsprovider.AuthorizationReport, error)
|
||||
Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error)
|
||||
VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error
|
||||
}
|
||||
@ -23,95 +22,6 @@ type aliyunDomainCheckRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
type aliyunScopeResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
Configured bool `json:"configured"`
|
||||
CredentialRef string `json:"credential_ref,omitempty"`
|
||||
PolicyMode string `json:"policy_mode"`
|
||||
GlobalDiscoveryAvailable bool `json:"global_discovery_available"`
|
||||
Message string `json:"message"`
|
||||
DiscoveredDomains []dnsprovider.AuthorizationDomain `json:"discovered_domains,omitempty"`
|
||||
ManagedDomains []string `json:"managed_domains"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
UserScope *dnsprovider.AliyunScope `json:"user_scope,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) getAliyunDNSScope(c *gin.Context) {
|
||||
s.configMu.RLock()
|
||||
provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider))
|
||||
credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef)
|
||||
s.configMu.RUnlock()
|
||||
|
||||
if provider != dnsprovider.Aliyun {
|
||||
c.JSON(http.StatusOK, aliyunScopeResponse{
|
||||
Provider: provider,
|
||||
Configured: false,
|
||||
PolicyMode: "not_aliyun",
|
||||
Message: "系统当前未将阿里云配置为主 DNS 服务商",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
response := aliyunScopeResponse{
|
||||
Provider: dnsprovider.Aliyun,
|
||||
Configured: true,
|
||||
CredentialRef: credentialRef,
|
||||
CheckedAt: s.now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
scope, scopeErr := dnsprovider.ParseAliyunScope(credentials)
|
||||
if scopeErr != nil {
|
||||
response.PolicyMode = "scope_required"
|
||||
response.Message = scopeErr.Error()
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
response.UserScope = &scope
|
||||
|
||||
// 读取当前系统内已经纳管的域名列表
|
||||
if s.repository != nil {
|
||||
if items, listErr := s.repository.List(c.Request.Context()); listErr == nil {
|
||||
for _, item := range items {
|
||||
response.ManagedDomains = append(response.ManagedDomains, item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.aliyunChecker == nil {
|
||||
response.PolicyMode = "uninitialized"
|
||||
response.Message = "阿里云权限探测器未装载"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
// 默认采用资源受限安全模式,不盲目调用全局 DescribeDomains 接口,避免产生 RAM 未授权异常告警
|
||||
// 仅在显式请求参数 discover=true 时尝试全局探测
|
||||
if c.Query("discover") == "true" {
|
||||
report, discoverErr := s.aliyunChecker.Discover(c.Request.Context(), credentials)
|
||||
if discoverErr == nil && len(report.Domains) > 0 {
|
||||
response.GlobalDiscoveryAvailable = true
|
||||
response.PolicyMode = "broad_or_read_all"
|
||||
response.Message = "当前 RAM 账号具备全局根域名读取权限"
|
||||
response.DiscoveredDomains = report.Domains
|
||||
} else {
|
||||
response.GlobalDiscoveryAvailable = false
|
||||
response.PolicyMode = "resource_scoped"
|
||||
response.Message = "当前 RAM 账号受特定域名资源限制策略(最小权限原则),不支持全局枚举,已启用单域名靶向权威考证"
|
||||
}
|
||||
} else {
|
||||
response.GlobalDiscoveryAvailable = false
|
||||
response.PolicyMode = "resource_scoped"
|
||||
response.Message = "系统当前遵循 RAM 最小权限安全原则,采用单域名靶向精准考证"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
|
||||
var request aliyunDomainCheckRequest
|
||||
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
|
||||
@ -145,7 +55,7 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(provider, []string{targetDomain}, credentials); err != nil {
|
||||
if err := dnsprovider.ValidateAliyunTargetDomains(provider, []string{targetDomain}); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"verified": false, "domain": targetDomain, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
@ -202,7 +112,7 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
|
||||
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
|
||||
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
|
||||
}
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(provider, domainNames, credentials); err != nil {
|
||||
if err := dnsprovider.ValidateAliyunTargetDomains(provider, domainNames); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, domainName := range domainNames {
|
||||
|
||||
@ -11,7 +11,7 @@ const authStateCapacity = 4096
|
||||
const authUserSessionCapacity = 16
|
||||
|
||||
func (session authSession) complete() bool {
|
||||
return strings.TrimSpace(session.UserID) != "" && strings.TrimSpace(session.Username) != "" &&
|
||||
return strings.TrimSpace(session.UserID) != "" && strings.TrimSpace(session.Username) != "" && strings.TrimSpace(session.Email) != "" &&
|
||||
strings.TrimSpace(session.PasswordHash) != "" &&
|
||||
(session.Role == repository.UserRoleUser || session.Role == repository.UserRoleAdmin || session.Role == repository.UserRoleSuperAdmin)
|
||||
}
|
||||
|
||||
@ -191,7 +191,6 @@ func (s *Server) registerRoutes() {
|
||||
adminAPI.PUT("/settings", s.updateSettings)
|
||||
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
|
||||
adminAPI.POST("/dns/test", s.dnsOperation, s.testAdminDNS)
|
||||
adminAPI.GET("/dns/aliyun/scope", s.getAliyunDNSScope)
|
||||
adminAPI.POST("/dns/aliyun/check", s.dnsOperation, s.checkAliyunDNSDomain)
|
||||
adminAPI.POST("/renewal/run", s.longOperation(certificateTaskTimeout), s.runRenewalNow)
|
||||
adminAPI.GET("/certificates", s.listCertificates)
|
||||
@ -372,9 +371,9 @@ func (s *Server) ready(c *gin.Context) {
|
||||
s.writeReadinessFailure(c, "credential_unavailable")
|
||||
return
|
||||
}
|
||||
if reference == dnsReference && cfg.DNS.Provider == dnsprovider.Aliyun {
|
||||
if _, err := dnsprovider.ParseAliyunScope(credentials); err != nil {
|
||||
s.writeReadinessFailure(c, "dns_scope_required")
|
||||
if reference == dnsReference {
|
||||
if _, err := dnsprovider.ValidateCredentials(cfg.DNS.Provider, credentials); err != nil {
|
||||
s.writeReadinessFailure(c, "dns_credential_invalid")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,8 +58,6 @@ type settingsDNSView struct {
|
||||
Provider string `json:"provider"`
|
||||
VerificationState string `json:"verification_state"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
UserScope *dnsprovider.AliyunScope `json:"user_scope,omitempty"`
|
||||
ScopeMessage string `json:"scope_message,omitempty"`
|
||||
}
|
||||
|
||||
type settingsNotificationView struct {
|
||||
@ -167,22 +165,6 @@ func (s *Server) getSettings(c *gin.Context) {
|
||||
cfg := s.config
|
||||
s.configMu.RUnlock()
|
||||
view := settingsViewFromConfig(cfg)
|
||||
if cfg.DNS.Provider == dnsprovider.Aliyun {
|
||||
credentials, err := s.readDNSCredentials(c.Request.Context(), cfg.DNS.CredentialRef)
|
||||
if err == nil {
|
||||
scope, scopeErr := dnsprovider.ParseAliyunScope(credentials)
|
||||
if scopeErr == nil {
|
||||
view.DNS.UserScope = &scope
|
||||
} else {
|
||||
view.DNS.ScopeMessage = scopeErr.Error()
|
||||
}
|
||||
} else {
|
||||
view.DNS.ScopeMessage = "无法读取已保存授权范围,请检查凭据存储或重新验证"
|
||||
}
|
||||
if view.DNS.UserScope == nil {
|
||||
view.DNS.VerificationState = "scope_required"
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,12 @@ import (
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
const dnsVerificationLifetime = 15 * time.Minute
|
||||
const (
|
||||
dnsVerificationLifetime = 15 * time.Minute
|
||||
dnsVerificationSessionCapacity = 128
|
||||
dnsVerificationInFlightCapacity = 16
|
||||
dnsVerificationCleanupTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type DNSCredentialTester interface {
|
||||
Verify(ctx context.Context, providerID, testDomain string, credentials map[string]string) (DNSVerificationReport, error)
|
||||
@ -53,6 +58,11 @@ type SecretWriter interface {
|
||||
Put(ctx context.Context, reference string, value map[string]string) error
|
||||
}
|
||||
|
||||
// SecretDeleter 用于回收过期、未绑定或保存失败的临时 DNS 凭据。
|
||||
type SecretDeleter interface {
|
||||
Delete(ctx context.Context, reference string) error
|
||||
}
|
||||
|
||||
type DNSVerificationInput struct {
|
||||
ProviderID string
|
||||
TestDomain string
|
||||
@ -77,13 +87,16 @@ type DNSVerificationManager struct {
|
||||
mu sync.Mutex
|
||||
tester DNSCredentialTester
|
||||
store SecretWriter
|
||||
deleter SecretDeleter
|
||||
now func() time.Time
|
||||
sessions map[string]dnsVerificationSession
|
||||
inFlight int
|
||||
}
|
||||
|
||||
func NewDNSVerificationManager(tester DNSCredentialTester, store SecretWriter) *DNSVerificationManager {
|
||||
deleter, _ := store.(SecretDeleter)
|
||||
return &DNSVerificationManager{
|
||||
tester: tester, store: store, now: time.Now,
|
||||
tester: tester, store: store, deleter: deleter, now: time.Now,
|
||||
sessions: make(map[string]dnsVerificationSession),
|
||||
}
|
||||
}
|
||||
@ -101,6 +114,15 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
|
||||
return DNSVerificationResult{}, fmt.Errorf("DNS 测试域名格式无效:%s", testDomain)
|
||||
}
|
||||
normalizedCredentials := normalizeCredentials(input.Credentials)
|
||||
if err := m.reserveVerificationSlot(); err != nil {
|
||||
return DNSVerificationResult{}, err
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
m.releaseVerificationSlot()
|
||||
}
|
||||
}()
|
||||
testReport, err := m.tester.Verify(ctx, providerID, testDomain, normalizedCredentials)
|
||||
if err != nil {
|
||||
return DNSVerificationResult{TestReport: testReport}, &DNSVerificationError{
|
||||
@ -118,16 +140,23 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
|
||||
}
|
||||
credentialRef := fmt.Sprintf("secret://dns/%s/%s", providerID, credentialID)
|
||||
if err := m.store.Put(ctx, credentialRef, normalizedCredentials); err != nil {
|
||||
cleanupErr := m.deleteCredential(credentialRef)
|
||||
if cleanupErr != nil {
|
||||
return DNSVerificationResult{}, fmt.Errorf("加密保存 DNS 凭据失败,且无法确认临时凭据已回收:%w", errors.Join(err, cleanupErr))
|
||||
}
|
||||
return DNSVerificationResult{}, fmt.Errorf("加密保存 DNS 凭据失败:%w", err)
|
||||
}
|
||||
expiresAt := m.now().UTC().Add(dnsVerificationLifetime)
|
||||
m.mu.Lock()
|
||||
m.clearExpiredLocked(m.now().UTC())
|
||||
expiredRefs := m.clearExpiredLocked(m.now().UTC())
|
||||
m.sessions[verificationToken] = dnsVerificationSession{
|
||||
ProviderID: providerID, TestDomain: testDomain,
|
||||
CredentialRef: credentialRef, ExpiresAt: expiresAt,
|
||||
}
|
||||
m.inFlight--
|
||||
released = true
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return DNSVerificationResult{
|
||||
CredentialRef: credentialRef, VerificationToken: verificationToken, ExpiresAt: expiresAt,
|
||||
TestReport: testReport,
|
||||
@ -152,29 +181,86 @@ func (m *DNSVerificationManager) validateSession(token, providerID, credentialRe
|
||||
}
|
||||
now := m.now().UTC()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.clearExpiredLocked(now)
|
||||
expiredRefs := m.clearExpiredLocked(now)
|
||||
session, ok := m.sessions[token]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return errors.New("DNS 验证令牌无效或已经过期,请重新测试")
|
||||
}
|
||||
if session.ProviderID != strings.ToLower(strings.TrimSpace(providerID)) ||
|
||||
session.CredentialRef != strings.TrimSpace(credentialRef) ||
|
||||
session.TestDomain != normalizeTestDomain(testDomain) {
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return errors.New("DNS 验证结果与当前配置不匹配,请重新测试")
|
||||
}
|
||||
if consume {
|
||||
delete(m.sessions, token)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *DNSVerificationManager) clearExpiredLocked(now time.Time) {
|
||||
func (m *DNSVerificationManager) reserveVerificationSlot() error {
|
||||
if m == nil {
|
||||
return errors.New("DNS 在线验证服务未初始化")
|
||||
}
|
||||
now := m.now().UTC()
|
||||
m.mu.Lock()
|
||||
expiredRefs := m.clearExpiredLocked(now)
|
||||
if m.inFlight >= dnsVerificationInFlightCapacity {
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return errors.New("DNS 在线验证请求过多,请稍后重试")
|
||||
}
|
||||
if len(m.sessions)+m.inFlight >= dnsVerificationSessionCapacity {
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return errors.New("DNS 验证令牌数量已达上限,请等待现有验证完成或过期后重试")
|
||||
}
|
||||
m.inFlight++
|
||||
m.mu.Unlock()
|
||||
m.deleteCredentials(expiredRefs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *DNSVerificationManager) releaseVerificationSlot() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.inFlight > 0 {
|
||||
m.inFlight--
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *DNSVerificationManager) clearExpiredLocked(now time.Time) []string {
|
||||
var references []string
|
||||
for token, session := range m.sessions {
|
||||
if !session.ExpiresAt.After(now) {
|
||||
delete(m.sessions, token)
|
||||
references = append(references, session.CredentialRef)
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
func (m *DNSVerificationManager) deleteCredentials(references []string) {
|
||||
for _, reference := range references {
|
||||
_ = m.deleteCredential(reference)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DNSVerificationManager) deleteCredential(reference string) error {
|
||||
if m == nil || m.deleter == nil || strings.TrimSpace(reference) == "" {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dnsVerificationCleanupTimeout)
|
||||
defer cancel()
|
||||
return m.deleter.Delete(ctx, reference)
|
||||
}
|
||||
|
||||
func normalizeCredentials(credentials map[string]string) map[string]string {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user