From 93e43fb3b061e15c12a4c98ed8a750817a1cdde0 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 12 Mar 2026 10:34:31 +0800 Subject: [PATCH] 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. --- README.md | 4 +- atsf_server/controller/update.go | 88 ++++++ atsf_server/router/api-router.go | 2 + atsf_server/router/update_test.go | 194 ++++++++++++ atsf_server/service/update.go | 286 +++++++++++++++++- atsf_server/service/update_test.go | 128 ++++++++ .../components/layout/dashboard-topbar.tsx | 89 +++++- atsf_server/web/features/update/api/update.ts | 22 +- .../components/version-upgrade-modal.tsx | 204 +++++++++++-- atsf_server/web/features/update/types.ts | 12 + docs/deployment.md | 1 + docs/design.md | 2 +- 12 files changed, 1004 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index aed2013d..96e12e4f 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部 * 反向代理规则管理:一个域名对应一个源站地址,统一维护、统一发布 * 配置版本化:支持预览、发布、激活、历史回滚,版本不可变 * 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token` -* Agent 自动应用:周期性同步、落盘、`nginx -t`、`nginx -s reload`、失败自动回滚 +* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚 * TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配 -* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server 自升级 +* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server GitHub 自升级、Server 手动上传二进制确认升级 * 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端 ## 界面预览 diff --git a/atsf_server/controller/update.go b/atsf_server/controller/update.go index 98cc26b3..56eb4a29 100644 --- a/atsf_server/controller/update.go +++ b/atsf_server/controller/update.go @@ -3,10 +3,15 @@ package controller import ( "atsflare/service" "net/http" + "strings" "github.com/gin-gonic/gin" ) +type confirmManualUpgradeRequest struct { + UploadToken string `json:"upload_token"` +} + // GetLatestRelease godoc // @Summary Get latest GitHub release // @Tags Update @@ -52,3 +57,86 @@ func UpgradeServer(c *gin.Context) { "data": release, }) } + +// UploadManualServerBinary godoc +// @Summary Upload server binary and inspect version before upgrade +// @Tags Update +// @Accept mpfd +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api/update/manual-upload [post] +func UploadManualServerBinary(c *gin.Context) { + fileHeader, err := c.FormFile("binary") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请先选择要上传的服务端二进制文件。", + }) + return + } + + file, err := fileHeader.Open() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "读取上传文件失败。", + }) + return + } + defer func() { + _ = file.Close() + }() + + info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + message := strings.TrimSpace(info.ComparisonMessage) + if message == "" { + message = "已完成上传并检查升级包版本。" + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": message, + "data": info, + }) +} + +// ConfirmManualServerUpgrade godoc +// @Summary Confirm upgrade with previously uploaded server binary +// @Tags Update +// @Accept json +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api/update/manual-upgrade [post] +func ConfirmManualServerUpgrade(c *gin.Context) { + var request confirmManualUpgradeRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "升级确认参数无效。", + }) + return + } + + info, err := service.ConfirmManualServerUpgrade(request.UploadToken) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "服务升级任务已启动,确认无误后将自动重启。", + "data": info, + }) +} diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 4310143e..897b5634 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -59,6 +59,8 @@ func SetApiRouter(router *gin.Engine) { updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) { updateRoute.GET("/latest-release", controller.GetLatestRelease) + updateRoute.POST("/manual-upload", controller.UploadManualServerBinary) + updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade) updateRoute.POST("/upgrade", controller.UpgradeServer) } fileRoute := apiRouter.Group("/file") diff --git a/atsf_server/router/update_test.go b/atsf_server/router/update_test.go index 4fa10bf9..c2f62c19 100644 --- a/atsf_server/router/update_test.go +++ b/atsf_server/router/update_test.go @@ -7,10 +7,13 @@ import ( "bytes" "encoding/json" "io" + "mime/multipart" "net/http" "net/http/httptest" + "runtime" "strings" "testing" + "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -105,3 +108,194 @@ func TestLatestReleaseProxy(t *testing.T) { t.Fatalf("unexpected current_version: %#v", data["current_version"]) } } + +func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) { + t.Helper() + gin.SetMode(gin.TestMode) + common.RedisEnabled = false + setupTestDB(t) + + engine := gin.New() + engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) + router.SetApiRouter(engine) + + loginBody, err := json.Marshal(map[string]string{ + "username": "root", + "password": "123456", + }) + if err != nil { + t.Fatalf("failed to marshal login body: %v", err) + } + loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginRecorder := httptest.NewRecorder() + engine.ServeHTTP(loginRecorder, loginReq) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("unexpected login status code: %d", loginRecorder.Code) + } + + return engine, loginRecorder.Result().Cookies() +} + +func fakeManualServerBinary(version string) (string, []byte) { + if runtime.GOOS == "windows" { + return "atsflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n") + } + return "atsflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n") +} + +func TestManualUploadRoute(t *testing.T) { + originalVersion := common.Version + common.Version = "v0.4.0" + t.Cleanup(func() { + common.Version = originalVersion + service.SetServerBinaryUpgradeExecutorForTest(nil) + service.SetServerUpgradeDispatchDelayForTest(500 * time.Millisecond) + }) + + engine, cookies := loginRootAndBuildEngine(t) + fileName, content := fakeManualServerBinary("v0.5.0") + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("binary", fileName) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err = part.Write(content); err != nil { + t.Fatalf("failed to write upload content: %v", err) + } + if err = writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + for _, cookieValue := range cookies { + req.AddCookie(cookieValue) + } + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status code: %d", recorder.Code) + } + + var resp apiResponse + if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if !resp.Success { + t.Fatalf("expected success response, got message: %s", resp.Message) + } + + var data map[string]any + if err = json.Unmarshal(resp.Data, &data); err != nil { + t.Fatalf("failed to decode response data: %v", err) + } + if data["detected_version"] != "v0.5.0" { + t.Fatalf("unexpected detected_version: %#v", data["detected_version"]) + } + if data["ready_to_upgrade"] != true { + t.Fatalf("expected ready_to_upgrade to be true: %#v", data["ready_to_upgrade"]) + } + if data["upload_token"] == "" { + t.Fatal("expected upload_token to be returned") + } +} + +func TestManualUpgradeConfirmRoute(t *testing.T) { + originalVersion := common.Version + originalExecutor := service.ServerBinaryUpgradeExecutorForTest() + originalDelay := service.ServerUpgradeDispatchDelayForTest() + common.Version = "v0.4.0" + called := make(chan string, 1) + service.SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error { + called <- tempPath + return nil + }) + service.SetServerUpgradeDispatchDelayForTest(0) + t.Cleanup(func() { + common.Version = originalVersion + service.SetServerBinaryUpgradeExecutorForTest(originalExecutor) + service.SetServerUpgradeDispatchDelayForTest(originalDelay) + }) + + engine, cookies := loginRootAndBuildEngine(t) + fileName, content := fakeManualServerBinary("v0.5.0") + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("binary", fileName) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err = part.Write(content); err != nil { + t.Fatalf("failed to write upload content: %v", err) + } + if err = writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + uploadReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body) + uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) + for _, cookieValue := range cookies { + uploadReq.AddCookie(cookieValue) + } + + uploadRecorder := httptest.NewRecorder() + engine.ServeHTTP(uploadRecorder, uploadReq) + if uploadRecorder.Code != http.StatusOK { + t.Fatalf("unexpected upload status code: %d", uploadRecorder.Code) + } + + var uploadResp apiResponse + if err = json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil { + t.Fatalf("failed to decode upload response: %v", err) + } + if !uploadResp.Success { + t.Fatalf("expected upload success, got message: %s", uploadResp.Message) + } + + var uploadData map[string]any + if err = json.Unmarshal(uploadResp.Data, &uploadData); err != nil { + t.Fatalf("failed to decode upload response data: %v", err) + } + uploadToken, _ := uploadData["upload_token"].(string) + if uploadToken == "" { + t.Fatal("expected upload token in upload response") + } + + confirmBody, err := json.Marshal(map[string]string{"upload_token": uploadToken}) + if err != nil { + t.Fatalf("failed to marshal confirm body: %v", err) + } + confirmReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upgrade", bytes.NewReader(confirmBody)) + confirmReq.Header.Set("Content-Type", "application/json") + for _, cookieValue := range cookies { + confirmReq.AddCookie(cookieValue) + } + + confirmRecorder := httptest.NewRecorder() + engine.ServeHTTP(confirmRecorder, confirmReq) + if confirmRecorder.Code != http.StatusOK { + t.Fatalf("unexpected confirm status code: %d", confirmRecorder.Code) + } + + var confirmResp apiResponse + if err = json.Unmarshal(confirmRecorder.Body.Bytes(), &confirmResp); err != nil { + t.Fatalf("failed to decode confirm response: %v", err) + } + if !confirmResp.Success { + t.Fatalf("expected confirm success, got message: %s", confirmResp.Message) + } + + select { + case tempPath := <-called: + if tempPath == "" { + t.Fatal("expected manual upgrade executor to receive temp path") + } + case <-time.After(time.Second): + t.Fatal("expected manual upgrade executor to be called") + } +} diff --git a/atsf_server/service/update.go b/atsf_server/service/update.go index e524f7ab..f918efe7 100644 --- a/atsf_server/service/update.go +++ b/atsf_server/service/update.go @@ -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 +} diff --git a/atsf_server/service/update_test.go b/atsf_server/service/update_test.go index 9f625e61..833a7bd8 100644 --- a/atsf_server/service/update_test.go +++ b/atsf_server/service/update_test.go @@ -2,9 +2,31 @@ package service import ( "atsflare/common" + "bytes" + "context" + "os" + "runtime" "testing" + "time" ) +func resetServerUpgradeTestState(t *testing.T) { + t.Helper() + serverUpgradeState.Lock() + serverUpgradeState.inProgress = false + serverUpgradeState.Unlock() + manualServerBinaryState.Lock() + cleanupManualServerBinaryCandidateLocked() + manualServerBinaryState.Unlock() +} + +func fakeServerBinaryFixture(version string) (string, []byte) { + if runtime.GOOS == "windows" { + return "atsflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n") + } + return "atsflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n") +} + func TestIsVersionNewer(t *testing.T) { testCases := []struct { name string @@ -85,3 +107,109 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) { t.Fatal("expected dev build not to support self-upgrade") } } + +func TestUploadManualServerBinary(t *testing.T) { + originalVersion := common.Version + common.Version = "v0.4.0" + t.Cleanup(func() { + common.Version = originalVersion + resetServerUpgradeTestState(t) + }) + + fileName, content := fakeServerBinaryFixture("v0.5.0") + info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content)) + if err != nil { + t.Fatalf("expected upload to succeed: %v", err) + } + if !info.ReadyToUpgrade { + t.Fatal("expected uploaded binary to be ready for upgrade") + } + if info.UploadToken == "" { + t.Fatal("expected upload token to be returned") + } + if info.DetectedVersion != "v0.5.0" { + t.Fatalf("unexpected detected version: %s", info.DetectedVersion) + } + + manualServerBinaryState.Lock() + candidate := manualServerBinaryState.candidate + manualServerBinaryState.Unlock() + if candidate == nil { + t.Fatal("expected manual upgrade candidate to be stored") + } + if _, err := os.Stat(candidate.TempPath); err != nil { + t.Fatalf("expected temporary binary to exist: %v", err) + } + if candidate.UploadToken != info.UploadToken { + t.Fatalf("unexpected stored upload token: %s", candidate.UploadToken) + } +} + +func TestUploadManualServerBinaryRejectsSameVersion(t *testing.T) { + originalVersion := common.Version + common.Version = "v0.5.0" + t.Cleanup(func() { + common.Version = originalVersion + resetServerUpgradeTestState(t) + }) + + fileName, content := fakeServerBinaryFixture("v0.5.0") + info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content)) + if err != nil { + t.Fatalf("expected upload to succeed: %v", err) + } + if info.ReadyToUpgrade { + t.Fatal("expected same-version upload not to be upgradeable") + } + if info.UploadToken != "" { + t.Fatal("expected same-version upload not to issue a token") + } + + manualServerBinaryState.Lock() + defer manualServerBinaryState.Unlock() + if manualServerBinaryState.candidate != nil { + t.Fatal("expected no pending manual upgrade candidate") + } +} + +func TestConfirmManualServerUpgrade(t *testing.T) { + originalVersion := common.Version + originalExecutor := ServerBinaryUpgradeExecutorForTest() + originalDelay := ServerUpgradeDispatchDelayForTest() + common.Version = "v0.4.0" + called := make(chan string, 1) + SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error { + called <- tempPath + return nil + }) + SetServerUpgradeDispatchDelayForTest(0) + t.Cleanup(func() { + common.Version = originalVersion + SetServerBinaryUpgradeExecutorForTest(originalExecutor) + SetServerUpgradeDispatchDelayForTest(originalDelay) + resetServerUpgradeTestState(t) + }) + + fileName, content := fakeServerBinaryFixture("v0.5.0") + info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content)) + if err != nil { + t.Fatalf("expected upload to succeed: %v", err) + } + + confirmed, err := ConfirmManualServerUpgrade(info.UploadToken) + if err != nil { + t.Fatalf("expected confirm to succeed: %v", err) + } + if confirmed.UploadToken != info.UploadToken { + t.Fatalf("unexpected confirmed upload token: %s", confirmed.UploadToken) + } + + select { + case tempPath := <-called: + if tempPath == "" { + t.Fatal("expected upgrade executor to receive temp path") + } + case <-time.After(time.Second): + t.Fatal("expected manual upgrade executor to be called") + } +} diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx index afb306ff..835bd62b 100644 --- a/atsf_server/web/components/layout/dashboard-topbar.tsx +++ b/atsf_server/web/components/layout/dashboard-topbar.tsx @@ -7,8 +7,14 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/components/providers/auth-provider'; import { ThemeToggle } from '@/components/ui/theme-toggle'; import { getPublicStatus } from '@/features/auth/api/public'; -import { getLatestRelease, upgradeServer } from '@/features/update/api/update'; +import { + confirmManualServerUpgrade, + getLatestRelease, + upgradeServer, + uploadServerBinary, +} from '@/features/update/api/update'; import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal'; +import type { UploadedServerBinaryInfo } from '@/features/update/types'; import { publicEnv } from '@/lib/env/public-env'; import { useAppShellStore } from '@/store/app-shell'; @@ -26,6 +32,14 @@ export function DashboardTopbar() { const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); const [versionFeedback, setVersionFeedback] = useState(null); + const [manualUpgradeStatus, setManualUpgradeStatus] = useState( + null, + ); + const [manualUpgradeError, setManualUpgradeError] = useState( + null, + ); + const [uploadedBinary, setUploadedBinary] = + useState(null); const menuRef = useRef(null); const isRoot = (user?.role ?? 0) >= 100; @@ -44,6 +58,9 @@ export function DashboardTopbar() { const upgradeMutation = useMutation({ mutationFn: upgradeServer, onSuccess: (release) => { + setUploadedBinary(null); + setManualUpgradeStatus(null); + setManualUpgradeError(null); setVersionFeedback( `服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`, ); @@ -56,6 +73,44 @@ export function DashboardTopbar() { }, }); + const uploadBinaryMutation = useMutation({ + mutationFn: uploadServerBinary, + onSuccess: (candidate) => { + setVersionFeedback(null); + setManualUpgradeError(null); + setUploadedBinary(candidate); + setManualUpgradeStatus(candidate.comparison_message); + }, + onError: (error) => { + setUploadedBinary(null); + setManualUpgradeStatus(null); + setManualUpgradeError( + error instanceof Error ? error.message : '上传升级包失败,请稍后重试。', + ); + }, + }); + + const confirmManualUpgradeMutation = useMutation({ + mutationFn: confirmManualServerUpgrade, + onSuccess: (candidate) => { + setVersionFeedback(null); + setManualUpgradeError(null); + setUploadedBinary(candidate); + setManualUpgradeStatus( + `手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`, + ); + void latestReleaseQuery.refetch(); + }, + onError: (error) => { + setManualUpgradeStatus(null); + setManualUpgradeError( + error instanceof Error + ? error.message + : '确认手动升级失败,请稍后重试。', + ); + }, + }); + useEffect(() => { if (!isUserMenuOpen) { return; @@ -100,6 +155,8 @@ export function DashboardTopbar() { const handleOpenVersionModal = () => { setVersionFeedback(null); + setManualUpgradeStatus(null); + setManualUpgradeError(null); setIsVersionModalOpen(true); if (isRoot) { void latestReleaseQuery.refetch(); @@ -108,9 +165,29 @@ export function DashboardTopbar() { const handleUpgrade = () => { setVersionFeedback(null); + setManualUpgradeStatus(null); + setManualUpgradeError(null); upgradeMutation.mutate(); }; + const handleUploadBinary = (binary: File) => { + setManualUpgradeStatus(null); + setManualUpgradeError(null); + uploadBinaryMutation.mutate(binary); + }; + + const handleConfirmManualUpgrade = () => { + if (!uploadedBinary?.upload_token) { + setManualUpgradeStatus(null); + setManualUpgradeError('请先上传并检查升级包。'); + return; + } + setVersionFeedback(null); + setManualUpgradeStatus(null); + setManualUpgradeError(null); + confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token); + }; + const release = latestReleaseQuery.data; const hasUpdate = Boolean(isRoot && release?.has_update); const currentVersion = publicStatusQuery.data?.version || 'unknown'; @@ -127,6 +204,7 @@ export function DashboardTopbar() { ? latestReleaseQuery.error.message : '版本检查失败,请稍后重试。' : undefined); + const manualUpgradeErrorMessage = manualUpgradeError ?? undefined; return ( <> @@ -206,11 +284,16 @@ export function DashboardTopbar() { frontendVersion={publicEnv.appVersion} startTime={publicStatusQuery.data?.start_time} release={release} + uploadedBinary={uploadedBinary} isLoading={latestReleaseQuery.isLoading && !release && isRoot} - errorMessage={versionErrorMessage} + releaseErrorMessage={versionErrorMessage} + manualStatusMessage={manualUpgradeStatus ?? undefined} + manualErrorMessage={manualUpgradeErrorMessage} canUpgrade={isRoot} isChecking={latestReleaseQuery.isFetching} isUpgrading={upgradeMutation.isPending} + isUploadingBinary={uploadBinaryMutation.isPending} + isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending} onRefresh={() => { setVersionFeedback(null); if (isRoot) { @@ -218,6 +301,8 @@ export function DashboardTopbar() { } }} onUpgrade={handleUpgrade} + onUploadBinary={handleUploadBinary} + onConfirmManualUpgrade={handleConfirmManualUpgrade} /> ); diff --git a/atsf_server/web/features/update/api/update.ts b/atsf_server/web/features/update/api/update.ts index ecc9128e..d454ccae 100644 --- a/atsf_server/web/features/update/api/update.ts +++ b/atsf_server/web/features/update/api/update.ts @@ -1,6 +1,9 @@ import { apiRequest } from '@/lib/api/client'; -import type { LatestReleaseInfo } from '@/features/update/types'; +import type { + LatestReleaseInfo, + UploadedServerBinaryInfo, +} from '@/features/update/types'; export function getLatestRelease() { return apiRequest('/update/latest-release'); @@ -11,3 +14,20 @@ export function upgradeServer() { method: 'POST', }); } + +export function uploadServerBinary(binary: File) { + const formData = new FormData(); + formData.append('binary', binary); + + return apiRequest('/update/manual-upload', { + method: 'POST', + body: formData, + }); +} + +export function confirmManualServerUpgrade(uploadToken: string) { + return apiRequest('/update/manual-upgrade', { + method: 'POST', + body: JSON.stringify({ upload_token: uploadToken }), + }); +} diff --git a/atsf_server/web/features/update/components/version-upgrade-modal.tsx b/atsf_server/web/features/update/components/version-upgrade-modal.tsx index 69bc980e..b29ade97 100644 --- a/atsf_server/web/features/update/components/version-upgrade-modal.tsx +++ b/atsf_server/web/features/update/components/version-upgrade-modal.tsx @@ -1,6 +1,7 @@ 'use client'; import { marked } from 'marked'; +import { useEffect, useState } from 'react'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; @@ -8,9 +9,14 @@ import { LoadingState } from '@/components/feedback/loading-state'; import { AppCard } from '@/components/ui/app-card'; import { AppModal } from '@/components/ui/app-modal'; import { StatusBadge } from '@/components/ui/status-badge'; -import type { LatestReleaseInfo } from '@/features/update/types'; +import type { + LatestReleaseInfo, + UploadedServerBinaryInfo, +} from '@/features/update/types'; import { PrimaryButton, + ResourceField, + ResourceInput, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; @@ -22,13 +28,20 @@ interface VersionUpgradeModalProps { frontendVersion: string; startTime?: number; release: LatestReleaseInfo | null | undefined; + uploadedBinary: UploadedServerBinaryInfo | null; isLoading: boolean; - errorMessage?: string; + releaseErrorMessage?: string; + manualStatusMessage?: string; + manualErrorMessage?: string; canUpgrade: boolean; isChecking: boolean; isUpgrading: boolean; + isUploadingBinary: boolean; + isConfirmingManualUpgrade: boolean; onRefresh: () => void; onUpgrade: () => void; + onUploadBinary: (file: File) => void; + onConfirmManualUpgrade: () => void; } function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) { @@ -51,33 +64,75 @@ export function VersionUpgradeModal({ frontendVersion, startTime, release, + uploadedBinary, isLoading, - errorMessage, + releaseErrorMessage, + manualStatusMessage, + manualErrorMessage, canUpgrade, isChecking, isUpgrading, + isUploadingBinary, + isConfirmingManualUpgrade, onRefresh, onUpgrade, + onUploadBinary, + onConfirmManualUpgrade, }: VersionUpgradeModalProps) { const upgradeBadge = getUpgradeBadge(release); + const [selectedBinary, setSelectedBinary] = useState(null); + + useEffect(() => { + if (!isOpen) { + setSelectedBinary(null); + } + }, [isOpen]); return ( - - {isChecking ? '检查中...' : '检查更新'} - - {canUpgrade ? ( + canUpgrade ? ( +
+ + {isChecking ? '检查中...' : '检查更新'} + + { + if (selectedBinary) { + onUploadBinary(selectedBinary); + } + }} + disabled={ + !selectedBinary || + isUploadingBinary || + isConfirmingManualUpgrade + } + > + {isUploadingBinary ? '上传检查中...' : '上传并检查'} + + + {isConfirmingManualUpgrade ? '升级中...' : '确认手动升级'} + {isUpgrading @@ -94,8 +151,8 @@ export function VersionUpgradeModal({ ? '升级中...' : '立即升级'} - ) : null} -
+ + ) : undefined } >
@@ -129,16 +186,16 @@ export function VersionUpgradeModal({
{isLoading ? : null} - {!isLoading && errorMessage ? ( - + {!isLoading && releaseErrorMessage ? ( + ) : null} - {!isLoading && !errorMessage && !release ? ( + {!isLoading && !releaseErrorMessage && !release ? ( ) : null} - {!isLoading && !errorMessage && release ? ( + {!isLoading && !releaseErrorMessage && release ? ( ) : null} + + {canUpgrade ? ( + +
+ + { + const file = event.target.files?.[0] ?? null; + setSelectedBinary(file); + }} + disabled={isUploadingBinary || isConfirmingManualUpgrade} + /> + + + {manualErrorMessage ? ( + + ) : null} + + {!manualErrorMessage && manualStatusMessage ? ( +
+ {manualStatusMessage} +
+ ) : null} + + {uploadedBinary ? ( +
+
+ + {!uploadedBinary.upgrade_supported ? ( + + ) : null} +
+ +
+
+

+ 文件名 +

+

+ {uploadedBinary.file_name} +

+
+
+

+ 上传时间 +

+

+ {uploadedBinary.uploaded_at + ? formatDateTime(uploadedBinary.uploaded_at) + : '未知'} +

+
+
+

+ 当前版本 +

+

+ {uploadedBinary.current_version} +

+
+
+

+ 上传版本 +

+

+ {uploadedBinary.detected_version} +

+
+
+ +

+ {uploadedBinary.comparison_message} +

+
+ ) : ( + + )} +
+
+ ) : null}
); diff --git a/atsf_server/web/features/update/types.ts b/atsf_server/web/features/update/types.ts index 5da03941..6b9f0231 100644 --- a/atsf_server/web/features/update/types.ts +++ b/atsf_server/web/features/update/types.ts @@ -8,3 +8,15 @@ export interface LatestReleaseInfo { upgrade_supported: boolean; in_progress: boolean; } + +export interface UploadedServerBinaryInfo { + upload_token: string; + file_name: string; + detected_version: string; + current_version: string; + has_update: boolean; + upgrade_supported: boolean; + ready_to_upgrade: boolean; + comparison_message: string; + uploaded_at: string; +} diff --git a/docs/deployment.md b/docs/deployment.md index b21d3c75..03dd204d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -96,6 +96,7 @@ docker compose up -d * Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release * 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级 * 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程 +* 也可在同一弹窗中手动上传 Server 二进制,服务端先检测上传文件版本,前端确认后再执行替换与重启 ### 2.4 首次登录 diff --git a/docs/design.md b/docs/design.md index 68c2b451..a283fe79 100644 --- a/docs/design.md +++ b/docs/design.md @@ -28,7 +28,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部 * 配置变更摘要 * Agent 运行参数下发 * Agent 自我更新与一键部署 -* Server 版本检查与自升级 +* Server 版本检查、GitHub 自升级与手动上传二进制确认升级 * 新版管理端 UI、主题切换与统一交互框架 默认工作方式: