完成证书历史版本恢复核心实现
补充历史证书版本读取、稳定标识、完整证书链恢复、恢复回滚保护及接口安全输出,并同步更新整改任务进度。
This commit is contained in:
parent
3c59ac5e80
commit
3f6a05d2b9
337
backend/internal/certificate/history.go
Normal file
337
backend/internal/certificate/history.go
Normal file
@ -0,0 +1,337 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type historyEntry struct {
|
||||
saved SavedResource
|
||||
resource Resource
|
||||
}
|
||||
|
||||
var errHistoryOtherType = errors.New("归档属于其他证书类型")
|
||||
|
||||
// openHistoryRoot 拒绝符号链接目录,并将所有后续读取限制在已打开的存储根内。
|
||||
func openHistoryRoot(path string) (*os.Root, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("证书存储根目录不能为空")
|
||||
}
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for current := absolute; ; current = filepath.Dir(current) {
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("证书存储路径必须为真实目录")
|
||||
}
|
||||
if filepath.Dir(current) == current {
|
||||
break
|
||||
}
|
||||
}
|
||||
return os.OpenRoot(absolute)
|
||||
}
|
||||
|
||||
func openHistoryDirectory(root *os.Root, relative string) (*os.Root, error) {
|
||||
if !filepath.IsLocal(relative) {
|
||||
return nil, errors.New("证书历史路径超出存储目录")
|
||||
}
|
||||
current := ""
|
||||
for _, part := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
|
||||
current = filepath.Join(current, part)
|
||||
info, err := root.Lstat(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("证书历史目录不允许符号链接")
|
||||
}
|
||||
}
|
||||
return root.OpenRoot(relative)
|
||||
}
|
||||
|
||||
func readHistoryFile(root *os.Root, name string) ([]byte, error) {
|
||||
info, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
const maximumFileSize = 4 << 20
|
||||
if !info.Mode().IsRegular() || info.Size() > maximumFileSize {
|
||||
return nil, errors.New("证书历史文件必须为大小受限的普通文件")
|
||||
}
|
||||
file, err := root.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, maximumFileSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maximumFileSize {
|
||||
return nil, errors.New("证书历史文件超过大小限制")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func readHistoryEntry(root *os.Root, relative, domain, certificateType string) (historyEntry, error) {
|
||||
directory, err := openHistoryDirectory(root, relative)
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
defer directory.Close()
|
||||
metadata, err := readHistoryFile(directory, "metadata.json")
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
var saved SavedResource
|
||||
if err := json.Unmarshal(metadata, &saved); err != nil {
|
||||
return historyEntry{}, fmt.Errorf("解析历史证书元数据失败:%w", err)
|
||||
}
|
||||
if saved.CertificateType != certificateType {
|
||||
return historyEntry{}, errHistoryOtherType
|
||||
}
|
||||
if directoryName(saved.Domain) != directoryName(domain) {
|
||||
return historyEntry{}, errors.New("历史证书归属域名不匹配")
|
||||
}
|
||||
if saved.Version != "" {
|
||||
if err := validateCertificateVersion(saved.Version); err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
}
|
||||
certificateData, err := readHistoryFile(directory, "cert.pem")
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
chain, err := readHistoryFile(directory, "fullchain.pem")
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
key, err := readHistoryFile(directory, "key.pem")
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
names := saved.SANs
|
||||
if len(names) == 0 {
|
||||
names = []string{domain}
|
||||
}
|
||||
chainLeafData, issuerData, err := splitCertificateChain(chain)
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
resource, err := ValidateResource(Resource{Domains: names, Certificate: certificateData, IssuerCertificate: issuerData, PrivateKey: key})
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
leaf, err := parseLeafCertificate(certificateData)
|
||||
if err != nil {
|
||||
return historyEntry{}, err
|
||||
}
|
||||
chainLeaf, err := parseLeafCertificate(chainLeafData)
|
||||
if err != nil || !bytes.Equal(leaf.Raw, chainLeaf.Raw) {
|
||||
return historyEntry{}, errors.New("历史证书与完整链的叶子证书不一致")
|
||||
}
|
||||
for _, name := range append(append([]string(nil), resource.Domains...), domain) {
|
||||
probe := strings.Replace(name, "*.", "scert-wildcard-probe.", 1)
|
||||
if err := leaf.VerifyHostname(probe); err != nil {
|
||||
return historyEntry{}, fmt.Errorf("历史证书不覆盖域名 %s:%w", name, err)
|
||||
}
|
||||
}
|
||||
remaining := chain
|
||||
chainLength := 0
|
||||
for len(bytes.TrimSpace(remaining)) > 0 {
|
||||
block, rest := pem.Decode(remaining)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return historyEntry{}, errors.New("历史证书完整链格式无效")
|
||||
}
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return historyEntry{}, fmt.Errorf("解析历史证书链失败:%w", err)
|
||||
}
|
||||
chainLength++
|
||||
remaining = rest
|
||||
}
|
||||
// 元数据中的路径、指纹、有效期均不可信,必须从实际文件重新生成。
|
||||
saved.Domain = domain
|
||||
saved.SANs = append([]string(nil), resource.Domains...)
|
||||
saved.NotBefore, saved.NotAfter = leaf.NotBefore, leaf.NotAfter
|
||||
saved.SerialNumber = leaf.SerialNumber.Text(16)
|
||||
saved.FingerprintSHA256 = fmt.Sprintf("%X", sha256.Sum256(leaf.Raw))
|
||||
saved.ChainLength = chainLength
|
||||
saved.CertificateIdentifier = ""
|
||||
if certificateType == "san" {
|
||||
saved.CertificateIdentifier = sanCertificateIdentifier(resource.Domains)
|
||||
}
|
||||
saved.Directory = filepath.Join(root.Name(), relative)
|
||||
saved.CertificatePath = filepath.Join(saved.Directory, "cert.pem")
|
||||
saved.FullChainPath = filepath.Join(saved.Directory, "fullchain.pem")
|
||||
saved.PrivateKeyPath = filepath.Join(saved.Directory, "key.pem")
|
||||
saved.BackupDirectory = ""
|
||||
saved.Current = false
|
||||
identity := fmt.Sprintf("%s\n%s\n%s\n%s\n%x", directoryName(domain), certificateType, saved.Version, strings.Join(resource.Domains, ","), sha256.Sum256(chain))
|
||||
saved.HistoryID = fmt.Sprintf("history-%x", sha256.Sum256([]byte(identity)))
|
||||
return historyEntry{saved: saved, resource: resource}, nil
|
||||
}
|
||||
|
||||
func splitCertificateChain(data []byte) ([]byte, []byte, error) {
|
||||
block, rest := pem.Decode(data)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, nil, errors.New("历史证书完整链缺少叶子证书")
|
||||
}
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return nil, nil, fmt.Errorf("解析历史证书叶子失败:%w", err)
|
||||
}
|
||||
issuer := append([]byte(nil), rest...)
|
||||
if len(bytes.TrimSpace(issuer)) == 0 {
|
||||
issuer = nil
|
||||
}
|
||||
return pem.EncodeToMemory(block), issuer, nil
|
||||
}
|
||||
|
||||
func (s FileStore) historyEntries(ctx context.Context, domain, certificateType string) ([]historyEntry, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.CurrentDirectory(domain, certificateType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
certificateType = strings.ToLower(strings.TrimSpace(certificateType))
|
||||
entries := make([]historyEntry, 0)
|
||||
seen := make(map[string]bool)
|
||||
appendEntry := func(entry historyEntry) {
|
||||
if !seen[entry.saved.HistoryID] {
|
||||
seen[entry.saved.HistoryID] = true
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
root, err := openHistoryRoot(s.Root)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
defer root.Close()
|
||||
absolute, err := filepath.Abs(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relative, err := filepath.Rel(root.Name(), absolute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := readHistoryEntry(root, relative, domain, certificateType)
|
||||
if err == nil {
|
||||
entry.saved.Current = true
|
||||
appendEntry(entry)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("读取当前证书版本失败:%w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(s.BackupRoot) == "" {
|
||||
return entries, nil
|
||||
}
|
||||
backups, err := openHistoryRoot(s.BackupRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return entries, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer backups.Close()
|
||||
directory, err := openHistoryDirectory(backups, directoryName(domain))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return entries, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer directory.Close()
|
||||
file, err := directory.Open(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children, err := file.ReadDir(-1)
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if child.Type()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("历史证书归档不允许符号链接")
|
||||
}
|
||||
if !child.IsDir() {
|
||||
continue
|
||||
}
|
||||
entry, err := readHistoryEntry(directory, child.Name(), domain, certificateType)
|
||||
if errors.Is(err, errHistoryOtherType) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取历史证书归档失败:%w", err)
|
||||
}
|
||||
appendEntry(entry)
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].saved.Current != entries[j].saved.Current {
|
||||
return entries[i].saved.Current
|
||||
}
|
||||
if !entries[i].saved.SavedAt.Equal(entries[j].saved.SavedAt) {
|
||||
return entries[i].saved.SavedAt.After(entries[j].saved.SavedAt)
|
||||
}
|
||||
return entries[i].saved.HistoryID < entries[j].saved.HistoryID
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s FileStore) loadHistoryVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, Resource, error) {
|
||||
if strings.HasPrefix(version, "history-") {
|
||||
digest, err := hex.DecodeString(strings.TrimPrefix(version, "history-"))
|
||||
if err != nil || len(digest) != sha256.Size {
|
||||
return SavedResource{}, Resource{}, errors.New("证书历史标识无效")
|
||||
}
|
||||
} else if err := validateCertificateVersion(version); err != nil {
|
||||
return SavedResource{}, Resource{}, err
|
||||
}
|
||||
entries, err := s.historyEntries(ctx, domain, certificateType)
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, err
|
||||
}
|
||||
var selected *historyEntry
|
||||
for index := range entries {
|
||||
entry := &entries[index]
|
||||
if entry.saved.HistoryID != version && entry.saved.Version != version {
|
||||
continue
|
||||
}
|
||||
if selected != nil {
|
||||
return SavedResource{}, Resource{}, errors.New("证书版本号存在歧义,请使用历史标识恢复")
|
||||
}
|
||||
selected = entry
|
||||
}
|
||||
if selected == nil {
|
||||
return SavedResource{}, Resource{}, errors.New("未找到指定证书历史版本")
|
||||
}
|
||||
resource, err := ValidateResourceForDomains(selected.resource, selected.resource.Domains)
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("历史证书不可恢复:%w", err)
|
||||
}
|
||||
return selected.saved, resource, nil
|
||||
}
|
||||
103
backend/internal/certificate/history_restore.go
Normal file
103
backend/internal/certificate/history_restore.go
Normal file
@ -0,0 +1,103 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// restoreResource 使用已经校验的内存快照,避免预检之后重新读取另一个历史文件。
|
||||
// 状态提交失败时恢复原目录和元数据;本地恢复不代表远端服务器已更新。
|
||||
func (s FileStore) restoreResource(ctx context.Context, domain, certificateType string, resource Resource, expected []string, commit func(SavedResource) error) (SavedResource, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
resource, err := ValidateResourceForDomains(resource, expected)
|
||||
if err != nil {
|
||||
return SavedResource{}, fmt.Errorf("历史证书不匹配当前域名配置:%w", err)
|
||||
}
|
||||
previous, err := s.CurrentDirectory(domain, certificateType)
|
||||
if err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
hadPrevious := directoryContainsCertificateFiles(previous)
|
||||
// 固定保存前的目录。回滚不能仅将旧元数据写入新目录而丢失原文件引用。
|
||||
saved, saveErr := s.SaveForType(ctx, domain, certificateType, resource)
|
||||
rollback := func(cause error) (SavedResource, error) {
|
||||
if saved.Directory == "" {
|
||||
return SavedResource{}, cause
|
||||
}
|
||||
if hadPrevious {
|
||||
if saved.BackupDirectory == "" {
|
||||
return SavedResource{}, errors.Join(cause, errors.New("恢复失败且缺少原当前证书归档"))
|
||||
}
|
||||
if err := restoreCertificateFiles(previous, saved.BackupDirectory); err != nil {
|
||||
return SavedResource{}, errors.Join(cause, fmt.Errorf("恢复原当前证书失败:%w", err))
|
||||
}
|
||||
if err := rewriteRestoredMetadata(previous); err != nil {
|
||||
return SavedResource{}, errors.Join(cause, fmt.Errorf("恢复原当前元数据失败:%w", err))
|
||||
}
|
||||
}
|
||||
typeDirectory := filepath.Join(s.Root, directoryName(domain), certificateType)
|
||||
if err := removeCertificateDirectory(saved.Directory, typeDirectory); err != nil {
|
||||
return SavedResource{}, errors.Join(cause, fmt.Errorf("清理未提交的恢复版本失败:%w", err))
|
||||
}
|
||||
return SavedResource{}, cause
|
||||
}
|
||||
if saveErr != nil {
|
||||
return rollback(saveErr)
|
||||
}
|
||||
root, err := openHistoryRoot(s.Root)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
defer root.Close()
|
||||
absolute, err := filepath.Abs(saved.Directory)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
relative, err := filepath.Rel(root.Name(), absolute)
|
||||
if err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
actual, err := readHistoryEntry(root, relative, domain, certificateType)
|
||||
if err != nil {
|
||||
return rollback(fmt.Errorf("恢复后读取证书失败:%w", err))
|
||||
}
|
||||
if _, err := ValidateResourceForDomains(actual.resource, expected); err != nil {
|
||||
return rollback(fmt.Errorf("恢复后证书校验失败:%w", err))
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
saved.HistoryID = actual.saved.HistoryID
|
||||
saved.Current = true
|
||||
if commit != nil {
|
||||
if err := commit(saved); err != nil {
|
||||
return rollback(fmt.Errorf("恢复后更新证书状态失败:%w", err))
|
||||
}
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
// validateCertificateWritePath 拒绝已有路径上的符号链接,不能借恢复写入其他目录。
|
||||
func validateCertificateWritePath(path string) error {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for current := absolute; ; current = filepath.Dir(current) {
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) {
|
||||
return errors.New("证书写入路径必须为真实目录,不能使用符号链接")
|
||||
}
|
||||
if filepath.Dir(current) == current {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
314
backend/internal/certificate/history_test.go
Normal file
314
backend/internal/certificate/history_test.go
Normal file
@ -0,0 +1,314 @@
|
||||
package certificate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
func historyTestResource(t *testing.T, names []string, before, after time.Time) Resource {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issuerPublic, issuerPrivate, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issuer := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(101), Subject: pkix.Name{CommonName: "历史测试签发机构"},
|
||||
NotBefore: before, NotAfter: after, IsCA: true, BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
issuerDER, err := x509.CreateCertificate(rand.Reader, issuer, issuer, issuerPublic, issuerPrivate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(102), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: before, NotAfter: after, KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, leaf, issuer, public, issuerPrivate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := x509.MarshalPKCS8PrivateKey(private)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Resource{
|
||||
Domains: names, Certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
IssuerCertificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER}),
|
||||
PrivateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}),
|
||||
}
|
||||
}
|
||||
|
||||
func historyTestStore(t *testing.T) FileStore {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return FileStore{Root: filepath.Join(root, "certificates"), BackupRoot: filepath.Join(root, "backups")}
|
||||
}
|
||||
|
||||
func historySave(t *testing.T, store FileStore, certificateType string, names []string) SavedResource {
|
||||
t.Helper()
|
||||
resource := historyTestResource(t, names, time.Now().Add(-time.Hour), time.Now().Add(48*time.Hour))
|
||||
saved, err := store.SaveForType(context.Background(), "example.com", certificateType, resource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
func historyRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func historyMetadata(t *testing.T, directory string, change func(*SavedResource)) {
|
||||
t.Helper()
|
||||
path := filepath.Join(directory, "metadata.json")
|
||||
var saved SavedResource
|
||||
if err := json.Unmarshal(historyRead(t, path), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change(&saved)
|
||||
data, err := json.Marshal(saved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRestorePreservesChainAndArchives(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
first := historySave(t, store, "single", []string{"example.com"})
|
||||
chain := historyRead(t, first.FullChainPath)
|
||||
key := historyRead(t, first.PrivateKeyPath)
|
||||
second := historySave(t, store, "single", []string{"example.com"})
|
||||
items, err := store.ListVersions(context.Background(), "example.com", "single")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || !items[0].Current || items[1].Current || items[1].HistoryID == "" {
|
||||
t.Fatalf("历史列表错误:%+v", items)
|
||||
}
|
||||
selected, resource, err := store.LoadVersion(context.Background(), "example.com", "single", items[1].HistoryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
combined := append(append([]byte(nil), resource.Certificate...), resource.IssuerCertificate...)
|
||||
if selected.Version != first.Version || !bytes.Equal(combined, chain) {
|
||||
t.Fatal("历史读取必须保留完整链")
|
||||
}
|
||||
result, err := store.RestoreVersion(context.Background(), "example.com", "single", items[1].HistoryID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Version == first.Version || result.Version == second.Version || !result.Current || result.ChainLength != 2 {
|
||||
t.Fatalf("恢复必须产生新的当前版本:%+v", result)
|
||||
}
|
||||
if !bytes.Equal(historyRead(t, result.FullChainPath), chain) || !bytes.Equal(historyRead(t, result.PrivateKeyPath), key) {
|
||||
t.Fatal("恢复后完整链或私钥发生变化")
|
||||
}
|
||||
if bytes.Count(historyRead(t, result.CertificatePath), []byte("BEGIN CERTIFICATE")) != 1 {
|
||||
t.Fatal("恢复后的叶子证书文件不应包含完整证书链")
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Dir(result.Directory))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != result.Version {
|
||||
t.Fatal("正式目录只能保留当前版本")
|
||||
}
|
||||
items, err = store.ListVersions(context.Background(), "example.com", "single")
|
||||
if err != nil || len(items) != 3 {
|
||||
t.Fatalf("恢复不得丢失原历史:%+v,%v", items, err)
|
||||
}
|
||||
if items[0].HistoryID != result.HistoryID || !items[0].Current {
|
||||
t.Fatal("恢复后的当前历史标识不稳定")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExpiredAndFutureVersionsRemainVisibleButCannotRestore(t *testing.T) {
|
||||
for _, future := range []bool{false, true} {
|
||||
name := "已过期"
|
||||
if future {
|
||||
name = "未生效"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
before, after := time.Now().Add(-48*time.Hour), time.Now().Add(-time.Hour)
|
||||
if future {
|
||||
before, after = time.Now().Add(time.Hour), time.Now().Add(48*time.Hour)
|
||||
}
|
||||
old, err := store.SaveForType(context.Background(), "example.com", "single", historyTestResource(t, []string{"example.com"}, before, after))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := historySave(t, store, "single", []string{"example.com"})
|
||||
original := historyRead(t, current.FullChainPath)
|
||||
items, err := store.ListVersions(context.Background(), "example.com", "single")
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("不可恢复的历史仍需展示:%v", err)
|
||||
}
|
||||
if _, err := store.RestoreVersion(context.Background(), "example.com", "single", old.Version); err == nil {
|
||||
t.Fatal("不允许恢复有效期外的证书")
|
||||
}
|
||||
if !bytes.Equal(original, historyRead(t, current.FullChainPath)) {
|
||||
t.Fatal("拒绝恢复不应改变当前证书")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRejectsAmbiguousSANVersionAndUsesStableIdentity(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
historySave(t, store, "san", []string{"example.com", "old.example.com"})
|
||||
current := historySave(t, store, "san", []string{"example.com", "new.example.com"})
|
||||
// 兼容旧版本按不同 SAN 目录独立编号产生的真实历史歧义。
|
||||
historyMetadata(t, current.BackupDirectory, func(saved *SavedResource) { saved.Version = current.Version })
|
||||
items, err := store.ListVersions(context.Background(), "example.com", "san")
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("读取同号历史失败:%v", err)
|
||||
}
|
||||
if items[0].HistoryID == items[1].HistoryID {
|
||||
t.Fatal("不同 SAN 历史必须具有不同标识")
|
||||
}
|
||||
if _, _, err := store.LoadVersion(context.Background(), "example.com", "san", current.Version); err == nil || !strings.Contains(err.Error(), "歧义") {
|
||||
t.Fatalf("不能默默选取同名版本:%v", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
selected, _, err := store.LoadVersion(context.Background(), "example.com", "san", item.HistoryID)
|
||||
if err != nil || selected.CertificateIdentifier != item.CertificateIdentifier {
|
||||
t.Fatalf("历史标识未精确选择:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryMetadataCannotOverrideFilesOrValidity(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
first := historySave(t, store, "single", []string{"example.com"})
|
||||
current := historySave(t, store, "single", []string{"example.com"})
|
||||
historyMetadata(t, current.BackupDirectory, func(saved *SavedResource) {
|
||||
saved.Directory = "/etc"
|
||||
saved.PrivateKeyPath = "/etc/shadow"
|
||||
saved.BackupDirectory = "/"
|
||||
saved.FingerprintSHA256 = "伪造"
|
||||
saved.NotAfter = time.Now().Add(999 * 24 * time.Hour)
|
||||
})
|
||||
saved, _, err := store.LoadVersion(context.Background(), "example.com", "single", first.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Directory != current.BackupDirectory || saved.PrivateKeyPath == "/etc/shadow" || saved.BackupDirectory != "" || saved.FingerprintSHA256 == "伪造" || !saved.NotAfter.Equal(first.NotAfter) {
|
||||
t.Fatalf("不应相信归档路径或伪造元数据:%+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRejectsInvalidSelectorAndCancellation(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
current := historySave(t, store, "single", []string{"example.com"})
|
||||
for _, selector := range []string{"../key.pem", "", "history-xyz", "history-" + strings.Repeat("a", 64), "20260830-V99.0"} {
|
||||
if _, err := store.RestoreVersion(context.Background(), "example.com", "single", selector); err == nil {
|
||||
t.Fatalf("非法或缺失历史不应恢复:%q", selector)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := store.ListVersions(ctx, "example.com", "single"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("未传播取消:%v", err)
|
||||
}
|
||||
if _, err := store.RestoreVersion(ctx, "example.com", "single", current.Version); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("未传播取消:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryRejectsSymlinkArchiveAndFiles(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("本轮只验收 Linux 符号链接安全")
|
||||
}
|
||||
for _, target := range []string{"archive", "key.pem", "metadata.json"} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
historySave(t, store, "single", []string{"example.com"})
|
||||
current := historySave(t, store, "single", []string{"example.com"})
|
||||
path := filepath.Join(current.BackupDirectory, target)
|
||||
if target == "archive" {
|
||||
path = current.BackupDirectory
|
||||
}
|
||||
moved := path + "-original"
|
||||
if err := os.Rename(path, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(moved, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.ListVersions(context.Background(), "example.com", "single"); err == nil {
|
||||
t.Fatal("不允许通过符号链接读取历史证书")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceHistoryRestoreRejectsChangedSANBeforeWriting(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := certificateTestConfig(root)
|
||||
store := FileStore{Root: cfg.Paths.Certificate, BackupRoot: cfg.Paths.Backup}
|
||||
first := historySave(t, store, "san", []string{"example.com", "old.example.com"})
|
||||
current := historySave(t, store, "san", []string{"example.com", "new.example.com"})
|
||||
before := historyRead(t, current.FullChainPath)
|
||||
item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSAN, []string{"new.example.com"}, true, domain.ACMEEnvironmentStaging, "cloudflare")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &serviceRepository{item: item}
|
||||
service := NewService(cfg, repository, nil, nil, nil)
|
||||
if _, err := service.RestoreVersion(context.Background(), item.ID, first.Version); err == nil {
|
||||
t.Fatal("历史证书必须覆盖当前配置的全部域名")
|
||||
}
|
||||
if repository.updateCalls != 0 || !bytes.Equal(before, historyRead(t, current.FullChainPath)) {
|
||||
t.Fatal("域名预检失败不能修改文件或数据库")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryFiltersOtherCertificateTypes(t *testing.T) {
|
||||
store := historyTestStore(t)
|
||||
historySave(t, store, "single", []string{"example.com"})
|
||||
historySave(t, store, "single", []string{"example.com"})
|
||||
historySave(t, store, "san", []string{"example.com", "www.example.com"})
|
||||
historySave(t, store, "san", []string{"example.com", "api.example.com"})
|
||||
for _, certificateType := range []string{"single", "san"} {
|
||||
items, err := store.ListVersions(context.Background(), "example.com", certificateType)
|
||||
if err != nil || len(items) != 2 {
|
||||
t.Fatalf("证书类型历史隔离失败:%s,%v", certificateType, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.CertificateType != certificateType {
|
||||
t.Fatal("历史列表混入其他类型")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,8 @@ type SavedResource struct {
|
||||
CertificateType string `json:"certificate_type,omitempty"`
|
||||
CertificateIdentifier string `json:"certificate_identifier,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
HistoryID string `json:"history_id,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
@ -224,6 +226,13 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res
|
||||
if err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
// SAN 组合变化或系统时间回拨时,新当前版本仍必须排在旧版本之后。
|
||||
if activeDirectory != "" && !newerCertificateDirectory(version, filepath.Base(activeDirectory)) {
|
||||
date, major, minor, valid := parseCertificateVersion(filepath.Base(activeDirectory))
|
||||
if valid {
|
||||
version = fmt.Sprintf("%s-V%d.%d", date, major, minor+1)
|
||||
}
|
||||
}
|
||||
currentDirectory = filepath.Join(directory, version)
|
||||
}
|
||||
protected := false
|
||||
@ -234,13 +243,26 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res
|
||||
}
|
||||
}
|
||||
backupDirectory := ""
|
||||
if err := validateCertificateWritePath(currentDirectory); err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
if activeDirectory != "" {
|
||||
if err := validateCertificateWritePath(activeDirectory); err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
backupDirectory, err = s.backupExisting(activeDirectory, primary)
|
||||
if err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(currentDirectory, 0o700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(currentDirectory), 0o700); err != nil {
|
||||
return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err)
|
||||
}
|
||||
if certificateType != "" {
|
||||
if err := os.Mkdir(currentDirectory, 0o700); err != nil {
|
||||
return SavedResource{}, fmt.Errorf("创建新证书版本失败:%w", err)
|
||||
}
|
||||
} else if err := os.MkdirAll(currentDirectory, 0o700); err != nil {
|
||||
return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err)
|
||||
}
|
||||
fullChain := resource.Certificate
|
||||
@ -281,7 +303,7 @@ func (s FileStore) save(ctx context.Context, domain, certificateType string, res
|
||||
}
|
||||
if !s.RetainPrevious && !protected && certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory {
|
||||
if err := removeCertificateDirectory(activeDirectory, typeDirectory); err != nil {
|
||||
return SavedResource{}, fmt.Errorf("清理旧当前证书目录失败:%w", err)
|
||||
return result, fmt.Errorf("清理旧当前证书目录失败:%w", err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@ -715,97 +737,31 @@ func (s FileStore) LoadCurrent(ctx context.Context, domain, certificateType stri
|
||||
return saved, resource, nil
|
||||
}
|
||||
|
||||
// LoadVersion 仅允许读取当前使用的版本,保留该方法用于兼容已有接口。
|
||||
// LoadVersion 读取可唯一识别的当前或历史版本,恢复前仍需校验当前域名配置。
|
||||
func (s FileStore) LoadVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, Resource, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return SavedResource{}, Resource{}, err
|
||||
}
|
||||
if err := validateCertificateVersion(version); err != nil {
|
||||
return SavedResource{}, Resource{}, err
|
||||
}
|
||||
directory, err := s.CurrentDirectory(domain, certificateType)
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, err
|
||||
}
|
||||
if filepath.Base(directory) != version {
|
||||
return SavedResource{}, Resource{}, errors.New("指定版本不是当前使用的证书版本")
|
||||
}
|
||||
certificateData, err := os.ReadFile(filepath.Join(directory, "cert.pem"))
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("读取当前证书失败:%w", err)
|
||||
}
|
||||
fullChainData, err := os.ReadFile(filepath.Join(directory, "fullchain.pem"))
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("读取当前完整证书链失败:%w", err)
|
||||
}
|
||||
privateKeyData, err := os.ReadFile(filepath.Join(directory, "key.pem"))
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("读取当前私钥失败:%w", err)
|
||||
}
|
||||
var saved SavedResource
|
||||
metadata, err := os.ReadFile(filepath.Join(directory, "metadata.json"))
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("读取当前证书元数据失败:%w", err)
|
||||
}
|
||||
if err := json.Unmarshal(metadata, &saved); err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("解析当前证书元数据失败:%w", err)
|
||||
}
|
||||
if saved.Version != version {
|
||||
return SavedResource{}, Resource{}, errors.New("当前证书版本标识不一致")
|
||||
}
|
||||
domains := saved.SANs
|
||||
if len(domains) == 0 {
|
||||
domains = []string{domain}
|
||||
}
|
||||
resource, err := ValidateResourceForDomains(Resource{
|
||||
Domains: domains,
|
||||
Certificate: certificateData,
|
||||
IssuerCertificate: fullChainData,
|
||||
PrivateKey: privateKeyData,
|
||||
}, domains)
|
||||
if err != nil {
|
||||
return SavedResource{}, Resource{}, fmt.Errorf("当前证书校验失败:%w", err)
|
||||
}
|
||||
saved.Directory = directory
|
||||
saved.CertificatePath = filepath.Join(directory, "cert.pem")
|
||||
saved.FullChainPath = filepath.Join(directory, "fullchain.pem")
|
||||
saved.PrivateKeyPath = filepath.Join(directory, "key.pem")
|
||||
saved.BackupDirectory = ""
|
||||
resource.IssuerCertificate = nil
|
||||
return saved, resource, nil
|
||||
return s.loadHistoryVersion(ctx, domain, certificateType, version)
|
||||
}
|
||||
|
||||
// RestoreVersion 已停用,正式证书目录只保留当前使用的版本。
|
||||
// RestoreVersion 从本地历史归档创建新的当前版本,不执行远端部署。
|
||||
func (s FileStore) RestoreVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
_, resource, err := s.LoadVersion(ctx, domain, certificateType, version)
|
||||
if err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
if err := validateCertificateVersion(version); err != nil {
|
||||
return SavedResource{}, err
|
||||
}
|
||||
return SavedResource{}, errors.New("当前仅保留当前使用的证书版本,不支持历史版本恢复")
|
||||
return s.restoreResource(ctx, domain, certificateType, resource, resource.Domains, nil)
|
||||
}
|
||||
|
||||
// ListVersions 返回当前唯一使用的证书版本,保留该方法用于兼容已有接口。
|
||||
// ListVersions 列出当前版本和本地归档,过期版本可展示但不可恢复。
|
||||
func (s FileStore) ListVersions(ctx context.Context, domain, certificateType string) ([]SavedResource, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
directory, err := s.CurrentDirectory(domain, certificateType)
|
||||
entries, err := s.historyEntries(ctx, domain, certificateType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(directory, "metadata.json")); statErr != nil {
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
return []SavedResource{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取当前证书版本失败:%w", statErr)
|
||||
items := make([]SavedResource, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
items = append(items, entry.saved)
|
||||
}
|
||||
saved, _, err := s.LoadCurrent(ctx, domain, certificateType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []SavedResource{saved}, nil
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func validateCertificateVersion(version string) error {
|
||||
|
||||
@ -65,7 +65,7 @@ func TestFileStoreUsesSingleDatedCurrentVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("读取当前证书版本失败:%v", err)
|
||||
}
|
||||
if len(versions) != 1 || versions[0].Version != second.Version {
|
||||
if len(versions) != 2 || versions[0].Version != second.Version || !versions[0].Current || versions[1].Version != first.Version || versions[1].Current {
|
||||
t.Fatalf("当前证书版本列表错误:%+v", versions)
|
||||
}
|
||||
metadata, loaded, err := store.LoadCurrent(context.Background(), "example.com", "single")
|
||||
@ -147,10 +147,10 @@ func TestRollbackSavedResourceClearsNewCertificateFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileStoreDoesNotRestoreHistoricalVersion(t *testing.T) {
|
||||
func TestFileStoreRejectsMissingHistoricalVersion(t *testing.T) {
|
||||
store := FileStore{Root: t.TempDir()}
|
||||
if _, err := store.RestoreVersion(context.Background(), "example.com", "single", "20260830-V1.0"); err == nil {
|
||||
t.Fatal("仅保留当前版本时不应支持历史版本恢复")
|
||||
t.Fatal("不存在的历史证书版本不应恢复成功")
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,7 +187,7 @@ func TestFileStoreUsesStableSANIdentifierAndRemovesPreviousIdentifier(t *testing
|
||||
if second.CertificateIdentifier == first.CertificateIdentifier {
|
||||
t.Fatal("different SAN sets should use different identifiers")
|
||||
}
|
||||
if second.Version != "20260830-V1.0" {
|
||||
if second.Version != "20260830-V1.1" {
|
||||
t.Fatalf("unexpected second SAN version: %s", second.Version)
|
||||
}
|
||||
if _, err := os.Stat(first.Directory); !errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@ -535,20 +535,12 @@ func (s *Service) RestoreVersion(ctx context.Context, domainID, version string)
|
||||
if _, err := ValidateResourceForDomains(resource, domains); err != nil {
|
||||
return IssueResult{}, fmt.Errorf("历史证书不匹配当前域名配置:%w", err)
|
||||
}
|
||||
saved, err := s.store.RestoreVersion(ctx, item.Name, string(item.CertificateType), strings.TrimSpace(version))
|
||||
saved, err := s.store.restoreResource(ctx, item.Name, string(item.CertificateType), resource, domains, func(saved SavedResource) error {
|
||||
return s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter)
|
||||
})
|
||||
if err != nil {
|
||||
return IssueResult{}, err
|
||||
}
|
||||
if _, err := ValidateResourceForDomains(resource, domains); err != nil {
|
||||
return IssueResult{}, fmt.Errorf("恢复后证书校验失败:%w", err)
|
||||
}
|
||||
if err := s.repository.UpdateCertificateState(ctx, item.ID, "valid", &saved.NotAfter); err != nil {
|
||||
rollbackErr := RollbackSavedResource(context.Background(), saved)
|
||||
if rollbackErr != nil {
|
||||
return IssueResult{}, errors.Join(err, rollbackErr)
|
||||
}
|
||||
return IssueResult{}, fmt.Errorf("certificate state update failed after restore: %w", err)
|
||||
}
|
||||
saved.SANs = append([]string(nil), domains...)
|
||||
return IssueResult{DomainID: item.ID, Domains: domains, Saved: saved}, nil
|
||||
}
|
||||
|
||||
@ -22,9 +22,10 @@ import (
|
||||
)
|
||||
|
||||
type serviceRepository struct {
|
||||
item domain.Domain
|
||||
states []string
|
||||
updateErr error
|
||||
item domain.Domain
|
||||
states []string
|
||||
updateErr error
|
||||
updateCalls int
|
||||
}
|
||||
|
||||
func (r *serviceRepository) GetByID(_ context.Context, _ string) (domain.Domain, error) {
|
||||
@ -32,6 +33,7 @@ func (r *serviceRepository) GetByID(_ context.Context, _ string) (domain.Domain,
|
||||
}
|
||||
|
||||
func (r *serviceRepository) UpdateCertificateState(_ context.Context, _ string, status string, expiresAt *time.Time) error {
|
||||
r.updateCalls++
|
||||
if status == "valid" && r.updateErr != nil {
|
||||
return r.updateErr
|
||||
}
|
||||
@ -301,8 +303,11 @@ func TestServiceRestoreVersionRollsBackFilesWhenStateUpdateFails(t *testing.T) {
|
||||
}
|
||||
repository := &serviceRepository{item: item, updateErr: errors.New("simulated state update failure")}
|
||||
service := NewService(cfg, repository, nil, nil, nil)
|
||||
if _, err := service.RestoreVersion(context.Background(), item.ID, first.Version); err == nil {
|
||||
t.Fatal("state update failure should be returned")
|
||||
if _, err := service.RestoreVersion(context.Background(), item.ID, first.Version); !errors.Is(err, repository.updateErr) {
|
||||
t.Fatalf("必须实际到达状态更新并返回注入的错误:%v", err)
|
||||
}
|
||||
if repository.updateCalls != 1 {
|
||||
t.Fatalf("恢复未实际到达状态更新:调用次数=%d", repository.updateCalls)
|
||||
}
|
||||
afterCertificate, err := os.ReadFile(second.CertificatePath)
|
||||
if err != nil {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@ -31,6 +32,34 @@ type certificatePathRequest struct {
|
||||
IssuerBundlePath string `json:"issuer_bundle_path"`
|
||||
}
|
||||
|
||||
// certificateVersionResponse 只公开证书历史元数据,不公开服务器本地路径。
|
||||
type certificateVersionResponse struct {
|
||||
Domain string `json:"domain"`
|
||||
CertificateType string `json:"certificate_type,omitempty"`
|
||||
CertificateIdentifier string `json:"certificate_identifier,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
HistoryID string `json:"history_id,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
SANs []string `json:"sans,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
FingerprintSHA256 string `json:"fingerprint_sha256,omitempty"`
|
||||
ChainLength int `json:"chain_length,omitempty"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
}
|
||||
|
||||
func publicCertificateVersion(item certificate.SavedResource) certificateVersionResponse {
|
||||
return certificateVersionResponse{
|
||||
Domain: item.Domain, CertificateType: item.CertificateType,
|
||||
CertificateIdentifier: item.CertificateIdentifier, Version: item.Version,
|
||||
HistoryID: item.HistoryID, Current: item.Current, SavedAt: item.SavedAt,
|
||||
SANs: append([]string(nil), item.SANs...), SerialNumber: item.SerialNumber,
|
||||
FingerprintSHA256: item.FingerprintSHA256, ChainLength: item.ChainLength,
|
||||
NotBefore: item.NotBefore, NotAfter: item.NotAfter,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listCertificates(c *gin.Context) {
|
||||
if !s.requireProjectResourceReader(c) {
|
||||
return
|
||||
@ -90,7 +119,11 @@ func (s *Server) listCertificateVersions(c *gin.Context) {
|
||||
s.writeError(c, certificateErrorStatus(err), "查询证书历史版本失败", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
publicItems := make([]certificateVersionResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
publicItems = append(publicItems, publicCertificateVersion(item))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": publicItems})
|
||||
}
|
||||
|
||||
func (s *Server) validateCertificate(c *gin.Context) {
|
||||
|
||||
@ -47,7 +47,7 @@ func TestCertificateReadAPIs(t *testing.T) {
|
||||
NotAfter: expiresAt,
|
||||
},
|
||||
},
|
||||
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000"}},
|
||||
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000", HistoryID: "history-test", Directory: "/private/cert", PrivateKeyPath: "/private/key.pem"}},
|
||||
}
|
||||
repo := &stubDomainRepository{items: []domain.Domain{{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSAN}}}
|
||||
repo.loaded = repo.items[0]
|
||||
@ -73,6 +73,9 @@ func TestCertificateReadAPIs(t *testing.T) {
|
||||
if versions.Code != http.StatusOK || !strings.Contains(versions.Body.String(), "20260827-120000.000000000") {
|
||||
t.Fatalf("证书历史版本响应错误:%d %s", versions.Code, versions.Body.String())
|
||||
}
|
||||
if strings.Contains(versions.Body.String(), "private_key_path") || strings.Contains(versions.Body.String(), "/private/key.pem") || strings.Contains(versions.Body.String(), "directory") {
|
||||
t.Fatalf("证书历史接口不应公开本地路径:%s", versions.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateListHidesReaderErrors(t *testing.T) {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月2日21:40 历史证书与恢复开发批次
|
||||
|
||||
需求沿用已确认的 P1-02/P2-03,不缩减历史版本恢复范围。修改前基线 3c59ac5 已提交推送;本轮不处理 Windows,不部署生产,不增加远端备份。
|
||||
|
||||
- [~] 复用本地证书归档读取历史版本,校验真实证书元数据,拒绝路径逃逸及符号链接;正式目录仍只保留当前版本及受保护的管理入口引用。
|
||||
- [ ] 解决 SAN 不同域名组合的同名版本歧义,提供稳定的历史标识;历史列表允许展示过期版本,但恢复拒绝过期、未生效、域名不匹配及密钥错误。
|
||||
- [ ] 恢复保留完整证书链,产生新的当前版本;状态更新或恢复后校验失败时恢复原当前文件及元数据。
|
||||
- [ ] 补齐真实文件存储与服务层测试,证明状态更新失败用例确实到达数据库更新,而不是提前拒绝恢复的假通过。
|
||||
- [ ] 接入持久化后台任务、执行前权限、幂等与前端历史操作,更新接口说明。
|
||||
- [ ] 执行 Linux 原生自动回归并记录证据,再安排统一正式部署验收。
|
||||
|
||||
|
||||
## 2026年10月2日21:30 手动续期后台化回归完成,等待复审
|
||||
|
||||
需求沿用已确认的 P1-05/P2-03:保留管理证书和全部业务域名的原续期调度,不缩减行为;先开发、自动回归,再统一部署。修改前基线 4ab3f93 已提交推送。
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 2026年10月2日21:40 当前开发进度:历史版本与恢复
|
||||
|
||||
- [~] P1-02:已确认真实存储仍不支持历史恢复,开始补齐本地历史归档读取、恢复校验与失败回滚,不以模拟接口测试代替实现。
|
||||
- [ ] P2-03 配套:恢复后台任务、实时权限复核及前端操作待接入。
|
||||
- [!] 本批尚未回归或部署;原升级、通知重置、三库首次 Web、备份恢复、监控与 P3 均保留。
|
||||
|
||||
|
||||
## 2026年10月2日21:30 当前开发进度:手动续期后台化完成待复审
|
||||
|
||||
- [x] P1-05/P2-03 子项:全局手动续期持久化为系统任务后返回 202,保留管理证书及业务域名原调度;支持幂等找回、活动任务去重、取消传播和执行前超级管理员复核。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user