Files
90CNServer/server/internal/platform/hotupdate/bundle.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

641 lines
20 KiB
Go

package hotupdate
import (
"archive/zip"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
const BundleManifestName = "longheng_hotupdate_manifest.json"
const BundleSignatureAlgorithm = "HMAC-SHA256"
const defMaxBundleBytes int64 = 256 << 20
const maxBundleManifest int64 = 1 << 20
type BundleOptions struct {
SigningKey []byte
HashCache *FileHashCache
}
type BundleVerifyOptions struct {
Checksum string
SigningKey []byte
RequireSignature bool
MaxBytes int64
}
type BundleManifest struct {
Version string `json:"version"`
Files []BundleFile `json:"files"`
Bytes int64 `json:"bytes"`
Checksum string `json:"checksum"`
SignatureAlg string `json:"signature_alg,omitempty"`
Signature string `json:"signature,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type BundleFile struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Checksum string `json:"checksum"`
}
func BuildBundle(ctx context.Context, sourceDir, bundlePath, version string) (BundleManifest, error) {
return BuildBundleWithOptions(ctx, sourceDir, bundlePath, version, BundleOptions{})
}
func BuildBundleWithOptions(ctx context.Context, sourceDir, bundlePath, version string, options BundleOptions) (BundleManifest, error) {
if err := contextErr(ctx); err != nil {
return BundleManifest{}, err
}
sourceDir = strings.TrimSpace(sourceDir)
bundlePath = strings.TrimSpace(bundlePath)
version = strings.TrimSpace(version)
if sourceDir == "" {
return BundleManifest{}, ErrSourceRequired
}
if bundlePath == "" {
return BundleManifest{}, fmt.Errorf("hot update bundle path is required")
}
if version == "" {
return BundleManifest{}, ErrVersionRequired
}
info, err := os.Lstat(sourceDir)
if err != nil {
return BundleManifest{}, err
}
if info.Mode()&os.ModeSymlink != 0 {
return BundleManifest{}, fmt.Errorf("hot update bundle source %q must not be a symlink", sourceDir)
}
if !info.IsDir() {
return BundleManifest{}, fmt.Errorf("hot update bundle source %q is not a directory", sourceDir)
}
files, err := bundleFilesHash(ctx, sourceDir, options.HashCache)
if err != nil {
return BundleManifest{}, err
}
manifest := BundleManifest{
Version: version,
Files: files,
CreatedAt: time.Now().UTC(),
}
manifest.Bytes, manifest.Checksum = bundleSummary(files)
if len(options.SigningKey) > 0 {
manifest.SignatureAlg = BundleSignatureAlgorithm
manifest.Signature = signBundleManifest(manifest, options.SigningKey)
}
if err := os.MkdirAll(filepath.Dir(bundlePath), 0o750); err != nil && filepath.Dir(bundlePath) != "." {
return BundleManifest{}, err
}
out, err := os.Create(bundlePath) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return BundleManifest{}, err
}
zipper := zip.NewWriter(out)
closeWithErr := func(first error) error {
if err := zipper.Close(); first == nil {
first = err
}
if err := out.Close(); first == nil {
first = err
}
return first
}
for _, file := range files {
if err := contextErr(ctx); err != nil {
return BundleManifest{}, closeWithErr(err)
}
if err := addBundleFile(zipper, sourceDir, file.Path); err != nil {
return BundleManifest{}, closeWithErr(err)
}
}
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return BundleManifest{}, closeWithErr(err)
}
entry, err := zipper.Create(BundleManifestName)
if err != nil {
return BundleManifest{}, closeWithErr(err)
}
if _, err := entry.Write(append(manifestData, '\n')); err != nil {
return BundleManifest{}, closeWithErr(err)
}
if err := closeWithErr(nil); err != nil {
return BundleManifest{}, err
}
return manifest, nil
}
func VerifyBundle(ctx context.Context, bundlePath, wantChecksum string) (BundleManifest, error) {
return VerifyBundleWithOptions(ctx, bundlePath, BundleVerifyOptions{Checksum: wantChecksum})
}
func VerifyBundleWithOptions(ctx context.Context, bundlePath string, options BundleVerifyOptions) (BundleManifest, error) {
if err := contextErr(ctx); err != nil {
return BundleManifest{}, err
}
reader, err := zip.OpenReader(strings.TrimSpace(bundlePath))
if err != nil {
return BundleManifest{}, err
}
defer func() { _ = reader.Close() }()
maxBytes := bundleReadLimit(options)
manifest, err := readBundleManifest(&reader.Reader)
if err != nil {
return BundleManifest{}, err
}
if err := validBundleManifest(manifest); err != nil {
return BundleManifest{}, err
}
if maxBytes > 0 && manifest.Bytes > maxBytes {
return BundleManifest{}, fmt.Errorf("hot update bundle exceeds max bytes: got %d want <= %d", manifest.Bytes, maxBytes)
}
if strings.TrimSpace(options.Checksum) != "" && !strings.EqualFold(options.Checksum, manifest.Checksum) {
return BundleManifest{}, fmt.Errorf("hot update bundle checksum mismatch: got %s want %s", manifest.Checksum, options.Checksum)
}
actual, err := bundleManifestZip(ctx, &reader.Reader, manifest.Version, maxBytes)
if err != nil {
return BundleManifest{}, err
}
if actual.Checksum != manifest.Checksum || actual.Bytes != manifest.Bytes || !sameBundleFiles(actual.Files, manifest.Files) {
return BundleManifest{}, fmt.Errorf("hot update bundle manifest mismatch")
}
if err := verifyBundleSig(manifest, options); err != nil {
return BundleManifest{}, err
}
return manifest, nil
}
func ExtractBundle(ctx context.Context, bundlePath, destination, wantChecksum string) (Package, error) {
return ExtractBundleWithOptions(ctx, bundlePath, destination, BundleVerifyOptions{Checksum: wantChecksum})
}
func ExtractBundleWithOptions(ctx context.Context, bundlePath, destination string, options BundleVerifyOptions) (Package, error) {
if err := contextErr(ctx); err != nil {
return Package{}, err
}
bundlePath = strings.TrimSpace(bundlePath)
destination = strings.TrimSpace(destination)
if destination == "" {
return Package{}, fmt.Errorf("hot update bundle destination is required")
}
manifest, err := VerifyBundleWithOptions(ctx, bundlePath, options)
if err != nil {
return Package{}, err
}
reader, err := zip.OpenReader(bundlePath)
if err != nil {
return Package{}, err
}
defer func() { _ = reader.Close() }()
parent := filepath.Dir(destination)
if err := os.MkdirAll(parent, 0o750); err != nil {
return Package{}, err
}
tempDir, err := os.MkdirTemp(parent, ".longheng-hotupdate-*")
if err != nil {
return Package{}, err
}
defer func() { _ = os.RemoveAll(tempDir) }()
if err := preflightEntries(ctx, &reader.Reader, tempDir); err != nil {
return Package{}, err
}
for _, file := range reader.File {
if err := contextErr(ctx); err != nil {
return Package{}, err
}
name := filepath.ToSlash(file.Name)
if name == BundleManifestName {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
return Package{}, fmt.Errorf("hot update bundle rejects symlink %q", name)
}
target, err := safeBundleTarget(tempDir, name)
if err != nil {
return Package{}, err
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o750); err != nil {
return Package{}, err
}
continue
}
if err := extractBundleFile(file, target, bundleReadLimit(options)); err != nil {
return Package{}, err
}
}
if err := replaceDirectory(destination, tempDir); err != nil {
return Package{}, err
}
return Package{
Version: manifest.Version,
SourceURI: bundlePath,
Directory: destination,
Checksum: manifest.Checksum,
Bytes: manifest.Bytes,
SignatureAlg: manifest.SignatureAlg,
Signature: manifest.Signature,
}, nil
}
func replaceDirectory(destination, source string) error {
backup := ""
if _, err := os.Stat(destination); err == nil {
backup = destination + ".rollback-" + fmt.Sprintf("%d", time.Now().UnixNano())
if err := os.Rename(destination, backup); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(source, destination); err != nil {
if backup != "" {
_ = os.Rename(backup, destination)
}
return err
}
if backup != "" {
return os.RemoveAll(backup)
}
return nil
}
func preflightEntries(ctx context.Context, reader *zip.Reader, destination string) error {
// 预检逐个计算目标路径,拒绝绝对路径、.. 逃逸和非法文件名,防止 zip slip。
for _, file := range reader.File {
if err := contextErr(ctx); err != nil {
return err
}
name := filepath.ToSlash(file.Name)
if name == BundleManifestName {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("hot update bundle rejects symlink %q", name)
}
if _, err := safeBundleTarget(destination, name); err != nil {
return err
}
}
return nil
}
func bundleFiles(ctx context.Context, root string) ([]BundleFile, error) {
var files []BundleFile
if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if ctxErr := contextErr(ctx); ctxErr != nil {
return ctxErr
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("hot update bundle rejects symlink %q", path)
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return err
}
sum := sha256.Sum256(data)
files = append(files, BundleFile{
Path: filepath.ToSlash(rel),
Bytes: int64(len(data)),
Checksum: hex.EncodeToString(sum[:]),
})
return nil
}); err != nil {
return nil, err
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return files, nil
}
func bundleSummary(files []BundleFile) (int64, string) {
hash := sha256.New()
var bytes int64
for _, file := range files {
bytes += file.Bytes
_, _ = hash.Write([]byte(file.Path))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(file.Checksum))
_, _ = hash.Write([]byte{0})
}
return bytes, hex.EncodeToString(hash.Sum(nil))
}
func signBundleManifest(manifest BundleManifest, key []byte) string {
mac := hmac.New(sha256.New, key)
writeSigSeed(mac, manifest)
return hex.EncodeToString(mac.Sum(nil))
}
func verifyBundleSig(manifest BundleManifest, options BundleVerifyOptions) error {
// 签名只覆盖 manifest 中的文件 hash,不覆盖外部元信息;生产必须配置 RequireSignature 和 signing key。
if len(options.SigningKey) == 0 {
if options.RequireSignature {
return fmt.Errorf("hot update bundle signing key is required")
}
return nil
}
if strings.TrimSpace(manifest.Signature) == "" {
return fmt.Errorf("hot update bundle signature is required")
}
if strings.TrimSpace(manifest.SignatureAlg) == "" {
return fmt.Errorf("hot update bundle signature algorithm is required")
}
if manifest.SignatureAlg != BundleSignatureAlgorithm {
return fmt.Errorf("hot update bundle signature algorithm %q is unsupported", manifest.SignatureAlg)
}
want := signBundleManifest(manifest, options.SigningKey)
got, err := hex.DecodeString(manifest.Signature)
if err != nil {
return fmt.Errorf("hot update bundle signature is invalid: %w", err)
}
expected, _ := hex.DecodeString(want)
if !hmac.Equal(got, expected) {
return fmt.Errorf("hot update bundle signature mismatch")
}
return nil
}
func writeSigSeed(w io.Writer, manifest BundleManifest) {
files := append([]BundleFile(nil), manifest.Files...)
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
_, _ = io.WriteString(w, manifest.Version)
_, _ = w.Write([]byte{0})
_, _ = io.WriteString(w, manifest.SignatureAlg)
_, _ = w.Write([]byte{0})
_, _ = io.WriteString(w, manifest.CreatedAt.UTC().Format(time.RFC3339Nano))
_, _ = w.Write([]byte{0})
_, _ = io.WriteString(w, fmt.Sprintf("%d", manifest.Bytes))
_, _ = w.Write([]byte{0})
_, _ = io.WriteString(w, manifest.Checksum)
_, _ = w.Write([]byte{0})
for _, file := range files {
_, _ = io.WriteString(w, file.Path)
_, _ = w.Write([]byte{0})
_, _ = io.WriteString(w, fmt.Sprintf("%d", file.Bytes))
_, _ = w.Write([]byte{0})
_, _ = io.WriteString(w, file.Checksum)
_, _ = w.Write([]byte{0})
}
}
func addBundleFile(zipper *zip.Writer, root, rel string) error {
path := filepath.Join(root, filepath.FromSlash(rel))
info, err := os.Lstat(path)
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.ToSlash(rel)
header.Method = zip.Deflate
entry, err := zipper.CreateHeader(header)
if err != nil {
return err
}
in, err := os.Open(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return err
}
defer func() { _ = in.Close() }()
_, err = io.Copy(entry, in)
return err
}
func readBundleManifest(reader *zip.Reader) (BundleManifest, error) {
data, err := readBundleZipPart(reader, BundleManifestName, maxBundleManifest)
if err != nil {
return BundleManifest{}, err
}
var manifest BundleManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return BundleManifest{}, err
}
if manifest.Version == "" || manifest.Checksum == "" {
return BundleManifest{}, fmt.Errorf("hot update bundle manifest is invalid")
}
sort.Slice(manifest.Files, func(i, j int) bool { return manifest.Files[i].Path < manifest.Files[j].Path })
return manifest, nil
}
func bundleManifestZip(ctx context.Context, reader *zip.Reader, version string, maxBytes int64) (BundleManifest, error) {
files := make([]BundleFile, 0, len(reader.File))
seen := make(map[string]struct{}, len(reader.File))
var totalBytes int64
for _, file := range reader.File {
if err := contextErr(ctx); err != nil {
return BundleManifest{}, err
}
name := filepath.ToSlash(file.Name)
if name == BundleManifestName || file.FileInfo().IsDir() {
continue
}
if file.FileInfo().Mode()&os.ModeSymlink != 0 {
return BundleManifest{}, fmt.Errorf("hot update bundle rejects symlink %q", name)
}
if err := validateBundlePath(name); err != nil {
return BundleManifest{}, err
}
name = path.Clean(name)
if _, ok := seen[name]; ok {
return BundleManifest{}, fmt.Errorf("hot update bundle path %q is duplicated", name)
}
seen[name] = struct{}{}
data, err := readBundleZipPart(reader, name, maxBytes)
if err != nil {
return BundleManifest{}, err
}
if maxBytes > 0 && totalBytes > maxBytes-int64(len(data)) {
return BundleManifest{}, fmt.Errorf("hot update bundle exceeds max bytes: got > %d", maxBytes)
}
totalBytes += int64(len(data))
sum := sha256.Sum256(data)
files = append(files, BundleFile{Path: name, Bytes: int64(len(data)), Checksum: hex.EncodeToString(sum[:])})
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
bytes, checksum := bundleSummary(files)
return BundleManifest{Version: version, Files: files, Bytes: bytes, Checksum: checksum}, nil
}
func readBundleZipPart(reader *zip.Reader, name string, maxBytes int64) ([]byte, error) {
for _, file := range reader.File {
if filepath.ToSlash(file.Name) != filepath.ToSlash(name) {
continue
}
if maxBytes > 0 && file.UncompressedSize64 > uint64(maxBytes) {
return nil, fmt.Errorf("hot update bundle part %s exceeds max bytes: got %d want <= %d", name, file.UncompressedSize64, maxBytes)
}
rc, err := file.Open()
if err != nil {
return nil, err
}
defer func() { _ = rc.Close() }()
return readAllLimited(rc, maxBytes, "hot update bundle part "+name)
}
return nil, fmt.Errorf("hot update bundle part %s not found", name)
}
func readAllLimited(r io.Reader, maxBytes int64, label string) ([]byte, error) {
if maxBytes <= 0 {
maxBytes = defMaxBundleBytes
}
data, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("%s exceeds max bytes: got > %d", label, maxBytes)
}
return data, nil
}
func bundleReadLimit(options BundleVerifyOptions) int64 {
if options.MaxBytes > 0 {
return options.MaxBytes
}
return defMaxBundleBytes
}
func safeBundleTarget(root, name string) (string, error) {
// 所有 bundle 文件最终路径必须仍在 root 下,防止通过清理后的相对路径写出工作区。
if err := validateBundlePath(name); err != nil {
return "", err
}
name = path.Clean(filepath.ToSlash(name))
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
target, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(name)))
if err != nil {
return "", err
}
rel, err := filepath.Rel(absRoot, target)
if err != nil {
return "", err
}
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return "", fmt.Errorf("hot update bundle target %q escapes %q", target, absRoot)
}
return target, nil
}
func validBundleManifest(manifest BundleManifest) error {
if strings.TrimSpace(manifest.Version) == "" || strings.TrimSpace(manifest.Checksum) == "" {
return fmt.Errorf("hot update bundle manifest is invalid")
}
seen := make(map[string]struct{}, len(manifest.Files))
for idx, file := range manifest.Files {
if err := validateBundlePath(file.Path); err != nil {
return fmt.Errorf("hot update bundle manifest file %d: %w", idx, err)
}
clean := path.Clean(filepath.ToSlash(file.Path))
if _, ok := seen[clean]; ok {
return fmt.Errorf("hot update bundle manifest file %q is duplicated", clean)
}
seen[clean] = struct{}{}
if file.Bytes < 0 {
return fmt.Errorf("hot update bundle manifest file %q has negative bytes", clean)
}
if len(file.Checksum) != sha256.Size*2 {
return fmt.Errorf("hot update bundle manifest file %q has invalid checksum", clean)
}
if _, err := hex.DecodeString(file.Checksum); err != nil {
return fmt.Errorf("hot update bundle manifest file %q has invalid checksum: %w", clean, err)
}
}
return nil
}
func validateBundlePath(name string) error {
raw := strings.TrimSpace(filepath.ToSlash(name))
if raw == "" || raw == "." || strings.HasPrefix(raw, "/") || filepath.IsAbs(name) || filepath.VolumeName(filepath.FromSlash(raw)) != "" {
return fmt.Errorf("hot update bundle path %q is invalid", name)
}
clean := path.Clean(raw)
trimmedRaw := strings.TrimSuffix(raw, "/")
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != trimmedRaw {
return fmt.Errorf("hot update bundle path %q is invalid", name)
}
for _, part := range strings.Split(clean, "/") {
if part == "" || part == "." || part == ".." {
return fmt.Errorf("hot update bundle path %q is invalid", name)
}
}
return nil
}
func sameBundleFiles(left, right []BundleFile) bool {
if len(left) != len(right) {
return false
}
left = append([]BundleFile(nil), left...)
right = append([]BundleFile(nil), right...)
sort.Slice(left, func(i, j int) bool { return left[i].Path < left[j].Path })
sort.Slice(right, func(i, j int) bool { return right[i].Path < right[j].Path })
for idx := range left {
leftPath := path.Clean(filepath.ToSlash(left[idx].Path))
rightPath := path.Clean(filepath.ToSlash(right[idx].Path))
if leftPath != rightPath || left[idx].Bytes != right[idx].Bytes || !strings.EqualFold(left[idx].Checksum, right[idx].Checksum) {
return false
}
}
return true
}
func extractBundleFile(file *zip.File, target string, maxBytes int64) error {
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return err
}
if maxBytes > 0 && file.UncompressedSize64 > uint64(maxBytes) {
return fmt.Errorf("hot update bundle file %s exceeds max bytes: got %d want <= %d", filepath.ToSlash(file.Name), file.UncompressedSize64, maxBytes)
}
rc, err := file.Open()
if err != nil {
return err
}
defer func() { _ = rc.Close() }()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.FileInfo().Mode()) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
if err != nil {
return err
}
if maxBytes <= 0 {
maxBytes = defMaxBundleBytes
}
written, err := io.Copy(out, io.LimitReader(rc, maxBytes+1))
if err != nil {
_ = out.Close()
return err
}
if written > maxBytes {
_ = out.Close()
return fmt.Errorf("hot update bundle file %s exceeds max bytes: got > %d", filepath.ToSlash(file.Name), maxBytes)
}
return out.Close()
}