From aa3b5f17a18c4257b42486492cfeb15f8353979d Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 15 Sep 2026 19:05:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8D=95=E5=9F=9F=E5=90=8DRA?= =?UTF-8?q?M=E6=9D=83=E9=99=90=E6=A3=80=E6=9F=A5=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=B9=B6=E9=AA=8C=E8=AF=81=E6=9C=AA=E6=8E=88=E6=9D=83=E7=AD=BE?= =?UTF-8?q?=E5=8F=91=E9=98=BB=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/certificate_authorization_test.go | 39 ++++++ .../certificate/authorization_test.go | 79 +++++++++++ backend/internal/cli/dns.go | 85 ++++++++++++ backend/internal/cli/dns_test.go | 123 ++++++++++++++++++ backend/internal/cli/root.go | 2 +- 5 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 backend/internal/app/certificate_authorization_test.go create mode 100644 backend/internal/certificate/authorization_test.go create mode 100644 backend/internal/cli/dns.go create mode 100644 backend/internal/cli/dns_test.go diff --git a/backend/internal/app/certificate_authorization_test.go b/backend/internal/app/certificate_authorization_test.go new file mode 100644 index 0000000..0a7c703 --- /dev/null +++ b/backend/internal/app/certificate_authorization_test.go @@ -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("权限失败不得报告工作流成功") + } +} diff --git a/backend/internal/certificate/authorization_test.go b/backend/internal/certificate/authorization_test.go new file mode 100644 index 0000000..0dea24e --- /dev/null +++ b/backend/internal/certificate/authorization_test.go @@ -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) + } +} diff --git a/backend/internal/cli/dns.go b/backend/internal/cli/dns.go new file mode 100644 index 0000000..3c4fde6 --- /dev/null +++ b/backend/internal/cli/dns.go @@ -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 +} diff --git a/backend/internal/cli/dns_test.go b/backend/internal/cli/dns_test.go new file mode 100644 index 0000000..8122fb8 --- /dev/null +++ b/backend/internal/cli/dns_test.go @@ -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 +} diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index a20fdb6..a3a83cd 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -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