293 lines
9.5 KiB
Go
293 lines
9.5 KiB
Go
package nginxactivation
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/processlock"
|
|
)
|
|
|
|
var ErrCoordinationPending = errors.New("管理证书协调仍未完成,已阻止再次申请证书")
|
|
|
|
type CertificateFiles struct {
|
|
CertificatePath string `json:"certificate_path"`
|
|
PrivateKeyPath string `json:"private_key_path"`
|
|
}
|
|
|
|
type ManagedCertificate struct {
|
|
CertificateFiles
|
|
Hostname string `json:"hostname"`
|
|
Mode string `json:"mode"`
|
|
LeafHash string `json:"leaf_hash"`
|
|
ProxyAddress string `json:"proxy_address"`
|
|
UpstreamAddress string `json:"upstream_address"`
|
|
}
|
|
|
|
type ManagedSession interface {
|
|
Snapshot() (ManagedCertificate, error)
|
|
Activate(CertificateFiles, string) error
|
|
}
|
|
|
|
// ManagedUpstream 的回调必须独占所有证书切换及相关配置写入,但不能阻塞 TLS 和就绪探测。
|
|
type ManagedUpstream interface {
|
|
WithCertificate(context.Context, func(ManagedSession) error) error
|
|
}
|
|
|
|
type ProxyClient interface {
|
|
Ready(context.Context) (ReadyState, error)
|
|
State(context.Context) (State, error)
|
|
StageRequest(context.Context, string, []byte, []byte) (string, error)
|
|
ResolveRequest(context.Context, string) (State, error)
|
|
Commit(context.Context, string) error
|
|
Reconcile(context.Context, string) (State, error)
|
|
}
|
|
|
|
// Coordinator 的目录归应用用户所有,与 root 激活器的专用目录分离。
|
|
type Coordinator struct {
|
|
Directory string
|
|
Proxy ProxyClient
|
|
Upstream ManagedUpstream
|
|
}
|
|
|
|
func (c Coordinator) Ready(ctx context.Context) error {
|
|
return c.withSession(ctx, func(root *os.Root, session ManagedSession) error {
|
|
if _, err := c.recover(ctx, root, session); err != nil {
|
|
return err
|
|
}
|
|
current, err := session.Snapshot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.ready(ctx, current)
|
|
})
|
|
}
|
|
|
|
func (c Coordinator) Recover(ctx context.Context) error {
|
|
return c.withSession(ctx, func(root *os.Root, session ManagedSession) error {
|
|
_, err := c.recover(ctx, root, session)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (c Coordinator) Activate(ctx context.Context, files CertificateFiles) error {
|
|
return c.withSession(ctx, func(root *os.Root, session ManagedSession) (resultErr error) {
|
|
if _, err := c.recover(ctx, root, session); err != nil {
|
|
return err
|
|
}
|
|
previous, err := session.Snapshot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.ready(ctx, previous); err != nil {
|
|
return err
|
|
}
|
|
chain, err := readManagedMaterial(files.CertificatePath, 1<<20)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key, err := readManagedMaterial(files.PrivateKeyPath, 64<<10)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer clear(key)
|
|
pair, err := tls.X509KeyPair(chain, key)
|
|
if err != nil {
|
|
return errors.New("候选管理证书与私钥不匹配")
|
|
}
|
|
candidate := previous
|
|
candidate.CertificateFiles, candidate.LeafHash = files, leafHash(pair.Certificate[0])
|
|
baseline, err := c.Proxy.State(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
requestID, err := newID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
record := coordinationRecord{Version: 1, Phase: "prepared", BaselineID: baseline.ID, RequestID: requestID, Previous: previous, Candidate: candidate}
|
|
if err := record.validate(); err != nil {
|
|
return err
|
|
}
|
|
if err := saveCoordination(root, record); err != nil {
|
|
return err
|
|
}
|
|
// 请求取消或应答丢失仍在同一独占区内恢复;不能把网络失败当成尚未执行。
|
|
defer func() {
|
|
if resultErr != nil {
|
|
recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 100*time.Second)
|
|
defer cancel()
|
|
_, recoveryErr := c.recover(recoveryCtx, root, session)
|
|
resultErr = errors.Join(resultErr, recoveryErr)
|
|
}
|
|
}()
|
|
id, err := c.Proxy.StageRequest(ctx, requestID, chain, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id != requestID {
|
|
return errors.New("代理返回的暂存事务编号无效")
|
|
}
|
|
record.ProxyID, record.Phase = id, "staged"
|
|
if err := saveCoordination(root, record); err != nil {
|
|
return err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := session.Activate(files, candidate.LeafHash); err != nil {
|
|
return err
|
|
}
|
|
record.Phase = "upstream"
|
|
if err := saveCoordination(root, record); err != nil {
|
|
return err
|
|
}
|
|
commitErr := c.Proxy.Commit(ctx, id)
|
|
verifyCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 100*time.Second)
|
|
defer cancel()
|
|
completed, err := c.recover(verifyCtx, root, session)
|
|
if err != nil || completed.Phase != "committed" {
|
|
return errors.Join(commitErr, err, ErrCoordinationPending)
|
|
}
|
|
// 只有同一事务终态及实时双层入口均已验证,才接受丢失提交应答后的成功。
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (c Coordinator) recover(ctx context.Context, root *os.Root, session ManagedSession) (coordinationRecord, error) {
|
|
record, err := readCoordination(root)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return coordinationRecord{}, nil
|
|
}
|
|
if err != nil {
|
|
return record, err
|
|
}
|
|
if record.Phase == "committed" || record.Phase == "rolled_back" || record.Phase == "canceled" {
|
|
return record, nil
|
|
}
|
|
current, err := session.Snapshot()
|
|
if err != nil {
|
|
return record, errors.Join(ErrCoordinationPending, err)
|
|
}
|
|
if current != record.Previous && current != record.Candidate {
|
|
return record, errors.Join(ErrCoordinationPending, errors.New("当前上游不属于持久化协调记录,拒绝覆盖"))
|
|
}
|
|
var state State
|
|
if record.RequestID != "" {
|
|
state, err = c.Proxy.ResolveRequest(ctx, record.RequestID)
|
|
} else {
|
|
state, err = c.Proxy.State(ctx)
|
|
}
|
|
if err != nil {
|
|
return record, errors.Join(ErrCoordinationPending, err)
|
|
}
|
|
if record.RequestID != "" && state.ID == record.RequestID && state.Status == "canceled" {
|
|
if record.Phase != "prepared" || record.ProxyID != "" || current != record.Previous {
|
|
return record, errors.Join(ErrCoordinationPending, errors.New("取消栅栏与已保存的协调阶段不一致"))
|
|
}
|
|
if err := c.ready(ctx, current); err != nil {
|
|
return record, errors.Join(ErrCoordinationPending, err)
|
|
}
|
|
record.Phase = "canceled"
|
|
return record, saveCoordination(root, record)
|
|
}
|
|
if !validID(state.ID) || state.ID == record.BaselineID ||
|
|
(record.RequestID != "" && state.ID != record.RequestID) ||
|
|
(record.ProxyID != "" && state.ID != record.ProxyID) ||
|
|
state.PreviousHash != record.Previous.LeafHash || state.CandidateHash != record.Candidate.LeafHash {
|
|
// 尚未观测到对应事务时保留意图;延迟到达的暂存请求仍可能执行,不能清除或重放。
|
|
return record, errors.Join(ErrCoordinationPending, errors.New("尚未确认对应的代理事务,保留协调记录"))
|
|
}
|
|
record.ProxyID = state.ID
|
|
if state.Status == "pending" || state.Status == "staged" {
|
|
state, err = c.Proxy.Reconcile(ctx, state.ID)
|
|
if err != nil {
|
|
return record, errors.Join(ErrCoordinationPending, err)
|
|
}
|
|
}
|
|
if state.ID != record.ProxyID || state.PreviousHash != record.Previous.LeafHash || state.CandidateHash != record.Candidate.LeafHash {
|
|
return record, errors.Join(ErrCoordinationPending, errors.New("代理恢复结果不属于当前事务"))
|
|
}
|
|
if (state.Status != "committed" || current != record.Candidate) && (state.Status != "rolled_back" || current != record.Previous) {
|
|
return record, errors.Join(ErrCoordinationPending, errors.New("代理终态与实际上游证书不一致"))
|
|
}
|
|
if err := c.ready(ctx, current); err != nil {
|
|
return record, errors.Join(ErrCoordinationPending, err)
|
|
}
|
|
record.Phase = state.Status
|
|
return record, saveCoordination(root, record)
|
|
}
|
|
|
|
func (c Coordinator) ready(ctx context.Context, expected ManagedCertificate) error {
|
|
if err := expected.validate(); err != nil {
|
|
return err
|
|
}
|
|
ready, err := c.Proxy.Ready(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ready.RequestFencing {
|
|
return errors.New("本地激活服务不支持请求取消栅栏,请先升级激活器")
|
|
}
|
|
if ready.Hostname != expected.Hostname || ready.LeafHash != expected.LeafHash || ready.ProxyAddress != expected.ProxyAddress || ready.UpstreamAddress != expected.UpstreamAddress {
|
|
return errors.New("本地激活策略或双层入口与应用管理配置不一致")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Coordinator) withSession(ctx context.Context, operation func(*os.Root, ManagedSession) error) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if c.Proxy == nil || c.Upstream == nil {
|
|
return errors.New("管理证书协调器尚未配置")
|
|
}
|
|
if err := validatePrivateDirectory(c.Directory); err != nil {
|
|
return err
|
|
}
|
|
lockPath := filepath.Join(c.Directory, ".coordination.lock")
|
|
if info, err := os.Lstat(lockPath); err == nil {
|
|
if err := validateStateFile(info, 4096); err != nil {
|
|
return err
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
lock, err := processlock.Acquire(lockPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lock.Close()
|
|
root, err := os.OpenRoot(c.Directory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer root.Close()
|
|
return c.Upstream.WithCertificate(ctx, func(session ManagedSession) error {
|
|
return operation(root, session)
|
|
})
|
|
}
|
|
|
|
func readManagedMaterial(path string, limit int64) ([]byte, error) {
|
|
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
|
|
return nil, errors.New("管理证书材料必须使用规范绝对路径")
|
|
}
|
|
if err := validateTrustedParents(filepath.Dir(path), uint32(os.Geteuid())); err != nil {
|
|
return nil, err
|
|
}
|
|
root, err := os.OpenRoot(filepath.Dir(path))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer root.Close()
|
|
data, err := readRegular(root, filepath.Base(path), limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取候选管理证书材料失败:%w", err)
|
|
}
|
|
return data, nil
|
|
}
|