331 lines
9.5 KiB
Go
331 lines
9.5 KiB
Go
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
|
||
}
|