增加单域名RAM权限检查命令并验证未授权签发阻断
This commit is contained in:
parent
db54a7362e
commit
aa3b5f17a1
39
backend/internal/app/certificate_authorization_test.go
Normal file
39
backend/internal/app/certificate_authorization_test.go
Normal 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("权限失败不得报告工作流成功")
|
||||
}
|
||||
}
|
||||
79
backend/internal/certificate/authorization_test.go
Normal file
79
backend/internal/certificate/authorization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
85
backend/internal/cli/dns.go
Normal file
85
backend/internal/cli/dns.go
Normal 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
|
||||
}
|
||||
123
backend/internal/cli/dns_test.go
Normal file
123
backend/internal/cli/dns_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user