DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,226 @@
|
||||
//go:build linux && amd64 && cgo
|
||||
|
||||
package nativepatch
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef struct {
|
||||
void *addr;
|
||||
unsigned char original[12];
|
||||
} lh_patch_slot;
|
||||
|
||||
static lh_patch_slot lh_patches[100];
|
||||
static int lh_patch_count = 0;
|
||||
|
||||
static int lh_mprotect_range(void *addr, size_t len, int prot) {
|
||||
size_t page_size = (size_t)sysconf(_SC_PAGESIZE);
|
||||
uintptr_t start = (uintptr_t)addr & ~(uintptr_t)(page_size - 1);
|
||||
uintptr_t end = ((uintptr_t)addr + len + page_size - 1) & ~(uintptr_t)(page_size - 1);
|
||||
return mprotect((void*)start, end - start, prot);
|
||||
}
|
||||
|
||||
static int lh_patch_symbol(const char *old_name, void *new_func) {
|
||||
if (new_func == NULL) {
|
||||
return -1;
|
||||
}
|
||||
if (lh_patch_count >= 100) {
|
||||
return -99;
|
||||
}
|
||||
void *old_func = NULL;
|
||||
if (old_name != NULL && strlen(old_name) > 2 && old_name[0] == '0' && old_name[1] == 'x') {
|
||||
old_func = (void*)strtoull(old_name, NULL, 16);
|
||||
} else {
|
||||
old_func = dlsym(NULL, old_name);
|
||||
}
|
||||
if (old_func == NULL) {
|
||||
return -2;
|
||||
}
|
||||
if (old_func == new_func) {
|
||||
return -3;
|
||||
}
|
||||
unsigned char jump[12] = {0x48, 0xba, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xe2};
|
||||
memcpy(&jump[2], &new_func, sizeof(void*));
|
||||
if (lh_mprotect_range(old_func, sizeof(jump), PROT_READ|PROT_WRITE|PROT_EXEC) != 0) {
|
||||
return -4;
|
||||
}
|
||||
lh_patch_slot *slot = &lh_patches[lh_patch_count];
|
||||
memcpy(slot->original, old_func, sizeof(jump));
|
||||
slot->addr = old_func;
|
||||
memcpy(old_func, jump, sizeof(jump));
|
||||
__builtin___clear_cache((char*)old_func, (char*)old_func + sizeof(jump));
|
||||
lh_patch_count++;
|
||||
if (lh_mprotect_range(old_func, sizeof(jump), PROT_READ|PROT_EXEC) != 0) {
|
||||
return -5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int lh_restore_symbols() {
|
||||
int i;
|
||||
int kept = 0;
|
||||
for (i = lh_patch_count - 1; i >= 0; i--) {
|
||||
lh_patch_slot *slot = &lh_patches[i];
|
||||
if (lh_mprotect_range(slot->addr, sizeof(slot->original), PROT_READ|PROT_WRITE|PROT_EXEC) != 0) {
|
||||
lh_patches[kept++] = *slot;
|
||||
continue;
|
||||
}
|
||||
memcpy(slot->addr, slot->original, sizeof(slot->original));
|
||||
__builtin___clear_cache((char*)slot->addr, (char*)slot->addr + sizeof(slot->original));
|
||||
(void)lh_mprotect_range(slot->addr, sizeof(slot->original), PROT_READ|PROT_EXEC);
|
||||
}
|
||||
lh_patch_count = kept;
|
||||
return kept;
|
||||
}
|
||||
|
||||
static int lh_patch_symbol_addr(const char *old_name, uintptr_t new_func_addr) {
|
||||
return lh_patch_symbol(old_name, (void*)new_func_addr);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type NativeEngine struct {
|
||||
mu sync.Mutex
|
||||
snapshot Snapshot
|
||||
plugins []*plugin.Plugin
|
||||
}
|
||||
|
||||
func NewNativeEngine() *NativeEngine {
|
||||
return defaultNativeEngine
|
||||
}
|
||||
|
||||
var defaultNativeEngine = &NativeEngine{snapshot: Snapshot{Supported: true}}
|
||||
|
||||
func (e *NativeEngine) Supported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Apply(ctx context.Context, pkg Package) (Result, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := ValidatePlan(pkg.Plan); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
// 应用新补丁前先恢复旧补丁,保证进程内同一时刻只有一组函数入口被改写。
|
||||
if remaining := int(C.lh_restore_symbols()); remaining != 0 {
|
||||
return Result{Version: pkg.Plan.Version, Target: pkg.Plan.Target, StartedAt: started, EndedAt: time.Now().UTC()}, fmt.Errorf("restore existing native patch left %d symbol(s)", remaining)
|
||||
}
|
||||
e.plugins = nil
|
||||
applied, plugins, err := applyPackage(ctx, pkg)
|
||||
if err != nil {
|
||||
// 任意符号替换失败都尝试回滚已替换部分,避免半套补丁继续运行。
|
||||
if remaining := int(C.lh_restore_symbols()); remaining != 0 {
|
||||
err = fmt.Errorf("%w; cleanup left %d native patch symbol(s)", err, remaining)
|
||||
}
|
||||
e.snapshot = Snapshot{Supported: true}
|
||||
return Result{Version: pkg.Plan.Version, Target: pkg.Plan.Target, StartedAt: started, EndedAt: time.Now().UTC()}, err
|
||||
}
|
||||
e.plugins = plugins
|
||||
ended := time.Now().UTC()
|
||||
e.snapshot = Snapshot{
|
||||
Supported: true,
|
||||
Applied: true,
|
||||
Version: pkg.Plan.Version,
|
||||
Target: pkg.Plan.Target,
|
||||
AppliedSymbols: applied,
|
||||
LastAppliedAt: ended,
|
||||
}
|
||||
return Result{Version: pkg.Plan.Version, Target: pkg.Plan.Target, Applied: applied, StartedAt: started, EndedAt: ended}, nil
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Restore(ctx context.Context) (Result, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
// Restore 必须在同一把锁内完成,避免 Apply/Restore 并发改写同一段函数入口。
|
||||
if remaining := int(C.lh_restore_symbols()); remaining != 0 {
|
||||
ended := time.Now().UTC()
|
||||
e.snapshot.Applied = true
|
||||
return Result{Restored: false, StartedAt: started, EndedAt: ended}, fmt.Errorf("restore native patch left %d symbol(s)", remaining)
|
||||
}
|
||||
e.plugins = nil
|
||||
ended := time.Now().UTC()
|
||||
e.snapshot = Snapshot{Supported: true, LastRestoredAt: ended}
|
||||
return Result{Restored: true, StartedAt: started, EndedAt: ended}, nil
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Snapshot() Snapshot {
|
||||
e.mu.Lock()
|
||||
snapshot := e.snapshot
|
||||
e.mu.Unlock()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func applyPackage(ctx context.Context, pkg Package) (int, []*plugin.Plugin, error) {
|
||||
applied := 0
|
||||
loaded := make([]*plugin.Plugin, 0, len(pkg.Plan.Plugins))
|
||||
for _, item := range pkg.Plan.Plugins {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return applied, loaded, err
|
||||
}
|
||||
plug, err := plugin.Open(resolvePluginPath(pkg.Directory, item.File))
|
||||
if err != nil {
|
||||
return applied, loaded, fmt.Errorf("open native patch plugin %s: %w", item.File, err)
|
||||
}
|
||||
loaded = append(loaded, plug)
|
||||
// symbols 映射含义是“新函数符号 -> 旧函数符号”,旧函数入口会被跳转到新函数。
|
||||
// 签名兼容必须由发布流程和人工评审保证,运行时只能校验符号存在。
|
||||
for newSymbol, oldSymbol := range item.Symbols {
|
||||
symbol, err := plug.Lookup(newSymbol)
|
||||
if err != nil {
|
||||
return applied, loaded, fmt.Errorf("lookup native patch symbol %s: %w", newSymbol, err)
|
||||
}
|
||||
ptr, err := functionPointer(reflect.ValueOf(symbol))
|
||||
if err != nil {
|
||||
return applied, loaded, fmt.Errorf("native patch symbol %s: %w", newSymbol, err)
|
||||
}
|
||||
oldName := C.CString(oldSymbol)
|
||||
code := C.lh_patch_symbol_addr(oldName, ptr)
|
||||
C.free(unsafe.Pointer(oldName))
|
||||
if code != 0 {
|
||||
return applied, loaded, fmt.Errorf("patch native symbol %s -> %s failed: code=%d", newSymbol, oldSymbol, int(code))
|
||||
}
|
||||
applied++
|
||||
}
|
||||
}
|
||||
return applied, loaded, nil
|
||||
}
|
||||
|
||||
func resolvePluginPath(root, file string) string {
|
||||
return filepath.Join(root, file)
|
||||
}
|
||||
|
||||
func functionPointer(value reflect.Value) (C.uintptr_t, error) {
|
||||
if !value.IsValid() || value.Kind() != reflect.Func {
|
||||
return 0, fmt.Errorf("exported symbol must be a function")
|
||||
}
|
||||
ptr := value.Pointer()
|
||||
if ptr == 0 {
|
||||
return 0, fmt.Errorf("function pointer is nil")
|
||||
}
|
||||
return C.uintptr_t(ptr), nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package nativepatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMemoryControlStore = errors.New("native patch memory control store is required")
|
||||
ErrMemoryControlStoreClosed = errors.New("native patch memory control store is closed")
|
||||
)
|
||||
|
||||
type ControlAction string
|
||||
|
||||
const (
|
||||
ControlApply ControlAction = "apply"
|
||||
ControlRestore ControlAction = "restore"
|
||||
)
|
||||
|
||||
type ControlIntent struct {
|
||||
Action ControlAction `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Version string `json:"version"`
|
||||
PackageDir string `json:"package_dir,omitempty"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AvailableAt time.Time `json:"available_at,omitempty"`
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
type ControlProgress struct {
|
||||
Target string `json:"target"`
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Action ControlAction `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ControlStore interface {
|
||||
Publish(context.Context, string, ControlIntent) error
|
||||
Watch(context.Context, string) (<-chan ControlIntent, error)
|
||||
Report(context.Context, ControlProgress) error
|
||||
ListProgress(context.Context, string) ([]ControlProgress, error)
|
||||
}
|
||||
|
||||
type MemoryControlStore struct {
|
||||
mu sync.RWMutex
|
||||
intents map[string]ControlIntent
|
||||
progress map[string][]ControlProgress
|
||||
watchers map[string]map[int]chan ControlIntent
|
||||
nextWatch int
|
||||
now func() time.Time
|
||||
done chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewMemoryControlStore() *MemoryControlStore {
|
||||
return NewMemoryCtrlClock(time.Now)
|
||||
}
|
||||
|
||||
func NewMemoryCtrlClock(now func() time.Time) *MemoryControlStore {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &MemoryControlStore{
|
||||
intents: make(map[string]ControlIntent),
|
||||
progress: make(map[string][]ControlProgress),
|
||||
watchers: make(map[string]map[int]chan ControlIntent),
|
||||
now: now,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryControlStore) Publish(ctx context.Context, target string, intent ControlIntent) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return fmt.Errorf("native patch control target is required")
|
||||
}
|
||||
intent.Target = target
|
||||
if intent.Action == "" {
|
||||
intent.Action = ControlApply
|
||||
}
|
||||
if intent.Action != ControlApply && intent.Action != ControlRestore {
|
||||
return fmt.Errorf("unsupported native patch control action %s", intent.Action)
|
||||
}
|
||||
if strings.TrimSpace(intent.Version) == "" {
|
||||
return ErrVersionRequired
|
||||
}
|
||||
if intent.Action == ControlApply && strings.TrimSpace(intent.PackageDir) == "" {
|
||||
return ErrPlanRequired
|
||||
}
|
||||
// 内存控制面只保留每个 target 的最新意图,适合本地演练,不提供跨进程持久化。
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return ErrMemoryControlStoreClosed
|
||||
}
|
||||
s.intents[target] = intent
|
||||
for _, watcher := range s.watchers[target] {
|
||||
// watcher 通道容量为 1;慢消费者只保留最新意图,避免执行过期补丁。
|
||||
sendLatestIntent(watcher, intent)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendLatestIntent(ch chan ControlIntent, intent ControlIntent) {
|
||||
select {
|
||||
case ch <- intent:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-ch:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case ch <- intent:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemoryControlStore) Watch(ctx context.Context, target string) (<-chan ControlIntent, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("native patch control target is required")
|
||||
}
|
||||
ch := make(chan ControlIntent, 1)
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
s.nextWatch++
|
||||
id := s.nextWatch
|
||||
if s.watchers[target] == nil {
|
||||
s.watchers[target] = make(map[int]chan ControlIntent)
|
||||
}
|
||||
s.watchers[target][id] = ch
|
||||
if intent, ok := s.intents[target]; ok {
|
||||
// 先发送当前快照,再等待后续发布,避免节点重启后错过最近一次补丁指令。
|
||||
ch <- intent
|
||||
}
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-s.done:
|
||||
}
|
||||
s.mu.Lock()
|
||||
removed := false
|
||||
if watchers := s.watchers[target]; watchers != nil {
|
||||
if _, ok := watchers[id]; ok {
|
||||
delete(watchers, id)
|
||||
removed = true
|
||||
}
|
||||
if len(watchers) == 0 {
|
||||
delete(s.watchers, target)
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
close(ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *MemoryControlStore) Report(ctx context.Context, progress ControlProgress) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := s.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress.Target = strings.TrimSpace(progress.Target)
|
||||
progress.NodeID = strings.TrimSpace(progress.NodeID)
|
||||
if progress.Target == "" || progress.NodeID == "" {
|
||||
return fmt.Errorf("native patch progress target and node_id are required")
|
||||
}
|
||||
if progress.UpdatedAt.IsZero() {
|
||||
progress.UpdatedAt = now().UTC()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return ErrMemoryControlStoreClosed
|
||||
}
|
||||
s.progress[progress.Target] = append(s.progress[progress.Target], progress)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryControlStore) ListProgress(ctx context.Context, target string) ([]ControlProgress, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
s.mu.RLock()
|
||||
out := append([]ControlProgress(nil), s.progress[target]...)
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close 关闭内存控制面并收敛所有 watcher。
|
||||
func (s *MemoryControlStore) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
if s.done == nil {
|
||||
s.done = make(chan struct{})
|
||||
}
|
||||
close(s.done)
|
||||
for target, watchers := range s.watchers {
|
||||
for id, ch := range watchers {
|
||||
delete(watchers, id)
|
||||
close(ch)
|
||||
}
|
||||
delete(s.watchers, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryControlStore) ready() (func() time.Time, error) {
|
||||
if s == nil {
|
||||
return nil, ErrMemoryControlStore
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.intents == nil {
|
||||
s.intents = make(map[string]ControlIntent)
|
||||
}
|
||||
if s.progress == nil {
|
||||
s.progress = make(map[string][]ControlProgress)
|
||||
}
|
||||
if s.watchers == nil {
|
||||
s.watchers = make(map[string]map[int]chan ControlIntent)
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = time.Now
|
||||
}
|
||||
if s.done == nil {
|
||||
s.done = make(chan struct{})
|
||||
}
|
||||
return s.now, nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package nativepatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrControlNotDue = errors.New("native patch control intent is not due yet")
|
||||
|
||||
type ControlManagerOptions struct {
|
||||
Target string
|
||||
NodeID string
|
||||
Store ControlStore
|
||||
Engine Engine
|
||||
Policy Policy
|
||||
Now func() time.Time
|
||||
// 控制面上报必须有硬超时,否则补丁已 apply 后可能被不可达后端卡在同步验收路径。
|
||||
ReportTimeout time.Duration
|
||||
}
|
||||
|
||||
type ControlManager struct {
|
||||
target string
|
||||
nodeID string
|
||||
store ControlStore
|
||||
engine Engine
|
||||
policy Policy
|
||||
now func() time.Time
|
||||
reportTimeout time.Duration
|
||||
|
||||
opMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
restoreTimer *time.Timer
|
||||
restoreGeneration int64
|
||||
}
|
||||
|
||||
func NewControlManager(options ControlManagerOptions) (*ControlManager, error) {
|
||||
if options.Target == "" {
|
||||
return nil, fmt.Errorf("native patch control target is required")
|
||||
}
|
||||
if options.NodeID == "" {
|
||||
return nil, fmt.Errorf("native patch control node id is required")
|
||||
}
|
||||
if options.Store == nil {
|
||||
return nil, fmt.Errorf("native patch control store is required")
|
||||
}
|
||||
if options.Engine == nil {
|
||||
return nil, fmt.Errorf("native patch engine is required")
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
reportTimeout := options.ReportTimeout
|
||||
if reportTimeout <= 0 {
|
||||
reportTimeout = 30 * time.Second
|
||||
}
|
||||
return &ControlManager{
|
||||
target: options.Target,
|
||||
nodeID: options.NodeID,
|
||||
store: options.Store,
|
||||
engine: options.Engine,
|
||||
policy: options.Policy,
|
||||
now: options.Now,
|
||||
reportTimeout: reportTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *ControlManager) Process(ctx context.Context, intent ControlIntent) (Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !intent.AvailableAt.IsZero() && intent.AvailableAt.After(m.now().UTC()) {
|
||||
_ = m.report(ctx, intent, "pending", "waiting")
|
||||
return Result{}, ErrControlNotDue
|
||||
}
|
||||
_ = m.report(ctx, intent, "applying", "")
|
||||
var result Result
|
||||
var err error
|
||||
armedDuration := time.Duration(0)
|
||||
armedRestore := false
|
||||
switch intent.Action {
|
||||
case "", ControlApply:
|
||||
// apply 只消费控制面下发的包路径;包内容仍要经过 LoadPackage 和 Policy 双重校验。
|
||||
pkg, loadErr := LoadPackage(ctx, intent.PackageDir)
|
||||
if loadErr != nil {
|
||||
err = loadErr
|
||||
break
|
||||
}
|
||||
if validateErr := m.policy.Validate(pkg.Plan); validateErr != nil {
|
||||
err = validateErr
|
||||
break
|
||||
}
|
||||
m.opMu.Lock()
|
||||
result, err = m.engine.Apply(ctx, pkg)
|
||||
if err == nil {
|
||||
armedDuration, armedRestore = m.armAutoRestore(intent)
|
||||
}
|
||||
m.opMu.Unlock()
|
||||
case ControlRestore:
|
||||
m.opMu.Lock()
|
||||
result, err = m.engine.Restore(ctx)
|
||||
if err == nil {
|
||||
m.cancelAutoRestore()
|
||||
}
|
||||
m.opMu.Unlock()
|
||||
default:
|
||||
err = fmt.Errorf("unsupported native patch control action %s", intent.Action)
|
||||
}
|
||||
if err != nil {
|
||||
_ = m.report(ctx, intent, "failed", err.Error())
|
||||
return result, err
|
||||
}
|
||||
_ = m.report(ctx, intent, "applied", "")
|
||||
if armedRestore {
|
||||
m.reportArmed(intent, armedDuration)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *ControlManager) Watch(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ch, err := m.store.Watch(ctx, m.target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case intent, ok := <-ch:
|
||||
if !ok {
|
||||
return ctx.Err()
|
||||
}
|
||||
// 未到生效时间不是失败;Watcher 继续等待下一条控制意图。
|
||||
if _, err := m.Process(ctx, intent); err != nil && !errors.Is(err, ErrControlNotDue) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ControlManager) report(ctx context.Context, intent ControlIntent, status string, detail string) error {
|
||||
reportCtx, cancel := m.reportContext(ctx)
|
||||
defer cancel()
|
||||
return m.store.Report(reportCtx, ControlProgress{
|
||||
Target: m.target,
|
||||
NodeID: m.nodeID,
|
||||
Version: intent.Version,
|
||||
Action: intent.Action,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
UpdatedAt: m.now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ControlManager) reportContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
}
|
||||
timeout := 30 * time.Second
|
||||
if m != nil && m.reportTimeout > 0 {
|
||||
timeout = m.reportTimeout
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func (m *ControlManager) armAutoRestore(intent ControlIntent) (time.Duration, bool) {
|
||||
if m == nil || m.policy.MaxLiveDuration <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
duration := m.policy.MaxLiveDuration
|
||||
m.mu.Lock()
|
||||
if m.restoreTimer != nil {
|
||||
m.restoreTimer.Stop()
|
||||
}
|
||||
m.restoreGeneration++
|
||||
generation := m.restoreGeneration
|
||||
m.restoreTimer = time.AfterFunc(duration, func() {
|
||||
m.runAutoRestore(generation, intent)
|
||||
})
|
||||
m.mu.Unlock()
|
||||
return duration, true
|
||||
}
|
||||
|
||||
func (m *ControlManager) reportArmed(intent ControlIntent, duration time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.reportTimeout)
|
||||
defer cancel()
|
||||
_ = m.report(ctx, intent, "restore_scheduled", duration.String())
|
||||
}
|
||||
|
||||
func (m *ControlManager) runAutoRestore(generation int64, intent ControlIntent) {
|
||||
if !m.autoRestoreCurrent(generation) {
|
||||
return
|
||||
}
|
||||
// 自动恢复走同一套进度上报,方便验收报告确认补丁没有超期滞留。
|
||||
restoreIntent := ControlIntent{
|
||||
Action: ControlRestore,
|
||||
Target: intent.Target,
|
||||
Version: intent.Version,
|
||||
RequestedBy: "auto_restore",
|
||||
Reason: "native patch max live duration reached",
|
||||
Sequence: intent.Sequence,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.reportTimeout)
|
||||
defer cancel()
|
||||
_ = m.report(ctx, restoreIntent, "restoring", "max_live_duration")
|
||||
if !m.autoRestoreCurrent(generation) {
|
||||
return
|
||||
}
|
||||
m.opMu.Lock()
|
||||
defer m.opMu.Unlock()
|
||||
if !m.autoRestoreCurrent(generation) {
|
||||
return
|
||||
}
|
||||
if _, err := m.engine.Restore(ctx); err != nil {
|
||||
if m.autoRestoreCurrent(generation) {
|
||||
_ = m.report(ctx, restoreIntent, "restore_failed", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if !m.autoRestoreCurrent(generation) {
|
||||
return
|
||||
}
|
||||
m.finishAutoRestore(generation)
|
||||
_ = m.report(ctx, restoreIntent, "restored", "max_live_duration")
|
||||
}
|
||||
|
||||
func (m *ControlManager) autoRestoreCurrent(generation int64) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.restoreTimer != nil && m.restoreGeneration == generation
|
||||
}
|
||||
|
||||
func (m *ControlManager) finishAutoRestore(generation int64) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.restoreGeneration == generation {
|
||||
m.restoreTimer = nil
|
||||
m.restoreGeneration++
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *ControlManager) cancelAutoRestore() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.restoreTimer != nil {
|
||||
m.restoreTimer.Stop()
|
||||
m.restoreTimer = nil
|
||||
}
|
||||
m.restoreGeneration++
|
||||
m.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build !linux || !amd64 || !cgo
|
||||
|
||||
package nativepatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NativeEngine struct{}
|
||||
|
||||
func NewNativeEngine() *NativeEngine {
|
||||
return &NativeEngine{}
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Supported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Apply(ctx context.Context, pkg Package) (Result, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return Result{Version: pkg.Plan.Version, Target: pkg.Plan.Target, StartedAt: now, EndedAt: now}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Restore(ctx context.Context) (Result, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return Result{StartedAt: now, EndedAt: now, Restored: false}, ErrUnsupported
|
||||
}
|
||||
|
||||
func (e *NativeEngine) Snapshot() Snapshot {
|
||||
return Snapshot{Supported: false}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package nativepatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
clientv3 "go.etcd.io/etcd/client/v3"
|
||||
)
|
||||
|
||||
const defaultEtcdNamespace = "longheng/nativepatch"
|
||||
|
||||
var (
|
||||
ErrEtcdControlInstanceID = errors.New("native patch etcd control instance id generation failed")
|
||||
ErrEtcdControlStore = errors.New("native patch etcd control store is required")
|
||||
etcdControlRandRead = rand.Read
|
||||
)
|
||||
|
||||
type EtcdClient interface {
|
||||
Get(context.Context, string, ...clientv3.OpOption) (*clientv3.GetResponse, error)
|
||||
Put(context.Context, string, string, ...clientv3.OpOption) (*clientv3.PutResponse, error)
|
||||
Watch(context.Context, string, ...clientv3.OpOption) clientv3.WatchChan
|
||||
Close() error
|
||||
}
|
||||
|
||||
type EtcdControlOptions struct {
|
||||
Client EtcdClient
|
||||
Namespace string
|
||||
CloseClient bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type EtcdControlStore struct {
|
||||
client EtcdClient
|
||||
namespace string
|
||||
closeClient bool
|
||||
instanceID string
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
func NewEtcdControlStore(options EtcdControlOptions) (*EtcdControlStore, error) {
|
||||
if options.Client == nil {
|
||||
return nil, ErrEtcdControlStore
|
||||
}
|
||||
namespace := normEtcdNamespace(options.Namespace)
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
instanceID, err := newEtcdInstID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EtcdControlStore{
|
||||
client: options.Client,
|
||||
namespace: namespace,
|
||||
closeClient: options.CloseClient,
|
||||
instanceID: instanceID,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) Publish(ctx context.Context, target string, intent ControlIntent) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
client, namespace, _, _, err := s.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return fmt.Errorf("native patch control target is required")
|
||||
}
|
||||
intent.Target = target
|
||||
if intent.Action == "" {
|
||||
intent.Action = ControlApply
|
||||
}
|
||||
if intent.Action != ControlApply && intent.Action != ControlRestore {
|
||||
return fmt.Errorf("unsupported native patch control action %s", intent.Action)
|
||||
}
|
||||
if strings.TrimSpace(intent.Version) == "" {
|
||||
return ErrVersionRequired
|
||||
}
|
||||
if intent.Action == ControlApply && strings.TrimSpace(intent.PackageDir) == "" {
|
||||
return ErrPlanRequired
|
||||
}
|
||||
data, err := json.Marshal(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Put(ctx, intentKey(namespace, target), string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) Watch(ctx context.Context, target string) (<-chan ControlIntent, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, namespace, _, _, err := s.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("native patch control target is required")
|
||||
}
|
||||
key := intentKey(namespace, target)
|
||||
out := make(chan ControlIntent, 1)
|
||||
resp, err := client.Get(ctx, key)
|
||||
if err != nil {
|
||||
close(out)
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Kvs) > 0 {
|
||||
if intent, err := decodeControlIntent(resp.Kvs[len(resp.Kvs)-1].Value); err == nil {
|
||||
if !sendControlIntent(ctx, out, intent) {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
watchCh := client.Watch(ctx, key)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case watchResp, ok := <-watchCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, event := range watchResp.Events {
|
||||
if event == nil || event.Kv == nil {
|
||||
continue
|
||||
}
|
||||
intent, err := decodeControlIntent(event.Kv.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !sendControlIntent(ctx, out, intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sendControlIntent(ctx context.Context, out chan ControlIntent, intent ControlIntent) bool {
|
||||
select {
|
||||
case out <- intent:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-out:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case out <- intent:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) Report(ctx context.Context, progress ControlProgress) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
client, namespace, now, instanceID, err := s.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress.Target = strings.TrimSpace(progress.Target)
|
||||
progress.NodeID = strings.TrimSpace(progress.NodeID)
|
||||
if progress.Target == "" || progress.NodeID == "" {
|
||||
return fmt.Errorf("native patch progress target and node_id are required")
|
||||
}
|
||||
if progress.UpdatedAt.IsZero() {
|
||||
progress.UpdatedAt = now().UTC()
|
||||
}
|
||||
data, err := json.Marshal(progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Put(ctx, s.progressKey(namespace, instanceID, progress), string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) ListProgress(ctx context.Context, target string) ([]ControlProgress, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, namespace, _, _, err := s.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Get(ctx, progressPrefix(namespace, target), clientv3.WithPrefix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(resp.Kvs, func(i, j int) bool {
|
||||
return string(resp.Kvs[i].Key) < string(resp.Kvs[j].Key)
|
||||
})
|
||||
out := make([]ControlProgress, 0, len(resp.Kvs))
|
||||
for _, kv := range resp.Kvs {
|
||||
var progress ControlProgress
|
||||
if err := json.Unmarshal(kv.Value, &progress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, progress)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) ready() (EtcdClient, string, func() time.Time, string, error) {
|
||||
if s == nil {
|
||||
return nil, "", nil, "", ErrEtcdControlStore
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.client == nil {
|
||||
return nil, "", nil, "", ErrEtcdControlStore
|
||||
}
|
||||
now := s.now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
instanceID := s.instanceID
|
||||
if instanceID == "" {
|
||||
generated, err := newEtcdInstID()
|
||||
if err != nil {
|
||||
return nil, "", nil, "", err
|
||||
}
|
||||
s.instanceID = generated
|
||||
instanceID = generated
|
||||
}
|
||||
return s.client, normEtcdNamespace(s.namespace), now, instanceID, nil
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) Close() error {
|
||||
if s == nil || s.client == nil || !s.closeClient {
|
||||
return nil
|
||||
}
|
||||
return s.client.Close()
|
||||
}
|
||||
|
||||
func normEtcdNamespace(namespace string) string {
|
||||
namespace = strings.Trim(strings.TrimSpace(namespace), "/")
|
||||
if namespace == "" {
|
||||
return defaultEtcdNamespace
|
||||
}
|
||||
return namespace
|
||||
}
|
||||
|
||||
func intentKey(namespace string, target string) string {
|
||||
return namespace + "/intent/" + encodeEtcdPart(target)
|
||||
}
|
||||
|
||||
func progressPrefix(namespace string, target string) string {
|
||||
return namespace + "/progress/" + encodeEtcdPart(target) + "/"
|
||||
}
|
||||
|
||||
func (s *EtcdControlStore) progressKey(namespace string, instanceID string, progress ControlProgress) string {
|
||||
s.mu.Lock()
|
||||
s.sequence++
|
||||
sequence := s.sequence
|
||||
s.mu.Unlock()
|
||||
return fmt.Sprintf("%s%s/%020d/%s/%020d", progressPrefix(namespace, progress.Target), encodeEtcdPart(progress.NodeID), progress.UpdatedAt.UnixNano(), instanceID, sequence)
|
||||
}
|
||||
|
||||
func newEtcdInstID() (string, error) {
|
||||
var b [8]byte
|
||||
if _, err := etcdControlRandRead(b[:]); err != nil {
|
||||
return "", fmt.Errorf("%w: %w", ErrEtcdControlInstanceID, err)
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func encodeEtcdPart(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "_"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
|
||||
func decodeControlIntent(data []byte) (ControlIntent, error) {
|
||||
var intent ControlIntent
|
||||
if err := json.Unmarshal(data, &intent); err != nil {
|
||||
return ControlIntent{}, err
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package nativepatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PlanFileName = "native_patch.json"
|
||||
SymbolManifestFunc = "LongHengPatchSymbols"
|
||||
MaxSymbols = 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupported = errors.New("native patch is unsupported on this platform")
|
||||
ErrPlanRequired = errors.New("native patch plan is required")
|
||||
ErrVersionRequired = errors.New("native patch version is required")
|
||||
ErrPluginRequired = errors.New("native patch plugin file is required")
|
||||
ErrSymbolRequired = errors.New("native patch symbol mapping is required")
|
||||
ErrTooManySymbols = errors.New("native patch symbol mapping exceeds limit")
|
||||
ErrUnsafePackageEntry = errors.New("native patch package contains unsafe entry")
|
||||
ErrPolicyRejected = errors.New("native patch policy rejected plan")
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
Version string `json:"version"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
RequestedBy string `json:"requested_by,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Plugins []PluginPlan `json:"plugins"`
|
||||
}
|
||||
|
||||
type PluginPlan struct {
|
||||
File string `json:"file"`
|
||||
Symbols map[string]string `json:"symbols"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
Directory string `json:"directory"`
|
||||
Plan Plan `json:"plan"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
AllowedTargets []string
|
||||
AllowedOldSymbols []string
|
||||
RequireBuildID bool
|
||||
RequireRequestedBy bool
|
||||
RequireReason bool
|
||||
MinReasonLength int
|
||||
MaxSymbols int
|
||||
MaxLiveDuration time.Duration
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Version string `json:"version"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Applied int `json:"applied"`
|
||||
Restored bool `json:"restored,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt time.Time `json:"ended_at"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Supported bool `json:"supported"`
|
||||
Applied bool `json:"applied"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
AppliedSymbols int `json:"applied_symbols,omitempty"`
|
||||
LastAppliedAt time.Time `json:"last_applied_at,omitempty"`
|
||||
LastRestoredAt time.Time `json:"last_restored_at,omitempty"`
|
||||
}
|
||||
|
||||
type Engine interface {
|
||||
Supported() bool
|
||||
Apply(context.Context, Package) (Result, error)
|
||||
Restore(context.Context) (Result, error)
|
||||
Snapshot() Snapshot
|
||||
}
|
||||
|
||||
func LoadPackage(ctx context.Context, directory string) (Package, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
directory = strings.TrimSpace(directory)
|
||||
if directory == "" {
|
||||
return Package{}, ErrPlanRequired
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return Package{}, fmt.Errorf("stat native patch package %q: %w", directory, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return Package{}, fmt.Errorf("%w: package root symlink %s", ErrUnsafePackageEntry, directory)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return Package{}, fmt.Errorf("native patch package %q is not a directory", directory)
|
||||
}
|
||||
// 热补丁包会被进程直接加载执行,必须先拒绝软链和越界路径。
|
||||
// 这里不信任打包端,所有文件都按外部输入处理。
|
||||
if err := rejectUnsafeEntries(ctx, directory); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(directory, PlanFileName)) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return Package{}, fmt.Errorf("read native patch plan: %w", err)
|
||||
}
|
||||
var plan Plan
|
||||
if err := json.Unmarshal(data, &plan); err != nil {
|
||||
return Package{}, fmt.Errorf("parse native patch plan: %w", err)
|
||||
}
|
||||
if err := ValidatePlan(plan); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if err := validatePackageFiles(directory, plan); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
return Package{Directory: directory, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func ValidatePlan(plan Plan) error {
|
||||
plan.Version = strings.TrimSpace(plan.Version)
|
||||
if plan.Version == "" {
|
||||
return ErrVersionRequired
|
||||
}
|
||||
if len(plan.Plugins) == 0 {
|
||||
return ErrPluginRequired
|
||||
}
|
||||
totalSymbols := 0
|
||||
seenFiles := make(map[string]struct{}, len(plan.Plugins))
|
||||
seenOldSymbols := make(map[string]struct{}, MaxSymbols)
|
||||
for idx, plugin := range plan.Plugins {
|
||||
file := strings.TrimSpace(plugin.File)
|
||||
if file != plugin.File {
|
||||
return fmt.Errorf("plugin %d: %w: surrounding whitespace in plugin file", idx, ErrUnsafePackageEntry)
|
||||
}
|
||||
if err := validatePluginFile(file); err != nil {
|
||||
return fmt.Errorf("plugin %d: %w", idx, err)
|
||||
}
|
||||
if _, ok := seenFiles[file]; ok {
|
||||
return fmt.Errorf("plugin %d: duplicate plugin file %q", idx, file)
|
||||
}
|
||||
seenFiles[file] = struct{}{}
|
||||
if len(plugin.Symbols) == 0 {
|
||||
return fmt.Errorf("plugin %d: %w", idx, ErrSymbolRequired)
|
||||
}
|
||||
keys := make([]string, 0, len(plugin.Symbols))
|
||||
for newSymbol := range plugin.Symbols {
|
||||
keys = append(keys, newSymbol)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
// 同一个旧函数只能被替换一次,否则恢复顺序和审计含义都会变得不可控。
|
||||
for _, newSymbol := range keys {
|
||||
oldSymbol := plugin.Symbols[newSymbol]
|
||||
if newSymbol != strings.TrimSpace(newSymbol) || oldSymbol != strings.TrimSpace(oldSymbol) {
|
||||
return fmt.Errorf("plugin %d: %w: symbol contains surrounding whitespace", idx, ErrSymbolRequired)
|
||||
}
|
||||
if strings.TrimSpace(newSymbol) == "" || strings.TrimSpace(oldSymbol) == "" {
|
||||
return fmt.Errorf("plugin %d: %w", idx, ErrSymbolRequired)
|
||||
}
|
||||
if hasUnsafeTokenRune(newSymbol) || hasUnsafeTokenRune(oldSymbol) {
|
||||
return fmt.Errorf("plugin %d: %w: symbol contains control character", idx, ErrUnsafePackageEntry)
|
||||
}
|
||||
if _, ok := seenOldSymbols[oldSymbol]; ok {
|
||||
return fmt.Errorf("plugin %d: duplicate old symbol %q", idx, oldSymbol)
|
||||
}
|
||||
seenOldSymbols[oldSymbol] = struct{}{}
|
||||
totalSymbols++
|
||||
if totalSymbols > MaxSymbols {
|
||||
return ErrTooManySymbols
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Policy) Validate(plan Plan) error {
|
||||
if err := ValidatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
// Policy 是热补丁的第二道门:包结构合法不代表允许在当前生产目标上执行。
|
||||
// 生产环境应至少要求 build_id、requested_by、reason 和允许替换的符号白名单。
|
||||
if p.RequireBuildID && strings.TrimSpace(plan.BuildID) == "" {
|
||||
return fmt.Errorf("%w: build_id is required", ErrPolicyRejected)
|
||||
}
|
||||
if p.RequireRequestedBy && strings.TrimSpace(plan.RequestedBy) == "" {
|
||||
return fmt.Errorf("%w: requested_by is required", ErrPolicyRejected)
|
||||
}
|
||||
reason := strings.TrimSpace(plan.Reason)
|
||||
if p.MinReasonLength < 0 {
|
||||
return fmt.Errorf("%w: min reason length must be non-negative", ErrPolicyRejected)
|
||||
}
|
||||
if p.RequireReason && reason == "" {
|
||||
return fmt.Errorf("%w: reason is required", ErrPolicyRejected)
|
||||
}
|
||||
if reason != "" {
|
||||
minReasonLength := p.MinReasonLength
|
||||
if p.RequireReason && minReasonLength == 0 {
|
||||
minReasonLength = 8
|
||||
}
|
||||
if minReasonLength > 0 && len([]rune(reason)) < minReasonLength {
|
||||
return fmt.Errorf("%w: reason must be at least %d characters", ErrPolicyRejected, minReasonLength)
|
||||
}
|
||||
}
|
||||
if p.MaxLiveDuration < 0 {
|
||||
return fmt.Errorf("%w: max live duration must be non-negative", ErrPolicyRejected)
|
||||
}
|
||||
if len(p.AllowedTargets) > 0 && !stringAllowed(strings.TrimSpace(plan.Target), p.AllowedTargets) {
|
||||
return fmt.Errorf("%w: target %q is not allowed", ErrPolicyRejected, plan.Target)
|
||||
}
|
||||
limit := p.MaxSymbols
|
||||
if limit <= 0 {
|
||||
limit = MaxSymbols
|
||||
}
|
||||
count := 0
|
||||
for _, plugin := range plan.Plugins {
|
||||
for _, oldSymbol := range plugin.Symbols {
|
||||
oldSymbol = strings.TrimSpace(oldSymbol)
|
||||
count++
|
||||
if count > limit {
|
||||
return ErrTooManySymbols
|
||||
}
|
||||
if len(p.AllowedOldSymbols) > 0 && !stringAllowed(oldSymbol, p.AllowedOldSymbols) {
|
||||
return fmt.Errorf("%w: old symbol %q is not allowed", ErrPolicyRejected, oldSymbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringAllowed(value string, allowed []string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, item := range allowed {
|
||||
if value == strings.TrimSpace(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validatePluginFile(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ErrPluginRequired
|
||||
}
|
||||
if hasUnsafeTokenRune(value) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsafePackageEntry, value)
|
||||
}
|
||||
if filepath.IsAbs(value) || strings.Contains(value, "..") {
|
||||
return fmt.Errorf("%w: %s", ErrUnsafePackageEntry, value)
|
||||
}
|
||||
clean := filepath.Clean(value)
|
||||
if clean == "." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsafePackageEntry, value)
|
||||
}
|
||||
base := filepath.Base(clean)
|
||||
if clean != base {
|
||||
return fmt.Errorf("%w: plugin file must be in package root: %s", ErrUnsafePackageEntry, value)
|
||||
}
|
||||
if !strings.HasPrefix(base, "patch") || !strings.HasSuffix(base, ".so") {
|
||||
return fmt.Errorf("native patch plugin %q must match patch*.so", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePackageFiles(directory string, plan Plan) error {
|
||||
for idx, plugin := range plan.Plugins {
|
||||
path := filepath.Join(directory, plugin.File)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin %d: stat native patch plugin %q: %w", idx, plugin.File, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%w: plugin symlink %s", ErrUnsafePackageEntry, plugin.File)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%w: plugin %s is not a regular file", ErrUnsafePackageEntry, plugin.File)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectUnsafeEntries(ctx context.Context, root string) error {
|
||||
// WalkDir 覆盖整个包目录,防止压缩包里藏软链、绝对路径或目录逃逸。
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%w: symlink %s", ErrUnsafePackageEntry, path)
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
clean := filepath.Clean(rel)
|
||||
if clean == "." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.IsAbs(clean) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsafePackageEntry, rel)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func contextErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func hasUnsafeTokenRune(value string) bool {
|
||||
for _, r := range value {
|
||||
if r == 0 || r < 0x20 || r == 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user