Scert/backend/internal/bootstrap/management_certificate.go
2026-09-16 18:39:07 +08:00

330 lines
11 KiB
Go
Raw Permalink 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 bootstrap
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
)
const managementCertificateLifetime = 397 * 24 * time.Hour
type managementCertificateFileSnapshot struct {
Data []byte
Mode os.FileMode
Exists bool
}
// ManagementCertificate 保存公网 IP 管理证书的落盘位置和回滚快照。
type ManagementCertificate struct {
TargetIP string
Directory string
CertificatePath string
FullChainPath string
PrivateKeyPath string
NotBefore time.Time
NotAfter time.Time
previous map[string]managementCertificateFileSnapshot
}
// GenerateManagementCertificate 生成只覆盖指定公网 IP 的长期自签名管理证书。
func GenerateManagementCertificate(ctx context.Context, certificateRoot, target string) (result ManagementCertificate, err error) {
if err := ctx.Err(); err != nil {
return result, err
}
ip, err := normalizePublicManagementIP(target)
if err != nil {
return result, err
}
root := filepath.Clean(strings.TrimSpace(certificateRoot))
if root == "." || root == "" || !filepath.IsAbs(root) {
return result, errors.New("管理证书根目录必须是绝对路径")
}
directory := filepath.Join(root, "management-ip", managementIPDirectoryName(ip))
if err := ensureManagementCertificateDirectory(directory); err != nil {
return result, err
}
result = ManagementCertificate{
TargetIP: ip.String(),
Directory: directory,
CertificatePath: filepath.Join(directory, "cert.pem"),
FullChainPath: filepath.Join(directory, "fullchain.pem"),
PrivateKeyPath: filepath.Join(directory, "key.pem"),
previous: make(map[string]managementCertificateFileSnapshot, 3),
}
for _, path := range managementCertificatePaths(result) {
snapshot, snapshotErr := snapshotManagementCertificateFile(path)
if snapshotErr != nil {
return ManagementCertificate{}, snapshotErr
}
result.previous[path] = snapshot
}
committed := false
defer func() {
if committed || err == nil {
return
}
rollbackCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if rollbackErr := RollbackManagementCertificate(rollbackCtx, result); rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("管理证书写入失败后的回滚未完成:%w", rollbackErr))
}
}()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return result, fmt.Errorf("生成管理证书私钥失败:%w", err)
}
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return result, fmt.Errorf("生成管理证书序列号失败:%w", err)
}
now := time.Now().UTC()
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Scert Management",
Organization: []string{"Scert"},
},
NotBefore: now.Add(-5 * time.Minute),
NotAfter: now.Add(managementCertificateLifetime),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: []net.IP{append(net.IP(nil), ip...)},
}
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
return result, fmt.Errorf("生成管理证书失败:%w", err)
}
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return result, fmt.Errorf("编码管理证书私钥失败:%w", err)
}
certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER})
privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyDER})
if _, err := tls.X509KeyPair(certificatePEM, privateKeyPEM); err != nil {
return result, fmt.Errorf("管理证书与私钥不匹配:%w", err)
}
if err := atomicWriteManagementCertificate(result.CertificatePath, certificatePEM, 0o644); err != nil {
return result, fmt.Errorf("写入管理证书失败:%w", err)
}
if err := atomicWriteManagementCertificate(result.FullChainPath, certificatePEM, 0o644); err != nil {
return result, fmt.Errorf("写入管理证书链失败:%w", err)
}
if err := atomicWriteManagementCertificate(result.PrivateKeyPath, privateKeyPEM, 0o600); err != nil {
return result, fmt.Errorf("写入管理证书私钥失败:%w", err)
}
if err := validateManagementCertificateFiles(ctx, result, ip); err != nil {
return result, err
}
leaf, err := x509.ParseCertificate(certificateDER)
if err != nil {
return result, fmt.Errorf("解析管理证书失败:%w", err)
}
result.NotBefore = leaf.NotBefore
result.NotAfter = leaf.NotAfter
committed = true
return result, nil
}
// RollbackManagementCertificate 精确恢复生成前的三个管理证书文件。
func RollbackManagementCertificate(ctx context.Context, saved ManagementCertificate) error {
if err := ctx.Err(); err != nil {
return err
}
var rollbackErrors []error
for _, path := range managementCertificatePaths(saved) {
if err := ctx.Err(); err != nil {
rollbackErrors = append(rollbackErrors, err)
break
}
snapshot, existed := saved.previous[path]
if existed && snapshot.Exists {
if err := atomicWriteManagementCertificate(path, snapshot.Data, snapshot.Mode); err != nil {
rollbackErrors = append(rollbackErrors, fmt.Errorf("恢复管理证书文件 %s 失败:%w", filepath.Base(path), err))
}
continue
}
if err := removeRegularManagementCertificateFile(path); err != nil {
rollbackErrors = append(rollbackErrors, err)
}
}
return errors.Join(rollbackErrors...)
}
func normalizePublicManagementIP(value string) (net.IP, error) {
value = strings.Trim(strings.TrimSpace(value), "[]")
ip := net.ParseIP(value)
if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
return nil, fmt.Errorf("管理页面公网 IP 格式无效或不是可公开访问的单播地址:%s", value)
}
if ipv4 := ip.To4(); ipv4 != nil {
return append(net.IP(nil), ipv4...), nil
}
return append(net.IP(nil), ip.To16()...), nil
}
func managementIPDirectoryName(ip net.IP) string {
return strings.ReplaceAll(ip.String(), ":", "_")
}
func ensureManagementCertificateDirectory(directory string) error {
for current := directory; ; current = filepath.Dir(current) {
if info, err := os.Lstat(current); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("管理证书目录必须是普通目录:%s", current)
}
break
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("检查管理证书目录失败:%w", err)
}
parent := filepath.Dir(current)
if parent == current {
break
}
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("创建管理证书目录失败:%w", err)
}
return os.Chmod(directory, 0o700)
}
func snapshotManagementCertificateFile(path string) (managementCertificateFileSnapshot, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return managementCertificateFileSnapshot{}, nil
}
if err != nil {
return managementCertificateFileSnapshot{}, fmt.Errorf("检查管理证书文件失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return managementCertificateFileSnapshot{}, fmt.Errorf("管理证书目标必须是普通文件:%s", path)
}
data, err := os.ReadFile(path)
if err != nil {
return managementCertificateFileSnapshot{}, fmt.Errorf("读取原管理证书文件失败:%w", err)
}
return managementCertificateFileSnapshot{Data: data, Mode: info.Mode().Perm(), Exists: true}, nil
}
func validateManagementCertificateFiles(ctx context.Context, saved ManagementCertificate, ip net.IP) error {
for _, path := range managementCertificatePaths(saved) {
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("管理证书必须是可读取的普通文件:%s", path)
}
}
if runtime.GOOS != "windows" {
info, err := os.Stat(saved.PrivateKeyPath)
if err != nil {
return fmt.Errorf("检查管理证书私钥权限失败:%w", err)
}
if info.Mode().Perm() != 0o600 {
return fmt.Errorf("管理证书私钥权限必须为 0600,当前为 %04o", info.Mode().Perm())
}
}
if _, err := (certificate.LocalValidator{}).ValidateFiles(
ctx,
saved.CertificatePath,
saved.FullChainPath,
saved.PrivateKeyPath,
[]string{ip.String()},
); err != nil {
return fmt.Errorf("管理证书文件校验失败:%w", err)
}
data, err := os.ReadFile(saved.CertificatePath)
if err != nil {
return fmt.Errorf("读取管理证书失败:%w", err)
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return errors.New("管理证书 PEM 格式无效")
}
leaf, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("解析管理证书失败:%w", err)
}
if len(leaf.DNSNames) != 0 || len(leaf.IPAddresses) != 1 || !leaf.IPAddresses[0].Equal(ip) {
return errors.New("管理证书只能包含用户指定的单一 IP SAN")
}
publicKey, ok := leaf.PublicKey.(*ecdsa.PublicKey)
if !ok || publicKey.Curve != elliptic.P256() {
return errors.New("管理证书必须使用 ECDSA P-256 公钥")
}
return nil
}
func atomicWriteManagementCertificate(path string, data []byte, mode os.FileMode) error {
if _, err := snapshotManagementCertificateFile(path); err != nil {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(path), ".scert-management-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
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 := removeRegularManagementCertificateFile(path); err != nil {
return err
}
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
return os.Chmod(path, mode)
}
func removeRegularManagementCertificateFile(path string) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("检查管理证书文件失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("拒绝删除非普通管理证书文件:%s", path)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("删除管理证书文件失败:%w", err)
}
return nil
}
func managementCertificatePaths(saved ManagementCertificate) []string {
return []string{saved.CertificatePath, saved.FullChainPath, saved.PrivateKeyPath}
}