diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index b75e9d8..88fc4b9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -112,6 +112,12 @@ jobs: upx --best --lzma gost-amd64 upx --best --lzma gost-arm64 + - name: Generate SHA256 checksums + working-directory: ./go-gost + run: | + sha256sum gost-amd64 > gost-amd64.sha256 + sha256sum gost-arm64 > gost-arm64.sha256 + - name: Upload GOST AMD64 artifact uses: actions/upload-artifact@v4 with: @@ -124,6 +130,18 @@ jobs: name: gost-binary-arm64 path: ./go-gost/gost-arm64 + - name: Upload GOST AMD64 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./go-gost/gost-amd64.sha256 + + - name: Upload GOST ARM64 checksum artifact + uses: actions/upload-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./go-gost/gost-arm64.sha256 + build-vite: name: Build & Push Vite Frontend needs: check-version @@ -238,7 +256,20 @@ jobs: name: gost-binary-arm64 path: ./artifacts/arm64 + - name: Download GOST AMD64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./artifacts/ + + - name: Download GOST ARM64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./artifacts/ + - name: Prepare release files + run: | VERSION="${{ needs.check-version.outputs.version }}" OWNER="${{ needs.check-version.outputs.image_owner }}" @@ -331,6 +362,10 @@ jobs: gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber + echo "📤 上传 GOST 校验文件..." + gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber + gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber + echo "📤 上传安装脚本..." gh release upload "${VERSION}" ./artifacts/install.sh --clobber gh release upload "${VERSION}" ./artifacts/panel_install.sh --clobber @@ -363,6 +398,18 @@ jobs: name: gost-binary-arm64 path: ./artifacts/arm64 + - name: Download GOST AMD64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-amd64 + path: ./artifacts/ + + - name: Download GOST ARM64 checksum + uses: actions/download-artifact@v4 + with: + name: gost-checksum-arm64 + path: ./artifacts/ + - name: Rename binaries run: | mv ./artifacts/amd64/gost-amd64 ./artifacts/gost-amd64 @@ -379,4 +426,9 @@ jobs: gh release upload "${VERSION}" ./artifacts/gost-amd64 --clobber gh release upload "${VERSION}" ./artifacts/gost-arm64 --clobber + echo "📤 上传 GOST 校验文件..." + gh release upload "${VERSION}" ./artifacts/gost-amd64.sha256 --clobber + gh release upload "${VERSION}" ./artifacts/gost-arm64.sha256 --clobber + echo "✅ GOST 二进制文件更新完成" + diff --git a/.gitignore b/.gitignore index c7b3db5..33f5c97 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,7 @@ build/ *.dylib your_app.exe go-backend/paneld +go-gost/gost # Go 测试二进制文件 *.test diff --git a/README.md b/README.md index 5133b80..9f92e44 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,7 @@ > **联系我们**: [Telegram群组](https://t.me/flvxpanel) -## Original Project -- **Name**: flux-panel -- **Source**: https://github.com/bqlpfy/flux-panel -- **License**: Apache License 2.0 -## Modifications -The following major changes and additions have been made in this fork (FLVX): - -### 1. Backend Architecture (Replaced) -- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed. -- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend. - -### 2. Forwarding Agent (Modified) -- **Modified**: `go-gost/` - Modified forwarding agent wrapper. -- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library. - -### 3. Frontend (Modified) -- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind). - -### 4. Mobile Applications (Removed) -- **Removed**: `android-app/` - Source code for the Android client. -- **Removed**: `ios-app/` - Source code for the iOS client. - -### 5. Infrastructure & Scripts -- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend). -- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic). -- **Added**: `AGENTS.md` (Project documentation). - ---- ## 特性 - 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制 @@ -39,6 +11,10 @@ The following major changes and additions have been made in this fork (FLVX): - 可针对 **指定用户的指定隧道进行限速** 设置 - 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型 - 提供灵活的转发策略配置,适用于多种网络场景 +- 面板分享,支持将节点分享给其他人,面板对接面板 +- 支持分组权限管理,隧道分组、用户分组 +- 支持批量功能,可以批量下发配置,启停等 +- 支持隧道修改配置、转发修改隧道 ## 部署流程 @@ -137,6 +113,37 @@ docker compose -f docker-compose-v4.yml up -d > ⚠️ 首次登录后请立即修改默认密码! +--- +## Original Project +- **Name**: flux-panel +- **Source**: https://github.com/bqlpfy/flux-panel +- **License**: Apache License 2.0 + +## Modifications +The following major changes and additions have been made in this fork (FLVX): + +### 1. Backend Architecture (Replaced) +- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed. +- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend. + +### 2. Forwarding Agent (Modified) +- **Modified**: `go-gost/` - Modified forwarding agent wrapper. +- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library. + +### 3. Frontend (Modified) +- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind). + +### 4. Mobile Applications (Removed) +- **Removed**: `android-app/` - Source code for the Android client. +- **Removed**: `ios-app/` - Source code for the iOS client. + +### 5. Infrastructure & Scripts +- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend). +- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic). +- **Added**: `AGENTS.md` (Project documentation). + +--- + ## 免责声明 diff --git a/doc/install.md b/doc/install.md index c76fbde..b6bd507 100644 --- a/doc/install.md +++ b/doc/install.md @@ -77,3 +77,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh - 安装完成后,服务会自动启动。 - 查看状态: `systemctl status flux_agent` - 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。 + +--- + +## 三、Caddy 反向代理(可选) + +如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。 + +### 1. 安装 Caddy + +```bash +# Debian / Ubuntu +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl +curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy +``` + +其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。 + +### 2. 配置 Caddyfile + +编辑 Caddy 配置文件: + +```bash +sudo nano /etc/caddy/Caddyfile +``` + +#### 面板域名配置 + +将 `panel.example.com` 替换为你自己的域名: + +```caddyfile +panel.example.com { + reverse_proxy localhost:6366 +} +``` + +Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。 + +### 3. 重启 Caddy + +```bash +sudo systemctl restart caddy +``` + +### 4. 注意事项 + +- 确保域名已正确解析到服务器 IP。 +- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。 +- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露: + ``` + FRONTEND_PORT=127.0.0.1:6366 + ``` diff --git a/go-backend/internal/http/client/federation.go b/go-backend/internal/http/client/federation.go index fdc04ee..128055a 100644 --- a/go-backend/internal/http/client/federation.go +++ b/go-backend/internal/http/client/federation.go @@ -85,6 +85,14 @@ func NewFederationClient() *FederationClient { } } +func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient { + return &FederationClient{ + client: &http.Client{ + Timeout: timeout, + }, + } +} + func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) { url = strings.TrimSuffix(url, "/") req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil) diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index af616a1..1825716 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -8,6 +8,7 @@ import ( "net/http" "sort" "strings" + "sync" "time" "go-backend/internal/http/client" @@ -40,6 +41,17 @@ type resetPeerShareFlowRequest struct { ID int64 `json:"id"` } +type updatePeerShareRequest struct { + ID int64 `json:"id"` + Name string `json:"name"` + MaxBandwidth int64 `json:"maxBandwidth"` + ExpiryTime int64 `json:"expiryTime"` + PortRangeStart int `json:"portRangeStart"` + PortRangeEnd int `json:"portRangeEnd"` + AllowedDomains string `json:"allowedDomains"` + AllowedIPs string `json:"allowedIps"` +} + type nodeImportRequest struct { RemoteURL string `json:"remoteUrl"` Token string `json:"token"` @@ -325,6 +337,80 @@ func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Reques response.WriteJSON(w, response.OKEmpty()) } +func (h *Handler) federationShareUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("Invalid method")) + return + } + + var req updatePeerShareRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("Invalid JSON")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("Share ID is required")) + return + } + + share, err := h.repo.GetPeerShare(req.ID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if share == nil { + response.WriteJSON(w, response.ErrDefault("Share not found")) + return + } + + if req.Name == "" { + response.WriteJSON(w, response.ErrDefault("Name is required")) + return + } + + if req.MaxBandwidth < 0 { + response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative")) + return + } + + if req.ExpiryTime < 0 { + response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative")) + return + } + + if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 { + response.WriteJSON(w, response.ErrDefault("Invalid port range")) + return + } + + if req.PortRangeStart > req.PortRangeEnd { + response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end")) + return + } + + allowedIPs, err := normalizePeerShareAllowedIPs(req.AllowedIPs) + if err != nil { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + + share.Name = req.Name + share.MaxBandwidth = req.MaxBandwidth + share.ExpiryTime = req.ExpiryTime + share.PortRangeStart = req.PortRangeStart + share.PortRangeEnd = req.PortRangeEnd + share.AllowedDomains = req.AllowedDomains + share.AllowedIPs = allowedIPs + share.UpdatedTime = time.Now().UnixMilli() + + if err := h.repo.UpdatePeerShare(share); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} + func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("Invalid method")) @@ -1315,6 +1401,74 @@ func isPeerIPAllowed(clientIP net.IP, whitelist string) bool { return false } +func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) { + type remoteEntry struct { + index int + remoteURL string + remoteToken string + } + + var remotes []remoteEntry + for i, item := range items { + isRemote, _ := item["isRemote"].(int) + if isRemote != 1 { + continue + } + url, _ := item["remoteUrl"].(string) + token, _ := item["remoteToken"].(string) + url = strings.TrimSpace(url) + token = strings.TrimSpace(token) + if url == "" || token == "" { + continue + } + remotes = append(remotes, remoteEntry{index: i, remoteURL: url, remoteToken: token}) + } + if len(remotes) == 0 { + return + } + + localDomain := h.federationLocalDomain() + fc := client.NewFederationClientWithTimeout(5 * time.Second) + + type syncResult struct { + index int + status int + syncError string + } + + results := make([]syncResult, len(remotes)) + var wg sync.WaitGroup + for i, entry := range remotes { + wg.Add(1) + go func(idx int, e remoteEntry) { + defer wg.Done() + info, err := fc.Connect(e.remoteURL, e.remoteToken, localDomain) + if err != nil { + errMsg := err.Error() + if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Invalid token") || strings.Contains(errMsg, "Unauthorized") { + results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_deleted"} + } else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Share is disabled") { + results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_disabled"} + } else if strings.Contains(errMsg, "Share expired") { + results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_expired"} + } else { + results[idx] = syncResult{index: e.index, status: 0, syncError: errMsg} + } + } else { + results[idx] = syncResult{index: e.index, status: info.Status, syncError: ""} + } + }(i, entry) + } + wg.Wait() + + for _, r := range results { + items[r.index]["status"] = r.status + if r.syncError != "" { + items[r.index]["syncError"] = r.syncError + } + } +} + func (h *Handler) cleanupPeerShareRuntimes(shareID int64) { if h == nil || h.repo == nil || shareID <= 0 { return diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 4bfec49..cc03eb6 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -105,6 +105,10 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/node/update-order", h.nodeUpdateOrder) mux.HandleFunc("/api/v1/node/batch-delete", h.nodeBatchDelete) mux.HandleFunc("/api/v1/node/check-status", h.nodeCheckStatus) + mux.HandleFunc("/api/v1/node/upgrade", h.nodeUpgrade) + mux.HandleFunc("/api/v1/node/batch-upgrade", h.nodeBatchUpgrade) + mux.HandleFunc("/api/v1/node/rollback", h.nodeRollback) + mux.HandleFunc("/api/v1/node/releases", h.listReleases) mux.HandleFunc("/api/v1/tunnel/list", h.tunnelList) mux.HandleFunc("/api/v1/tunnel/create", h.tunnelCreate) mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet) @@ -155,6 +159,7 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore) mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList) mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate) + mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate) mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete) mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow) mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList) @@ -320,6 +325,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } + + h.syncRemoteNodeStatuses(items) + response.WriteJSON(w, response.OK(items)) } diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index bafe95c..4ee0407 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -414,7 +414,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) { response.WriteJSON(w, response.Err(-2, err.Error())) return } - cmd := fmt.Sprintf("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret) + cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flvx/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret) response.WriteJSON(w, response.OK(cmd)) } diff --git a/go-backend/internal/http/handler/upgrade.go b/go-backend/internal/http/handler/upgrade.go new file mode 100644 index 0000000..99e0cbb --- /dev/null +++ b/go-backend/internal/http/handler/upgrade.go @@ -0,0 +1,291 @@ +package handler + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "go-backend/internal/http/response" +) + +const ( + githubRepo = "Sagit-chu/flvx" + githubProxy = "https://gcode.hostcentral.cc" + githubAPIBase = "https://api.github.com" + githubHTMLBase = "https://github.com" + upgradeTimeout = 5 * time.Minute + batchWorkers = 5 +) + +func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + ID int64 `json:"id"` + Version string `json:"version"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("节点ID无效")) + return + } + + version := strings.TrimSpace(req.Version) + if version == "" { + var err error + version, err = resolveLatestRelease() + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) + return + } + } + + downloadURL := fmt.Sprintf( + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}", + githubHTMLBase, githubRepo, version, + ) + checksumURL := fmt.Sprintf( + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256", + githubHTMLBase, githubRepo, version, + ) + + result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{ + "downloadUrl": downloadURL, + "checksumUrl": checksumURL, + }, upgradeTimeout) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("升级失败: %v", err))) + return + } + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "version": version, + "message": result.Message, + })) +} + +func resolveLatestRelease() (string, error) { + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: 10 * time.Second, + } + + resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest") + if err != nil { + return "", fmt.Errorf("请求GitHub失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusMovedPermanently { + return resolveLatestReleaseAPI() + } + + location := resp.Header.Get("Location") + if location == "" { + return resolveLatestReleaseAPI() + } + + parts := strings.Split(location, "/") + tag := parts[len(parts)-1] + if tag == "" || tag == "latest" { + return resolveLatestReleaseAPI() + } + + return tag, nil +} + +func resolveLatestReleaseAPI() (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases/latest") + if err != nil { + return "", fmt.Errorf("请求GitHub API失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body)) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("解析GitHub API响应失败: %v", err) + } + if strings.TrimSpace(release.TagName) == "" { + return "", fmt.Errorf("无法从GitHub获取最新版本号") + } + + return release.TagName, nil +} + +func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + IDs []int64 `json:"ids"` + Version string `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if len(req.IDs) == 0 { + response.WriteJSON(w, response.ErrDefault("ids不能为空")) + return + } + + version := strings.TrimSpace(req.Version) + if version == "" { + var err error + version, err = resolveLatestRelease() + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新版本失败: %v", err))) + return + } + } + + downloadURL := fmt.Sprintf( + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}", + githubHTMLBase, githubRepo, version, + ) + checksumURL := fmt.Sprintf( + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256", + githubHTMLBase, githubRepo, version, + ) + + type upgradeResult struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` + } + + results := make([]upgradeResult, len(req.IDs)) + sem := make(chan struct{}, batchWorkers) + var wg sync.WaitGroup + + for i, id := range req.IDs { + wg.Add(1) + go func(index int, nodeID int64) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + result, err := h.wsServer.SendCommand(nodeID, "UpgradeAgent", map[string]interface{}{ + "downloadUrl": downloadURL, + "checksumUrl": checksumURL, + }, upgradeTimeout) + if err != nil { + results[index] = upgradeResult{ID: nodeID, Success: false, Message: err.Error()} + return + } + results[index] = upgradeResult{ID: nodeID, Success: true, Message: result.Message} + }(i, id) + } + wg.Wait() + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "version": version, + "results": results, + })) +} + +func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(githubAPIBase + "/repos/" + githubRepo + "/releases?per_page=20") + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: GitHub API返回 %d: %s", resp.StatusCode, string(body)))) + return + } + + var releases []struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + PublishedAt string `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + } + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("解析版本列表失败: %v", err))) + return + } + + type releaseItem struct { + Version string `json:"version"` + Name string `json:"name"` + PublishedAt string `json:"publishedAt"` + Prerelease bool `json:"prerelease"` + } + + items := make([]releaseItem, 0, len(releases)) + for _, r := range releases { + if r.Draft { + continue + } + items = append(items, releaseItem{ + Version: r.TagName, + Name: r.Name, + PublishedAt: r.PublishedAt, + Prerelease: r.Prerelease, + }) + } + + response.WriteJSON(w, response.OK(items)) +} + +func (h *Handler) nodeRollback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + ID int64 `json:"id"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("节点ID无效")) + return + } + + result, err := h.wsServer.SendCommand(req.ID, "RollbackAgent", map[string]interface{}{}, 30*time.Second) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("回退失败: %v", err))) + return + } + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "message": result.Message, + })) +} diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index ec68fd2..5de6f2b 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -168,7 +168,13 @@ func Open(path string) (*Repository, error) { return nil, err } - raw, err := sql.Open("sqlite", path) + // Use _pragma DSN parameters so every connection from the pool gets + // the same settings (busy_timeout and synchronous are per-connection). + dsn := "file:" + path + + "?_pragma=busy_timeout(5000)" + + "&_pragma=journal_mode(WAL)" + + "&_pragma=synchronous(NORMAL)" + raw, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } diff --git a/go-backend/internal/ws/server.go b/go-backend/internal/ws/server.go index f36ec97..54edfa0 100644 --- a/go-backend/internal/ws/server.go +++ b/go-backend/internal/ws/server.go @@ -54,6 +54,12 @@ type pendingRequest struct { ch chan CommandResult } +const ( + wsPingPeriod = 15 * time.Second + wsPongWait = 45 * time.Second + wsWriteWait = 5 * time.Second +) + type CommandResult struct { Type string `json:"type"` Success bool `json:"success"` @@ -120,12 +126,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { return } cw := &connWrap{conn: conn} + _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsPongWait)) + }) + done := make(chan struct{}) + go startKeepalive(cw, done) s.mu.Lock() s.admins[cw] = struct{}{} s.mu.Unlock() defer func() { + close(done) s.mu.Lock() delete(s.admins, cw) s.mu.Unlock() @@ -145,6 +158,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 return } cw := &connWrap{conn: conn} + _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsPongWait)) + }) + done := make(chan struct{}) + go startKeepalive(cw, done) version := r.URL.Query().Get("version") httpVal := parseIntDefault(r.URL.Query().Get("http"), 0) @@ -165,6 +184,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 s.broadcastStatus(nodeID, 1) defer func() { + close(done) needOfflineBroadcast := false s.mu.Lock() current, ok := s.nodes[nodeID] @@ -190,7 +210,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 msg := decryptIfNeeded(payload, secret) s.tryResolvePending(nodeID, msg) - s.broadcastInfo(nodeID, msg) + + var parsed struct { + Type string `json:"type"` + } + if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" { + s.broadcastTyped(nodeID, "upgrade_progress", msg) + } else { + s.broadcastInfo(nodeID, msg) + } } } @@ -264,7 +292,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim } ns.conn.mu.Lock() + _ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData) + _ = ns.conn.conn.SetWriteDeadline(time.Time{}) ns.conn.mu.Unlock() if err != nil { cleanup() @@ -385,6 +415,12 @@ func (s *Server) broadcastInfo(nodeID int64, data string) { s.broadcastToAdmins(string(raw)) } +func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) { + payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data} + raw, _ := json.Marshal(payload) + s.broadcastToAdmins(string(raw)) +} + func (s *Server) broadcastToAdmins(message string) { s.mu.RLock() admins := make([]*connWrap, 0, len(s.admins)) @@ -395,7 +431,9 @@ func (s *Server) broadcastToAdmins(message string) { for _, c := range admins { c.mu.Lock() + _ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err := c.conn.WriteMessage(websocket.TextMessage, []byte(message)) + _ = c.conn.SetWriteDeadline(time.Time{}) c.mu.Unlock() if err != nil { log.Printf("websocket broadcast failed: %v", err) @@ -428,3 +466,28 @@ func parseIntDefault(v string, fallback int) int { } return x } + +func startKeepalive(cw *connWrap, done <-chan struct{}) { + if cw == nil || cw.conn == nil { + return + } + ticker := time.NewTicker(wsPingPeriod) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + cw.mu.Lock() + _ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) + err := cw.conn.WriteMessage(websocket.PingMessage, nil) + _ = cw.conn.SetWriteDeadline(time.Time{}) + cw.mu.Unlock() + if err != nil { + _ = cw.conn.Close() + return + } + } + } +} diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 9a5817f..743f510 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -4,10 +4,17 @@ import ( "bytes" "compress/gzip" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "io" "net" + "net/http" "net/url" + "os" + "os/exec" + "runtime" "strconv" "strings" "sync" // 新增:用于管理连接状态的互斥锁 @@ -21,7 +28,6 @@ import ( "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" - "os" ) // SystemInfo 系统信息结构体 @@ -85,6 +91,11 @@ type TcpPingResponse struct { RequestId string `json:"requestId,omitempty"` } +const ( + reporterReadWait = 60 * time.Second + reporterWriteWait = 5 * time.Second +) + type WebSocketReporter struct { url string addr string // 保存服务器地址 @@ -237,6 +248,14 @@ func (w *WebSocketReporter) connect() error { w.conn = conn w.connected = true + _ = conn.SetReadDeadline(time.Now().Add(reporterReadWait)) + conn.SetPingHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(reporterReadWait)) + return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(reporterWriteWait)) + }) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(reporterReadWait)) + }) // 设置关闭处理器来检测连接状态 w.conn.SetCloseHandler(func(code int, text string) error { @@ -377,7 +396,7 @@ func (w *WebSocketReporter) receiveMessages() { } // 设置读取超时 - conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + conn.SetReadDeadline(time.Now().Add(reporterReadWait)) messageType, message, err := conn.ReadMessage() if err != nil { @@ -466,9 +485,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt } if cmdMsg.Type != "call" { - // TcpPing 诊断命令异步执行,避免阻塞其他命令 // 其他状态变更命令保持同步,确保顺序执行 - if cmdMsg.Type == "TcpPing" { + if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { go w.routeCommand(cmdMsg) } else { w.routeCommand(cmdMsg) @@ -483,9 +501,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt return } if cmdMsg.Type != "call" { - // TcpPing 诊断命令异步执行,避免阻塞其他命令 // 其他状态变更命令保持同步,确保顺序执行 - if cmdMsg.Type == "TcpPing" { + if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { go w.routeCommand(cmdMsg) } else { w.routeCommand(cmdMsg) @@ -579,6 +596,18 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) { response.Type = "SetProtocolResponse" needSaveConfig = true + // 升级 Agent 命令(异步执行,不需要保存配置) + case "UpgradeAgent": + err = w.handleUpgradeAgent(cmd.Data) + response.Type = "UpgradeAgentResponse" + // needSaveConfig = false (默认值) + + // 回退 Agent 到旧版本 + case "RollbackAgent": + err = w.handleRollbackAgent(cmd.Data) + response.Type = "RollbackAgentResponse" + // needSaveConfig = false (默认值) + default: err = fmt.Errorf("未知命令类型: %s", cmd.Type) response.Type = "UnknownCommandResponse" @@ -881,6 +910,186 @@ func (w *WebSocketReporter) handleSetProtocol(data interface{}) error { return nil } +// sendUpgradeProgress 通过 WS 发送升级进度消息 +func (w *WebSocketReporter) sendUpgradeProgress(stage string, percent int, message string) { + response := CommandResponse{ + Type: "UpgradeProgress", + Success: true, + Message: message, + Data: map[string]interface{}{ + "stage": stage, + "percent": percent, + }, + } + w.sendResponse(response) +} + +func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("序列化数据失败: %v", err) + } + + var req struct { + DownloadURL string `json:"downloadUrl"` + ChecksumURL string `json:"checksumUrl"` + } + if err := json.Unmarshal(jsonData, &req); err != nil { + return fmt.Errorf("解析升级参数失败: %v", err) + } + if strings.TrimSpace(req.DownloadURL) == "" { + return fmt.Errorf("下载地址不能为空") + } + + // 替换架构占位符 + downloadURL := strings.ReplaceAll(req.DownloadURL, "{ARCH}", runtime.GOARCH) + checksumURL := strings.ReplaceAll(req.ChecksumURL, "{ARCH}", runtime.GOARCH) + + w.sendUpgradeProgress("downloading", 0, "开始下载升级包...") + fmt.Printf("📦 开始下载升级包: %s\n", downloadURL) + + // 下载新版本二进制 + const binaryPath = "/etc/flux_agent/flux_agent" + tmpPath := binaryPath + ".new" + backupPath := binaryPath + ".old" + + resp, err := http.Get(downloadURL) + if err != nil { + return fmt.Errorf("下载升级包失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载升级包失败, HTTP状态码: %d", resp.StatusCode) + } + + outFile, err := os.Create(tmpPath) + if err != nil { + return fmt.Errorf("创建临时文件失败: %v", err) + } + + // 带进度的下载 + totalSize := resp.ContentLength + var downloaded int64 + buf := make([]byte, 32*1024) + lastPercent := 0 + hasher := sha256.New() + + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, wErr := outFile.Write(buf[:n]); wErr != nil { + outFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("写入升级包失败: %v", wErr) + } + hasher.Write(buf[:n]) + downloaded += int64(n) + if totalSize > 0 { + percent := int(downloaded * 100 / totalSize) + if percent-lastPercent >= 10 { + lastPercent = percent + w.sendUpgradeProgress("downloading", percent, fmt.Sprintf("下载中... %d%%", percent)) + } + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + outFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("读取升级包失败: %v", readErr) + } + } + outFile.Close() + + if downloaded == 0 { + os.Remove(tmpPath) + return fmt.Errorf("下载的升级包为空") + } + + w.sendUpgradeProgress("downloading", 100, fmt.Sprintf("下载完成 (%d bytes)", downloaded)) + + // Checksum 校验 + if checksumURL != "" { + w.sendUpgradeProgress("verifying", 0, "校验文件完整性...") + checksumResp, err := http.Get(checksumURL) + if err == nil { + defer checksumResp.Body.Close() + if checksumResp.StatusCode == http.StatusOK { + checksumBody, err := io.ReadAll(checksumResp.Body) + if err == nil { + // 格式: " " 或 "" + expectedHash := strings.TrimSpace(strings.Split(string(checksumBody), " ")[0]) + actualHash := hex.EncodeToString(hasher.Sum(nil)) + if !strings.EqualFold(expectedHash, actualHash) { + os.Remove(tmpPath) + return fmt.Errorf("校验失败: 期望 %s, 实际 %s", expectedHash, actualHash) + } + fmt.Printf("✅ Checksum 校验通过: %s\n", actualHash) + } + } + } + w.sendUpgradeProgress("verifying", 100, "校验通过") + } + + if err := os.Chmod(tmpPath, 0755); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("设置执行权限失败: %v", err) + } + + // 备份旧版本 + w.sendUpgradeProgress("installing", 50, "备份旧版本...") + if _, err := os.Stat(binaryPath); err == nil { + // 复制旧文件作为备份(不用 rename,因为可能正在运行) + oldData, err := os.ReadFile(binaryPath) + if err == nil { + _ = os.WriteFile(backupPath, oldData, 0755) + fmt.Println("📦 旧版本已备份到", backupPath) + } + } + + w.sendUpgradeProgress("installing", 80, "准备重启...") + fmt.Printf("✅ 升级包下载完成 (%d bytes), 准备重启...\n", downloaded) + + // 执行重启脚本 + // 使用 systemd-run 在独立的 transient unit 中运行重启脚本, + // 避免 systemctl stop 杀死 flux_agent cgroup 内所有进程(包括此脚本自身)导致 mv 未执行。 + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath) + cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) + if err := cmd.Start(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("启动重启脚本失败: %v", err) + } + + w.sendUpgradeProgress("installing", 100, "重启中...") + fmt.Println("🔄 重启脚本已启动, Agent 将在 1 秒后重启...") + return nil +} + +func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error { + const binaryPath = "/etc/flux_agent/flux_agent" + backupPath := binaryPath + ".old" + + // 检查备份文件是否存在 + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + return fmt.Errorf("没有可用的备份文件,无法回退") + } + + fmt.Println("🔄 开始回退到旧版本...") + + // 执行回退脚本(同升级逻辑,使用 systemd-run 避免 cgroup 问题) + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath) + cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动回退脚本失败: %v", err) + } + + fmt.Println("🔄 回退脚本已启动, Agent 将在 1 秒后重启...") + return nil +} + // updateLocalConfigJSON 将 http/tls/socks 写入工作目录下的 config.json func updateLocalConfigJSON(httpVal int, tlsVal int, socksVal int) error { path := "config.json" diff --git a/vite-frontend/nginx.conf b/vite-frontend/nginx.conf index e832412..207eb04 100644 --- a/vite-frontend/nginx.conf +++ b/vite-frontend/nginx.conf @@ -87,6 +87,8 @@ http { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -94,4 +96,4 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } } -} \ No newline at end of file +} diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index f0b56d2..846283d 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -41,6 +41,14 @@ export const checkNodeStatus = (nodeId?: number) => { return Network.post("/node/check-status", params); }; +export const upgradeNode = (id: number, version?: string) => + Network.post("/node/upgrade", { id, version: version || "" }, { timeout: 5 * 60 * 1000 }); +export const batchUpgradeNodes = (ids: number[], version?: string) => + Network.post("/node/batch-upgrade", { ids, version: version || "" }, { timeout: 15 * 60 * 1000 }); +export const getNodeReleases = () => Network.post("/node/releases"); +export const rollbackNode = (id: number) => + Network.post("/node/rollback", { id }); + // 隧道CRUD操作 - 全部使用POST请求 export const createTunnel = (data: any) => Network.post("/tunnel/create", data); export const getTunnelList = () => Network.post("/tunnel/list"); @@ -200,6 +208,16 @@ export const createPeerShare = (data: { allowedDomains?: string; allowedIps?: string; }) => Network.post("/federation/share/create", data); +export const updatePeerShare = (data: { + id: number; + name: string; + maxBandwidth: number; + expiryTime: number; + portRangeStart: number; + portRangeEnd: number; + allowedDomains: string; + allowedIps: string; +}) => Network.post("/federation/share/update", data); export const deletePeerShare = (id: number) => Network.post("/federation/share/delete", { id }); export const resetPeerShareFlow = (id: number) => diff --git a/vite-frontend/src/api/network.ts b/vite-frontend/src/api/network.ts index 53486e9..d7af375 100644 --- a/vite-frontend/src/api/network.ts +++ b/vite-frontend/src/api/network.ts @@ -43,6 +43,10 @@ interface ApiResponse { data: T; } +interface RequestOptions { + timeout?: number; +} + // 处理token失效的逻辑 function handleTokenExpired() { // 清除localStorage中的token @@ -71,6 +75,7 @@ const Network = { get: function ( path: string = "", data: any = {}, + options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { // 如果baseURL是默认值且是WebView环境,说明没有设置面板地址 @@ -83,7 +88,7 @@ const Network = { axios .get(path, { params: data, - timeout: 30000, + timeout: options.timeout ?? 30000, headers: { Authorization: window.localStorage.getItem("token"), }, @@ -117,6 +122,7 @@ const Network = { post: function ( path: string = "", data: any = {}, + options: RequestOptions = {}, ): Promise> { return new Promise(function (resolve) { // 如果baseURL是默认值且是WebView环境,说明没有设置面板地址 @@ -128,7 +134,7 @@ const Network = { axios .post(path, data, { - timeout: 30000, + timeout: options.timeout ?? 30000, headers: { Authorization: window.localStorage.getItem("token"), "Content-Type": "application/json", diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 178c0b5..14b6f97 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@heroui/spinner"; import { Alert } from "@heroui/alert"; import { Progress } from "@heroui/progress"; import { Accordion, AccordionItem } from "@heroui/accordion"; +import { Select, SelectItem } from "@heroui/select"; import { Checkbox } from "@heroui/checkbox"; import toast from "react-hot-toast"; import axios from "axios"; @@ -45,6 +46,10 @@ import { getNodeInstallCommand, updateNodeOrder, batchDeleteNodes, + upgradeNode, + batchUpgradeNodes, + getNodeReleases, + rollbackNode, } from "@/api"; interface Node { @@ -65,6 +70,7 @@ interface Node { status: number; isRemote?: number; remoteUrl?: string; + syncError?: string; connectionStatus: "online" | "offline"; systemInfo?: { cpuUsage: number; @@ -76,6 +82,8 @@ interface Node { uptime: number; } | null; copyLoading?: boolean; + upgradeLoading?: boolean; + rollbackLoading?: boolean; } interface NodeForm { @@ -117,7 +125,7 @@ const SortableItem = ({ }; return ( -
+
{children(listeners)}
); @@ -164,6 +172,16 @@ export default function NodePage() { const [installCommand, setInstallCommand] = useState(""); const [currentNodeName, setCurrentNodeName] = useState(""); + // 升级相关状态 + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">("single"); + const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState(null); + const [releases, setReleases] = useState>([]); + const [releasesLoading, setReleasesLoading] = useState(false); + const [selectedVersion, setSelectedVersion] = useState(""); + const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false); + const [upgradeProgress, setUpgradeProgress] = useState>({}); + const websocketRef = useRef(null); const reconnectTimerRef = useRef(null); const reconnectAttemptsRef = useRef(0); @@ -219,7 +237,8 @@ export default function NodePage() { const nodesData: Node[] = (res.data || []).map((node: any) => ({ ...node, inx: node.inx ?? 0, - connectionStatus: node.status === 1 ? "online" : "offline", + connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline", + syncError: node.syncError || undefined, systemInfo: null, copyLoading: false, })); @@ -423,6 +442,22 @@ export default function NodePage() { return node; }), ); + } else if (type === "upgrade_progress") { + try { + const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData; + if (progressData?.data) { + setUpgradeProgress((prev) => ({ + ...prev, + [nodeId]: { + stage: progressData.data.stage || "", + percent: progressData.data.percent || 0, + message: progressData.message || "", + }, + })); + } + } catch { + // ignore parse errors + } } }; @@ -768,6 +803,93 @@ export default function NodePage() { } }; + + + // 打开版本选择弹窗 + const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => { + setUpgradeTarget(target); + setUpgradeTargetNodeId(nodeId || null); + setSelectedVersion(""); + setUpgradeModalOpen(true); + setReleasesLoading(true); + try { + const res = await getNodeReleases(); + if (res.code === 0 && Array.isArray(res.data)) { + setReleases(res.data); + } else { + toast.error(res.msg || "获取版本列表失败"); + } + } catch { + toast.error("获取版本列表失败"); + } finally { + setReleasesLoading(false); + } + }; + + // 确认升级(从版本弹窗) + const handleConfirmUpgrade = async () => { + const version = selectedVersion || undefined; + if (upgradeTarget === "single" && upgradeTargetNodeId) { + setUpgradeModalOpen(false); + // Find the node + const node = nodeList.find((n) => n.id === upgradeTargetNodeId); + if (!node) return; + setNodeList((prev) => + prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n)), + ); + try { + const res = await upgradeNode(upgradeTargetNodeId, version); + if (res.code === 0) { + toast.success(`节点升级命令已发送,节点将自动重启`); + } else { + toast.error(res.msg || "升级失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setNodeList((prev) => + prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n)), + ); + } + } else if (upgradeTarget === "batch") { + setBatchUpgradeLoading(true); + setUpgradeModalOpen(false); + try { + const res = await batchUpgradeNodes(Array.from(selectedIds), version); + if (res.code === 0) { + toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`); + } else { + toast.error(res.msg || "批量升级失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setBatchUpgradeLoading(false); + } + } + }; + + // 回退节点 + const handleRollbackNode = async (node: Node) => { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)), + ); + try { + const res = await rollbackNode(node.id); + if (res.code === 0) { + toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`); + } else { + toast.error(res.msg || "回退失败"); + } + } catch { + toast.error("网络错误,请重试"); + } finally { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: false } : n)), + ); + } + }; + // 提交表单 const handleSubmit = async () => { if (!validateForm()) return; @@ -1046,6 +1168,15 @@ export default function NodePage() { + + + +
+ )} +
{!isRemoteNode && ( - <> - - - + )} + + + + )} + + + {/* 批量删除确认模态框 */} { setLoading(true); try { @@ -239,6 +253,52 @@ export default function PanelSharingPage() { } }; + const openEditShare = (share: PeerShare) => { + setEditForm({ + id: share.id, + name: share.name, + maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0, + expiryTime: share.expiryTime, + portRangeStart: share.portRangeStart, + portRangeEnd: share.portRangeEnd, + allowedDomains: share.allowedDomains || "", + allowedIps: share.allowedIps || "", + }); + setEditShareOpen(true); + }; + + const handleEditShare = async () => { + if (!editForm.name) { + toast.error("名称不能为空"); + return; + } + if (editForm.maxBandwidth < 0) { + toast.error("流量上限不能为负数"); + return; + } + try { + const res = await updatePeerShare({ + id: editForm.id, + name: editForm.name, + maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024, + expiryTime: editForm.expiryTime, + portRangeStart: editForm.portRangeStart, + portRangeEnd: editForm.portRangeEnd, + allowedDomains: editForm.allowedDomains, + allowedIps: editForm.allowedIps, + }); + if (res.code === 0) { + toast.success("编辑成功"); + setEditShareOpen(false); + loadShares(); + } else { + toast.error(res.msg || "编辑失败"); + } + } catch { + toast.error("网络错误"); + } + }; + const handleImportNode = async () => { if (!importForm.remoteUrl || !importForm.token) { toast.error("请填写完整信息"); @@ -326,6 +386,13 @@ export default function PanelSharingPage() {

{share.name}

+ + + + + + {/* Import Node Modal */} setImportNodeOpen(false)}>