检查点:保存待验证的Nginx证书激活事务组件

This commit is contained in:
xuans 2026-09-18 01:45:27 +08:00
parent 7d90077639
commit 10645ea1e4
4 changed files with 548 additions and 0 deletions

View File

@ -0,0 +1,40 @@
package nginxactivation
import (
"context"
"errors"
"os/exec"
"path/filepath"
"time"
)
// CommandReloader 的参数来自本地受信策略,证书材料不能指定命令或参数。
type CommandReloader struct {
Binary string
Config string
Prefix string
}
func (r CommandReloader) Check(ctx context.Context) error {
return r.run(ctx, "-t")
}
func (r CommandReloader) Reload(ctx context.Context) error {
return r.run(ctx, "-s", "reload")
}
func (r CommandReloader) run(ctx context.Context, operation ...string) error {
if !filepath.IsAbs(r.Binary) || !filepath.IsAbs(r.Config) || !filepath.IsAbs(r.Prefix) {
return errors.New("Nginx 命令策略必须使用绝对路径")
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
arguments := append([]string{"-p", r.Prefix, "-c", r.Config}, operation...)
command := exec.CommandContext(ctx, r.Binary, arguments...)
command.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C"}
// 不向应用返回可能包含站点配置和文件内容的子进程输出。
if err := command.Run(); err != nil {
return errors.Join(errors.New("Nginx 受限命令执行失败"), err, ctx.Err())
}
return nil
}

View File

@ -0,0 +1,103 @@
//go:build linux
package nginxactivation
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
func readRegular(root *os.Root, name string, limit int64) ([]byte, error) {
info, err := root.Lstat(name)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > limit {
return nil, errors.New("激活状态必须是大小受限的普通文件")
}
file, err := root.Open(name)
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil || int64(len(data)) > limit {
return nil, errors.Join(errors.New("读取激活状态失败"), err)
}
return data, nil
}
func writeExclusive(root *os.Root, name string, data []byte) error {
file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, writeErr := file.Write(data)
syncErr := file.Sync()
return errors.Join(writeErr, syncErr, file.Close())
}
func writeAtomic(root *os.Root, name string, data []byte) error {
if info, err := root.Lstat(name); err == nil {
if !info.Mode().IsRegular() {
return errors.New("拒绝覆盖非常规激活状态文件")
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
id, err := newID()
if err != nil {
return err
}
// 失败的临时文件保留供诊断,不覆盖已有快照。
temporary := ".pending-" + id
if err := writeExclusive(root, temporary, data); err != nil {
return err
}
if err := root.Rename(temporary, name); err != nil {
return err
}
directory, err := root.Open(".")
if err != nil {
return err
}
return errors.Join(directory.Sync(), directory.Close())
}
func newID() (string, error) {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "", err
}
return hex.EncodeToString(data[:]), nil
}
func validateDirectory(directory string) error {
if !filepath.IsAbs(directory) || filepath.Clean(directory) != directory || directory == string(filepath.Separator) {
return errors.New("Nginx 激活目录必须是独立的绝对目录")
}
// 目录来自本地部署策略,仍拒绝可改变 Nginx 指令语义的字符。
for _, char := range directory {
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '/' || char == '-' || char == '_' || char == '.') {
return errors.New("Nginx 激活目录包含不允许的字符")
}
}
for path := directory; ; path = filepath.Dir(path) {
info, err := os.Lstat(path)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("激活目录及其父路径必须是真实目录:%w", err)
}
if path == directory && info.Mode().Perm()&0o077 != 0 {
return errors.New("激活目录只能由激活器所属用户访问")
}
if filepath.Dir(path) == path {
break
}
}
return nil
}

View File

