From 5ddb46c3c253ea0fbc84d539b48511a2ccd1d664 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 02:25:16 +0000 Subject: [PATCH 01/16] docs: add caddy reverse proxy guide --- doc/install.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) 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 + ``` From d6b83a0c1bec66f3d628519adaf610b54bdc0abe Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 02:48:57 +0000 Subject: [PATCH 02/16] feat(panel-sharing): add edit support for peer shares Allow editing port range, traffic limit, allowed domains, allowed API IPs and expiry time on existing shares via a new update endpoint and edit modal in the frontend. --- .../internal/http/handler/federation.go | 85 ++++++++++++ go-backend/internal/http/handler/handler.go | 1 + vite-frontend/src/api/index.ts | 10 ++ vite-frontend/src/pages/panel-sharing.tsx | 127 ++++++++++++++++++ 4 files changed, 223 insertions(+) diff --git a/go-backend/internal/http/handler/federation.go b/go-backend/internal/http/handler/federation.go index e3bea0d..4f6c26f 100644 --- a/go-backend/internal/http/handler/federation.go +++ b/go-backend/internal/http/handler/federation.go @@ -40,6 +40,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 +336,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")) diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 4bfec49..a0ce67c 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -155,6 +155,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) diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index f0b56d2..bd8c71e 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -200,6 +200,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/pages/panel-sharing.tsx b/vite-frontend/src/pages/panel-sharing.tsx index fdd881c..2b5205a 100644 --- a/vite-frontend/src/pages/panel-sharing.tsx +++ b/vite-frontend/src/pages/panel-sharing.tsx @@ -20,6 +20,7 @@ import { resetPeerShareFlow, getPeerRemoteUsageList, importRemoteNode, + updatePeerShare, } from "@/api"; interface Node { @@ -91,6 +92,7 @@ export default function PanelSharingPage() { // Modals const [createShareOpen, setCreateShareOpen] = useState(false); + const [editShareOpen, setEditShareOpen] = useState(false); const [importNodeOpen, setImportNodeOpen] = useState(false); // Forms @@ -110,6 +112,17 @@ export default function PanelSharingPage() { token: "", }); + const [editForm, setEditForm] = useState({ + id: 0, + name: "", + maxBandwidth: 0, + expiryTime: 0, + portRangeStart: 10000, + portRangeEnd: 20000, + allowedDomains: "", + allowedIps: "", + }); + const loadShares = useCallback(async () => { setLoading(true); try { @@ -239,6 +252,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 +385,13 @@ export default function PanelSharingPage() {

{share.name}

+ + + + + + {/* Import Node Modal */} setImportNodeOpen(false)}> From 14063e66c3d55319b46b66c951370ede856d6b9f Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:59:02 +0800 Subject: [PATCH 03/16] Update README with project modifications and disclaimers Removed outdated project details and added modifications section. --- README.md | 63 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 66ae164..414e43d 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): - 可针对 **指定用户的指定隧道进行限速** 设置 - 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型 - 提供灵活的转发策略配置,适用于多种网络场景 +- 面板分享,支持将节点分享给其他人,面板对接面板 +- 支持分组权限管理,隧道分组、用户分组 +- 支持批量功能,可以批量下发配置,启停等 +- 支持隧道修改配置、转发修改隧道 ## 部署流程 @@ -73,6 +49,37 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install. > ⚠️ 首次登录后请立即修改默认密码! +--- +## 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). + +--- + ## 免责声明 From 87605ce8f8aca2bbdccd5604ba36baa7fa1c1ad9 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 03:07:42 +0000 Subject: [PATCH 04/16] fix(federation): sync remote node status on list and detect deleted provider shares Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- go-backend/internal/http/client/federation.go | 8 +++ .../internal/http/handler/federation.go | 69 +++++++++++++++++++ go-backend/internal/http/handler/handler.go | 3 + 3 files changed, 80 insertions(+) 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 4f6c26f..625357f 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" @@ -1402,6 +1403,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 a0ce67c..9319956 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -321,6 +321,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)) } From 896f2bc5f8a3a9a46faff03d32fd05203ce34351 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 03:07:59 +0000 Subject: [PATCH 05/16] fix(panel-sharing): display provider share deletion warnings for remote nodes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- vite-frontend/src/pages/node.tsx | 15 ++++++++++++++- vite-frontend/src/pages/panel-sharing.tsx | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 178c0b5..225e8cf 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -65,6 +65,7 @@ interface Node { status: number; isRemote?: number; remoteUrl?: string; + syncError?: string; connectionStatus: "online" | "offline"; systemInfo?: { cpuUsage: number; @@ -219,7 +220,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, })); @@ -1181,6 +1183,17 @@ export default function NodePage() { + {isRemoteNode && node.syncError && ( +
+ {node.syncError === "provider_share_deleted" + ? "提供方已删除该分享" + : node.syncError === "provider_share_disabled" + ? "提供方已禁用该分享" + : node.syncError === "provider_share_expired" + ? "提供方分享已过期" + : `远程同步失败: ${node.syncError}`} +
+ )} {/* 基础信息 */}
diff --git a/vite-frontend/src/pages/panel-sharing.tsx b/vite-frontend/src/pages/panel-sharing.tsx index 2b5205a..df05e73 100644 --- a/vite-frontend/src/pages/panel-sharing.tsx +++ b/vite-frontend/src/pages/panel-sharing.tsx @@ -78,6 +78,7 @@ interface RemoteUsageNode { usedPorts: number[]; bindings: RemoteUsageBinding[]; activeBindingNum: number; + syncError?: string; } export default function PanelSharingPage() { @@ -456,6 +457,17 @@ export default function PanelSharingPage() { 绑定 {node.activeBindingNum || 0} + {node.syncError && ( +
+ {node.syncError === "provider_share_deleted" + ? "提供方已删除该分享" + : node.syncError === "provider_share_disabled" + ? "提供方已禁用该分享" + : node.syncError === "provider_share_expired" + ? "提供方分享已过期" + : `远程同步失败: ${node.syncError}`} +
+ )} {node.remoteUrl &&

远程地址: {node.remoteUrl}

}

共享ID: {node.shareId || "-"}

端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}

From 65a61054694f0caa277481e6c0356dd6a9414c74 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 05:59:11 +0000 Subject: [PATCH 06/16] feat(node-install): add gcode.hostcentral.cc proxy to install script download URL --- go-backend/internal/http/handler/mutations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 9784d04..41893c2 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -413,7 +413,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/flux-panel/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)) } From 311840b29b22a7c8e6d16689a13ec26fe134327c Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 07:01:00 +0000 Subject: [PATCH 07/16] feat: complete agent upgrade system with batch upgrade, version selection, progress reporting, and rollback --- .github/workflows/docker-build.yml | 52 ++++ go-backend/internal/http/handler/handler.go | 4 + go-backend/internal/http/handler/upgrade.go | 258 +++++++++++++++++++ go-backend/internal/ws/server.go | 16 +- go-gost/x/socket/websocket_reporter.go | 201 ++++++++++++++- vite-frontend/src/api/index.ts | 8 + vite-frontend/src/pages/node.tsx | 264 ++++++++++++++++++++ 7 files changed, 801 insertions(+), 2 deletions(-) create mode 100644 go-backend/internal/http/handler/upgrade.go 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/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 4bfec49..9832b59 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) diff --git a/go-backend/internal/http/handler/upgrade.go b/go-backend/internal/http/handler/upgrade.go new file mode 100644 index 0000000..1087b86 --- /dev/null +++ b/go-backend/internal/http/handler/upgrade.go @@ -0,0 +1,258 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go-backend/internal/http/response" +) + +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( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}", + version, + ) + checksumURL := fmt.Sprintf( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}.sha256", + version, + ) + + result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{ + "downloadUrl": downloadURL, + "checksumUrl": checksumURL, + }, 120*time.Second) + 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("https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/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("https://gcode.hostcentral.cc/https://api.github.com/repos/Sagit-chu/flux-panel/releases/latest") + if err != nil { + return "", fmt.Errorf("请求GitHub API失败: %v", err) + } + defer resp.Body.Close() + + 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( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}", + version, + ) + checksumURL := fmt.Sprintf( + "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}.sha256", + version, + ) + + type upgradeResult struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` + } + + results := make([]upgradeResult, 0, len(req.IDs)) + for _, id := range req.IDs { + result, err := h.wsServer.SendCommand(id, "UpgradeAgent", map[string]interface{}{ + "downloadUrl": downloadURL, + "checksumUrl": checksumURL, + }, 120*time.Second) + if err != nil { + results = append(results, upgradeResult{ID: id, Success: false, Message: err.Error()}) + } else { + results = append(results, upgradeResult{ID: id, Success: true, Message: result.Message}) + } + } + + 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("https://gcode.hostcentral.cc/https://api.github.com/repos/Sagit-chu/flux-panel/releases?per_page=20") + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) + return + } + defer resp.Body.Close() + + 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/ws/server.go b/go-backend/internal/ws/server.go index f36ec97..e1d35da 100644 --- a/go-backend/internal/ws/server.go +++ b/go-backend/internal/ws/server.go @@ -190,7 +190,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) + } } } @@ -385,6 +393,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)) diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 9a5817f..63fb854 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -4,13 +4,21 @@ 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" // 新增:用于管理连接状态的互斥锁 + "syscall" "time" "github.com/go-gost/x/config" @@ -21,7 +29,6 @@ import ( "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" - "os" ) // SystemInfo 系统信息结构体 @@ -579,6 +586,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 +900,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) + + // 执行重启脚本(分离进程,不阻塞当前进程) + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && mv %s %s && systemctl start flux_agent", tmpPath, binaryPath) + cmd := exec.Command("/bin/sh", "-c", script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + 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("🔄 开始回退到旧版本...") + + // 执行回退脚本 + script := fmt.Sprintf("sleep 1 && systemctl stop flux_agent && cp %s %s && systemctl start flux_agent", backupPath, binaryPath) + cmd := exec.Command("/bin/sh", "-c", script) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + 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/src/api/index.ts b/vite-frontend/src/api/index.ts index f0b56d2..64776f7 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 || "" }); +export const batchUpgradeNodes = (ids: number[], version?: string) => + Network.post("/node/batch-upgrade", { ids, version: version || "" }); +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"); diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 178c0b5..f8dc944 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 { @@ -76,6 +81,8 @@ interface Node { uptime: number; } | null; copyLoading?: boolean; + upgradeLoading?: boolean; + rollbackLoading?: boolean; } interface NodeForm { @@ -164,6 +171,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); @@ -423,6 +440,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 +801,116 @@ export default function NodePage() { } }; + // 升级节点 + const handleUpgradeNode = async (node: Node) => { + setNodeList((prev) => + prev.map((n) => (n.id === node.id ? { ...n, upgradeLoading: true } : n)), + ); + + try { + const res = await upgradeNode(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, upgradeLoading: false } : n, + ), + ); + } + }; + + // 打开版本选择弹窗 + 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 +1189,15 @@ export default function NodePage() { +
+ {upgradeProgress[node.id] && upgradeProgress[node.id].percent < 100 && ( +
+ +
+ )}
开机时间 @@ -1373,6 +1537,28 @@ export default function NodePage() { > 安装 + + + + + + )} + + + {/* 批量删除确认模态框 */} Date: Wed, 11 Feb 2026 07:04:50 +0000 Subject: [PATCH 08/16] fix: remove unused handleUpgradeNode function --- vite-frontend/src/pages/node.tsx | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index f8dc944..168bda3 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -801,30 +801,7 @@ export default function NodePage() { } }; - // 升级节点 - const handleUpgradeNode = async (node: Node) => { - setNodeList((prev) => - prev.map((n) => (n.id === node.id ? { ...n, upgradeLoading: true } : n)), - ); - try { - const res = await upgradeNode(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, upgradeLoading: false } : n, - ), - ); - } - }; // 打开版本选择弹窗 const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => { From c184a75f22301679291faa7d0e29a8d5345185c9 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 07:35:02 +0000 Subject: [PATCH 09/16] fix: use direct GitHub API for release queries instead of proxy The gcode.hostcentral.cc proxy only supports github.com file downloads, not api.github.com requests. API calls through the proxy returned 404 HTML pages, causing JSON decode error: invalid character '<'. Also updates repo name from flux-panel to flvx across upgrade and install URLs. --- go-backend/internal/http/handler/mutations.go | 2 +- go-backend/internal/http/handler/upgrade.go | 41 ++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 41893c2..f46cb86 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -413,7 +413,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://gcode.hostcentral.cc/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 index 1087b86..19b9dca 100644 --- a/go-backend/internal/http/handler/upgrade.go +++ b/go-backend/internal/http/handler/upgrade.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "fmt" + "io" "net/http" "strings" "time" @@ -10,6 +11,13 @@ import ( "go-backend/internal/http/response" ) +const ( + githubRepo = "Sagit-chu/flvx" + githubProxy = "https://gcode.hostcentral.cc" + githubAPIBase = "https://api.github.com" + githubHTMLBase = "https://github.com" +) + func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) @@ -40,12 +48,12 @@ func (h *Handler) nodeUpgrade(w http.ResponseWriter, r *http.Request) { } downloadURL := fmt.Sprintf( - "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}", - version, + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}", + githubHTMLBase, githubRepo, version, ) checksumURL := fmt.Sprintf( - "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}.sha256", - version, + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256", + githubHTMLBase, githubRepo, version, ) result, err := h.wsServer.SendCommand(req.ID, "UpgradeAgent", map[string]interface{}{ @@ -71,7 +79,7 @@ func resolveLatestRelease() (string, error) { Timeout: 10 * time.Second, } - resp, err := client.Get("https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/latest") + resp, err := client.Get(githubProxy + "/" + githubHTMLBase + "/" + githubRepo + "/releases/latest") if err != nil { return "", fmt.Errorf("请求GitHub失败: %v", err) } @@ -97,12 +105,17 @@ func resolveLatestRelease() (string, error) { func resolveLatestReleaseAPI() (string, error) { client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get("https://gcode.hostcentral.cc/https://api.github.com/repos/Sagit-chu/flux-panel/releases/latest") + 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"` } @@ -146,12 +159,12 @@ func (h *Handler) nodeBatchUpgrade(w http.ResponseWriter, r *http.Request) { } downloadURL := fmt.Sprintf( - "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}", - version, + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}", + githubHTMLBase, githubRepo, version, ) checksumURL := fmt.Sprintf( - "https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/download/%s/gost-{ARCH}.sha256", - version, + githubProxy+"/%s/%s/releases/download/%s/gost-{ARCH}.sha256", + githubHTMLBase, githubRepo, version, ) type upgradeResult struct { @@ -186,13 +199,19 @@ func (h *Handler) listReleases(w http.ResponseWriter, r *http.Request) { } client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Get("https://gcode.hostcentral.cc/https://api.github.com/repos/Sagit-chu/flux-panel/releases?per_page=20") + 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"` From 73bf672e62455b06d32506708aaa774b08dafd35 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 08:07:04 +0000 Subject: [PATCH 10/16] fix: use systemd-run for agent upgrade/rollback restart to avoid cgroup kill --- go-gost/x/socket/websocket_reporter.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index 63fb854..8376ce1 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -18,7 +18,6 @@ import ( "strconv" "strings" "sync" // 新增:用于管理连接状态的互斥锁 - "syscall" "time" "github.com/go-gost/x/config" @@ -1043,10 +1042,11 @@ func (w *WebSocketReporter) handleUpgradeAgent(data interface{}) error { 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("/bin/sh", "-c", script) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) if err := cmd.Start(); err != nil { os.Remove(tmpPath) return fmt.Errorf("启动重启脚本失败: %v", err) @@ -1068,10 +1068,9 @@ func (w *WebSocketReporter) handleRollbackAgent(data interface{}) error { 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("/bin/sh", "-c", script) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd := exec.Command("systemd-run", "--quiet", "/bin/sh", "-c", script) if err := cmd.Start(); err != nil { return fmt.Errorf("启动回退脚本失败: %v", err) } From f4e56d091ebe7c48a3c0e8e76d6e1c302863ecc9 Mon Sep 17 00:00:00 2001 From: sagit Date: Wed, 11 Feb 2026 08:45:58 +0000 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8C=89=E9=92=AE=E6=94=B9=E4=B8=BA=E4=B8=A4=E8=A1=8C?= =?UTF-8?q?=20grid=20=E5=B8=83=E5=B1=80=EF=BC=8C=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=BB=91=E5=8A=A8=E6=BA=A2=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 操作按钮从单行 flex 改为两行 grid (3+2),避免窄屏挤压 - SortableItem 和 Card 添加 overflow-hidden,阻止内容溢出导致页面横滑 --- vite-frontend/src/pages/node.tsx | 96 ++++++++++++++++---------------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/vite-frontend/src/pages/node.tsx b/vite-frontend/src/pages/node.tsx index 63d7730..14b6f97 100644 --- a/vite-frontend/src/pages/node.tsx +++ b/vite-frontend/src/pages/node.tsx @@ -125,7 +125,7 @@ const SortableItem = ({ }; return ( -
+
{children(listeners)}
); @@ -1253,7 +1253,7 @@ export default function NodePage() { {(listeners) => (
@@ -1514,54 +1514,56 @@ export default function NodePage() { {/* 操作按钮 */}
-
+ {!isRemoteNode && ( +
+ + + +
+ )} +
{!isRemoteNode && ( - <> - - - - - + )}