增加单域名RAM权限检查命令并验证未授权签发阻断

This commit is contained in:
xuans 2026-09-15 19:05:57 +08:00
parent db54a7362e
commit aa3b5f17a1
5 changed files with 327 additions and 1 deletions

View File

@ -0,0 +1,39 @@
package app
import (
"context"
"errors"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
type deniedWorkflowCertificateService struct {
workflowCertificateService
}
func (*deniedWorkflowCertificateService) Issue(context.Context, string) (certificate.IssueResult, error) {
return certificate.IssueResult{}, errors.New("DNS 凭据无权操作域名:Forbidden.NoPermission")
}
type forbiddenDeployer struct {
t *testing.T
}
func (d forbiddenDeployer) DeploySaved(context.Context, []deployment.Target, deployment.SavedFiles) (deployment.Report, error) {
d.t.Fatal("权限检查失败后不得发起 SSH/SFTP 部署")
return deployment.Report{}, nil
}
func TestWorkflowDoesNotDeployAfterDNSPermissionDenied(t *testing.T) {
workflow := &CertificateWorkflow{
Certificates: &deniedWorkflowCertificateService{},
Domains: &workflowDomainState{},
Servers: workflowServerLister{servers: []deployment.Server{{ID: "target"}}},
Deployment: forbiddenDeployer{t: t},
}
if _, err := workflow.IssueWithReport(t.Context(), "domain"); err == nil {
t.Fatal("权限失败不得报告工作流成功")
}
}

View File

@ -0,0 +1,79 @@
package certificate
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/go-acme/lego/v5/challenge"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
type scopedAuthorizationChecker struct {
denied string
calls []string
}
func (c *scopedAuthorizationChecker) VerifyDomain(_ context.Context, _ string, name string, _ map[string]string) error {
c.calls = append(c.calls, name)
if name == c.denied {
return errors.New("Forbidden.NoPermission")
}
return nil
}
func TestServiceRejectsUnauthorizedSANBeforeIssuerOrFileWrites(t *testing.T) {
for _, denied := range []string{"example.com", "other.example.org"} {
t.Run(denied, func(t *testing.T) {
cfg := certificateTestConfig(t.TempDir())
cfg.DNS.Provider = "aliyun"
item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSAN, []string{"other.example.org"}, false, "staging", "aliyun")
if err != nil {
t.Fatal(err)
}
provider := &recordingProviderFactory{}
repo := &serviceRepository{item: item}
checker := &scopedAuthorizationChecker{denied: denied}
service := NewService(cfg, repo, serviceCredentials{}, provider, serviceAccounts{})
service.SetDNSAuthorizationChecker(checker)
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer {
t.Fatal("任意 SAN 权限拒绝后不得创建 ACME 签发器")
return nil
}
_, err = service.Issue(t.Context(), item.ID)
if err == nil || !strings.Contains(err.Error(), denied) || !strings.Contains(err.Error(), "Forbidden.NoPermission") {
t.Fatalf("必须拒绝并指出无权限的 SAN:%v", err)
}
if provider.providerID != "" {
t.Fatal("权限拒绝后不得创建 DNS 挑战 Provider")
}
if repo.item.Status != "failed" {
t.Fatalf("失败状态未保存:%s", repo.item.Status)
}
if _, err := os.Stat(cfg.Paths.Certificate); !errors.Is(err, os.ErrNotExist) {
t.Fatal("权限拒绝后不得写入证书文件")
}
})
}
}
func TestSetupRejectsUnauthorizedDomainBeforeIssuer(t *testing.T) {
cfg := certificateTestConfig(t.TempDir())
service := NewService(cfg, nil, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
service.SetDNSAuthorizationChecker(&scopedAuthorizationChecker{denied: "other.example.org"})
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer {
t.Fatal("初始化证书中的未授权域名必须在 ACME 请求前拒绝")
return nil
}
_, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{
Domains: []string{"example.com", "other.example.org"}, CertificateType: "san",
ProviderID: "aliyun", CredentialRef: "secret://dns/test", Environment: "staging",
CertificateRoot: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup,
})
if err == nil || !strings.Contains(err.Error(), "other.example.org") {
t.Fatalf("初始化证书申请未拒绝未授权域名:%v", err)
}
}