@ -0,0 +1,114 @@
package nginxactivation
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
// Endpoint 只允许探测本机指定端口,域名仍用于 SNI、Host 和可信链校验。
type Endpoint struct {
Address string
Hostname string
Roots *x509.CertPool
}
func (e Endpoint) validate() error {
host, port, err := net.SplitHostPort(e.Address)
number, portErr := strconv.Atoi(port)
ip := net.ParseIP(host)
if err != nil || portErr != nil || number < 1 || number > 65535 || ip == nil || !ip.IsLoopback() {
return errors.New("管理证书探测地址必须是本机回环 IP 和有效端口")
}
if !domain.IsValidName(e.Hostname) || net.ParseIP(e.Hostname) != nil {
return errors.New("管理证书探测域名无效")
}
return nil
}
// Check 每次建立新 TLS 连接,不复用连接、不使用系统代理、不跟随跳转。
// expected 为空时读取已验证的当前证书,否则同时校验实际叶证书字节。
func (e Endpoint) Check(ctx context.Context, expected []byte) ([]byte, error) {
if err := e.validate(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_, port, _ := net.SplitHostPort(e.Address)
transport := &http.Transport{
Proxy: nil, DisableKeepAlives: true, DisableCompression: true,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: e.Hostname, RootCAs: e.Roots},
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "tcp", e.Address)
},
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
address := url.URL{Scheme: "https", Host: net.JoinHostPort(e.Hostname, port), Path: "/readyz"}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address.String(), nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("管理入口 TLS 探测失败:%w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || response.TLS == nil || len(response.TLS.VerifiedChains) == 0 || len(response.TLS.PeerCertificates) == 0 {
return nil, errors.New("管理入口未通过 HTTPS 就绪检查")
}
actual := response.TLS.PeerCertificates[0].Raw
if len(expected) != 0 && !bytes.Equal(expected, actual) {
return nil, errors.New("管理入口仍未提供预期的新证书")
}
body, err := io.ReadAll(io.LimitReader(response.Body, 4097))
if err != nil || len(body) > 4096 {
return nil, errors.New("管理入口就绪响应读取失败或超过限制")
}
var readiness struct {
Service string `json:"service"`
Status string `json:"status"`
}
if err := json.Unmarshal(body, &readiness); err != nil || readiness.Service != "scert" || readiness.Status != "ready" {
return nil, errors.New("管理入口未返回 Scert 就绪状态")
}
return bytes.Clone(actual), nil
}
func (e Endpoint) Wait(ctx context.Context, expected []byte) error {
if len(expected) == 0 {
return errors.New("等待管理入口切换时必须指定证书")
}
if err := e.validate(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
for {
_, err := e.Check(ctx, expected)
if err == nil {
return nil
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return errors.Join(ctx.Err(), err)
case <-timer.C:
}
}
}

View File

@ -0,0 +1,291 @@
package nginxactivation
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/processlock"
)
const (
includeName = "active.conf"
journalName = "transaction.json"
)
type Reloader interface {
Check(context.Context) error
Reload(context.Context) error
}
// Rotation 的目录与命令必须由本地受信部署策略提供,不能来自 HTTP 或证书请求。
// 调用方须先安装引用 active.conf 的专用站点,并将目录交给独立激活器用户。
type Rotation struct {
Directory string
Proxy Endpoint
Upstream Endpoint
Reloader Reloader
}
type transaction struct {
ID string `json:"id"`
Status string `json:"status"`
PreviousInclude []byte `json:"previous_include"`
CandidateInclude []byte `json:"candidate_include"`
PreviousLeaf []byte `json:"previous_leaf"`
CandidateLeaf []byte `json:"candidate_leaf"`
}
// Stage 只切换 Nginx 并验证入口;上游切换后必须调用 Commit 才算双层激活完成。
// 任意中断均保留持久化记录,下一次启动应先调用 Recover。
func (r Rotation) Stage(ctx context.Context, chain, key []byte) (id string, resultErr error) {
root, lock, err := r.open(ctx)
if err != nil {
return "", err
}
defer root.Close()
defer lock.Close()
previous, err := readTransaction(root)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err == nil && (previous.Status == "pending" || previous.Status == "staged") {
return "", errors.New("仍有未完成的管理证书激活,请先恢复")
}
leaf, err := r.validateMaterial(ctx, chain, key)
if err != nil {
return "", err
}
oldLeaf, err := r.Proxy.Check(ctx, nil)
if err != nil {
return "", err
}
oldCertificate, err := x509.ParseCertificate(oldLeaf)
if err != nil || !leaf.NotAfter.After(oldCertificate.NotAfter) {
return "", errors.New("候选管理证书没有延长入口实际有效期")
}
if _, err := r.Upstream.Check(ctx, nil); err != nil {
return "", err
}
oldInclude, err := readRegular(root, includeName, 4096)
if err != nil {
return "", err
}
if err := r.Reloader.Check(ctx); err != nil {
return "", fmt.Errorf("Nginx 原配置检查失败:%w", err)
}
id, err = newID()
if err != nil {
return "", err
}
if err := root.Mkdir(id, 0o700); err != nil {
return "", err
}
if err := writeExclusive(root, filepath.Join(id, "fullchain.pem"), chain); err != nil {
return "", err
}
if err := writeExclusive(root, filepath.Join(id, "key.pem"), key); err != nil {
return "", err
}
// 先同步新版本目录,再持久化引用,避免断电后只留下不存在的证书路径。
versionDir, err := root.Open(id)
if err != nil {
return "", err
}
if err := errors.Join(versionDir.Sync(), versionDir.Close()); err != nil {
return "", err
}
current := transaction{
ID: id, Status: "pending", PreviousInclude: oldInclude, PreviousLeaf: oldLeaf,
CandidateLeaf: leaf.Raw,
CandidateInclude: []byte(fmt.Sprintf("ssl_certificate %s/%s/fullchain.pem;\nssl_certificate_key %s/%s/key.pem;\n", r.Directory, id, r.Directory, id)),
}
if err := saveTransaction(root, current); err != nil {
return "", err
}
defer func() {
if resultErr != nil {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 25*time.Second)
defer cancel()
resultErr = errors.Join(resultErr, r.rollback(rollbackCtx, root, current))
}
}()
if err := writeAtomic(root, includeName, current.CandidateInclude); err != nil {
return id, err
}
if err := r.Reloader.Check(ctx); err != nil {
return id, fmt.Errorf("Nginx 候选配置检查失败:%w", err)
}
if err := r.Reloader.Reload(ctx); err != nil {
return id, fmt.Errorf("Nginx 重载失败:%w", err)
}
if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil {
return id, err
}
current.Status = "staged"
return id, saveTransaction(root, current)
}
// Commit 验证代理和上游均提供同一候选证书且就绪;失败时保留事务供协调者回退。
func (r Rotation) Commit(ctx context.Context, id string) error {
root, lock, err := r.open(ctx)
if err != nil {
return err
}
defer root.Close()
defer lock.Close()
current, err := readTransaction(root)
if err != nil {
return err
}
if id == "" || current.ID != id || current.Status != "staged" {
return errors.New("管理证书激活事务不匹配或尚未完成暂存")
}
include, err := readRegular(root, includeName, 4096)
if err != nil || !bytes.Equal(include, current.CandidateInclude) {
return errors.New("管理证书专用引用已被修改,拒绝提交")
}
if err := r.Upstream.Wait(ctx, current.CandidateLeaf); err != nil {
return err
}
if err := r.Proxy.Wait(ctx, current.CandidateLeaf); err != nil {
return err
}
current.Status = "committed"
return saveTransaction(root, current)
}
func (r Rotation) Rollback(ctx context.Context, id string) error {
return r.restore(ctx, id, false)
}
// Recover 处理进程退出时残留的事务,不回退已经提交的双层证书。
func (r Rotation) Recover(ctx context.Context) error {
return r.restore(ctx, "", true)
}
func (r Rotation) restore(ctx context.Context, id string, recoverOnly bool) error {
root, lock, err := r.open(ctx)
if err != nil {
return err
}
defer root.Close()
defer lock.Close()
current, err := readTransaction(root)
if errors.Is(err, os.ErrNotExist) && recoverOnly {
return nil
}
if err != nil {
return err
}
if recoverOnly && (current.Status == "committed" || current.Status == "rolled_back") {
return nil
}
if !recoverOnly && (id == "" || current.ID != id) {
return errors.New("管理证书回退事务不匹配")
}
if current.Status == "committed" {
return errors.New("已提交事务不能通过临时激活回退接口恢复")
}
return r.rollback(ctx, root, current)
}
func (r Rotation) rollback(ctx context.Context, root *os.Root, current transaction) error {
active, err := readRegular(root, includeName, 4096)
if err != nil {
return err
}
if !bytes.Equal(active, current.CandidateInclude) && !bytes.Equal(active, current.PreviousInclude) {
return errors.New("Nginx 证书引用发生外部变化,拒绝回退覆盖")
}
if err := writeAtomic(root, includeName, current.PreviousInclude); err != nil {
return fmt.Errorf("恢复 Nginx 证书引用失败:%w", err)
}
if err := r.Reloader.Check(ctx); err != nil {
return fmt.Errorf("Nginx 回退配置检查失败:%w", err)
}
if err := r.Reloader.Reload(ctx); err != nil {
return fmt.Errorf("Nginx 回退重载失败:%w", err)
}
if err := r.Proxy.Wait(ctx, current.PreviousLeaf); err != nil {
return fmt.Errorf("Nginx 回退就绪检查失败:%w", err)
}
current.Status = "rolled_back"
return saveTransaction(root, current)
}
func (r Rotation) validateMaterial(ctx context.Context, chain, key []byte) (*x509.Certificate, error) {
if len(chain) == 0 || len(chain) > 1<<20 || len(key) == 0 || len(key) > 64<<10 {
return nil, errors.New("候选证书或私钥大小无效")
}
pair, err := tls.X509KeyPair(chain, key)
if err != nil {
return nil, errors.New("候选证书和私钥不匹配")
}
validator := certificate.LocalValidator{VerifyChain: true, Roots: r.Proxy.Roots}
if _, err := validator.ValidatePEM(ctx, chain, []string{r.Proxy.Hostname}); err != nil {
return nil, fmt.Errorf("候选证书未通过可信校验:%w", err)
}
if len(pair.Certificate[0]) > 16<<10 {
return nil, errors.New("候选叶证书超过大小限制")
}
return x509.ParseCertificate(pair.Certificate[0])
}
func (r Rotation) open(ctx context.Context) (*os.Root, *processlock.Lock, error) {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
if r.Reloader == nil || r.Proxy.Address == r.Upstream.Address || r.Proxy.Hostname != r.Upstream.Hostname {
return nil, nil, errors.New("Nginx 激活策略必须配置独立代理、上游和重载器")
}
if err := errors.Join(r.Proxy.validate(), r.Upstream.validate(), validateDirectory(r.Directory)); err != nil {
return nil, nil, err
}
lock, err := processlock.Acquire(filepath.Join(r.Directory, ".activation.lock"))
if err != nil {
return nil, nil, err
}
root, err := os.OpenRoot(r.Directory)
if err != nil {
lock.Close()
return nil, nil, err
}
return root, lock, nil
}
func readTransaction(root *os.Root) (transaction, error) {
var current transaction
data, err := readRegular(root, journalName, 64<<10)
if err != nil {
return current, err
}
if err := json.Unmarshal(data, &current); err != nil {
return current, err
}
if current.ID == "" || len(current.PreviousInclude) == 0 || len(current.CandidateInclude) == 0 || len(current.PreviousLeaf) == 0 || len(current.CandidateLeaf) == 0 {
return current, errors.New("管理证书激活记录不完整")
}
switch current.Status {
case "pending", "staged", "committed", "rolled_back":
return current, nil
default:
return current, errors.New("管理证书激活记录状态无效")
}
}
func saveTransaction(root *os.Root, current transaction) error {
data, err := json.Marshal(current)
if err != nil {
return err
}
return writeAtomic(root, journalName, data)
}