检查点:保存待验证的Nginx证书激活事务组件
This commit is contained in:
parent
7d90077639
commit
10645ea1e4
40
backend/internal/nginxactivation/command_linux.go
Normal file
40
backend/internal/nginxactivation/command_linux.go
Normal 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
|
||||
}
|
||||
103
backend/internal/nginxactivation/files.go
Normal file
103
backend/internal/nginxactivation/files.go
Normal 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
|
||||
}
|
||||
114
backend/internal/nginxactivation/probe.go
Normal file
114
backend/internal/nginxactivation/probe.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
291
backend/internal/nginxactivation/rotation_linux.go
Normal file
291
backend/internal/nginxactivation/rotation_linux.go
Normal 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, ¤t); 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user