View File

@ -0,0 +1,85 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
)
type dnsPermissionVerifier func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error)
func newDNSCommand() *cobra.Command {
command := &cobra.Command{Use: "dns", Short: "检查指定域名的阿里云 RAM 权限"}
checker := dnsprovider.NewAliyunAuthorizationChecker()
command.AddCommand(newDNSCheckCommand(checker.Verify))
return command
}
// 权限检查不启动应用、续期调度或数据库,也不调用全局域名发现。
func newDNSCheckCommand(verify dnsPermissionVerifier) *cobra.Command {
var requestedDomain string
var confirmWrite bool
command := &cobra.Command{
Use: "check",
Short: "对指定域名写入临时 TXT 并精确清理,验证当前权限",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
requestedDomain = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(requestedDomain), "."))
if !domain.IsValidName(requestedDomain) {
return errors.New("必须提供有效的待检查域名")
}
if !confirmWrite {
return errors.New("此检查会写入并删除专用临时 TXT 记录;确认已获目标域名测试授权后,请添加 --confirm-dns-write")
}
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.DNS.Provider != dnsprovider.Aliyun || strings.TrimSpace(cfg.DNS.CredentialRef) == "" {
return errors.New("请先配置阿里云 DNS 凭据引用")
}
// 不允许诊断入口在密钥缺失时生成新密钥,避免掩盖原有凭据损坏。
for _, name := range []string{"master.key", "secrets.json"} {
info, err := os.Stat(filepath.Join(cfg.Paths.Config, name))
if err != nil || !info.Mode().IsRegular() {
return fmt.Errorf("现有凭据文件 %s 不可用,请检查配置目录", name)
}
}
store, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
return err
}
credentials, err := store.Get(cmd.Context(), cfg.DNS.CredentialRef)
if err != nil {
return fmt.Errorf("读取阿里云 DNS 凭据失败:%w", err)
}
ctx, cancel := context.WithTimeout(cmd.Context(), 45*time.Second)
defer cancel()
report, verifyErr := verify(ctx, requestedDomain, credentials)
if err := printJSON(cmd, report); err != nil {
return err
}
if verifyErr != nil {
return fmt.Errorf("域名权限检查未通过:%w", verifyErr)
}
if !report.Verified {
return errors.New("域名权限检查未通过:未获得写入及清理均成功的证据")
}
return nil
},
}
command.Flags().StringVar(&requestedDomain, "domain", "", "明确授权检查的单个域名,不枚举全账号域名")
command.Flags().BoolVar(&confirmWrite, "confirm-dns-write", false, "确认允许专用临时 TXT 记录写入和精确清理")
_ = command.MarkFlagRequired("domain")
return command
}

View File

