推进阿里云授权边界与验证加固

This commit is contained in:
xuans 2026-09-16 02:59:10 +08:00
parent 6e7720b53d
commit e9c31312d4
13 changed files with 183 additions and 397 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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"

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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 {