diff --git a/backend/internal/certificate/authorization_test.go b/backend/internal/certificate/authorization_test.go index 0dea24e..331564a 100644 --- a/backend/internal/certificate/authorization_test.go +++ b/backend/internal/certificate/authorization_test.go @@ -77,3 +77,26 @@ func TestSetupRejectsUnauthorizedDomainBeforeIssuer(t *testing.T) { t.Fatalf("初始化证书申请未拒绝未授权域名:%v", err) } } + +func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + cfg.DNS.Provider = "aliyun" + item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSingle, nil, false, "staging", "aliyun") + if err != nil { + t.Fatal(err) + } + provider := &recordingProviderFactory{} + service := NewService(cfg, &serviceRepository{item: item}, serviceCredentials{}, provider, serviceAccounts{}) + if _, err := service.Issue(t.Context(), item.ID); err == nil || !strings.Contains(err.Error(), "探测器未初始化") { + t.Fatalf("缺失检查器必须阻止签发:%v", err) + } + if _, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{ + Domains: []string{"example.com"}, CertificateType: "single", ProviderID: "aliyun", + CredentialRef: cfg.DNS.CredentialRef, Environment: "staging", + }); err == nil || !strings.Contains(err.Error(), "探测器未初始化") { + t.Fatalf("缺失检查器必须阻止初始化证书签发:%v", err) + } + if provider.providerID != "" { + t.Fatal("缺失检查器时不得创建 DNS 挑战 Provider") + } +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index a39502f..ec08f29 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -12,6 +12,7 @@ import ( "github.com/go-acme/lego/v5/challenge" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" "git.pcux.cn/xuans/Scert/backend/internal/domain" ) @@ -270,6 +271,9 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error { if s == nil || s.authorization == nil { + if strings.EqualFold(strings.TrimSpace(providerID), dnsprovider.Aliyun) { + return errors.New("阿里云权限探测器未初始化,已阻止证书申请") + } return nil } for _, requestedDomain := range domains { diff --git a/backend/internal/deployment/artifact.go b/backend/internal/deployment/artifact.go new file mode 100644 index 0000000..4d16b1e --- /dev/null +++ b/backend/internal/deployment/artifact.go @@ -0,0 +1,53 @@ +package deployment + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "time" +) + +// validateArtifact 是传输前的完整性检查;域名覆盖和环境信任策略由证书服务负责。 +func validateArtifact(artifact Artifact) error { + if len(artifact.Certificate) == 0 || len(artifact.FullChain) == 0 || len(artifact.PrivateKey) == 0 { + return errors.New("证书部署文件不完整") + } + certificate, err := tls.X509KeyPair(artifact.Certificate, artifact.PrivateKey) + if err != nil { + return fmt.Errorf("部署证书与私钥不匹配:%w", err) + } + chain, err := tls.X509KeyPair(artifact.FullChain, artifact.PrivateKey) + if err != nil { + return fmt.Errorf("部署证书链与私钥不匹配:%w", err) + } + if !bytes.Equal(certificate.Certificate[0], chain.Certificate[0]) { + return errors.New("部署证书与完整证书链的叶子证书不一致") + } + var previous *x509.Certificate + remaining := bytes.TrimSpace(artifact.FullChain) + now := time.Now() + for len(remaining) > 0 { + block, rest := pem.Decode(remaining) + if block == nil || block.Type != "CERTIFICATE" || !bytes.HasPrefix(remaining, []byte("-----BEGIN CERTIFICATE-----")) { + return errors.New("部署证书链包含无效 PEM 内容") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("解析部署证书链失败:%w", err) + } + if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) { + return errors.New("部署证书链包含尚未生效或已过期的证书") + } + if previous != nil { + if err := previous.CheckSignatureFrom(cert); err != nil { + return fmt.Errorf("部署证书链签名或顺序无效:%w", err) + } + } + previous = cert + remaining = bytes.TrimSpace(rest) + } + return nil +} diff --git a/backend/internal/deployment/output.go b/backend/internal/deployment/output.go new file mode 100644 index 0000000..a4f99e8 --- /dev/null +++ b/backend/internal/deployment/output.go @@ -0,0 +1,28 @@ +package deployment + +import "sync" + +const commandOutputLimit = 64 << 10 + +// commandOutput 限制远程标准输出和错误输出占用,防止异常命令耗尽管理端内存。 +type commandOutput struct { + mu sync.Mutex + data []byte +} + +func (b *commandOutput) Write(data []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + remaining := commandOutputLimit - len(b.data) + if remaining > len(data) { + remaining = len(data) + } + b.data = append(b.data, data[:remaining]...) + return len(data), nil +} + +func (b *commandOutput) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + return append([]byte(nil), b.data...) +} diff --git a/backend/internal/deployment/remote_acceptance_test.go b/backend/internal/deployment/remote_acceptance_test.go new file mode 100644 index 0000000..9c443c2 --- /dev/null +++ b/backend/internal/deployment/remote_acceptance_test.go @@ -0,0 +1,146 @@ +package deployment + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "io" + "os" + "path" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" +) + +// TestRemoteDeploymentIsolatedAcceptance 仅在显式配置后运行,不启动业务数据库、DNS 或 ACME。 +// 每次使用全新隔离目录,保留验收产物;不执行目标机已有服务的校验、重载或重启。 +func TestRemoteDeploymentIsolatedAcceptance(t *testing.T) { + if os.Getenv("SCERT_ACCEPTANCE_CONFIRM") != "isolated-directory" { + t.Skip("未启用隔离实机部署验收") + } + cfg, err := config.Load(os.Getenv("SCERT_ACCEPTANCE_CONFIG")) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"master.key", "secrets.json"} { + info, err := os.Lstat(filepath.Join(cfg.Paths.Config, name)) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("现有凭据文件不可用:%s", name) + } + } + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(os.Getenv("SCERT_ACCEPTANCE_TARGET")) + if err != nil { + t.Fatal(err) + } + var server Server + if err := json.Unmarshal(data, &server); err != nil { + t.Fatal(err) + } + root := "/etc/ssl/scert-acceptance-" + time.Now().UTC().Format("20060102-150405.000000000") + server.CertificatePath = path.Join(root, "cert.pem") + server.CertificateChainPath = path.Join(root, "fullchain.pem") + server.PrivateKeyPath = path.Join(root, "key.pem") + server.ValidationCommand = []string{"/usr/bin/true"} + server.ReloadCommand = []string{"/usr/bin/true"} + factory := SSHFactory{Secrets: store, Timeout: 10 * time.Second} + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + exists, err := client.Exists(t.Context(), root) + _ = client.Close() + if err != nil || exists { + t.Fatalf("隔离目录必须尚不存在:exists=%t err=%v", exists, err) + } + t.Logf("隔离目标:%s,目录:%s;命令使用 true,不代表真实服务重载验收", server.Address, root) + service := NewService(factory) + original := testArtifact(t) + updated := testArtifact(t) + for index, artifact := range []Artifact{original, updated} { + report := service.Deploy(t.Context(), []Target{{Server: server}}, artifact) + if report.Status != "success" { + t.Fatalf("第 %d 次隔离部署失败:%+v", index+1, report) + } + t.Logf("隔离部署 %d 成功,事务:%s", index+1, report.Results[0].BackupID) + } + verify := func() { + t.Helper() + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + defer client.Close() + adapter := client.(*sshClientAdapter) + for filename, expected := range map[string][]byte{"cert.pem": updated.Certificate, "fullchain.pem": updated.FullChain, "key.pem": updated.PrivateKey} { + file, err := adapter.sftp.Open(path.Join(root, filename)) + if err != nil { + t.Fatal(err) + } + actual, err := io.ReadAll(file) + _ = file.Close() + if err != nil || !bytes.Equal(actual, expected) { + t.Fatalf("文件内容不一致:%s %v", filename, err) + } + info, err := adapter.sftp.Stat(path.Join(root, filename)) + if err != nil { + t.Fatal(err) + } + wantMode := os.FileMode(0o644) + if filename == "key.pem" { + wantMode = 0o600 + } + if info.Mode().Perm() != wantMode { + t.Fatalf("文件权限错误:%s %o", filename, info.Mode().Perm()) + } + if filename != "key.pem" { + t.Logf("内容与权限验证:%s SHA256=%x mode=%o", filename, sha256.Sum256(actual), wantMode) + } + } + files, err := adapter.sftp.ReadDir(root) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.Contains(file.Name(), ".scert-tmp-") { + t.Fatalf("残留临时文件:%s", file.Name()) + } + if strings.HasPrefix(file.Name(), "key.pem") && file.Mode().Perm() != 0o600 { + t.Fatal("私钥或私钥备份权限不安全") + } + } + } + verify() + for _, scenario := range []string{"validation_failure", "reload_failure", "validation_timeout"} { + candidate := server + ctx := t.Context() + cancel := func() {} + switch scenario { + case "validation_failure": + candidate.ValidationCommand = []string{"/usr/bin/false"} + case "reload_failure": + candidate.ReloadCommand = []string{"/usr/bin/false"} + case "validation_timeout": + candidate.ValidationCommand = []string{"/usr/bin/sleep", "30"} + ctx, cancel = context.WithTimeout(ctx, 4*time.Second) + } + report := service.Deploy(ctx, []Target{{Server: candidate}}, original) + cancel() + if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "文件已回滚") || strings.Contains(report.Results[0].Message, "自动回滚失败") { + t.Fatalf("故障场景未安全回滚 %s:%+v", scenario, report) + } + if scenario == "validation_timeout" && !strings.Contains(report.Results[0].Message, "deadline exceeded") { + t.Fatalf("必须实际触发超时:%+v", report) + } + t.Logf("故障场景 %s:%s", scenario, report.Results[0].Message) + verify() + } +} diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index a5bd117..5f12283 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -109,6 +109,8 @@ func (s *Service) DeploySaved(ctx context.Context, targets []Target, saved Saved } func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifact) Result { + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() server = server.Normalize() result := Result{ServerID: server.ID, Server: server.Name, Status: "failed"} if err := server.Validate(); err != nil { @@ -120,7 +122,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac result.Message = fmt.Sprintf("连接服务器失败:%v", err) return result } - defer client.Close() + defer func() { _ = client.Close() }() paths := []remoteFile{ {path: RemotePath(server.CertificatePath), data: artifact.Certificate, mode: 0o644}, {path: RemotePath(server.CertificateChainPath), data: artifact.FullChain, mode: 0o644}, @@ -137,6 +139,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac return result } backupID := s.backupID() + result.BackupID = backupID type backup struct{ original, saved string } backups := make([]backup, 0, len(paths)) temporary := make([]string, 0, len(paths)) @@ -144,6 +147,14 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac rollback := func() error { rollbackContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), deploymentRollbackTimeout) defer cancel() + if state, ok := client.(interface{ Disconnected() bool }); ok && state.Disconnected() { + _ = client.Close() + reconnected, err := s.factory.Connect(rollbackContext, server) + if err != nil { + return fmt.Errorf("重新连接服务器以回滚失败:%w", err) + } + client = reconnected + } var rollbackErrors []error for _, path := range temporary { if err := client.Remove(rollbackContext, path); err != nil { @@ -157,15 +168,31 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac } for index := len(backups) - 1; index >= 0; index-- { item := backups[index] + // 备份请求超时可能丢失响应,重连后以目标文件是否存在核实实际结果。 + exists, err := client.Exists(rollbackContext, item.saved) + if err != nil { + rollbackErrors = append(rollbackErrors, fmt.Errorf("检查备份文件 %s 失败:%w", item.saved, err)) + continue + } + if !exists { + continue + } if err := client.Rename(rollbackContext, item.saved, item.original); err != nil { rollbackErrors = append(rollbackErrors, fmt.Errorf("恢复备份文件 %s 失败:%w", item.original, err)) } } return errors.Join(rollbackErrors...) } + reloadAttempted := false failed := func(message string) Result { if rollbackErr := rollback(); rollbackErr != nil { message += fmt.Sprintf(";自动回滚失败:%v", rollbackErr) + } else { + message += ";文件已回滚" + } + if reloadAttempted { + // 命令可能已部分执行;文件回滚并不证明运行中的服务已经恢复。 + message += ";服务运行状态未确认,请检查服务并在确认旧配置后重新加载" } result.Message = message return result @@ -174,27 +201,31 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac if err := client.MkdirAll(ctx, parentPath(file.path)); err != nil { return failed(fmt.Sprintf("创建远程目录失败:%v", err)) } + temporaryPath := file.path + ".scert-tmp-" + backupID + // 上传失败也可能留下半份私钥,必须在开始上传前登记清理路径。 + temporary = append(temporary, temporaryPath) + if err := client.Upload(ctx, temporaryPath, file.data, file.mode); err != nil { + return failed(fmt.Sprintf("上传远程证书失败:%v", err)) + } + } + // 所有文件上传完成后才触碰当前版本,避免上传失败造成不必要的证书替换。 + for _, file := range paths { exists, err := client.Exists(ctx, file.path) if err != nil { return failed(fmt.Sprintf("检查远程文件失败:%v", err)) } if exists { backupPath := file.path + ".scert-backup-" + backupID + backups = append(backups, backup{original: file.path, saved: backupPath}) if err := client.Rename(ctx, file.path, backupPath); err != nil { return failed(fmt.Sprintf("备份远程证书失败:%v", err)) } - backups = append(backups, backup{original: file.path, saved: backupPath}) } temporaryPath := file.path + ".scert-tmp-" + backupID - if err := client.Upload(ctx, temporaryPath, file.data, file.mode); err != nil { - return failed(fmt.Sprintf("上传远程证书失败:%v", err)) - } - temporary = append(temporary, temporaryPath) + installed = append(installed, file.path) if err := client.Rename(ctx, temporaryPath, file.path); err != nil { return failed(fmt.Sprintf("替换远程证书失败:%v", err)) } - temporary = temporary[:len(temporary)-1] - installed = append(installed, file.path) } validationResult, err := client.Run(ctx, server.ValidationCommand) if err != nil || validationResult.ExitCode != 0 { @@ -203,6 +234,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac } return failed(fmt.Sprintf("配置校验失败:%v", err)) } + reloadAttempted = true commandResult, err := client.Run(ctx, server.ReloadCommand) if err != nil || commandResult.ExitCode != 0 { if err == nil { @@ -237,13 +269,6 @@ func validateDeploymentPaths(files []remoteFile) error { return nil } -func validateArtifact(artifact Artifact) error { - if len(artifact.Certificate) == 0 || len(artifact.FullChain) == 0 || len(artifact.PrivateKey) == 0 { - return errors.New("证书部署文件不完整") - } - return nil -} - func (s *Service) backupID() string { var random [6]byte _, _ = rand.Read(random[:]) diff --git a/backend/internal/deployment/service_test.go b/backend/internal/deployment/service_test.go index 76cf797..9a819d7 100644 --- a/backend/internal/deployment/service_test.go +++ b/backend/internal/deployment/service_test.go @@ -1,8 +1,15 @@ package deployment import ( + "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" "errors" + "math/big" "strings" "testing" "time" @@ -38,6 +45,7 @@ func (c *memoryClient) Exists(_ context.Context, path string) (bool, error) { func (c *memoryClient) Upload(_ context.Context, path string, data []byte, mode uint32) error { if c.uploadError != "" && strings.Contains(path, c.uploadError) { + c.files[path] = memoryFile{data: append([]byte(nil), data[:len(data)/2]...), mode: mode} return errors.New("模拟上传失败") } c.files[path] = memoryFile{data: append([]byte(nil), data...), mode: mode} @@ -105,11 +113,12 @@ func TestDeployReplacesFilesAndKeepsBackup(t *testing.T) { client.files[server.PrivateKeyPath] = memoryFile{data: []byte("old-key"), mode: 0o600} service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) service.now = func() time.Time { return time.Date(2026, 8, 27, 1, 2, 3, 4, time.UTC) } - report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + artifact := testArtifact(t) + report := service.Deploy(context.Background(), []Target{{Server: server}}, artifact) if report.Status != "success" || report.Success != 1 { t.Fatalf("部署结果错误:%+v", report) } - if string(client.files[server.CertificatePath].data) != "new-cert" || client.files[server.PrivateKeyPath].mode != 0o600 { + if !bytes.Equal(client.files[server.CertificatePath].data, artifact.Certificate) || client.files[server.PrivateKeyPath].mode != 0o600 { t.Fatalf("远程证书内容或权限错误:%+v", client.files) } if len(client.commands) != 2 || len(client.commands[0]) != 2 || client.commands[0][1] != "-t" || len(client.commands[1]) != 3 || client.commands[1][0] != "nginx" { @@ -132,7 +141,7 @@ func TestDeployRollsBackWhenReloadFails(t *testing.T) { server := testServer("server-1") client.files[server.CertificatePath] = memoryFile{data: []byte("old-cert"), mode: 0o644} service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) - report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t)) if report.Status != "failed" || report.Failed != 1 { t.Fatalf("reload 失败结果错误:%+v", report) } @@ -157,7 +166,7 @@ func TestDeployRollsBackWhenValidationFailsWithoutReload(t *testing.T) { client.files[server.CertificateChainPath] = memoryFile{data: []byte("old-chain"), mode: 0o644} client.files[server.PrivateKeyPath] = memoryFile{data: []byte("old-key"), mode: 0o600} service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) - report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t)) if report.Status != "failed" || report.Failed != 1 || !strings.Contains(report.Results[0].Message, "配置校验失败") { t.Fatalf("配置校验失败结果错误:%+v", report) } @@ -181,7 +190,7 @@ func TestDeployReportsRollbackFailure(t *testing.T) { server := testServer("server-1") client.files[server.CertificatePath] = memoryFile{data: []byte("old-cert"), mode: 0o644} service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) - report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t)) if report.Status != "failed" || report.Failed != 1 { t.Fatalf("回滚失败结果错误:%+v", report) } @@ -197,19 +206,41 @@ func TestDeployReportsPartialSuccess(t *testing.T) { firstServer := testServer("first") secondServer := testServer("second") service := NewService(memoryFactory{clients: map[string]*memoryClient{"first": first, "second": second}}) - report := service.Deploy(context.Background(), []Target{{Server: firstServer}, {Server: secondServer}}, testArtifact()) + report := service.Deploy(context.Background(), []Target{{Server: firstServer}, {Server: secondServer}}, testArtifact(t)) if report.Status != "partial" || report.Success != 1 || report.Failed != 1 { t.Fatalf("部分成功汇总错误:%+v", report) } } +func TestDeployCleansPartialPrivateKeyUploadWithoutReplacingCurrentFiles(t *testing.T) { + client := newMemoryClient() + client.uploadError = "key.pem" + server := testServer("partial-key") + for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} { + client.files[path] = memoryFile{data: []byte("original"), mode: 0o600} + } + service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) + report := service.Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t)) + if report.Status != "failed" || report.Results[0].BackupID == "" { + t.Fatalf("失败报告必须包含事务标识:%+v", report) + } + if len(client.files) != 3 || len(client.commands) != 0 { + t.Fatalf("部分上传失败不得残留临时文件或执行命令:files=%v commands=%v", client.files, client.commands) + } + for path, file := range client.files { + if string(file.data) != "original" || file.mode != 0o600 { + t.Fatalf("上传阶段不得替换原文件:%s", path) + } + } +} + func TestDeployRejectsDuplicateCertificatePaths(t *testing.T) { client := newMemoryClient() server := testServer("server-1") server.PrivateKeyPath = server.CertificatePath service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) - report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t)) if report.Status != "failed" || report.Failed != 1 { t.Fatalf("重复远程路径应直接失败:%+v", report) } @@ -227,7 +258,7 @@ func TestDeployRejectsRelativeCertificatePaths(t *testing.T) { server.CertificatePath = "cert.pem" service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}) - report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact()) + report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t)) if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "必须是绝对路径") { t.Fatalf("相对远程路径应被拒绝:%+v", report) } @@ -242,6 +273,48 @@ func testServer(id string) Server { } } -func testArtifact() Artifact { - return Artifact{Certificate: []byte("new-cert"), FullChain: []byte("new-chain"), PrivateKey: []byte("new-key")} +func testArtifact(t *testing.T) Artifact { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), DNSNames: []string{"example.com"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + privateKey, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + return Artifact{Certificate: cert, FullChain: append([]byte(nil), cert...), PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKey})} +} + +func TestDeployRejectsCorruptArtifactsBeforeConnecting(t *testing.T) { + for _, scenario := range []string{"invalid_pem", "wrong_key", "wrong_leaf", "garbage_chain"} { + t.Run(scenario, func(t *testing.T) { + artifact := testArtifact(t) + switch scenario { + case "invalid_pem": + artifact.Certificate = []byte("invalid") + case "wrong_key": + artifact.PrivateKey = testArtifact(t).PrivateKey + case "wrong_leaf": + artifact.FullChain = testArtifact(t).FullChain + case "garbage_chain": + artifact.FullChain = append(artifact.FullChain, []byte("invalid")...) + } + service := NewService(memoryFactory{}) + report := service.Deploy(t.Context(), []Target{{Server: testServer("invalid")}}, artifact) + if report.Status != "failed" { + t.Fatalf("无效产物未被拒绝:%+v", report) + } + }) + } } diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index 20d0e0e..a682c2c 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -8,6 +8,7 @@ import ( "net" "os" "strings" + "sync/atomic" "time" "github.com/pkg/sftp" @@ -64,12 +65,16 @@ func (f SSHFactory) Connect(ctx context.Context, server Server) (Client, error) if err != nil { return nil, err } - sftpClient, err := sftp.NewClient(sshClient) + adapter := &sshClientAdapter{ssh: sshClient} + sftpClient, err := sshOperation(timeoutContext, adapter, func() (*sftp.Client, error) { + return sftp.NewClient(sshClient) + }) if err != nil { _ = sshClient.Close() return nil, fmt.Errorf("创建 SFTP 客户端失败:%w", err) } - return &sshClientAdapter{ssh: sshClient, sftp: sftpClient}, nil + adapter.sftp = sftpClient + return adapter, nil } func (f SSHFactory) secureHostKeyAlgorithms() ([]string, error) { @@ -138,7 +143,13 @@ func connectSSHClient( return nil }, } + stopCancel := context.AfterFunc(ctx, func() { _ = connection.Close() }) clientConn, channels, requests, handshakeErr := ssh.NewClientConn(connection, address, config) + stopCancel() + if err := ctx.Err(); err != nil { + _ = connection.Close() + return nil, fmt.Errorf("SSH 握手已取消:%w", err) + } if handshakeErr == nil { if err := connection.SetDeadline(time.Time{}); err != nil { _ = clientConn.Close() @@ -174,64 +185,96 @@ func connectSSHClient( } type sshClientAdapter struct { - ssh *ssh.Client - sftp *sftp.Client + ssh *ssh.Client + sftp *sftp.Client + disconnected atomic.Bool +} + +// sshOperation 取消时关闭传输并等待操作退出,避免回滚与仍在写入的操作并行。 +// 已断开的连接由部署事务使用独立回滚上下文重新建立,仍严格校验主机指纹。 +func sshOperation[T any](ctx context.Context, c *sshClientAdapter, operation func() (T, error)) (T, error) { + var zero T + if err := ctx.Err(); err != nil { + return zero, err + } + type outcome struct { + value T + err error + } + done := make(chan outcome, 1) + go func() { + value, err := operation() + done <- outcome{value, err} + }() + select { + case result := <-done: + if err := ctx.Err(); err != nil { + c.disconnected.Store(true) + _ = c.ssh.Close() + return zero, err + } + return result.value, result.err + case <-ctx.Done(): + c.disconnected.Store(true) + _ = c.ssh.Close() + <-done + return zero, ctx.Err() + } +} + +func (c *sshClientAdapter) Disconnected() bool { return c.disconnected.Load() } + +func (c *sshClientAdapter) fileOperation(ctx context.Context, operation func() error) error { + _, err := sshOperation(ctx, c, func() (struct{}, error) { return struct{}{}, operation() }) + return err } func (c *sshClientAdapter) MkdirAll(ctx context.Context, path string) error { - if err := ctx.Err(); err != nil { - return err - } - return c.sftp.MkdirAll(path) + return c.fileOperation(ctx, func() error { return c.sftp.MkdirAll(path) }) } func (c *sshClientAdapter) Exists(ctx context.Context, path string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - _, err := c.sftp.Stat(path) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return err == nil, err + return sshOperation(ctx, c, func() (bool, error) { + _, err := c.sftp.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return err == nil, err + }) } func (c *sshClientAdapter) Upload(ctx context.Context, path string, data []byte, mode uint32) error { - if err := ctx.Err(); err != nil { - return err - } - file, err := c.sftp.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) - if err != nil { - return err - } - closeFile := func() error { return file.Close() } - if _, err := file.Write(data); err != nil { - _ = closeFile() - return err - } - if err := file.Chmod(os.FileMode(mode)); err != nil { - _ = closeFile() - return err - } - return closeFile() + return c.fileOperation(ctx, func() error { + // 只创建新临时文件,不跟随已有符号链接,也不截断已存在的文件。 + file, err := c.sftp.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + return err + } + // 先收紧空文件权限,再写入任何证书或私钥内容。 + if err := file.Chmod(os.FileMode(mode)); err != nil { + _ = file.Close() + return err + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return err + } + return file.Close() + }) } func (c *sshClientAdapter) Rename(ctx context.Context, source, target string) error { - if err := ctx.Err(); err != nil { - return err - } - return c.sftp.Rename(source, target) + return c.fileOperation(ctx, func() error { return c.sftp.Rename(source, target) }) } func (c *sshClientAdapter) Remove(ctx context.Context, path string) error { - if err := ctx.Err(); err != nil { + return c.fileOperation(ctx, func() error { + err := c.sftp.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } return err - } - err := c.sftp.Remove(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err + }) } func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandResult, error) { @@ -241,12 +284,20 @@ func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandRe if err := validateCommand(command); err != nil { return CommandResult{}, err } + return sshOperation(ctx, c, func() (CommandResult, error) { return c.runCommand(command) }) +} + +func (c *sshClientAdapter) runCommand(command []string) (CommandResult, error) { session, err := c.ssh.NewSession() if err != nil { return CommandResult{}, err } defer session.Close() - output, err := session.CombinedOutput(joinShellArgs(command)) + outputBuffer := &commandOutput{} + session.Stdout = outputBuffer + session.Stderr = outputBuffer + err = session.Run(joinShellArgs(command)) + output := outputBuffer.Bytes() if err != nil { var exitError *ssh.ExitError if errors.As(err, &exitError) { @@ -254,21 +305,18 @@ func (c *sshClientAdapter) Run(ctx context.Context, command []string) (CommandRe } return CommandResult{Output: output, ExitCode: -1}, err } - if err := ctx.Err(); err != nil { - return CommandResult{Output: output, ExitCode: -1}, err - } return CommandResult{Output: output}, nil } func (c *sshClientAdapter) Close() error { var first error - if c.sftp != nil { - if err := c.sftp.Close(); err != nil { - first = err - } - } + // 先关闭传输,避免无响应的 SFTP 服务阻塞关闭过程。 if c.ssh != nil { - if err := c.ssh.Close(); err != nil && first == nil { + c.disconnected.Store(true) + first = c.ssh.Close() + } + if c.sftp != nil { + if err := c.sftp.Close(); err != nil && first == nil { first = err } } diff --git a/backend/internal/deployment/ssh_hostkey_test.go b/backend/internal/deployment/ssh_hostkey_test.go index 5be593f..bf4f0d7 100644 --- a/backend/internal/deployment/ssh_hostkey_test.go +++ b/backend/internal/deployment/ssh_hostkey_test.go @@ -182,17 +182,27 @@ func TestSSHFactoryFiltersUnsupportedAndDuplicateHostKeyAlgorithms(t *testing.T) } type testSSHServer struct { - listener net.Listener - accepted atomic.Int32 - host string - port int - config *ssh.ServerConfig + listener net.Listener + accepted atomic.Int32 + host string + port int + config *ssh.ServerConfig + channelHandler func(ssh.Channel, <-chan *ssh.Request) } func startTestSSHServer( t *testing.T, authorize func(ssh.PublicKey) error, hostSigners ...ssh.Signer, +) *testSSHServer { + return startTestSSHServerWithHandler(t, authorize, serveTestSFTP, hostSigners...) +} + +func startTestSSHServerWithHandler( + t *testing.T, + authorize func(ssh.PublicKey) error, + handler func(ssh.Channel, <-chan *ssh.Request), + hostSigners ...ssh.Signer, ) *testSSHServer { t.Helper() config := &ssh.ServerConfig{ @@ -218,10 +228,11 @@ func startTestSSHServer( t.Fatalf("解析测试 SSH 端口失败:%v", err) } server := &testSSHServer{ - listener: listener, - host: host, - port: port, - config: config, + listener: listener, + host: host, + port: port, + config: config, + channelHandler: handler, } t.Cleanup(func() { listener.Close() @@ -257,7 +268,7 @@ func (s *testSSHServer) serveConnection(connection net.Conn) { if err != nil { continue } - go serveTestSFTP(channel, requests) + go s.channelHandler(channel, requests) } } diff --git a/backend/internal/deployment/ssh_safety_test.go b/backend/internal/deployment/ssh_safety_test.go new file mode 100644 index 0000000..eb3841c --- /dev/null +++ b/backend/internal/deployment/ssh_safety_test.go @@ -0,0 +1,255 @@ +package deployment + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type observedSFTP struct { + handlers sftp.Handlers + mode atomic.Uint32 + writes atomic.Int32 + unsafeWrite atomic.Bool + exclusive atomic.Bool + denyMode bool +} + +func (h *observedSFTP) Filewrite(request *sftp.Request) (io.WriterAt, error) { + h.exclusive.Store(request.Pflags().Excl && !request.Pflags().Trunc) + writer, err := h.handlers.FilePut.Filewrite(request) + if err != nil { + return nil, err + } + return &observedWriter{WriterAt: writer, owner: h}, nil +} + +func (h *observedSFTP) Filecmd(request *sftp.Request) error { + if request.Method == "Setstat" { + if h.denyMode { + return errors.New("测试拒绝权限设置") + } + h.mode.Store(request.Attributes().Mode & 0o777) + } + return h.handlers.FileCmd.Filecmd(request) +} + +type observedWriter struct { + io.WriterAt + owner *observedSFTP +} + +func (w *observedWriter) WriteAt(data []byte, offset int64) (int, error) { + w.owner.writes.Add(1) + if w.owner.mode.Load() != 0o600 { + w.owner.unsafeWrite.Store(true) + } + return w.WriterAt.WriteAt(data, offset) +} + +func (w *observedWriter) Close() error { + if closer, ok := w.WriterAt.(io.Closer); ok { + return closer.Close() + } + return nil +} + +func safetyFactory(t *testing.T, handler func(ssh.Channel, <-chan *ssh.Request)) (SSHFactory, Server) { + t.Helper() + _, privateKey := newTestSSHSigner(t) + hostSigner, _ := newTestSSHSigner(t) + server := startTestSSHServerWithHandler(t, func(ssh.PublicKey) error { return nil }, handler, hostSigner) + model := testSSHServerModel(server, "secret://ssh/safety", ssh.FingerprintSHA256(hostSigner.PublicKey())) + return SSHFactory{ + Secrets: staticSecretReader{model.PrivateKeyRef: {"private_key": privateKey}}, + Timeout: time.Second, HostKeyAlgorithms: []string{ssh.KeyAlgoED25519}, + }, model +} + +func requestSFTPHandler(handlers sftp.Handlers, stallVersion, stallCommand bool) func(ssh.Channel, <-chan *ssh.Request) { + return func(channel ssh.Channel, requests <-chan *ssh.Request) { + defer channel.Close() + for request := range requests { + switch request.Type { + case "subsystem": + _ = request.Reply(true, nil) + if stallVersion { + _, _ = io.Copy(io.Discard, channel) + return + } + server := sftp.NewRequestServer(channel, handlers) + _ = server.Serve() + _ = server.Close() + return + case "exec": + _ = request.Reply(true, nil) + if stallCommand { + for request := range requests { + _ = request.Reply(false, nil) + } + return + } + _, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0})) + return + default: + _ = request.Reply(false, nil) + } + } + } +} + +func TestSSHUploadRestrictsPermissionsBeforeWritingAndRejectsExistingFile(t *testing.T) { + for _, denyMode := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "chmod_denied"}[denyMode], func(t *testing.T) { + observer := &observedSFTP{handlers: sftp.InMemHandler(), denyMode: denyMode} + handlers := observer.handlers + handlers.FilePut = observer + handlers.FileCmd = observer + factory, server := safetyFactory(t, requestSFTPHandler(handlers, false, false)) + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + defer client.Close() + err = client.Upload(t.Context(), "/key.tmp", []byte("private-test-key"), 0o600) + if denyMode { + if err == nil || observer.writes.Load() != 0 { + t.Fatal("权限设置失败后不得写入私钥") + } + return + } + if err != nil { + t.Fatal(err) + } + if observer.unsafeWrite.Load() || observer.writes.Load() == 0 || !observer.exclusive.Load() { + t.Fatal("上传必须独占创建并在权限收紧后才写入") + } + if err := client.Upload(t.Context(), "/key.tmp", []byte("overwrite"), 0o600); err == nil { + t.Fatal("不得覆盖已有上传目标") + } + }) + } +} + +func TestSSHFactoryCancelsUnresponsiveSFTPInitialization(t *testing.T) { + factory, server := safetyFactory(t, requestSFTPHandler(sftp.InMemHandler(), true, false)) + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + start := time.Now() + if _, err := factory.Connect(ctx, server); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("SFTP 握手无响应必须超时:%v", err) + } + if time.Since(start) > 2*time.Second { + t.Fatal("SFTP 握手取消不及时") + } +} + +type stalledLister struct{ release <-chan struct{} } + +func (l stalledLister) Filelist(*sftp.Request) (sftp.ListerAt, error) { + <-l.release + return nil, errors.New("测试结束") +} + +func TestSSHOperationsCancelUnresponsiveServer(t *testing.T) { + for _, operation := range []string{"stat", "command"} { + t.Run(operation, func(t *testing.T) { + handlers := sftp.InMemHandler() + release := make(chan struct{}) + defer close(release) + if operation == "stat" { + handlers.FileList = stalledLister{release: release} + } + factory, server := safetyFactory(t, requestSFTPHandler(handlers, false, true)) + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + defer client.Close() + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + start := time.Now() + if operation == "stat" { + _, err = client.Exists(ctx, "/test") + } else { + _, err = client.Run(ctx, []string{"sleep", "60"}) + } + if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 2*time.Second { + t.Fatalf("无响应操作未及时取消:%v", err) + } + if !client.(*sshClientAdapter).Disconnected() { + t.Fatal("取消后必须标记传输已断开以触发重连回滚") + } + }) + } +} + +func TestSSHDeploymentReconnectsAndRollsBackAfterCommandTimeout(t *testing.T) { + handlers := sftp.InMemHandler() + factory, server := safetyFactory(t, requestSFTPHandler(handlers, false, true)) + client, err := factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + if err := client.MkdirAll(t.Context(), "/tmp"); err != nil { + t.Fatal(err) + } + for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} { + if err := client.Upload(t.Context(), path, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + } + _ = client.Close() + ctx, cancel := context.WithTimeout(t.Context(), 400*time.Millisecond) + defer cancel() + report := NewService(factory).Deploy(ctx, []Target{{Server: server}}, testArtifact(t)) + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatal("测试必须实际触发命令超时,而不是直接执行失败") + } + if report.Status != "failed" || !strings.Contains(report.Results[0].Message, "文件已回滚") || strings.Contains(report.Results[0].Message, "自动回滚失败") { + t.Fatalf("超时后应重连完成文件回滚:%+v", report) + } + client, err = factory.Connect(t.Context(), server) + if err != nil { + t.Fatal(err) + } + defer client.Close() + adapter := client.(*sshClientAdapter) + for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} { + file, err := adapter.sftp.Open(path) + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(file) + _ = file.Close() + if err != nil || !bytes.Equal(data, []byte("original")) { + t.Fatalf("原文件未恢复:%s %v", path, err) + } + } + files, err := adapter.sftp.ReadDir("/tmp") + if err != nil { + t.Fatal(err) + } + if len(files) != 3 { + t.Fatalf("回滚后残留事务文件:%v", files) + } +} + +func TestCommandOutputIsBounded(t *testing.T) { + buffer := &commandOutput{} + data := bytes.Repeat([]byte("x"), commandOutputLimit*3) + if written, err := buffer.Write(data); written != len(data) || err != nil { + t.Fatal("截断不应阻塞远程输出") + } + if len(buffer.Bytes()) != commandOutputLimit { + t.Fatal("远程输出未限制内存占用") + } +} diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index f7a9157..f5eb3b3 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -3,13 +3,11 @@ package dnsprovider import ( "context" "crypto/rand" - "crypto/sha256" "encoding/base64" "errors" "fmt" "sort" "strings" - "sync" "time" "git.pcux.cn/xuans/Scert/backend/internal/domain" @@ -19,10 +17,9 @@ import ( ) const ( - aliyunDefaultRegion = "cn-hangzhou" - aliyunAuthorizationTTL = 600 - aliyunAuthorizationTimeout = 30 * time.Second - aliyunAuthorizationCacheTTL = 10 * time.Minute + aliyunDefaultRegion = "cn-hangzhou" + aliyunAuthorizationTTL = 600 + aliyunAuthorizationTimeout = 30 * time.Second ) // AuthorizationDomain 描述一个阿里云 DNS 根域名的授权探测结果。 @@ -48,14 +45,12 @@ type AuthorizationReport struct { // AliyunAuthorizationChecker 负责发现并证明 RAM 账号对阿里云 DNS 根域名的实际写权限。 type AliyunAuthorizationChecker struct { - mu sync.Mutex now func() time.Time - cache map[string]time.Time verifyDomainFunc func(context.Context, string, map[string]string) error } func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker { - return &AliyunAuthorizationChecker{now: time.Now, cache: make(map[string]time.Time)} + return &AliyunAuthorizationChecker{now: time.Now} } func (c *AliyunAuthorizationChecker) Supports(providerID string) bool { @@ -158,21 +153,26 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis) } -// VerifyDomain 实现证书服务使用的最小授权检查接口。 +// VerifyDomain 每次重新证明写入和清理能力,不使用跨请求缓存掩盖撤权。 func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error { if !c.Supports(providerID) { return nil } + if c == nil { + return errors.New("阿里云权限探测器未初始化") + } + if err := ctx.Err(); err != nil { + return err + } requestedDomain = normalizeAuthorizationDomain(requestedDomain) - if c.cached(requestedDomain, credentials) { - return nil + if !domain.IsValidName(requestedDomain) { + return fmt.Errorf("待验证域名格式无效:%s", requestedDomain) + } + if _, err := ValidateCredentials(Aliyun, credentials); err != nil { + return err } if c.verifyDomainFunc != nil { - if err := c.verifyDomainFunc(ctx, requestedDomain, credentials); err != nil { - return err - } - c.remember(requestedDomain, credentials) - return nil + return c.verifyDomainFunc(ctx, requestedDomain, credentials) } report, err := c.Verify(ctx, requestedDomain, credentials) if err != nil { @@ -181,23 +181,11 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI if !report.Verified { return errors.New("阿里云 DNS 域名授权未通过") } - c.remember(requestedDomain, credentials) return nil } -// InvalidateDomain 在实际 ACME 写入失败后清除短时授权缓存,确保权限收缩能够被重新探测。 +// InvalidateDomain 保留旧调用方接口;授权检查已不再缓存,无需额外失效操作。 func (c *AliyunAuthorizationChecker) InvalidateDomain(providerID, requestedDomain string, credentials map[string]string) { - if c == nil || !c.Supports(providerID) { - return - } - key := authorizationCacheKey(providerID, requestedDomain, credentials) - c.mu.Lock() - if c.cache == nil { - c.mu.Unlock() - return - } - delete(c.cache, key) - c.mu.Unlock() } func (c *AliyunAuthorizationChecker) currentTime() time.Time { @@ -321,49 +309,6 @@ func authorizationZoneCandidates(requested string) []string { return result } -func (c *AliyunAuthorizationChecker) cached(requestedDomain string, credentials map[string]string) bool { - if c == nil { - return false - } - now := c.currentTime().UTC() - key := authorizationCacheKey(Aliyun, requestedDomain, credentials) - c.mu.Lock() - defer c.mu.Unlock() - expiresAt, ok := c.cache[key] - if !ok || !expiresAt.After(now) { - delete(c.cache, key) - return false - } - return true -} - -func (c *AliyunAuthorizationChecker) remember(requestedDomain string, credentials map[string]string) { - if c == nil { - return - } - key := authorizationCacheKey(Aliyun, requestedDomain, credentials) - c.mu.Lock() - if c.cache == nil { - c.cache = make(map[string]time.Time) - } - c.cache[key] = c.currentTime().UTC().Add(aliyunAuthorizationCacheTTL) - c.mu.Unlock() -} - -func authorizationCacheKey(providerID, requestedDomain string, credentials map[string]string) string { - cleaned, _ := ValidateCredentials(providerID, credentials) - keys := make([]string, 0, len(cleaned)) - for key := range cleaned { - keys = append(keys, key) - } - sort.Strings(keys) - hash := sha256.New() - for _, key := range keys { - fmt.Fprintf(hash, "%d:%s=%d:%s\n", len(key), key, len(cleaned[key]), cleaned[key]) - } - return fmt.Sprintf("%s|%s|%x", strings.ToLower(strings.TrimSpace(providerID)), normalizeAuthorizationDomain(requestedDomain), hash.Sum(nil)) -} - func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) { for index := range report.Domains { if report.Domains[index].Domain == domainName { diff --git a/backend/internal/dnsprovider/aliyun_authorization_test.go b/backend/internal/dnsprovider/aliyun_authorization_test.go index e026d77..e761007 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_test.go @@ -20,13 +20,17 @@ func TestAuthorizationZoneCandidatesPreferSpecificZones(t *testing.T) { } } -func TestAliyunAuthorizationCheckerCachesSuccessfulAuthorization(t *testing.T) { +func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *testing.T) { now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) calls := 0 + revoked := false checker := NewAliyunAuthorizationChecker() checker.now = func() time.Time { return now } checker.verifyDomainFunc = func(context.Context, string, map[string]string) error { calls++ + if revoked { + return errors.New("Forbidden.RAM") + } return nil } credentials := map[string]string{ @@ -40,16 +44,16 @@ func TestAliyunAuthorizationCheckerCachesSuccessfulAuthorization(t *testing.T) { if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil { t.Fatalf("缓存授权检查失败:%v", err) } - if calls != 1 { - t.Fatalf("短时授权缓存未生效:calls=%d", calls) + if calls != 2 { + t.Fatalf("每次操作必须重新验证权限:calls=%d", calls) } - now = now.Add(aliyunAuthorizationCacheTTL + time.Second) - if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil { - t.Fatalf("缓存过期后的授权检查失败:%v", err) + revoked = true + if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err == nil { + t.Fatal("撤权后不得复用之前的成功结果") } - if calls != 2 { - t.Fatalf("授权缓存过期后未重新探测:calls=%d", calls) + if calls != 3 { + t.Fatalf("撤权后未重新探测:calls=%d", calls) } } diff --git a/backend/internal/httpapi/aliyun_scope_api.go b/backend/internal/httpapi/aliyun_scope_api.go index 101c4de..3e8e18f 100644 --- a/backend/internal/httpapi/aliyun_scope_api.go +++ b/backend/internal/httpapi/aliyun_scope_api.go @@ -159,8 +159,8 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) { } func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainName string) error { - if s == nil || s.aliyunChecker == nil { - return nil + if s == nil { + return errors.New("域名授权检查服务未初始化") } s.configMu.RLock() setupReady := s.config.SetupReady @@ -168,13 +168,22 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa credentialRef := strings.TrimSpace(s.config.DNS.CredentialRef) s.configMu.RUnlock() - if !setupReady || provider != dnsprovider.Aliyun || credentialRef == "" { + if provider != dnsprovider.Aliyun { return nil } + if !setupReady { + return errors.New("系统初始化尚未完成,不能验证域名授权") + } + if s.aliyunChecker == nil { + return errors.New("阿里云权限探测器未装载,不能确认域名授权") + } credentials, err := s.readDNSCredentials(ctx, credentialRef) - if err != nil || len(credentials) == 0 { - return nil + if err != nil { + return fmt.Errorf("读取阿里云 DNS 凭据失败,不能确认域名授权:%w", err) + } + if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil { + return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err) } if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil { @@ -193,4 +202,4 @@ func (s *Server) readDNSCredentials(ctx context.Context, credentialRef string) ( return nil, errors.New("凭据存储未就绪") } return reader.Get(ctx, credentialRef) -} \ No newline at end of file +} diff --git a/backend/internal/httpapi/aliyun_scope_api_test.go b/backend/internal/httpapi/aliyun_scope_api_test.go index 2d9a192..526fc96 100644 --- a/backend/internal/httpapi/aliyun_scope_api_test.go +++ b/backend/internal/httpapi/aliyun_scope_api_test.go @@ -165,4 +165,57 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) { t.Fatalf("阻断提示应包含针对该域名的 RAM 授权指导,got: %s", w.Body.String()) } } -} \ No newline at end of file +} + +func TestAliyunDomainAuthorizationFailsClosed(t *testing.T) { + for _, scenario := range []string{"checker_missing", "reference_missing", "store_missing", "secret_missing", "secret_empty", "secret_incomplete", "setup_incomplete"} { + t.Run(scenario, func(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.DNS.Provider = dnsprovider.Aliyun + cfg.DNS.CredentialRef = "secret://dns/test" + store := &fakeSecretReaderStore{data: map[string]map[string]string{ + cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}, + }} + deps := Dependencies{Config: cfg, Secrets: store, AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}}} + switch scenario { + case "checker_missing": + deps.AliyunChecker = nil + case "reference_missing": + deps.Config.DNS.CredentialRef = "" + case "store_missing": + deps.Secrets = nil + case "secret_missing": + store.data = nil + case "secret_empty": + store.data[cfg.DNS.CredentialRef] = nil + case "secret_incomplete": + store.data[cfg.DNS.CredentialRef] = map[string]string{"access_key_id": "test-id"} + case "setup_incomplete": + deps.Config.SetupReady = false + } + server := New(deps) + if err := server.validateDomainAliyunAuthorization(t.Context(), "example.com"); err == nil { + t.Fatal("授权检查依赖异常必须拒绝,不能当作成功") + } + }) + } +} + +func TestAliyunDomainAPIRejectsPartiallyUnauthorizedSAN(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.DNS.Provider = dnsprovider.Aliyun + cfg.DNS.CredentialRef = "secret://dns/test" + server := New(Dependencies{ + Config: cfg, Repository: &stubDomainRepository{}, + Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{ + cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}, + }}, + AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["example.org"]}`)) + serveAdminHTTP(server, w, req) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "example.org") { + t.Fatalf("部分 SAN 未授权应整单拒绝:%d %s", w.Code, w.Body.String()) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 3475b4d..2da76c8 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -463,6 +463,9 @@ func (s *Server) createDomain(c *gin.Context) { return } for _, san := range item.SANs { + if san == item.Name { + continue + } if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil { s.writeError(c, http.StatusBadRequest, err.Error(), err) return @@ -507,6 +510,9 @@ func (s *Server) updateDomain(c *gin.Context) { return } for _, san := range item.SANs { + if san == item.Name { + continue + } if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil { s.writeError(c, http.StatusBadRequest, err.Error(), err) return @@ -789,4 +795,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}) -} \ No newline at end of file +}