@ -0,0 +1,123 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
)
func TestDNSCheckRequiresExplicitWriteConfirmation(t *testing.T) {
called := false
command := newDNSCheckCommand(func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) {
called = true
return dnsprovider.AuthorizationReport{}, nil
})
command.SetArgs([]string{"--domain", "example.com"})
if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "--confirm-dns-write") {
t.Fatalf("缺少写入确认时必须拒绝:%v", err)
}
if called {
t.Fatal("确认前不得调用云端 API")
}
}
func TestDNSCheckReportsLiveResultWithoutPersistingConfiguration(t *testing.T) {
for _, outcome := range []string{"allowed", "denied", "unknown", "unverified"} {
t.Run(outcome, func(t *testing.T) {
cfg := prepareDNSCheckConfig(t)
before, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
calls := 0
command := newDNSCheckCommand(func(ctx context.Context, name string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
calls++
if name != "example.com" || credentials["access_key_id"] != "test-id" {
t.Fatal("检查目标或凭据来源不正确")
}
if _, ok := ctx.Deadline(); !ok {
t.Fatal("权限请求必须设置超时")
}
report := dnsprovider.AuthorizationReport{Provider: "aliyun", RequestedDomain: name, Verified: outcome == "allowed"}
switch outcome {
case "denied":
return report, errors.New("Forbidden.NoPermission")
case "unknown":
return report, context.DeadlineExceeded
default:
return report, nil
}
})
var output bytes.Buffer
command.SilenceErrors, command.SilenceUsage = true, true
command.SetOut(&output)
command.SetArgs([]string{"--domain", "EXAMPLE.COM.", "--confirm-dns-write"})
err = command.Execute()
if (err == nil) != (outcome == "allowed") || calls != 1 {
t.Fatalf("检查结果错误:outcome=%s calls=%d err=%v", outcome, calls, err)
}
var report dnsprovider.AuthorizationReport
if err := json.Unmarshal(output.Bytes(), &report); err != nil || report.Verified != (outcome == "allowed") {
t.Fatalf("必须输出可机器读取的真实报告:%s", output.String())
}
if strings.Contains(output.String(), "test-secret") || strings.Contains(output.String(), "test-id") {
t.Fatal("输出不得包含凭据")
}
after, err := os.ReadFile(configPath)
if err != nil || !bytes.Equal(before, after) {
t.Fatal("权限诊断不得回写配置或初始化状态")
}
if _, err := os.Stat(cfg.Paths.Data); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("权限诊断不得初始化业务数据库:%v", err)
}
})
}
}
func TestDNSCheckDoesNotCreateMissingMasterKey(t *testing.T) {
oldPath := configPath
t.Cleanup(func() { configPath = oldPath })
cfg := config.Default(t.TempDir())
cfg.DNS.Provider, cfg.DNS.CredentialRef = "aliyun", "secret://dns/check"
configPath = filepath.Join(cfg.Paths.Config, "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatal(err)
}
command := newDNSCheckCommand(nil)
command.SetArgs([]string{"--domain", "example.com", "--confirm-dns-write"})
if err := command.Execute(); err == nil {
t.Fatal("主密钥缺失必须拒绝")
}
if _, err := os.Stat(filepath.Join(cfg.Paths.Config, "master.key")); !errors.Is(err, os.ErrNotExist) {
t.Fatal("诊断不得创建新主密钥")
}
}
func prepareDNSCheckConfig(t *testing.T) config.Config {
t.Helper()
oldPath := configPath
t.Cleanup(func() { configPath = oldPath })
cfg := config.Default(t.TempDir())
cfg.DNS.Provider, cfg.DNS.CredentialRef = "aliyun", "secret://dns/check"
configPath = filepath.Join(cfg.Paths.Config, "scert.json")
if err := cfg.Save(configPath); err != nil {
t.Fatal(err)
}
store, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
t.Fatal(err)
}
if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}); err != nil {
t.Fatal(err)
}
return cfg
}

View File

@ -74,7 +74,7 @@ func newRootCommand() *cobra.Command {
&cobra.Command{Use: "version", Short: "显示版本信息", Run: func(cmd *cobra.Command, args []string) { cmd.Printf("Scert %s\n", version) }},
newInitCommand(), newStatusCommand(), newServeCommand(), newDomainCommand(), newCertificateCommand(),
newRenewCommand(), newDeployCommand(), newLogsCommand(), newConfigCommand(), newBackupCommand(), newRestoreCommand(),
newServerCommand(), newSSHKeyCommand(), newServiceCommand(),
newServerCommand(), newSSHKeyCommand(), newServiceCommand(), newDNSCommand(),
)
localizeHelpFlags(root)
return root