diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 6ffc046..a3460ff 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -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 { diff --git a/backend/internal/cli/dns.go b/backend/internal/cli/dns.go index fb09a7a..a002376 100644 --- a/backend/internal/cli/dns.go +++ b/backend/internal/cli/dns.go @@ -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 } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2443c90..23f1c0f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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" diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 42c7223..3a63bad 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -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 { diff --git a/backend/internal/dnsprovider/aliyun_provider.go b/backend/internal/dnsprovider/aliyun_provider.go index e6a677f..6764db0 100644 --- a/backend/internal/dnsprovider/aliyun_provider.go +++ b/backend/internal/dnsprovider/aliyun_provider.go @@ -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 { diff --git a/backend/internal/dnsprovider/aliyun_scope.go b/backend/internal/dnsprovider/aliyun_scope.go index 0cf3541..2a0fdda 100644 --- a/backend/internal/dnsprovider/aliyun_scope.go +++ b/backend/internal/dnsprovider/aliyun_scope.go @@ -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 } } diff --git a/backend/internal/dnsprovider/catalog.go b/backend/internal/dnsprovider/catalog.go index b467a89..c7f2653 100644 --- a/backend/internal/dnsprovider/catalog.go +++ b/backend/internal/dnsprovider/catalog.go @@ -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) } } diff --git a/backend/internal/dnsprovider/tester.go b/backend/internal/dnsprovider/tester.go index 39f2fcf..690639e 100644 --- a/backend/internal/dnsprovider/tester.go +++ b/backend/internal/dnsprovider/tester.go @@ -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) { diff --git a/backend/internal/httpapi/aliyun_scope_api.go b/backend/internal/httpapi/aliyun_scope_api.go index b607933..45d8b31 100644 --- a/backend/internal/httpapi/aliyun_scope_api.go +++ b/backend/internal/httpapi/aliyun_scope_api.go @@ -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 { diff --git a/backend/internal/httpapi/auth_retention.go b/backend/internal/httpapi/auth_retention.go index 0ccd964..1db4c21 100644 --- a/backend/internal/httpapi/auth_retention.go +++ b/backend/internal/httpapi/auth_retention.go @@ -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) } diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index a62d576..2d65f8c 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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 } } diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index d67821b..fb8a615 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -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) } diff --git a/backend/internal/setup/dns_verification.go b/backend/internal/setup/dns_verification.go index 2dd5320..ee8996a 100644 --- a/backend/internal/setup/dns_verification.go +++ b/backend/internal/setup/dns_verification.go @@ -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 {