按权威托管区验证阿里云域名权限
This commit is contained in:
parent
bb389ece32
commit
9f8a5fe2e4
@ -49,78 +49,86 @@ type AliyunAuthorizationChecker struct {
|
||||
now func() time.Time
|
||||
verifyDomainFunc func(context.Context, string, map[string]string) error
|
||||
recordAPI func(map[string]string) (aliyunRecordAPI, error)
|
||||
zoneResolver AliyunZoneResolver
|
||||
}
|
||||
|
||||
func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker {
|
||||
return &AliyunAuthorizationChecker{now: time.Now, recordAPI: newAliyunAuthorizationRecordAPI}
|
||||
return &AliyunAuthorizationChecker{
|
||||
now: time.Now,
|
||||
recordAPI: newAliyunAuthorizationRecordAPI,
|
||||
zoneResolver: newDefaultAliyunZoneResolver(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) Supports(providerID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(providerID), Aliyun)
|
||||
}
|
||||
|
||||
// Verify 仅针对本次输入所推导的唯一注册根域执行一次写入和精确清理。
|
||||
// Verify 仅针对本次 SOA 识别出的唯一权威托管区执行一次写入和精确清理。
|
||||
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()}
|
||||
zone, err := ResolveAliyunTargetDomain(requestedDomain)
|
||||
if c == nil {
|
||||
return report, errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
verificationCtx, cancel := permissionVerificationContext(ctx)
|
||||
defer cancel()
|
||||
zone, err := c.resolveTargetDomain(verificationCtx, requestedDomain)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
return c.verifyResolved(verificationCtx, requestedDomain, zone, credentials, report)
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) verifyResolved(ctx context.Context, requestedDomain, zone string, credentials map[string]string, report AuthorizationReport) (result AuthorizationReport, resultErr error) {
|
||||
result = report
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
return result, err
|
||||
}
|
||||
verificationCtx, cancel := context.WithTimeout(ctx, PermissionVerificationTimeoutFor(Aliyun))
|
||||
defer cancel()
|
||||
report.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}}
|
||||
result.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}}
|
||||
if c == nil || c.recordAPI == nil {
|
||||
return report, errors.New("阿里云权限探测器未初始化")
|
||||
return result, errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
cleaned, err := ValidateCredentials(Aliyun, credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
return result, err
|
||||
}
|
||||
api, err := c.recordAPI(cleaned)
|
||||
if err != nil {
|
||||
return report, err
|
||||
return result, err
|
||||
}
|
||||
suffix, err := randomAuthorizationValue(18)
|
||||
if err != nil {
|
||||
return report, err
|
||||
return result, err
|
||||
}
|
||||
value, err := randomAuthorizationValue(32)
|
||||
if err != nil {
|
||||
return report, err
|
||||
return result, err
|
||||
}
|
||||
rr := "_scert-authorization-" + suffix
|
||||
base := strings.TrimPrefix(requestedDomain, "*.")
|
||||
if base != zone {
|
||||
rr += "." + strings.TrimSuffix(base, "."+zone)
|
||||
}
|
||||
report.RecordName = rr + "." + zone + "."
|
||||
addCtx, addCancel := context.WithTimeout(verificationCtx, aliyunAPICallTimeout)
|
||||
result.RecordName = rr + "." + zone + "."
|
||||
addCtx, addCancel := context.WithTimeout(ctx, aliyunAPICallTimeout)
|
||||
recordID, err := api.Add(addCtx, zone, rr, value)
|
||||
addCancel()
|
||||
if err != nil {
|
||||
markAuthorizationFailure(&report, zone, err)
|
||||
report.Diagnosis = DiagnoseAliyunError(err, zone)
|
||||
return report, err
|
||||
markAuthorizationFailure(&result, zone, err)
|
||||
result.Diagnosis = DiagnoseAliyunError(err, zone)
|
||||
return result, err
|
||||
}
|
||||
report.Domains[0].Writable = true
|
||||
report.MatchedDomain = zone
|
||||
result.Domains[0].Writable = true
|
||||
result.MatchedDomain = zone
|
||||
defer func() {
|
||||
cleanupBase := context.WithoutCancel(verificationCtx)
|
||||
if deadline, ok := verificationCtx.Deadline(); ok {
|
||||
var cleanupDeadlineCancel context.CancelFunc
|
||||
cleanupBase, cleanupDeadlineCancel = context.WithDeadline(cleanupBase, deadline)
|
||||
defer cleanupDeadlineCancel()
|
||||
}
|
||||
cleanupBase := context.WithoutCancel(ctx)
|
||||
cleanupContext, cleanupCancel := context.WithTimeout(cleanupBase, aliyunAPICallTimeout)
|
||||
defer cleanupCancel()
|
||||
if cleanupErr := api.Delete(cleanupContext, recordID); cleanupErr != nil {
|
||||
cleanupErr = fmt.Errorf("清理授权探测 TXT 记录失败:%w", cleanupErr)
|
||||
markAuthorizationFailure(&report, zone, cleanupErr)
|
||||
report.Verified = false
|
||||
markAuthorizationFailure(&result, zone, cleanupErr)
|
||||
result.Verified = false
|
||||
if resultErr == nil {
|
||||
resultErr = cleanupErr
|
||||
} else {
|
||||
@ -128,26 +136,26 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
|
||||
}
|
||||
return
|
||||
}
|
||||
report.Domains[0].Cleanup = true
|
||||
result.Domains[0].Cleanup = true
|
||||
}()
|
||||
describeCtx, describeCancel := context.WithTimeout(verificationCtx, aliyunAPICallTimeout)
|
||||
describeCtx, describeCancel := context.WithTimeout(ctx, aliyunAPICallTimeout)
|
||||
info, err := api.Describe(describeCtx, recordID)
|
||||
describeCancel()
|
||||
if err != nil {
|
||||
resultErr = fmt.Errorf("查询授权探测 TXT 记录失败:%w", err)
|
||||
markAuthorizationFailure(&report, zone, resultErr)
|
||||
report.Diagnosis = DiagnoseAliyunError(err, zone)
|
||||
return report, resultErr
|
||||
markAuthorizationFailure(&result, zone, resultErr)
|
||||
result.Diagnosis = DiagnoseAliyunError(err, zone)
|
||||
return result, resultErr
|
||||
}
|
||||
if !matchesAliyunAuthorizationRecord(info, recordID, zone, rr, value) {
|
||||
resultErr = errors.New("阿里云返回的临时 TXT 记录与本次验证不一致")
|
||||
markAuthorizationFailure(&report, zone, resultErr)
|
||||
return report, resultErr
|
||||
markAuthorizationFailure(&result, zone, resultErr)
|
||||
return result, resultErr
|
||||
}
|
||||
report.Domains[0].Readable = true
|
||||
report.Domains[0].Status = "可操作"
|
||||
report.Verified = true
|
||||
return report, nil
|
||||
result.Domains[0].Readable = true
|
||||
result.Domains[0].Status = "可操作"
|
||||
result.Verified = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func matchesAliyunAuthorizationRecord(info aliyunRecordInfo, recordID, zone, rr, value string) bool {
|
||||
@ -166,20 +174,24 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
|
||||
if c == nil {
|
||||
return errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
verificationCtx, cancel := permissionVerificationContext(ctx)
|
||||
defer cancel()
|
||||
if err := verificationCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
|
||||
if _, err := ResolveAliyunTargetDomain(requestedDomain); err != nil {
|
||||
zone, err := c.resolveTargetDomain(verificationCtx, requestedDomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ValidateCredentials(Aliyun, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.verifyDomainFunc != nil {
|
||||
return c.verifyDomainFunc(ctx, requestedDomain, credentials)
|
||||
return c.verifyDomainFunc(verificationCtx, requestedDomain, credentials)
|
||||
}
|
||||
report, err := c.Verify(ctx, requestedDomain, credentials)
|
||||
report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
|
||||
report, err = c.verifyResolved(verificationCtx, requestedDomain, zone, credentials, report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -189,6 +201,20 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) resolveTargetDomain(ctx context.Context, requestedDomain string) (string, error) {
|
||||
if c == nil {
|
||||
return "", errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
return resolveAliyunTargetDomain(ctx, requestedDomain, c.zoneResolver)
|
||||
}
|
||||
|
||||
func permissionVerificationContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithTimeout(ctx, PermissionVerificationTimeoutFor(Aliyun))
|
||||
}
|
||||
|
||||
// InvalidateDomain 保留旧调用方接口;授权检查已不再缓存,无需额外失效操作。
|
||||
func (c *AliyunAuthorizationChecker) InvalidateDomain(providerID, requestedDomain string, credentials map[string]string) {
|
||||
}
|
||||
@ -277,6 +303,9 @@ func DiagnoseAliyunError(err error, domainName string) string {
|
||||
}
|
||||
msg := err.Error()
|
||||
lowered := strings.ToLower(msg)
|
||||
if strings.Contains(msg, "权威 DNS 托管区") || strings.Contains(lowered, "soa lookup") {
|
||||
return "未能从公开 DNS 的 SOA 记录识别该域名唯一的权威托管区,系统未构造阿里云客户端,也未创建临时 TXT 记录。请检查域名是否已正确委派并可被公网 DNS 查询后重试"
|
||||
}
|
||||
if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "forbidden.ram") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") {
|
||||
return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/%s,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限", domainName, domainName)
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *t
|
||||
now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
revoked := false
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.now = func() time.Time { return now }
|
||||
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
|
||||
calls++
|
||||
@ -76,7 +76,7 @@ func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *t
|
||||
|
||||
func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing.T) {
|
||||
calls := 0
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
|
||||
calls++
|
||||
return nil
|
||||
@ -102,7 +102,7 @@ func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing.
|
||||
|
||||
func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T) {
|
||||
calls := 0
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
|
||||
calls++
|
||||
return errors.New("权限不足")
|
||||
@ -126,7 +126,7 @@ func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T)
|
||||
}
|
||||
|
||||
func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) {
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
@ -175,6 +175,11 @@ func TestDiagnoseAliyunError(t *testing.T) {
|
||||
domain: "solit.cn",
|
||||
expected: "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置",
|
||||
},
|
||||
{
|
||||
err: errors.New("无法识别域名 www.example.com 的权威 DNS 托管区,已停止验证:SOA lookup failed"),
|
||||
domain: "www.example.com",
|
||||
expected: "未能从公开 DNS 的 SOA 记录识别该域名唯一的权威托管区,系统未构造阿里云客户端,也未创建临时 TXT 记录。请检查域名是否已正确委派并可被公网 DNS 查询后重试",
|
||||
},
|
||||
{
|
||||
err: nil,
|
||||
domain: "solit.cn",
|
||||
|
||||
@ -75,7 +75,7 @@ func (a *authorizationRecordAPI) recordCall(name, id string, ctx context.Context
|
||||
}
|
||||
|
||||
func newAuthorizationCheckerForTest(api aliyunRecordAPI) *AliyunAuthorizationChecker {
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
|
||||
return api, nil
|
||||
}
|
||||
|
||||
@ -93,8 +93,14 @@ type scopedAliyunProvider struct {
|
||||
newAPI func(map[string]string) (aliyunRecordAPI, error)
|
||||
api aliyunRecordAPI
|
||||
mu sync.Mutex
|
||||
records map[[32]byte]string
|
||||
records map[[32]byte]aliyunPendingRecord
|
||||
challengeInfo func(context.Context, string, string) (string, string, string)
|
||||
zoneResolver AliyunZoneResolver
|
||||
}
|
||||
|
||||
type aliyunPendingRecord struct {
|
||||
ID string
|
||||
Zone string
|
||||
}
|
||||
|
||||
func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvider, error) {
|
||||
@ -104,7 +110,9 @@ func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvid
|
||||
}
|
||||
return &scopedAliyunProvider{
|
||||
credentials: cleaned, newAPI: newAliyunRecordAPI,
|
||||
records: make(map[[32]byte]string), challengeInfo: aliyunChallengeInfo,
|
||||
records: make(map[[32]byte]aliyunPendingRecord),
|
||||
challengeInfo: aliyunChallengeInfo,
|
||||
zoneResolver: newDefaultAliyunZoneResolver(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -115,7 +123,13 @@ 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 := ResolveAliyunTargetDomain(name)
|
||||
if p == nil {
|
||||
return errors.New("阿里云 DNS Provider 未初始化")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
zone, err := resolveAliyunTargetDomain(ctx, name, p.zoneResolver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -152,18 +166,21 @@ func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.records[key] = id
|
||||
p.records[key] = aliyunPendingRecord{ID: id, Zone: zone}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth string) error {
|
||||
if _, err := ResolveAliyunTargetDomain(name); err != nil {
|
||||
return err
|
||||
if p == nil {
|
||||
return errors.New("阿里云 DNS Provider 未初始化")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
key := sha256.Sum256([]byte(normalizeAuthorizationDomain(name) + "\x00" + token + "\x00" + keyAuth))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
id, ok := p.records[key]
|
||||
record, ok := p.records[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
@ -172,7 +189,7 @@ func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
|
||||
defer cancel()
|
||||
if err := p.api.Delete(cleanupCtx, id); err != nil {
|
||||
if err := p.api.Delete(cleanupCtx, record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(p.records, key)
|
||||
|
||||
@ -1,19 +1,109 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
"github.com/go-acme/lego/v5/challenge/dns01"
|
||||
)
|
||||
|
||||
const protectedAliyunDomain = "pcux.cn"
|
||||
const (
|
||||
protectedAliyunDomain = "pcux.cn"
|
||||
aliyunZoneResolutionTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// ResolveAliyunTargetDomain 从本次输入中得到唯一可写入的注册根域。
|
||||
// 它不枚举账号域名、不猜测托管区,也绝不因一次失败改写为父域或其他候选域名。
|
||||
// AliyunZoneResolver 只读识别用户域名当前的权威 DNS 托管区。
|
||||
// 它绝不能枚举 RAM 账号域名,也不能触发任何阿里云写操作。
|
||||
type AliyunZoneResolver interface {
|
||||
Resolve(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
type aliyunZoneResolverFunc func(context.Context, string) (string, error)
|
||||
|
||||
func (f aliyunZoneResolverFunc) Resolve(ctx context.Context, requested string) (string, error) {
|
||||
return f(ctx, requested)
|
||||
}
|
||||
|
||||
type authoritativeAliyunZoneResolver struct {
|
||||
findZone func(context.Context, string) (string, error)
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func newDefaultAliyunZoneResolver() AliyunZoneResolver {
|
||||
return authoritativeAliyunZoneResolver{
|
||||
findZone: func(ctx context.Context, fqdn string) (string, error) {
|
||||
client := dns01.NewClient(&dns01.Options{Timeout: aliyunZoneResolutionTimeout})
|
||||
return client.FindZoneByFqdn(ctx, fqdn)
|
||||
},
|
||||
timeout: aliyunZoneResolutionTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (r authoritativeAliyunZoneResolver) Resolve(ctx context.Context, requested string) (string, error) {
|
||||
base, err := validateAliyunRequestedDomain(requested)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.findZone == nil {
|
||||
return "", errors.New("阿里云权威 DNS 托管区识别器未初始化")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timeout := r.timeout
|
||||
if timeout <= 0 {
|
||||
timeout = aliyunZoneResolutionTimeout
|
||||
}
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
zone, err := r.findZone(resolveCtx, base+".")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("无法识别域名 %s 的权威 DNS 托管区,已停止验证:%w", requested, err)
|
||||
}
|
||||
return normalizeAuthorizationDomain(zone), nil
|
||||
}
|
||||
|
||||
// ResolveAliyunTargetDomain 根据公开 DNS 的 SOA 结果识别唯一权威托管区。
|
||||
// SOA 查询可能读取父标签以定位权威区,但整个过程从不向候选父域写入 TXT 记录。
|
||||
func ResolveAliyunTargetDomain(requested string) (string, error) {
|
||||
return resolveAliyunTargetDomain(context.Background(), requested, newDefaultAliyunZoneResolver())
|
||||
}
|
||||
|
||||
func resolveAliyunTargetDomain(ctx context.Context, requested string, resolver AliyunZoneResolver) (string, error) {
|
||||
base, err := validateAliyunRequestedDomain(requested)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resolver == nil {
|
||||
return "", errors.New("阿里云权威 DNS 托管区识别器未初始化")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
zone, err := resolver.Resolve(ctx, base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
zone = normalizeAuthorizationDomain(zone)
|
||||
if !domain.IsValidName(zone) || strings.HasPrefix(zone, "*.") {
|
||||
return "", fmt.Errorf("权威 DNS 托管区识别结果无效:%s", zone)
|
||||
}
|
||||
if isProtectedAliyunDomain(zone) {
|
||||
return "", fmt.Errorf("域名 %s 的权威 DNS 托管区 %s 受本机保护策略限制", requested, zone)
|
||||
}
|
||||
if base != zone && !strings.HasSuffix(base, "."+zone) {
|
||||
return "", fmt.Errorf("权威 DNS 托管区 %s 不属于待操作域名 %s,已停止验证", zone, requested)
|
||||
}
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
// validateAliyunRequestedDomain 仅做不访问网络的本地格式和保护策略检查。
|
||||
// 这使受保护域在读取凭据、构造客户端或进行 SOA 查询前被拒绝。
|
||||
func validateAliyunRequestedDomain(requested string) (string, error) {
|
||||
requested = normalizeAuthorizationDomain(requested)
|
||||
base := strings.TrimPrefix(requested, "*.")
|
||||
if !domain.IsValidName(requested) || base == "" {
|
||||
@ -22,11 +112,7 @@ func ResolveAliyunTargetDomain(requested string) (string, error) {
|
||||
if isProtectedAliyunDomain(base) {
|
||||
return "", fmt.Errorf("域名 %s 受本机保护策略限制,未构造阿里云客户端或调用云 API", requested)
|
||||
}
|
||||
zone, err := publicsuffix.EffectiveTLDPlusOne(base)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("待操作域名必须包含可注册根域:%s", requested)
|
||||
}
|
||||
return normalizeAuthorizationDomain(zone), nil
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func isProtectedAliyunDomain(name string) bool {
|
||||
@ -44,7 +130,7 @@ func ValidateAliyunTargetDomains(providerID string, domains []string) error {
|
||||
return errors.New("至少需要一个待操作域名")
|
||||
}
|
||||
for _, name := range domains {
|
||||
if _, err := ResolveAliyunTargetDomain(name); err != nil {
|
||||
if _, err := validateAliyunRequestedDomain(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func scopeCredentials() map[string]string {
|
||||
@ -14,34 +15,100 @@ func scopeCredentials() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAliyunTargetDomainUsesOnlyRegistrableRoot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{name: "www.sub.example.com", want: "example.com"},
|
||||
{name: "WWW.EXAMPLE.COM.", want: "example.com"},
|
||||
{name: "*.example.com", want: "example.com"},
|
||||
{name: "example.co.uk", want: "example.co.uk"},
|
||||
func staticAliyunZoneResolver(zone string) AliyunZoneResolver {
|
||||
return aliyunZoneResolverFunc(func(context.Context, string) (string, error) {
|
||||
return zone, nil
|
||||
})
|
||||
}
|
||||
|
||||
func newAliyunAuthorizationCheckerForTest() *AliyunAuthorizationChecker {
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker.zoneResolver = staticAliyunZoneResolver("example.com")
|
||||
return checker
|
||||
}
|
||||
|
||||
func TestResolveAliyunTargetDomainUsesOnlyAuthoritativeZone(t *testing.T) {
|
||||
resolverCalls := make([]string, 0, 1)
|
||||
resolver := aliyunZoneResolverFunc(func(_ context.Context, requested string) (string, error) {
|
||||
resolverCalls = append(resolverCalls, requested)
|
||||
if requested != "www.sub.example.com" {
|
||||
t.Fatalf("托管区识别收到了错误域名:%s", requested)
|
||||
}
|
||||
return "sub.example.com.", nil
|
||||
})
|
||||
|
||||
got, err := resolveAliyunTargetDomain(t.Context(), "www.sub.example.com", resolver)
|
||||
if err != nil || got != "sub.example.com" {
|
||||
t.Fatalf("权威托管区识别结果错误:got=%q err=%v", got, err)
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ResolveAliyunTargetDomain(test.name)
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("ResolveAliyunTargetDomain(%q) = %q, %v; want %q", test.name, got, err, test.want)
|
||||
}
|
||||
})
|
||||
if len(resolverCalls) != 1 {
|
||||
t.Fatalf("不应枚举候选父域:resolverCalls=%v", resolverCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAliyunTargetDomainRejectsProtectedAndInvalidDomains(t *testing.T) {
|
||||
func TestAuthoritativeAliyunZoneResolverUsesFQDNAndBoundedDeadline(t *testing.T) {
|
||||
const timeout = 80 * time.Millisecond
|
||||
called := false
|
||||
resolver := authoritativeAliyunZoneResolver{
|
||||
timeout: timeout,
|
||||
findZone: func(ctx context.Context, fqdn string) (string, error) {
|
||||
called = true
|
||||
if fqdn != "www.sub.example.com." {
|
||||
t.Fatalf("权威托管区查询必须使用完整域名:%q", fqdn)
|
||||
}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("权威托管区查询必须具有截止时间")
|
||||
}
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 || remaining > timeout {
|
||||
t.Fatalf("权威托管区查询截止时间未受限:%s", remaining)
|
||||
}
|
||||
return "sub.example.com.", nil
|
||||
},
|
||||
}
|
||||
|
||||
zone, err := resolver.Resolve(t.Context(), "WWW.Sub.Example.Com.")
|
||||
if err != nil || zone != "sub.example.com" {
|
||||
t.Fatalf("权威托管区解析结果错误:zone=%q err=%v", zone, err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("未执行权威托管区查询")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAliyunTargetDomainRejectsProtectedAndInvalidDomainsBeforeResolver(t *testing.T) {
|
||||
resolverCalls := 0
|
||||
resolver := aliyunZoneResolverFunc(func(context.Context, string) (string, error) {
|
||||
resolverCalls++
|
||||
return "example.com", nil
|
||||
})
|
||||
for _, name := range []string{"pcux.cn", "*.pcux.cn", "api.pcux.cn", "com", "*.com", ""} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := ResolveAliyunTargetDomain(name); err == nil {
|
||||
t.Fatalf("ResolveAliyunTargetDomain(%q) unexpectedly succeeded", name)
|
||||
if _, err := resolveAliyunTargetDomain(t.Context(), name, resolver); err == nil {
|
||||
t.Fatalf("resolveAliyunTargetDomain(%q) unexpectedly succeeded", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if resolverCalls != 0 {
|
||||
t.Fatalf("受保护或无效域名不应发起 SOA 查询:%d", resolverCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAliyunTargetDomainFailsClosedWhenAuthoritativeZoneCannotBeIdentified(t *testing.T) {
|
||||
resolver := aliyunZoneResolverFunc(func(context.Context, string) (string, error) {
|
||||
return "", errors.New("SOA lookup failed")
|
||||
})
|
||||
if _, err := resolveAliyunTargetDomain(t.Context(), "www.example.com", resolver); err == nil || !strings.Contains(err.Error(), "SOA lookup failed") {
|
||||
t.Fatalf("权威托管区识别失败必须阻止后续操作:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAliyunTargetDomainRejectsUnrelatedAuthoritativeZone(t *testing.T) {
|
||||
resolver := staticAliyunZoneResolver("unrelated.example.net")
|
||||
if _, err := resolveAliyunTargetDomain(t.Context(), "www.example.com", resolver); err == nil || !strings.Contains(err.Error(), "不属于") {
|
||||
t.Fatalf("无关的权威托管区必须失败关闭:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAliyunRecords struct {
|
||||
@ -87,9 +154,10 @@ func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error {
|
||||
return f.deleteErr
|
||||
}
|
||||
|
||||
func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) {
|
||||
func TestAliyunProbeUsesTheSingleAuthoritativeZoneWithoutFallback(t *testing.T) {
|
||||
api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")}
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.zoneResolver = staticAliyunZoneResolver("sub.example.com")
|
||||
constructed := 0
|
||||
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
|
||||
constructed++
|
||||
@ -99,8 +167,8 @@ func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) {
|
||||
if _, err := checker.Verify(t.Context(), "www.sub.example.com", scopeCredentials()); err == nil {
|
||||
t.Fatal("RAM denial unexpectedly succeeded")
|
||||
}
|
||||
if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" {
|
||||
t.Fatalf("probe did not use only the registrable root: constructed=%d zones=%v", constructed, api.zones)
|
||||
if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "sub.example.com" {
|
||||
t.Fatalf("探测未严格使用唯一权威托管区:constructed=%d zones=%v", constructed, api.zones)
|
||||
}
|
||||
if len(api.deleted) != 0 {
|
||||
t.Fatalf("failed Add must not guess a record ID to delete: %v", api.deleted)
|
||||
@ -109,7 +177,7 @@ func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) {
|
||||
|
||||
func TestAliyunProtectedDomainDoesNotConstructClientOrCallCloud(t *testing.T) {
|
||||
api := &fakeAliyunRecords{}
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
constructed := 0
|
||||
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
|
||||
constructed++
|
||||
@ -126,6 +194,26 @@ func TestAliyunProtectedDomainDoesNotConstructClientOrCallCloud(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunAuthorizationDoesNotConstructClientWhenZoneResolutionFails(t *testing.T) {
|
||||
api := &fakeAliyunRecords{}
|
||||
checker := newAliyunAuthorizationCheckerForTest()
|
||||
checker.zoneResolver = aliyunZoneResolverFunc(func(context.Context, string) (string, error) {
|
||||
return "", errors.New("SOA lookup failed")
|
||||
})
|
||||
constructed := 0
|
||||
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
|
||||
constructed++
|
||||
return api, nil
|
||||
}
|
||||
|
||||
if _, err := checker.Verify(t.Context(), "www.example.com", scopeCredentials()); err == nil {
|
||||
t.Fatal("权威托管区识别失败时不应继续探测")
|
||||
}
|
||||
if constructed != 0 || len(api.zones) != 0 || len(api.described) != 0 || len(api.deleted) != 0 {
|
||||
t.Fatalf("权威托管区识别失败时触碰了云端:constructed=%d zones=%v described=%v deleted=%v", constructed, api.zones, api.described, api.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T) {
|
||||
provider, err := newScopedAliyunProvider(scopeCredentials())
|
||||
if err != nil {
|
||||
@ -137,6 +225,7 @@ func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T)
|
||||
constructed++
|
||||
return api, nil
|
||||
}
|
||||
provider.zoneResolver = staticAliyunZoneResolver("sub.example.com")
|
||||
provider.challengeInfo = func(_ context.Context, name, _ string) (string, string, string) {
|
||||
fqdn := "_acme-challenge." + name + "."
|
||||
return fqdn, fqdn, "txt-value"
|
||||
@ -148,8 +237,8 @@ func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T)
|
||||
if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" || api.rrs[0] != "_acme-challenge.www.sub" {
|
||||
t.Fatalf("provider wrote outside its single registrable root: constructed=%d zones=%v rrs=%v", constructed, api.zones, api.rrs)
|
||||
if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "sub.example.com" || api.rrs[0] != "_acme-challenge.www" {
|
||||
t.Fatalf("provider 写入位置不属于唯一权威托管区:constructed=%d zones=%v rrs=%v", constructed, api.zones, api.rrs)
|
||||
}
|
||||
if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil {
|
||||
t.Fatal("duplicate challenge unexpectedly overwrote a pending record")
|
||||
@ -181,6 +270,7 @@ func TestAliyunProviderRejectsCNAMEBeforeClientConstruction(t *testing.T) {
|
||||
constructed++
|
||||
return &fakeAliyunRecords{}, nil
|
||||
}
|
||||
provider.zoneResolver = staticAliyunZoneResolver("example.com")
|
||||
provider.challengeInfo = func(context.Context, string, string) (string, string, string) {
|
||||
return "_acme-challenge.www.sub.example.com.", "_acme-challenge.delegated.example.net.", "txt-value"
|
||||
}
|
||||
@ -192,3 +282,25 @@ func TestAliyunProviderRejectsCNAMEBeforeClientConstruction(t *testing.T) {
|
||||
t.Fatalf("CNAME redirection constructed a cloud client: %d", constructed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunProviderDoesNotConstructClientWhenZoneResolutionFails(t *testing.T) {
|
||||
provider, err := newScopedAliyunProvider(scopeCredentials())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.zoneResolver = aliyunZoneResolverFunc(func(context.Context, string) (string, error) {
|
||||
return "", errors.New("SOA lookup failed")
|
||||
})
|
||||
constructed := 0
|
||||
provider.newAPI = func(map[string]string) (aliyunRecordAPI, error) {
|
||||
constructed++
|
||||
return &fakeAliyunRecords{}, nil
|
||||
}
|
||||
|
||||
if err := provider.Present(t.Context(), "www.example.com", "token", "key"); err == nil {
|
||||
t.Fatal("权威托管区识别失败时不应继续写入")
|
||||
}
|
||||
if constructed != 0 {
|
||||
t.Fatalf("权威托管区识别失败时不应构造阿里云客户端:%d", constructed)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user