diff --git a/backend/internal/certificate/authorization_test.go b/backend/internal/certificate/authorization_test.go index 8706a8b..722c4b1 100644 --- a/backend/internal/certificate/authorization_test.go +++ b/backend/internal/certificate/authorization_test.go @@ -99,6 +99,7 @@ func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) { if _, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{ Domains: []string{"example.com"}, CertificateType: "single", ProviderID: "aliyun", CredentialRef: cfg.DNS.CredentialRef, Environment: "staging", + CertificateRoot: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup, }); err == nil || !strings.Contains(err.Error(), "探测器未初始化") { t.Fatalf("缺失检查器必须阻止初始化证书签发:%v", err) } diff --git a/backend/internal/certificate/lifecycle.go b/backend/internal/certificate/lifecycle.go index 3862999..615f47a 100644 --- a/backend/internal/certificate/lifecycle.go +++ b/backend/internal/certificate/lifecycle.go @@ -64,6 +64,8 @@ type FileStore struct { Now func() time.Time // 管理入口仍可能引用旧版本,完成双层 TLS 切换前不得清理该目录。 RetainPrevious bool + // 兼容仍引用业务目录的既有管理入口,禁止清理包含这些文件的旧版本。 + ProtectedPaths []string } func NormalizeDomains(domains []string) ([]string, error) { @@ -224,6 +226,13 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res } currentDirectory = filepath.Join(directory, version) } + protected := false + if !s.RetainPrevious && certificateType != "" && activeDirectory != "" { + protected, err = s.isProtectedDirectory(activeDirectory) + if err != nil { + return SavedResource{}, fmt.Errorf("检查旧证书引用失败:%w", err) + } + } backupDirectory := "" if activeDirectory != "" { backupDirectory, err = s.backupExisting(activeDirectory, primary) @@ -270,7 +279,7 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res } return SavedResource{}, fmt.Errorf("写入证书元数据失败:%w", err) } - if !s.RetainPrevious && certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory { + if !s.RetainPrevious && !protected && certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory { if err := removeCertificateDirectory(activeDirectory, typeDirectory); err != nil { return SavedResource{}, fmt.Errorf("清理旧当前证书目录失败:%w", err) } diff --git a/backend/internal/certificate/management_isolation_test.go b/backend/internal/certificate/management_isolation_test.go new file mode 100644 index 0000000..0f5afe3 --- /dev/null +++ b/backend/internal/certificate/management_isolation_test.go @@ -0,0 +1,225 @@ +package certificate + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/go-acme/lego/v5/challenge" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +func TestManagementStorageIsolatedFromBusinessCertificates(t *testing.T) { + for _, certificateType := range []domain.CertificateType{domain.CertificateSingle, domain.CertificateWildcard, domain.CertificateSAN} { + t.Run(string(certificateType), func(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + name := "management.example.com" + if certificateType == domain.CertificateWildcard { + name = "*.example.com" + } + item, err := domain.NewDomainWithOptions(name, certificateType, []string{"www.example.com"}, true, "staging", "cloudflare") + if err != nil { + t.Fatal(err) + } + names, err := requestedDomains(item) + if err != nil { + t.Fatal(err) + } + issuer := &serviceIssuer{resource: testResourceForDomains(t, names)} + service := NewService(cfg, &serviceRepository{item: item}, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service.acmeRoots = testResourceRoots(t, issuer.resource) + service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } + request := SetupCertificateRequest{ + Domains: names, CertificateType: string(certificateType), + ProviderID: "cloudflare", CredentialRef: cfg.DNS.CredentialRef, + Environment: "staging", CertificateRoot: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup, + } + management, err := service.IssueForSetup(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if !pathWithinDirectory(filepath.Join(cfg.Paths.Certificate, "management-domain"), management.Directory) { + t.Fatalf("管理证书未进入独立目录:%s", management.Directory) + } + original := snapshotCertificateFiles(t, management) + business, err := service.Issue(t.Context(), item.ID) + if err != nil { + t.Fatal(err) + } + if pathWithinDirectory(filepath.Join(cfg.Paths.Certificate, "management-domain"), business.Saved.Directory) { + t.Fatal("业务证书进入了管理证书目录") + } + businessOriginal := snapshotCertificateFiles(t, business.Saved) + renewed, err := service.IssueForSetup(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if !pathWithinDirectory(filepath.Join(cfg.Paths.Backup, "management-domain"), renewed.BackupDirectory) { + t.Fatalf("管理证书备份未隔离:%s", renewed.BackupDirectory) + } + assertCertificateFilesUnchanged(t, businessOriginal) + issuer.resource = testResourceForDomains(t, names) + service.acmeRoots = testResourceRoots(t, issuer.resource) + if _, err := service.Issue(t.Context(), item.ID); err != nil { + t.Fatal(err) + } + assertCertificateFilesUnchanged(t, original) + if _, err := os.Stat(business.Saved.Directory); !os.IsNotExist(err) { + t.Fatalf("普通业务仍应清理旧当前版本:%v", err) + } + current, err := service.Current(t.Context(), item.ID) + if err != nil || current.Saved.Directory == management.Directory || current.Saved.Directory == renewed.Directory { + t.Fatalf("业务当前证书读取越界:%+v %v", current.Saved, err) + } + }) + } +} + +func TestManagementLegacyReferenceSurvivesBusinessIssueAndImport(t *testing.T) { + for _, operation := range []string{"issue", "import"} { + t.Run(operation, func(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + item, err := domain.NewDomain("management.example.com", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + legacyStore := FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup} + legacy, err := legacyStore.SaveForType(t.Context(), item.Name, "single", testResource(t, item.Name)) + if err != nil { + t.Fatal(err) + } + original := snapshotCertificateFiles(t, legacy) + // 分别覆盖仅通过证书或私钥路径保护旧版本的情况。 + if operation == "issue" { + cfg.Server.TLSCert = legacy.FullChainPath + } else { + cfg.Server.TLSKey = legacy.PrivateKeyPath + } + issuer := &serviceIssuer{resource: testResource(t, item.Name)} + inputDirectory := t.TempDir() + if err := writeCertificateFiles(inputDirectory, issuer.resource.Certificate, issuer.resource.Certificate, issuer.resource.PrivateKey); err != nil { + t.Fatal(err) + } + service := NewService(cfg, &serviceRepository{item: item}, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + service.acmeRoots = testResourceRoots(t, issuer.resource) + service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer } + var first SavedResource + for index := 0; index < 2; index++ { + var result IssueResult + if operation == "issue" { + result, err = service.Issue(t.Context(), item.ID) + } else { + result, err = service.Import(t.Context(), ImportRequest{ + DomainID: item.ID, Certificate: filepath.Join(inputDirectory, "cert.pem"), PrivateKey: filepath.Join(inputDirectory, "key.pem"), + }) + } + if err != nil { + t.Fatal(err) + } + if index == 0 { + first = result.Saved + } + assertCertificateFilesUnchanged(t, original) + } + if _, err := os.Stat(first.Directory); !os.IsNotExist(err) { + t.Fatalf("保护管理入口不应保留无引用的业务旧版本:%v", err) + } + }) + } +} + +func TestManagementStorageRejectsInvalidRootsBeforeExternalCalls(t *testing.T) { + for _, roots := range [][2]string{{"", ""}, {"relative", t.TempDir()}, {t.TempDir(), "relative"}} { + cfg := certificateTestConfig(t.TempDir()) + credentials := &protectedDomainCredentials{} + providers := &protectedDomainProviderFactory{} + accounts := &protectedDomainAccounts{} + service := NewService(cfg, nil, credentials, providers, accounts) + _, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{ + Domains: []string{"management.example.com"}, CertificateType: "single", + ProviderID: "cloudflare", CredentialRef: cfg.DNS.CredentialRef, + CertificateRoot: roots[0], BackupRoot: roots[1], + }) + if err == nil || credentials.getCalls != 0 || providers.createCalls != 0 || accounts.loadOrCreateCalls != 0 { + t.Fatalf("无效目录必须在外部调用之前被拒绝:%v", err) + } + } +} + +func TestManagementProtectedPathsResolveSymlinks(t *testing.T) { + root := t.TempDir() + version := filepath.Join(root, "20260918-V1.0") + if err := os.Mkdir(version, 0o700); err != nil { + t.Fatal(err) + } + referenced := filepath.Join(version, "key.pem") + if err := os.WriteFile(referenced, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "current-key.pem") + if err := os.Symlink(referenced, alias); err != nil { + t.Skipf("当前系统无法创建测试符号链接:%v", err) + } + store := FileStore{ProtectedPaths: []string{alias}} + if protected, err := store.isProtectedDirectory(version); err != nil || !protected { + t.Fatalf("符号链接引用的管理私钥必须受保护:%v %v", protected, err) + } +} + +func TestManagementProtectedPathsDoNotMatchSiblingPrefix(t *testing.T) { + root := t.TempDir() + store := FileStore{ProtectedPaths: []string{filepath.Join(root+"-other", "key.pem")}} + if protected, err := store.isProtectedDirectory(root); err != nil || protected { + t.Fatalf("路径前缀相同不代表目录包含:%v %v", protected, err) + } +} + +func TestManagementUnresolvableReferenceDoesNotWriteCandidate(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + store := FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup} + resource := testResource(t, "management.example.com") + first, err := store.SaveForType(t.Context(), "management.example.com", "single", resource) + if err != nil { + t.Fatal(err) + } + original := snapshotCertificateFiles(t, first) + alias := filepath.Join(t.TempDir(), "invalid-link") + if err := os.Symlink(alias, alias); err != nil { + t.Skipf("当前系统无法创建测试符号链接:%v", err) + } + store.ProtectedPaths = []string{alias} + if _, err := store.SaveForType(t.Context(), "management.example.com", "single", resource); err == nil { + t.Fatal("无法解析管理证书引用时必须停止写入") + } + assertCertificateFilesUnchanged(t, original) + entries, err := os.ReadDir(filepath.Dir(first.Directory)) + if err != nil || len(entries) != 1 { + t.Fatalf("引用检查失败不应产生候选版本:%d %v", len(entries), err) + } +} + +func snapshotCertificateFiles(t *testing.T, saved SavedResource) map[string][]byte { + t.Helper() + result := make(map[string][]byte) + for _, name := range []string{"cert.pem", "fullchain.pem", "key.pem", "metadata.json"} { + path := filepath.Join(saved.Directory, name) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result[path] = data + } + return result +} + +func assertCertificateFilesUnchanged(t *testing.T, expected map[string][]byte) { + t.Helper() + for path, original := range expected { + actual, err := os.ReadFile(path) + if err != nil || !bytes.Equal(original, actual) { + t.Fatalf("仍被引用的证书文件发生变化:%s %v", path, err) + } + } +} diff --git a/backend/internal/certificate/management_store.go b/backend/internal/certificate/management_store.go new file mode 100644 index 0000000..956d472 --- /dev/null +++ b/backend/internal/certificate/management_store.go @@ -0,0 +1,68 @@ +package certificate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// 不含点的固定目录名不能与经过校验的业务域名目录重合。 +const managementDirectory = "management-domain" + +func managementFileStore(certificateRoot, backupRoot string) (FileStore, error) { + certificateRoot = strings.TrimSpace(certificateRoot) + backupRoot = strings.TrimSpace(backupRoot) + if !filepath.IsAbs(certificateRoot) || !filepath.IsAbs(backupRoot) { + return FileStore{}, errors.New("管理证书和备份根目录必须是绝对路径") + } + return FileStore{ + Root: filepath.Join(certificateRoot, managementDirectory), + BackupRoot: filepath.Join(backupRoot, managementDirectory), + RetainPrevious: true, + }, nil +} + +// 同时检查配置路径与符号链接实际指向;无法确认时保留旧版本并返回错误。 +func (s FileStore) isProtectedDirectory(directory string) (bool, error) { + if len(s.ProtectedPaths) == 0 { + return false, nil + } + absoluteDirectory, err := filepath.Abs(directory) + if err != nil { + return false, err + } + resolvedDirectory, err := filepath.EvalSymlinks(absoluteDirectory) + if err != nil { + return false, err + } + for _, path := range s.ProtectedPaths { + if strings.TrimSpace(path) == "" { + continue + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return false, err + } + if pathWithinDirectory(absoluteDirectory, absolutePath) { + return true, nil + } + resolvedPath, err := filepath.EvalSymlinks(absolutePath) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return false, fmt.Errorf("解析管理证书引用失败:%w", err) + } + if pathWithinDirectory(resolvedDirectory, resolvedPath) { + return true, nil + } + } + return false, nil +} + +func pathWithinDirectory(directory, path string) bool { + relative, err := filepath.Rel(directory, path) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 52d2bb6..edf1517 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -92,7 +92,10 @@ type SetupCertificateRequest struct { func NewService(cfg config.Config, repository DomainStateRepository, credentials CredentialReader, providers ProviderFactory, accounts AccountManager) *Service { return &Service{ config: cfg, repository: repository, credentials: credentials, providers: providers, accounts: accounts, - store: FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup}, + store: FileStore{ + Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup, + ProtectedPaths: []string{cfg.Server.TLSCert, cfg.Server.TLSKey}, + }, newIssuer: func(user *AccountUser, provider challenge.Provider, environment string) Issuer { return &LegoIssuer{ User: user, @@ -222,6 +225,10 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil { return SavedResource{}, err } + store, err := managementFileStore(request.CertificateRoot, request.BackupRoot) + if err != nil { + return SavedResource{}, err + } environment := strings.ToLower(strings.TrimSpace(request.Environment)) if environment == "" { environment = s.config.ACME.Environment @@ -271,7 +278,6 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq if err := s.validateACMEResource(ctx, resource, domains, environment); err != nil { return SavedResource{}, err } - store := FileStore{Root: request.CertificateRoot, BackupRoot: request.BackupRoot, RetainPrevious: true} saved, err := store.SaveForType(ctx, domains[0], certificateType, resource) if err != nil { return SavedResource{}, fmt.Errorf("保存初始化证书失败:%w", err) diff --git a/backend/internal/certificate/service_test.go b/backend/internal/certificate/service_test.go index c275923..80baf99 100644 --- a/backend/internal/certificate/service_test.go +++ b/backend/internal/certificate/service_test.go @@ -374,7 +374,7 @@ func TestServiceIssuesSetupCertificateWithTypedLayout(t *testing.T) { if err != nil { t.Fatalf("申请初始化证书失败:%v", err) } - wantDirectory := filepath.Join(cfg.Paths.Certificate, "scert.example.com", "single", saved.Version) + wantDirectory := filepath.Join(cfg.Paths.Certificate, "management-domain", "scert.example.com", "single", saved.Version) if saved.Directory != wantDirectory { t.Fatalf("初始化证书目录错误:got=%q want=%q", saved.Directory, wantDirectory) }