feat: add manual server binary upload and confirmation upgrade process

- Implemented UploadManualServerBinary endpoint for uploading server binaries and checking their versions.
- Added ConfirmManualServerUpgrade endpoint to confirm the upgrade with the uploaded binary.
- Updated API routes to include manual upload and upgrade confirmation.
- Enhanced service layer to handle manual binary uploads, version detection, and upgrade execution.
- Introduced new types for handling uploaded binary information.
- Updated frontend components to support manual binary upload and confirmation, including UI feedback for users.
- Modified documentation to reflect new manual upload and upgrade features.
This commit is contained in:
ryan
2026-03-12 10:34:31 +08:00
parent 29a0c64ba3
commit 93e43fb3b0
12 changed files with 1004 additions and 28 deletions
+284 -2
View File
@@ -3,12 +3,15 @@ package service
import (
"atsflare/common"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
@@ -28,6 +31,15 @@ var serverUpgradeState struct {
inProgress bool
}
var manualServerBinaryState struct {
sync.Mutex
candidate *manualServerBinaryCandidate
}
var serverBinaryUpgradeExecutor = replaceAndRestartServer
var serverUpgradeDispatchDelay = 500 * time.Millisecond
type LatestServerRelease struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
@@ -58,6 +70,28 @@ type preparedServerUpgrade struct {
execPath string
}
type UploadedServerBinary struct {
UploadToken string `json:"upload_token"`
FileName string `json:"file_name"`
DetectedVersion string `json:"detected_version"`
CurrentVersion string `json:"current_version"`
HasUpdate bool `json:"has_update"`
UpgradeSupported bool `json:"upgrade_supported"`
ReadyToUpgrade bool `json:"ready_to_upgrade"`
ComparisonMessage string `json:"comparison_message"`
UploadedAt time.Time `json:"uploaded_at"`
}
type manualServerBinaryCandidate struct {
UploadToken string
FileName string
DetectedVersion string
CurrentVersion string
TempPath string
ExecPath string
UploadedAt time.Time
}
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
release, err := fetchLatestRelease(ctx)
if err != nil {
@@ -85,7 +119,7 @@ func ScheduleServerUpgrade() (*LatestServerRelease, error) {
prepared.release.InProgress = true
go func(task *preparedServerUpgrade) {
time.Sleep(500 * time.Millisecond)
time.Sleep(serverUpgradeDispatchDelay)
if err := executeServerUpgrade(task); err != nil {
log.Printf("server self-update failed: %v", err)
serverUpgradeState.Lock()
@@ -97,6 +131,123 @@ func ScheduleServerUpgrade() (*LatestServerRelease, error) {
return prepared.release, nil
}
func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Reader) (*UploadedServerBinary, error) {
serverUpgradeState.Lock()
inProgress := serverUpgradeState.inProgress
serverUpgradeState.Unlock()
if inProgress {
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
}
if strings.TrimSpace(fileName) == "" {
return nil, fmt.Errorf("缺少上传文件名")
}
if reader == nil {
return nil, fmt.Errorf("缺少上传文件内容")
}
tempPath, err := persistUploadedServerBinary(fileName, reader)
if err != nil {
return nil, err
}
detectedVersion, err := detectUploadedServerBinaryVersion(ctx, tempPath)
if err != nil {
_ = os.Remove(tempPath)
return nil, err
}
currentVersion := strings.TrimSpace(common.Version)
uploadedAt := time.Now()
info := buildUploadedServerBinaryView(fileName, currentVersion, detectedVersion, uploadedAt)
if !info.ReadyToUpgrade {
_ = os.Remove(tempPath)
return info, nil
}
execPath, err := os.Executable()
if err != nil {
_ = os.Remove(tempPath)
return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err)
}
if err = verifyExecutableDirectoryWritable(execPath); err != nil {
_ = os.Remove(tempPath)
return nil, err
}
uploadToken, err := newUpgradeToken()
if err != nil {
_ = os.Remove(tempPath)
return nil, fmt.Errorf("生成升级令牌失败: %v", err)
}
manualServerBinaryState.Lock()
cleanupManualServerBinaryCandidateLocked()
manualServerBinaryState.candidate = &manualServerBinaryCandidate{
UploadToken: uploadToken,
FileName: fileName,
DetectedVersion: detectedVersion,
CurrentVersion: currentVersion,
TempPath: tempPath,
ExecPath: execPath,
UploadedAt: uploadedAt,
}
manualServerBinaryState.Unlock()
info.UploadToken = uploadToken
return info, nil
}
func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, error) {
uploadToken = strings.TrimSpace(uploadToken)
if uploadToken == "" {
return nil, fmt.Errorf("缺少升级令牌")
}
serverUpgradeState.Lock()
if serverUpgradeState.inProgress {
serverUpgradeState.Unlock()
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
}
serverUpgradeState.Unlock()
manualServerBinaryState.Lock()
candidate := manualServerBinaryState.candidate
if candidate == nil {
manualServerBinaryState.Unlock()
return nil, fmt.Errorf("未找到待确认的上传升级包,请重新上传")
}
if candidate.UploadToken != uploadToken {
manualServerBinaryState.Unlock()
return nil, fmt.Errorf("升级令牌无效或已过期,请重新上传")
}
manualServerBinaryState.candidate = nil
manualServerBinaryState.Unlock()
info := buildUploadedServerBinaryView(candidate.FileName, candidate.CurrentVersion, candidate.DetectedVersion, candidate.UploadedAt)
info.UploadToken = candidate.UploadToken
if !info.ReadyToUpgrade {
_ = os.Remove(candidate.TempPath)
return nil, fmt.Errorf("当前上传的二进制不满足升级条件")
}
serverUpgradeState.Lock()
serverUpgradeState.inProgress = true
serverUpgradeState.Unlock()
go func(task *manualServerBinaryCandidate) {
time.Sleep(serverUpgradeDispatchDelay)
if err := executeManualServerUpgrade(task); err != nil {
log.Printf("server manual upgrade failed: %v", err)
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
_ = os.Remove(task.TempPath)
}
}(candidate)
return info, nil
}
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
if err != nil {
@@ -244,7 +395,12 @@ func executeServerUpgrade(task *preparedServerUpgrade) error {
}
common.SysLog("server self-update starting: from=" + strings.TrimSpace(common.Version) + " to=" + task.release.TagName)
return replaceAndRestartServer(task.execPath, tmpPath)
return serverBinaryUpgradeExecutor(task.execPath, tmpPath)
}
func executeManualServerUpgrade(task *manualServerBinaryCandidate) error {
common.SysLog("server manual self-update starting: from=" + strings.TrimSpace(task.CurrentVersion) + " to=" + strings.TrimSpace(task.DetectedVersion))
return serverBinaryUpgradeExecutor(task.ExecPath, task.TempPath)
}
func serverAssetName(goos string, goarch string) string {
@@ -318,6 +474,109 @@ func parseVersionParts(version string) []int {
return parts
}
func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary {
upgradeSupported := isManualServerUpgradeSupported(currentVersion)
hasUpdate := false
comparisonMessage := ""
switch {
case !upgradeSupported:
comparisonMessage = "当前服务版本不支持手动升级确认流程"
case normalizeVersion(currentVersion) == normalizeVersion(detectedVersion):
comparisonMessage = "上传二进制与当前服务版本一致,无需升级"
case isVersionNewer(currentVersion, detectedVersion):
hasUpdate = true
comparisonMessage = fmt.Sprintf("检测到可升级版本:%s -> %s", strings.TrimSpace(currentVersion), strings.TrimSpace(detectedVersion))
default:
comparisonMessage = "上传二进制版本不高于当前服务版本,已拒绝升级"
}
return &UploadedServerBinary{
FileName: strings.TrimSpace(fileName),
DetectedVersion: strings.TrimSpace(detectedVersion),
CurrentVersion: strings.TrimSpace(currentVersion),
HasUpdate: hasUpdate,
UpgradeSupported: upgradeSupported,
ReadyToUpgrade: upgradeSupported && hasUpdate,
ComparisonMessage: comparisonMessage,
UploadedAt: uploadedAt,
}
}
func isManualServerUpgradeSupported(currentVersion string) bool {
normalized := strings.TrimSpace(strings.TrimPrefix(currentVersion, "v"))
return normalized != "" && !strings.EqualFold(normalized, "dev")
}
func persistUploadedServerBinary(fileName string, reader io.Reader) (string, error) {
suffix := filepath.Ext(strings.TrimSpace(fileName))
if runtime.GOOS == "windows" && suffix == "" {
suffix = ".exe"
}
tempFile, err := os.CreateTemp("", "atsflare-server-manual-upgrade-*"+suffix)
if err != nil {
return "", fmt.Errorf("创建临时升级文件失败: %v", err)
}
tempPath := tempFile.Name()
if _, err = io.Copy(tempFile, reader); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return "", fmt.Errorf("写入上传二进制失败: %v", err)
}
if err = tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", fmt.Errorf("关闭临时升级文件失败: %v", err)
}
if err = os.Chmod(tempPath, 0o755); err != nil && runtime.GOOS != "windows" {
_ = os.Remove(tempPath)
return "", fmt.Errorf("设置临时升级文件权限失败: %v", err)
}
return tempPath, nil
}
func detectUploadedServerBinaryVersion(ctx context.Context, filePath string) (string, error) {
commandCtx := ctx
if commandCtx == nil {
commandCtx = context.Background()
}
cmd := exec.CommandContext(commandCtx, filePath, "--version")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("检查上传二进制版本失败: %w: %s", err, strings.TrimSpace(string(output)))
}
version := strings.TrimSpace(string(output))
if version == "" {
return "", fmt.Errorf("上传二进制未返回有效版本号")
}
for _, line := range strings.Split(version, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
return trimmed, nil
}
}
return "", fmt.Errorf("上传二进制未返回有效版本号")
}
func cleanupManualServerBinaryCandidateLocked() {
if manualServerBinaryState.candidate == nil {
return
}
_ = os.Remove(manualServerBinaryState.candidate.TempPath)
manualServerBinaryState.candidate = nil
}
func newUpgradeToken() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
func normalizeVersion(version string) string {
return strings.TrimSpace(strings.TrimPrefix(version, "v"))
}
func UpdateHTTPClientForTest() *http.Client {
return updateHTTPClient
}
@@ -325,3 +584,26 @@ func UpdateHTTPClientForTest() *http.Client {
func SetUpdateHTTPClientForTest(client *http.Client) {
updateHTTPClient = client
}
func ServerBinaryUpgradeExecutorForTest() func(string, string) error {
return serverBinaryUpgradeExecutor
}
func SetServerBinaryUpgradeExecutorForTest(executor func(string, string) error) {
if executor == nil {
serverBinaryUpgradeExecutor = replaceAndRestartServer
return
}
serverBinaryUpgradeExecutor = executor
}
func ServerUpgradeDispatchDelayForTest() time.Duration {
return serverUpgradeDispatchDelay
}
func SetServerUpgradeDispatchDelayForTest(delay time.Duration) {
if delay < 0 {
delay = 0
}
serverUpgradeDispatchDelay = delay
}