package certificate import ( "bytes" "context" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "os" "path/filepath" "runtime" "sort" "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"` Version string `json:"version,omitempty"` 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)) directory := baseDirectory if certificateType != "" { certificateType = strings.ToLower(strings.TrimSpace(certificateType)) if certificateType != "single" && certificateType != "wildcard" && certificateType != "san" { return SavedResource{}, fmt.Errorf("证书类型无效:%s", certificateType) } directory = filepath.Join(baseDirectory, certificateType) } currentDirectory := directory if certificateType != "" { currentDirectory = filepath.Join(directory, "current") } if err := os.MkdirAll(currentDirectory, 0o700); err != nil { return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err) } backupDirectory, err := s.backupExisting(currentDirectory, primary) if err != nil { return SavedResource{}, err } fullChain := resource.Certificate if len(resource.IssuerCertificate) > 0 && !bytes.Contains(resource.Certificate, resource.IssuerCertificate) { fullChain = append(append([]byte(nil), resource.Certificate...), resource.IssuerCertificate...) } version := "" if certificateType != "" { now := time.Now if s.Now != nil { now = s.Now } version = now().UTC().Format("20060102-150405.000000000") versionDirectory := filepath.Join(directory, "versions", version) for suffix := 1; ; suffix++ { if _, statErr := os.Stat(versionDirectory); errors.Is(statErr, os.ErrNotExist) { break } else if statErr != nil { return SavedResource{}, fmt.Errorf("读取证书版本目录失败:%w", statErr) } version = fmt.Sprintf("%s-%02d", now().UTC().Format("20060102-150405.000000000"), suffix) versionDirectory = filepath.Join(directory, "versions", version) } if err := os.MkdirAll(versionDirectory, 0o700); err != nil { return SavedResource{}, fmt.Errorf("创建证书版本目录失败:%w", err) } if err := writeCertificateFiles(versionDirectory, resource.Certificate, fullChain, resource.PrivateKey); err != nil { return SavedResource{}, err } } if err := writeCertificateFiles(currentDirectory, resource.Certificate, fullChain, resource.PrivateKey); err != nil { return SavedResource{}, err } leaf, _ := parseLeafCertificate(resource.Certificate) result := SavedResource{ Domain: primary, CertificateType: certificateType, Version: version, 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 { return SavedResource{}, fmt.Errorf("生成证书元数据失败:%w", err) } if err := atomicWrite(filepath.Join(currentDirectory, "metadata.json"), metadata, 0o600); err != nil { return SavedResource{}, fmt.Errorf("写入证书元数据失败:%w", err) } if certificateType != "" { versionMetadata := filepath.Join(directory, "versions", version, "metadata.json") if err := atomicWrite(versionMetadata, metadata, 0o600); 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 } backupDirectory := filepath.Join(backupRoot, directoryName(domain), now().UTC().Format("20060102-150405.000000000")) 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 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 returns the active certificate directory for a domain and certificate type. 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("证书存储根目录不能为空") } return filepath.Join(root, directoryName(primary), certificateType, "current"), nil } // 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 } // ListVersions returns all stored certificate versions in descending order. func (s FileStore) ListVersions(ctx context.Context, domain, certificateType string) ([]SavedResource, error) { if err := ctx.Err(); err != nil { return nil, err } current, err := s.CurrentDirectory(domain, certificateType) if err != nil { return nil, err } certificateDirectory := filepath.Dir(current) versionsDirectory := filepath.Join(certificateDirectory, "versions") entries, err := os.ReadDir(versionsDirectory) if err != nil { if errors.Is(err, os.ErrNotExist) { return []SavedResource{}, nil } return nil, fmt.Errorf("读取证书版本失败:%w", err) } versions := make([]SavedResource, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { continue } metadata, readErr := os.ReadFile(filepath.Join(versionsDirectory, entry.Name(), "metadata.json")) if readErr != nil { return nil, fmt.Errorf("读取证书版本元数据失败:%w", readErr) } var item SavedResource if err := json.Unmarshal(metadata, &item); err != nil { return nil, fmt.Errorf("解析证书版本元数据失败:%w", err) } versions = append(versions, item) } sort.SliceStable(versions, func(i, j int) bool { return versions[i].Version > versions[j].Version }) return versions, 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) } 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) } } 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 }