Scert/backend/internal/certificate/lifecycle.go

331 lines
9.5 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/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"`
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) {
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("证书存储根目录不能为空")
}
directory := filepath.Join(root, directoryName(primary))
if err := os.MkdirAll(directory, 0o700); err != nil {
return SavedResource{}, fmt.Errorf("创建证书目录失败:%w", err)
}
backupDirectory, err := s.backupExisting(directory, primary)
if err != nil {
return SavedResource{}, err
}
certificatePath := filepath.Join(directory, "cert.pem")
fullChainPath := filepath.Join(directory, "fullchain.pem")
privateKeyPath := filepath.Join(directory, "key.pem")
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 := atomicWrite(certificatePath, resource.Certificate, 0o644); err != nil {
return SavedResource{}, fmt.Errorf("保存证书失败:%w", err)
}
if err := atomicWrite(fullChainPath, fullChain, 0o644); err != nil {
return SavedResource{}, fmt.Errorf("保存完整证书链失败:%w", err)
}
if err := atomicWrite(privateKeyPath, resource.PrivateKey, 0o600); err != nil {
return SavedResource{}, fmt.Errorf("保存私钥失败:%w", err)
}
result := SavedResource{
Domain: primary, Directory: directory, CertificatePath: certificatePath,
FullChainPath: fullChainPath, PrivateKeyPath: privateKeyPath,
NotBefore: resource.NotBefore, NotAfter: resource.NotAfter,
BackupDirectory: backupDirectory,
}
metadata, err := json.MarshalIndent(result, "", " ")
if err != nil {
return SavedResource{}, fmt.Errorf("生成证书元数据失败:%w", err)
}
if err := atomicWrite(filepath.Join(directory, "metadata.json"), metadata, 0o600); err != nil {
return SavedResource{}, fmt.Errorf("保存证书元数据失败:%w", err)
}
return result, nil
}
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
}