Scert/backend/internal/certificate/lifecycle.go

901 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package certificate
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
type Issuer interface {
Obtain(ctx context.Context, request ObtainRequest) (Resource, error)
}
type ObtainRequest struct {
Domains []string
AccountEmail string
Environment string
PreferredChain string
}
type Resource struct {
Domains []string
Certificate []byte
IssuerCertificate []byte
PrivateKey []byte
NotBefore time.Time
NotAfter time.Time
}
type SavedResource struct {
Domain string `json:"domain"`
CertificateType string `json:"certificate_type,omitempty"`
CertificateIdentifier string `json:"certificate_identifier,omitempty"`
Version string `json:"version,omitempty"`
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"`
Directory string `json:"directory"`
CertificatePath string `json:"certificate_path"`
FullChainPath string `json:"fullchain_path"`
PrivateKeyPath string `json:"private_key_path"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
BackupDirectory string `json:"backup_directory,omitempty"`
}
type FileStore struct {
Root string
BackupRoot string
Now func() time.Time
}
func NormalizeDomains(domains []string) ([]string, error) {
seen := make(map[string]struct{}, len(domains))
result := make([]string, 0, len(domains))
for _, value := range domains {
value = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), "."))
if value == "" {
continue
}
if strings.HasPrefix(value, "*.") {
value = "*." + strings.TrimPrefix(value, "*.")
}
if !validDomain(value) {
return nil, fmt.Errorf("证书域名无效:%s", value)
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if len(result) == 0 {
return nil, errors.New("证书域名不能为空")
}
sort.Strings(result)
return result, nil
}
func ValidateResource(resource Resource) (Resource, error) {
if len(resource.Certificate) == 0 || len(resource.PrivateKey) == 0 {
return Resource{}, errors.New("证书和私钥不能为空")
}
certificate, err := parseLeafCertificate(resource.Certificate)
if err != nil {
return Resource{}, fmt.Errorf("解析证书失败:%w", err)
}
if _, err := tls.X509KeyPair(resource.Certificate, resource.PrivateKey); err != nil {
return Resource{}, fmt.Errorf("证书与私钥不匹配:%w", err)
}
domains, err := NormalizeDomains(resource.Domains)
if err != nil {
return Resource{}, err
}
if resource.NotBefore.IsZero() {
resource.NotBefore = certificate.NotBefore
}
if resource.NotAfter.IsZero() {
resource.NotAfter = certificate.NotAfter
}
if !resource.NotAfter.After(resource.NotBefore) {
return Resource{}, errors.New("证书有效期无效")
}
resource.Domains = domains
if len(resource.IssuerCertificate) > 0 {
if _, err := parseLeafCertificate(resource.IssuerCertificate); err != nil {
return Resource{}, fmt.Errorf("解析签发者证书失败:%w", err)
}
}
return resource, nil
}
// ValidateResourceForDomains 校验证书内容,并确认叶子证书覆盖全部申请域名。
func ValidateResourceForDomains(resource Resource, expected []string) (Resource, error) {
resource, err := ValidateResource(resource)
if err != nil {
return Resource{}, err
}
wanted, err := NormalizeDomains(expected)
if err != nil {
return Resource{}, err
}
leaf, err := parseLeafCertificate(resource.Certificate)
if err != nil {
return Resource{}, fmt.Errorf("解析证书失败:%w", err)
}
now := time.Now().UTC()
if now.Before(leaf.NotBefore) {
return Resource{}, fmt.Errorf("证书尚未生效:%s", leaf.NotBefore.UTC().Format(time.RFC3339))
}
if !now.Before(leaf.NotAfter) {
return Resource{}, fmt.Errorf("证书已经过期:%s", leaf.NotAfter.UTC().Format(time.RFC3339))
}
for _, name := range wanted {
probe := name
if strings.HasPrefix(probe, "*.") {
probe = "scert-wildcard-probe." + strings.TrimPrefix(probe, "*.")
}
if err := leaf.VerifyHostname(probe); err != nil {
return Resource{}, fmt.Errorf("证书不覆盖域名 %s:%w", name, err)
}
}
return resource, nil
}
func (s FileStore) Save(ctx context.Context, domain string, resource Resource) (SavedResource, error) {
return s.save(ctx, domain, "", resource)
}
// SaveForType 按证书类型保存唯一的当前证书版本。
func (s FileStore) SaveForType(ctx context.Context, domain, certificateType string, resource Resource) (SavedResource, error) {
return s.save(ctx, domain, certificateType, resource)
}
func (s FileStore) save(ctx context.Context, domain, certificateType string, resource Resource) (SavedResource, error) {
if err := ctx.Err(); err != nil {
return SavedResource{}, err
}
resource, err := ValidateResource(resource)
if err != nil {
return SavedResource{}, err
}
primary := strings.TrimSpace(domain)
if primary == "" {
primary = resource.Domains[0]
}
if _, err := NormalizeDomains([]string{primary}); err != nil {
return SavedResource{}, err
}
root := filepath.Clean(strings.TrimSpace(s.Root))
if root == "." || root == "" {
return SavedResource{}, errors.New("证书存储根目录不能为空")
}
baseDirectory := filepath.Join(root, directoryName(primary))
savedAt := time.Now().UTC()
if s.Now != nil {
savedAt = s.Now().UTC()
}
typeDirectory := baseDirectory
directory := baseDirectory
certificateIdentifier := ""
if certificateType != "" {
certificateType = strings.ToLower(strings.TrimSpace(certificateType))
if certificateType != "single" && certificateType != "wildcard" && certificateType != "san" {
return SavedResource{}, fmt.Errorf("证书类型无效:%s", certificateType)
}
typeDirectory = filepath.Join(baseDirectory, certificateType)
directory = typeDirectory
if certificateType == "san" {
certificateIdentifier = sanCertificateIdentifier(resource.Domains)
directory = filepath.Join(typeDirectory, certificateIdentifier)
}
}
activeDirectory := directory
currentDirectory := directory
version := ""
if certificateType != "" {
activeDirectory, err = s.activeCertificateDirectoryForType(typeDirectory, certificateType)
if err != nil {
return SavedResource{}, err
}
if !directoryContainsCertificateFiles(activeDirectory) {
activeDirectory = ""
}
version, err = nextCertificateVersion(directory, savedAt)
if err != nil {
return SavedResource{}, err
}
currentDirectory = filepath.Join(directory, version)
}
backupDirectory := ""
if activeDirectory != "" {
backupDirectory, err = s.backupExisting(activeDirectory, primary)
if err != nil {
return SavedResource{}, err
}
}
if err := os.MkdirAll(currentDirectory, 0o700); err != nil {
return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err)
}
fullChain := resource.Certificate
if len(resource.IssuerCertificate) > 0 && !bytes.Contains(resource.Certificate, resource.IssuerCertificate) {
fullChain = append(append([]byte(nil), resource.Certificate...), resource.IssuerCertificate...)
}
if err := writeCertificateFiles(currentDirectory, resource.Certificate, fullChain, resource.PrivateKey); err != nil {
if certificateType != "" {
_ = os.RemoveAll(currentDirectory)
}
return SavedResource{}, err
}
leaf, _ := parseLeafCertificate(resource.Certificate)
result := SavedResource{
Domain: primary, CertificateType: certificateType, CertificateIdentifier: certificateIdentifier,
Version: version, SavedAt: savedAt, SANs: append([]string(nil), resource.Domains...),
Directory: currentDirectory, CertificatePath: filepath.Join(currentDirectory, "cert.pem"),
FullChainPath: filepath.Join(currentDirectory, "fullchain.pem"), PrivateKeyPath: filepath.Join(currentDirectory, "key.pem"),
NotBefore: resource.NotBefore, NotAfter: resource.NotAfter, BackupDirectory: backupDirectory,
ChainLength: certificateChainLength(fullChain),
}
if leaf != nil {
result.SerialNumber = leaf.SerialNumber.Text(16)
result.FingerprintSHA256 = fmt.Sprintf("%X", sha256.Sum256(leaf.Raw))
}
metadata, err := json.MarshalIndent(result, "", " ")
if err != nil {
if certificateType != "" {
_ = os.RemoveAll(currentDirectory)
}
return SavedResource{}, fmt.Errorf("生成证书元数据失败:%w", err)
}
if err := atomicWrite(filepath.Join(currentDirectory, "metadata.json"), metadata, 0o600); err != nil {
if certificateType != "" {
_ = os.RemoveAll(currentDirectory)
}
return SavedResource{}, fmt.Errorf("写入证书元数据失败:%w", err)
}
if certificateType != "" && activeDirectory != "" && activeDirectory != currentDirectory {
if err := removeCertificateDirectory(activeDirectory, typeDirectory); err != nil {
return SavedResource{}, fmt.Errorf("清理旧当前证书目录失败:%w", err)
}
}
return result, nil
}
func writeCertificateFiles(directory string, certificate, fullChain, privateKey []byte) error {
if err := atomicWrite(filepath.Join(directory, "cert.pem"), certificate, 0o644); err != nil {
return fmt.Errorf("写入证书文件失败:%w", err)
}
if err := atomicWrite(filepath.Join(directory, "fullchain.pem"), fullChain, 0o644); err != nil {
return fmt.Errorf("写入完整证书链失败:%w", err)
}
if err := atomicWrite(filepath.Join(directory, "key.pem"), privateKey, 0o600); err != nil {
return fmt.Errorf("写入私钥文件失败:%w", err)
}
return nil
}
func certificateChainLength(data []byte) int {
count := 0
remaining := data
for len(remaining) > 0 {
block, rest := pem.Decode(remaining)
if block == nil {
break
}
remaining = rest
if block.Type == "CERTIFICATE" {
count++
}
}
return count
}
func (s FileStore) backupExisting(directory, domain string) (string, error) {
entries, err := os.ReadDir(directory)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", fmt.Errorf("读取原证书目录失败:%w", err)
}
if len(entries) == 0 {
return "", nil
}
backupRoot := filepath.Clean(strings.TrimSpace(s.BackupRoot))
if backupRoot == "." || backupRoot == "" {
return "", errors.New("证书备份根目录不能为空")
}
now := time.Now
if s.Now != nil {
now = s.Now
}
backupBase := filepath.Join(backupRoot, directoryName(domain), now().UTC().Format("20060102-150405.000000000"))
backupDirectory := backupBase
for suffix := 1; ; suffix++ {
if _, statErr := os.Stat(backupDirectory); errors.Is(statErr, os.ErrNotExist) {
break
} else if statErr != nil {
return "", fmt.Errorf("读取证书备份目录失败:%w", statErr)
}
backupDirectory = fmt.Sprintf("%s-%02d", backupBase, suffix)
}
if err := os.MkdirAll(filepath.Dir(backupDirectory), 0o700); err != nil {
return "", fmt.Errorf("创建证书备份目录失败:%w", err)
}
if err := copyDirectory(directory, backupDirectory); err != nil {
return "", fmt.Errorf("备份原证书失败:%w", err)
}
return backupDirectory, nil
}
func atomicWrite(path string, data []byte, mode os.FileMode) error {
temporary, err := os.CreateTemp(filepath.Dir(path), ".scert-*")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err := temporary.Chmod(mode); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if runtime.GOOS == "windows" {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return os.Rename(temporaryName, path)
}
func copyDirectory(source, target string) error {
entries, err := os.ReadDir(source)
if err != nil {
return err
}
if err := os.MkdirAll(target, 0o700); err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join(source, entry.Name()))
if err != nil {
return err
}
if err := atomicWrite(filepath.Join(target, entry.Name()), data, 0o600); err != nil {
return err
}
}
return nil
}
func parseLeafCertificate(data []byte) (*x509.Certificate, error) {
remaining := data
for len(remaining) > 0 {
block, rest := pem.Decode(remaining)
if block == nil {
break
}
remaining = rest
if block.Type != "CERTIFICATE" {
continue
}
return x509.ParseCertificate(block.Bytes)
}
return nil, errors.New("未找到 PEM 证书")
}
func directoryName(domain string) string {
name := strings.ToLower(strings.TrimSpace(domain))
name = strings.ReplaceAll(name, "*.", "_wildcard.")
name = strings.ReplaceAll(name, string(filepath.Separator), "_")
return name
}
func nextCertificateVersion(directory string, now time.Time) (string, error) {
date := now.UTC().Format("20060102")
major := 1
minor := -1
entries, err := os.ReadDir(directory)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("读取证书类型目录失败:%w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
entryDate, entryMajor, entryMinor, valid := parseCertificateVersion(entry.Name())
if !valid || entryDate != date {
continue
}
if entryMajor > major || entryMajor == major && entryMinor > minor {
major = entryMajor
minor = entryMinor
}
}
if minor < 0 {
minor = 0
} else {
minor++
}
return fmt.Sprintf("%s-V%d.%d", date, major, minor), nil
}
func parseCertificateVersion(version string) (string, int, int, bool) {
if len(version) < len("20060102-V1.0") || filepath.Base(version) != version || strings.ContainsAny(version, `/\\`) {
return "", 0, 0, false
}
if version[8:10] != "-V" {
return "", 0, 0, false
}
date := version[:8]
for _, r := range date {
if r < '0' || r > '9' {
return "", 0, 0, false
}
}
parts := strings.Split(version[10:], ".")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", 0, 0, false
}
major, err := strconv.Atoi(parts[0])
if err != nil || major < 1 {
return "", 0, 0, false
}
minor, err := strconv.Atoi(parts[1])
if err != nil || minor < 0 {
return "", 0, 0, false
}
return date, major, minor, true
}
func (s FileStore) activeCertificateDirectory(directory string) (string, error) {
entries, err := os.ReadDir(directory)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return directory, nil
}
return "", fmt.Errorf("读取证书类型目录失败:%w", err)
}
active := ""
for _, entry := range entries {
if !entry.IsDir() {
continue
}
candidate := filepath.Join(directory, entry.Name())
if _, _, _, valid := parseCertificateVersion(entry.Name()); valid && newerCertificateDirectory(candidate, active) {
active = candidate
}
}
if active != "" {
return active, nil
}
legacyCurrent := filepath.Join(directory, "current")
if info, statErr := os.Stat(legacyCurrent); statErr == nil && info.IsDir() {
return legacyCurrent, nil
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return "", fmt.Errorf("读取旧版当前证书目录失败:%w", statErr)
}
return directory, nil
}
func sanCertificateIdentifier(domains []string) string {
sum := sha256.Sum256([]byte(strings.Join(domains, "\n")))
return fmt.Sprintf("san-%x", sum[:8])
}
func directoryContainsCertificateFiles(directory string) bool {
if strings.TrimSpace(directory) == "" {
return false
}
for _, name := range []string{"cert.pem", "fullchain.pem", "key.pem", "metadata.json"} {
if info, err := os.Stat(filepath.Join(directory, name)); err == nil && !info.IsDir() {
return true
}
}
return false
}
func newerCertificateDirectory(candidate, current string) bool {
if current == "" {
return true
}
candidateDate, candidateMajor, candidateMinor, candidateValid := parseCertificateVersion(filepath.Base(candidate))
currentDate, currentMajor, currentMinor, currentValid := parseCertificateVersion(filepath.Base(current))
if !candidateValid {
return false
}
if !currentValid {
return true
}
if candidateDate != currentDate {
return candidateDate > currentDate
}
if candidateMajor != currentMajor {
return candidateMajor > currentMajor
}
if candidateMinor != currentMinor {
return candidateMinor > currentMinor
}
return candidate > current
}
func (s FileStore) activeCertificateDirectoryForType(typeDirectory, certificateType string) (string, error) {
active, err := s.activeCertificateDirectory(typeDirectory)
if err != nil {
return "", err
}
if certificateType != "san" || directoryContainsCertificateFiles(active) {
return active, nil
}
entries, err := os.ReadDir(typeDirectory)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return typeDirectory, nil
}
return "", err
}
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == "current" {
continue
}
if !strings.HasPrefix(entry.Name(), "san-") {
continue
}
if _, _, _, valid := parseCertificateVersion(entry.Name()); valid {
continue
}
candidate, candidateErr := s.activeCertificateDirectory(filepath.Join(typeDirectory, entry.Name()))
if candidateErr != nil {
return "", candidateErr
}
if directoryContainsCertificateFiles(candidate) && newerCertificateDirectory(candidate, active) {
active = candidate
}
}
return active, nil
}
func removeCertificateDirectory(directory, certificateTypeDirectory string) error {
directory = filepath.Clean(directory)
certificateTypeDirectory = filepath.Clean(certificateTypeDirectory)
parentDirectory := filepath.Dir(directory)
if parentDirectory != certificateTypeDirectory && filepath.Dir(parentDirectory) != certificateTypeDirectory {
return errors.New("旧证书目录超出证书类型目录")
}
name := filepath.Base(directory)
if name != "current" {
if err := validateCertificateVersion(name); err != nil {
return err
}
}
if err := os.RemoveAll(directory); err != nil {
return err
}
if parentDirectory != certificateTypeDirectory {
entries, err := os.ReadDir(parentDirectory)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if len(entries) == 0 {
return os.Remove(parentDirectory)
}
}
return nil
}
func validDomain(value string) bool {
value = strings.TrimPrefix(value, "*.")
if len(value) < 3 || len(value) > 253 || !strings.Contains(value, ".") {
return false
}
for _, label := range strings.Split(value, ".") {
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, r := range label {
if !(r == '-' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9') {
return false
}
}
}
return true
}
// CurrentDirectory 返回当前唯一使用的证书版本目录。
func (s FileStore) CurrentDirectory(domain, certificateType string) (string, error) {
primary := strings.TrimSpace(domain)
if _, err := NormalizeDomains([]string{primary}); err != nil {
return "", err
}
certificateType = strings.ToLower(strings.TrimSpace(certificateType))
if certificateType != "single" && certificateType != "wildcard" && certificateType != "san" {
return "", fmt.Errorf("证书类型无效:%s", certificateType)
}
root := filepath.Clean(strings.TrimSpace(s.Root))
if root == "." || root == "" {
return "", errors.New("证书存储根目录不能为空")
}
directory := filepath.Join(root, directoryName(primary), certificateType)
return s.activeCertificateDirectoryForType(directory, certificateType)
}
// LoadCurrent reads the active certificate files and metadata.
func (s FileStore) LoadCurrent(ctx context.Context, domain, certificateType string) (SavedResource, Resource, error) {
if err := ctx.Err(); err != nil {
return SavedResource{}, Resource{}, err
}
directory, err := s.CurrentDirectory(domain, certificateType)
if err != nil {
return SavedResource{}, Resource{}, err
}
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)
}
resource, err := ValidateResourceForDomains(Resource{
Domains: []string{domain},
Certificate: certificateData,
IssuerCertificate: fullChainData,
PrivateKey: privateKeyData,
}, []string{domain})
if err != nil {
return SavedResource{}, Resource{}, err
}
resource.IssuerCertificate = nil
var saved SavedResource
metadata, metadataErr := os.ReadFile(filepath.Join(directory, "metadata.json"))
if metadataErr == nil {
if err := json.Unmarshal(metadata, &saved); err != nil {
return SavedResource{}, Resource{}, fmt.Errorf("解析证书元数据失败:%w", err)
}
}
if saved.Domain == "" {
saved = SavedResource{
Domain: domain,
CertificateType: certificateType,
Directory: directory,
CertificatePath: filepath.Join(directory, "cert.pem"),
FullChainPath: filepath.Join(directory, "fullchain.pem"),
PrivateKeyPath: filepath.Join(directory, "key.pem"),
NotBefore: resource.NotBefore,
NotAfter: resource.NotAfter,
SANs: resource.Domains,
ChainLength: certificateChainLength(fullChainData),
}
}
return saved, resource, nil
}
// 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
}
// RestoreVersion 已停用,正式证书目录只保留当前使用的版本。
func (s FileStore) RestoreVersion(ctx context.Context, domain, certificateType, version string) (SavedResource, error) {
if err := ctx.Err(); err != nil {
return SavedResource{}, err
}
if err := validateCertificateVersion(version); err != nil {
return SavedResource{}, err
}
return SavedResource{}, errors.New("当前仅保留当前使用的证书版本,不支持历史版本恢复")
}
// 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)
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)
}
saved, _, err := s.LoadCurrent(ctx, domain, certificateType)
if err != nil {
return nil, err
}
return []SavedResource{saved}, nil
}
func validateCertificateVersion(version string) error {
if _, _, _, valid := parseCertificateVersion(version); !valid {
return errors.New("证书版本标识无效,应使用 YYYYMMDD-Vx.x 格式")
}
return nil
}
func removeCertificateFiles(directory string) error {
for _, name := range []string{"cert.pem", "fullchain.pem", "key.pem", "metadata.json"} {
if err := os.Remove(filepath.Join(directory, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
// RollbackSavedResource 在初始化后续阶段失败时恢复证书目录。
// 如果保存前存在旧证书,则从备份目录恢复;否则仅清理本次写入的固定证书文件。
func RollbackSavedResource(ctx context.Context, saved SavedResource) error {
if err := ctx.Err(); err != nil {
return err
}
directory := filepath.Clean(strings.TrimSpace(saved.Directory))
if directory == "." || directory == "" {
return errors.New("证书回滚目录不能为空")
}
if strings.TrimSpace(saved.BackupDirectory) != "" {
backupDirectory := filepath.Clean(strings.TrimSpace(saved.BackupDirectory))
if backupDirectory == "." || backupDirectory == "" {
return errors.New("证书备份目录无效")
}
if _, err := os.Stat(backupDirectory); err != nil {
return fmt.Errorf("读取证书备份目录失败:%w", err)
}
if err := restoreCertificateFiles(directory, backupDirectory); err != nil {
return fmt.Errorf("恢复原证书失败:%w", err)
}
if err := rewriteRestoredMetadata(directory); err != nil {
return fmt.Errorf("更新回滚证书元数据失败:%w", err)
}
return nil
}
for _, name := range []string{"cert.pem", "fullchain.pem", "key.pem", "metadata.json"} {
path := filepath.Join(directory, name)
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("清理失败证书文件 %s 失败:%w", name, err)
}
}
if _, _, _, valid := parseCertificateVersion(filepath.Base(directory)); valid {
if err := os.Remove(directory); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("清理失败证书目录失败:%w", err)
}
}
return nil
}
func restoreCertificateFiles(directory, backupDirectory string) error {
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
for _, name := range []string{"cert.pem", "fullchain.pem", "key.pem", "metadata.json"} {
source := filepath.Join(backupDirectory, name)
data, err := os.ReadFile(source)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
mode := os.FileMode(0o600)
if name == "cert.pem" || name == "fullchain.pem" {
mode = 0o644
}
if err := atomicWrite(filepath.Join(directory, name), data, mode); err != nil {
return err
}
}
return nil
}
func rewriteRestoredMetadata(directory string) error {
path := filepath.Join(directory, "metadata.json")
data, err := os.ReadFile(path)
if err != nil {
return err
}
var saved SavedResource
if err := json.Unmarshal(data, &saved); err != nil {
return 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 = ""
data, err = json.MarshalIndent(saved, "", " ")
if err != nil {
return err
}
return atomicWrite(path, data, 0o600)
}