完成阿里云RAM权限双轨考证与域名操作前置拦截

This commit is contained in:
xuans 2026-09-15 11:30:24 +08:00
parent 0dbc20d0b6
commit 8fa1c82730
6 changed files with 444 additions and 4 deletions

View File

@ -114,7 +114,8 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
sshKeys := sshkey.NewService(secretStore)
accounts := certificate.NewAccountRepository(secretStore)
certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts)
certificates.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker())
aliyunAuthChecker := dnsprovider.NewAliyunAuthorizationChecker()
certificates.SetDNSAuthorizationChecker(aliyunAuthChecker)
deploymentService := deployment.NewService(deployment.SSHFactory{Secrets: secretStore})
notifier := notification.New(cfg.Notification, secretStore)
remoteValidator := certificate.RemoteValidator{}
@ -140,7 +141,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
})
server := httpapi.New(httpapi.Dependencies{
Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys,
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(),
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
IssueCertificate: workflow.IssueForHTTP,
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {
return certificates.IssueForSetup(ctx, request)

View File

@ -42,6 +42,7 @@ type AuthorizationReport struct {
MatchedDomain string `json:"matched_domain,omitempty"`
Domains []AuthorizationDomain `json:"domains"`
Verified bool `json:"verified"`
Diagnosis string `json:"diagnosis,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
@ -124,7 +125,8 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
if lastErr == nil {
lastErr = errors.New("未找到可验证的阿里云 DNS 托管根域名")
}
return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w", requestedDomain, lastErr)
report.Diagnosis = DiagnoseAliyunError(lastErr, requestedDomain)
return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis)
}
// VerifyDomain 实现证书服务使用的最小授权检查接口。
@ -373,3 +375,22 @@ func uniqueStrings(values []string) []string {
}
return result
}
// DiagnoseAliyunError 对阿里云 API 返回的错误进行分类并提供明确的修复指导。
func DiagnoseAliyunError(err error, domainName string) string {
if err == nil {
return ""
}
msg := err.Error()
lowered := strings.ToLower(msg)
if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") {
return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/%s 的操作权限(Action: alidns:*)", domainName, domainName)
}
if strings.Contains(lowered, "nosuchdomain") || strings.Contains(lowered, "invaliddomainname.noexist") {
return fmt.Sprintf("域名 %s 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名", domainName)
}
if strings.Contains(lowered, "invalidaccesskeyid.notfound") || strings.Contains(lowered, "signaturedoesnotmatch") {
return "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置"
}
return fmt.Sprintf("阿里云 RAM 权限验证未通过:%s", msg)
}

View File

@ -121,3 +121,39 @@ func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) {
<-done
}
}
func TestDiagnoseAliyunError(t *testing.T) {
testCases := []struct {
err error
domain string
expected string
}{
{
err: errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized."),
domain: "solit.cn",
expected: "当前阿里云 RAM 账号未获得域名 solit.cn 的 DNS 解析权限。请在阿里云 RAM 控制台授予该账号针对 acs:alidns:*:*:domain/solit.cn 的操作权限(Action: alidns:*)",
},
{
err: errors.New("SDKError: Code: NoSuchDomain, Message: The specified domain name does not exist."),
domain: "nonexistent.cn",
expected: "域名 nonexistent.cn 未在当前阿里云账号下添加 DNS 托管,请先在阿里云云解析 DNS 控制台添加此域名",
},
{
err: errors.New("SDKError: Code: InvalidAccessKeyId.NotFound, Message: Specified access key is not found."),
domain: "solit.cn",
expected: "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置",
},
{
err: nil,
domain: "solit.cn",
expected: "",
},
}
for _, tc := range testCases {
got := DiagnoseAliyunError(tc.err, tc.domain)
if got != tc.expected {
t.Errorf("DiagnoseAliyunError(%v, %s) = %q; want %q", tc.err, tc.domain, got, tc.expected)
}
}
}

View File

@ -0,0 +1,189 @@
package httpapi
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
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
}
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"`
}
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"),
}
// 读取当前系统内已经纳管的域名列表
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
}
// 尝试全局发现
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 账号受特定域名资源策略限制(最小权限原则),不支持全局枚举,已启用单域名靶向权威考证"
}
c.JSON(http.StatusOK, response)
}
func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
var request aliyunDomainCheckRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
targetDomain := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(request.Domain), "."))
if !domain.IsValidName(targetDomain) {
s.writeError(c, http.StatusBadRequest, fmt.Sprintf("域名格式无效:%s", targetDomain), nil)
return
}
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 {
s.writeError(c, http.StatusBadRequest, "当前系统 DNS 服务商不是阿里云,无法进行阿里云 RAM 权限考证", nil)
return
}
credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err)
return
}
if s.aliyunChecker == nil {
s.writeError(c, http.StatusNotImplemented, "阿里云权限探测器未装载", nil)
return
}
report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials)
if verifyErr != nil {
c.JSON(http.StatusOK, gin.H{
"verified": false,
"domain": targetDomain,
"matched_domain": report.MatchedDomain,
"diagnosis": report.Diagnosis,
"error": verifyErr.Error(),
"report": report,
})
return
}
c.JSON(http.StatusOK, gin.H{
"verified": true,
"domain": targetDomain,
"matched_domain": report.MatchedDomain,
"message": "阿里云 RAM 账号对该域名的 DNS TXT 记录写入与清理权限验证通过",
"report": report,
})
}
func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainName string) error {
if s == nil || s.aliyunChecker == nil {
return nil
}
s.configMu.RLock()
setupReady := s.config.SetupReady
provider := strings.ToLower(strings.TrimSpace(s.config.DNS.Provider))
credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef)
s.configMu.RUnlock()
if !setupReady || provider != dnsprovider.Aliyun || credentialRef == "" {
return nil
}
credentials, err := s.readDNSCredentials(ctx, credentialRef)
if err != nil || len(credentials) == 0 {
return nil
}
if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil {
diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName)
return fmt.Errorf("阿里云 RAM 账号无法操作域名 %s:%s", domainName, diagnosis)
}
return nil
}
func (s *Server) readDNSCredentials(ctx context.Context, credentialRef string) (map[string]string, error) {
if s == nil || strings.TrimSpace(credentialRef) == "" {
return nil, errors.New("DNS 凭据引用未配置")
}
reader, ok := s.secrets.(readinessSecretReader)
if !ok || reader == nil {
return nil, errors.New("凭据存储未就绪")
}
return reader.Get(ctx, credentialRef)
}

