新增公网IP管理证书
This commit is contained in:
parent
9a68fb80d9
commit
642a7c0399
329
backend/internal/bootstrap/management_certificate.go
Normal file
329
backend/internal/bootstrap/management_certificate.go
Normal file
@ -0,0 +1,329 @@
|
||||
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}
|
||||
}
|
||||
141
backend/internal/bootstrap/management_certificate_test.go
Normal file
141
backend/internal/bootstrap/management_certificate_test.go
Normal file
@ -0,0 +1,141 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateManagementCertificateUsesOnlyRequestedIPSAN(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "certificates")
|
||||
saved, err := GenerateManagementCertificate(context.Background(), root, "203.0.113.25")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(saved.CertificatePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
t.Fatal("管理证书不是 PEM 格式")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(leaf.DNSNames) != 0 || len(leaf.IPAddresses) != 1 || !leaf.IPAddresses[0].Equal(net.ParseIP("203.0.113.25")) {
|
||||
t.Fatalf("管理证书 SAN 超出目标 IP:DNS=%v IP=%v", leaf.DNSNames, leaf.IPAddresses)
|
||||
}
|
||||
publicKey, ok := leaf.PublicKey.(*ecdsa.PublicKey)
|
||||
if !ok || publicKey.Curve != elliptic.P256() {
|
||||
t.Fatalf("管理证书未使用 ECDSA P-256:%T", leaf.PublicKey)
|
||||
}
|
||||
if duration := leaf.NotAfter.Sub(leaf.NotBefore); duration < managementCertificateLifetime || duration > managementCertificateLifetime+10*time.Minute {
|
||||
t.Fatalf("管理证书有效期不符合预期:%s", duration)
|
||||
}
|
||||
if saved.TargetIP != "203.0.113.25" || saved.CertificatePath == saved.PrivateKeyPath {
|
||||
t.Fatalf("管理证书结果不完整:%+v", saved)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(saved.PrivateKeyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("私钥权限不是 0600:%04o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateManagementCertificateRejectsNonPublicAddresses(t *testing.T) {
|
||||
for _, value := range []string{"", "not-an-ip", "127.0.0.1", "::1", "10.0.0.8", "192.168.1.9", "169.254.1.2", "224.0.0.1"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
_, err := GenerateManagementCertificate(context.Background(), filepath.Join(t.TempDir(), "certificates"), value)
|
||||
if err == nil {
|
||||
t.Fatalf("非公网 IP 未被拒绝:%q", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateManagementCertificateRejectsSymlinkTarget(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "certificates")
|
||||
targetDirectory := filepath.Join(root, "management-ip", "203.0.113.31")
|
||||
if err := os.MkdirAll(targetDirectory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
external := filepath.Join(t.TempDir(), "external.pem")
|
||||
if err := os.WriteFile(external, []byte("do-not-touch"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(external, filepath.Join(targetDirectory, "key.pem")); err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("当前 Windows 环境无法创建符号链接:%v", err)
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GenerateManagementCertificate(context.Background(), root, "203.0.113.31"); err == nil {
|
||||
t.Fatal("符号链接私钥目标未被拒绝")
|
||||
}
|
||||
data, err := os.ReadFile(external)
|
||||
if err != nil || string(data) != "do-not-touch" {
|
||||
t.Fatalf("符号链接外部文件被修改:%q err=%v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagementCertificateRestoresExactPreviousFiles(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "certificates")
|
||||
directory := filepath.Join(root, "management-ip", "203.0.113.42")
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := map[string]string{
|
||||
"cert.pem": "previous-certificate",
|
||||
"fullchain.pem": "previous-fullchain",
|
||||
"key.pem": "previous-private-key",
|
||||
}
|
||||
for name, value := range previous {
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
saved, err := GenerateManagementCertificate(context.Background(), root, "203.0.113.42")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RollbackManagementCertificate(context.Background(), saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range previous {
|
||||
data, err := os.ReadFile(filepath.Join(directory, name))
|
||||
if err != nil || string(data) != value {
|
||||
t.Fatalf("回滚未恢复 %s:%q err=%v", name, data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackManagementCertificateRemovesNewFilesOnly(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "certificates")
|
||||
saved, err := GenerateManagementCertificate(context.Background(), root, "203.0.113.50")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RollbackManagementCertificate(context.Background(), saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range managementCertificatePaths(saved) {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("新管理证书文件未被回滚:%s err=%v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user