View File

@ -0,0 +1,168 @@
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
)
type fakeAliyunScopeChecker struct {
allowedZones []string
}
func (f *fakeAliyunScopeChecker) Discover(ctx context.Context, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
// 模拟 RAM 账号处于最小资源限制模式,无全局枚举权限
return dnsprovider.AuthorizationReport{}, errors.New("SDKError: Code: Forbidden.NoPermission")
}
func (f *fakeAliyunScopeChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
report := dnsprovider.AuthorizationReport{
Provider: dnsprovider.Aliyun,
RequestedDomain: requestedDomain,
}
for _, zone := range f.allowedZones {
if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) {
report.Verified = true
report.MatchedDomain = zone
return report, nil
}
}
err := errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized.")
report.Diagnosis = dnsprovider.DiagnoseAliyunError(err, requestedDomain)
return report, err
}
func (f *fakeAliyunScopeChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error {
for _, zone := range f.allowedZones {
if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) {
return nil
}
}
return errors.New("SDKError: Code: Forbidden.NoPermission, Message: Specified access key is not authorized.")
}
type fakeSecretReaderStore struct {
data map[string]map[string]string
}
func (s *fakeSecretReaderStore) Put(ctx context.Context, ref string, val map[string]string) error {
if s.data == nil {
s.data = make(map[string]map[string]string)
}
s.data[ref] = val
return nil
}
func (s *fakeSecretReaderStore) Delete(ctx context.Context, ref string) error {
delete(s.data, ref)
return nil
}
func (s *fakeSecretReaderStore) Get(ctx context.Context, ref string) (map[string]string, error) {
if val, ok := s.data[ref]; ok {
return val, nil
}
return nil, errors.New("secret not found")
}
func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.SetupReady = true
cfg.DNS.Provider = "aliyun"
cfg.DNS.CredentialRef = "secret://dns/aliyun/ref1"
store := &fakeSecretReaderStore{
data: map[string]map[string]string{
"secret://dns/aliyun/ref1": {
"access_key_id": "LTAI_test",
"access_key_secret": "test_secret",
},
},
}
checker := &fakeAliyunScopeChecker{
allowedZones: []string{"solit.cn", "odacm.cn", "apmmk.cn", "ifmj.uno", "vraq.uno"},
}
repo := &stubDomainRepository{}
server := New(Dependencies{
Config: cfg,
Repository: repo,
Secrets: store,
AliyunChecker: checker,
})
// 1. 测试 GET /api/v1/dns/aliyun/scope(受限模式报告)
{
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dns/aliyun/scope", nil)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/v1/dns/aliyun/scope status = %d; want 200, body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "resource_scoped") {
t.Fatalf("expected resource_scoped policy mode, got: %s", w.Body.String())
}
}
// 2. 测试 POST /api/v1/dns/aliyun/check 验证授权域名(如 solit.cn)
{
w := httptest.NewRecorder()
body := strings.NewReader(`{"domain":"solit.cn"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusOK {
t.Fatalf("POST check solit.cn status = %d; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"verified":true`) {
t.Fatalf("solit.cn 应验证通过: %s", w.Body.String())
}
}
// 3. 测试 POST /api/v1/dns/aliyun/check 验证未授权域名(如 unauth.com)
{
w := httptest.NewRecorder()
body := strings.NewReader(`{"domain":"unauth.com"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusOK {
t.Fatalf("POST check unauth.com status = %d; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"verified":false`) {
t.Fatalf("unauth.com 应验证失败: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "acs:alidns:*:*:domain/unauth.com") {
t.Fatalf("应包含明确的 RAM 策略建议: %s", w.Body.String())
}
}
// 4. 测试 POST /api/v1/domains 添加授权域名(例如 *.solit.cn)
{
w := httptest.NewRecorder()
body := strings.NewReader(`{"name":"test.solit.cn","certificate_type":"single"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", body)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusCreated {
t.Fatalf("创建授权域名应成功,got status = %d; body=%s", w.Code, w.Body.String())
}
}
// 5. 测试 POST /api/v1/domains 添加未授权域名(例如 forbidden.cn),应被前置阻断
{
w := httptest.NewRecorder()
body := strings.NewReader(`{"name":"forbidden.cn","certificate_type":"single"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", body)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("添加未授权域名应被 400 阻断,got status = %d; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "acs:alidns:*:*:domain/forbidden.cn") {
t.Fatalf("阻断提示应包含针对该域名的 RAM 授权指导,got: %s", w.Body.String())
}
}
}

View File

@ -64,6 +64,7 @@ type Dependencies struct {
Audit repository.AuditRepository
Tasks repository.TaskRepository
ReadinessCheck func(context.Context) error
AliyunChecker aliyunScopeChecker
}
type Server struct {
@ -93,6 +94,7 @@ type Server struct {
audit repository.AuditRepository
tasks repository.TaskRepository
readinessCheck func(context.Context) error
aliyunChecker aliyunScopeChecker
taskMu sync.Mutex
taskCancels map[string]context.CancelFunc
issueTaskMu sync.Mutex
@ -135,6 +137,7 @@ func New(deps Dependencies) *Server {
audit: deps.Audit,
tasks: deps.Tasks,
readinessCheck: deps.ReadinessCheck,
aliyunChecker: deps.AliyunChecker,
taskCancels: make(map[string]context.CancelFunc),
resetLimiter: newResetRateLimiter(),
authSessions: make(map[string]authSession),
@ -180,6 +183,8 @@ func (s *Server) registerRoutes() {
adminAPI.PUT("/settings", s.updateSettings)
adminAPI.GET("/dns/providers", s.listAdminDNSProviders)
adminAPI.POST("/dns/test", s.testAdminDNS)
adminAPI.GET("/dns/aliyun/scope", s.getAliyunDNSScope)
adminAPI.POST("/dns/aliyun/check", s.checkAliyunDNSDomain)
adminAPI.POST("/renewal/run", s.runRenewalNow)
adminAPI.GET("/certificates", s.listCertificates)
adminAPI.GET("/certificates/:id", s.getCertificate)
@ -453,6 +458,16 @@ func (s *Server) createDomain(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
return
}
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
for _, san := range item.SANs {
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
}
if err := s.repository.Create(c.Request.Context(), item); err != nil {
if repository.IsConflict(err) {
s.writeError(c, http.StatusConflict, "域名已经存在", err)
@ -487,6 +502,16 @@ func (s *Server) updateDomain(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
return
}
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
for _, san := range item.SANs {
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
item.Status = "pending"
@ -764,4 +789,4 @@ func (s *Server) writeError(c *gin.Context, status int, message string, err erro
s.logger.Error(message, "error", err)
}
c.JSON(status, gin.H{"error": message})
}
}