From 7d63dd4cc31c5f5b352dd65b05386f1420e6da43 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Wed, 13 May 2026 10:49:33 +0800 Subject: [PATCH] docs: add proxy protocol analysis and panel self-upgrade plans --- docs/proxy-protocol-analysis.md | 178 +++ .../plans/2026-05-04-panel-self-upgrade.md | 1316 +++++++++++++++++ .../2026-05-04-panel-self-upgrade-design.md | 295 ++++ 3 files changed, 1789 insertions(+) create mode 100644 docs/proxy-protocol-analysis.md create mode 100644 docs/superpowers/plans/2026-05-04-panel-self-upgrade.md create mode 100644 docs/superpowers/specs/2026-05-04-panel-self-upgrade-design.md diff --git a/docs/proxy-protocol-analysis.md b/docs/proxy-protocol-analysis.md new file mode 100644 index 0000000..f2ae996 --- /dev/null +++ b/docs/proxy-protocol-analysis.md @@ -0,0 +1,178 @@ +# Proxy Protocol 传输分析报告 + +**日期**: 2026-05-07 +**测试环境**: 20.118.172.127 (Server 1) ↔ 108.181.90.137 (Server 2) + +--- + +## 1. 代码流程分析 + +### 完整数据链路 + +``` +前端 (proxyProtocol: 0|1|2) + → 后端 handler mutations.go:1936 + → 数据库存储 forward.proxy_protocol (model.go:50) + → 控制面 buildForwardServiceConfigs (control_plane.go:1791-1796) + → handler metadata: {"proxyProtocol": 2} + → Agent metadata 解析 (metadata.go:42) + → handler.go:256 WrapClientConn() + → conn.go:14 HeaderProxyFromAddrs(byte(ppv), src, dst) + → conn.go:15 header.WriteTo(c) + → 目标服务器收到 PROXY protocol header +``` + +### 关键代码 + +**写入 PROXY header** (`go-gost/x/internal/net/proxyproto/conn.go`): +```go +func WrapClientConn(ppv int, src, dst net.Addr, c net.Conn) net.Conn { + if ppv <= 0 { + return c + } + header := proxyproto.HeaderProxyFromrs(byte(ppv), src, dst) + header.WriteTo(c) + return c +} +``` + +**Handler 调用** (`go-gost/x/handler/forward/local/handler.go:256`): +```go +cc = proxyproto.WrapClientConn(h.md.proxyProtocol, conn.RemoteAddr(), conn.LocalAddr(), cc) +``` + +- `src` = `conn.RemoteAddr()` → 客户端真实 IP ✅ +- `dst` = `conn.LocalAddr()` → agent 监听地址 ✅ +- `ppv` = 1 或 2 → 版本号正确 ✅ + +--- + +## 2. 实际传输测试结果 + +### 测试方法 + +1. 在 Server 2 启动 Python TCP 监听器,解析 PROXY protocol header +2. 在 Server 1 用当前代码编译 gost,配置 `proxyProtocol: 2` 转发到 Server 2 +3. 通过 `nc` 发送测试数据,验证 Server 2 是否收到正确的 PROXY header + +### 测试结果 + +| 版本 | 状态 | 接收到的 Header | +|------|------|----------------| +| **PPv2** | ✅ 成功 | `PP2 family=1 alen=12 SRC=127.0.0.1:45410 DST=127.0.0.1:20001` | +| **PPv1** | ✅ 成功 | `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001` | + +### 测试详情 + +**PPv2 原始数据**: +``` +Got 28 bytes +PP2 family=1 alen=12 +SRC=127.0.0.1:45410 DST=127.0.0.1:20001 +``` + +**PPv1 原始数据** (hex): +``` +50524f58592054435034203132372e302e302e31203132372e302e302e312034333831362032303030310d0a +``` +解码: `PROXY TCP4 127.0.0.1 127.0.0.1 43816 20001` + +--- + +## 3. 单元测试结果 + +``` +go-gost/x/handler/forward/local/ → TestLocalForwardHandlerSendsProxyProtocolToTarget ✅ +go-backend/internal/http/handler/ → TestBuildForwardServiceConfigsSendsProxyProtocolToForwardHandler ✅ +go-backend/internal/store/repo/ → TestGetForwardRecordIncludesProxyProtocol ✅ +``` + +全部通过 (3/3)。 + +--- + +## 4. 发现的问题 + +### 问题 1: `WriteTo` 错误未检查 + +**位置**: `go-gost/x/internal/net/proxyproto/conn.go:15` + +```go +header.WriteTo(c) // 返回 (int64, error) 被忽略 +``` + +**影响**: 如果写入失败(连接已断开、网络错误等),后续数据传输会在没有 PROXY header 的情况下继续,目标服务器可能解析出错。 + +**建议**: +```go +if _, err := header.WriteTo(c); err != nil { + return c // 或包装一个带错误的 conn +} +``` + +**严重程度**: 低(实际场景中,写入失败后 `Transport` 也会很快失败) + +--- + +### 问题 2: 部署版本过旧 + +**服务器状态**: + +| 服务器 | 组件 | 版本 | 状态 | +|--------|------|------|------| +| 20.118.172.127 | flux_agent | UPX 压缩,无法读取版本 | ✅ 运行中 | +| 20.118.172.127 | paneld | `/app/paneld` | ✅ 运行中 | +| 20.118.172.127 | /usr/local/bin/gost | v3.0.0 (go1.23.4) | 旧版,不支持 handler metadata 中的 proxyProtocol | +| 108.181.90.137 | flux_agent | 8.8MB | ✅ 运行中 | + +**影响**: 旧版 gost 二进制不识别 handler metadata 中的 `proxyProtocol` 字段,PROXY protocol 功能在生产环境不可用。 + +**验证**: 用旧版 gost 测试时,目标服务器收到的原始数据为空,无 PROXY header。 + +--- + +### 问题 3: 数据库 Schema 缺失 + +**位置**: 20.118.172.127 的 `/app/data/gost.db` + +**当前 forward 表 schema**: +```sql +CREATE TABLE `forward` ( + `id` integer PRIMARY KEY AUTOINCREMENT, + `user_id` integer NOT NULL, + `user_name` varchar(100) NOT NULL, + `name` varchar(100) NOT NULL, + `tunnel_id` integer NOT NULL, + `remote_addr` text NOT NULL, + `strategy` varchar(100) NOT NULL DEFAULT "fifo", + `in_flow` integer NOT NULL DEFAULT 0, + `out_flow` integer NOT NULL DEFAULT 0, + `created_time` integer NOT NULL, + `updated_time` integer NOT NULL, + `status` integer NOT NULL, + `inx` integer NOT NULL DEFAULT 0, + `speed_id` integer +); +``` + +**缺失字段**: +- `proxy_protocol` — PROXY protocol 版本 +- `max_conn` — 最大连接数 +- `ip_max_conn` — 每 IP 最大连接数 +- `ip_speed_id` — 每 IP 限速 ID + +**影响**: 后端无法存储和读取 proxy_protocol 配置,前端设置不会生效。 + +--- + +## 5. 结论 + +| 维度 | 状态 | 说明 | +|------|------|------| +| **代码实现** | ✅ 正确 | 完整的写入链路,版本/地址正确 | +| **单元测试** | ✅ 通过 | 3/3 测试覆盖 handler、repo、控制面 | +| **实际传输 (新编译版)** | ✅ 成功 | PPv1 和 PPv2 均正确传输 | +| **实际传输 (部署版)** | ❌ 不工作 | 旧版不支持 handler metadata 中的 proxyProtocol | +| **数据库 Schema** | ❌ 缺字段 | 需要迁移添加 proxy_protocol 等列 | + +**总结**: 代码实现正确,PROXY protocol 传输逻辑无误。但生产服务器运行的是旧版本,需要升级 backend 和 agent 才能启用此功能。 diff --git a/docs/superpowers/plans/2026-05-04-panel-self-upgrade.md b/docs/superpowers/plans/2026-05-04-panel-self-upgrade.md new file mode 100644 index 0000000..565e9b5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-panel-self-upgrade.md @@ -0,0 +1,1316 @@ +# Panel Self-Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an admin-only “panel self-upgrade” flow that checks FLVX GitHub releases and upgrades the backend/frontend Docker Compose deployment from the settings page. + +**Architecture:** Keep the upgrade entrypoint inside `go-backend`, but do not let the backend container rebuild itself directly. The backend updates the mounted deployment files, discovers its current image ID through Docker, and launches a detached helper container from that same image. The helper container inherits the deployment bind mount and Docker socket, then runs the fixed `docker compose pull backend frontend` and `docker compose up -d backend frontend` sequence. The frontend adds a new card to `src/pages/config.tsx` that shows capability state, checks updates, and triggers the upgrade with a confirmation modal. + +**Tech Stack:** Go 1.25 / net-http handlers / existing release helpers in `upgrade.go` / Docker CLI via multi-stage Dockerfile copy / React 18 / TypeScript / HeroUI bridge / existing `Network.post` API client. + +**Execution Constraints:** Do not create a git commit during execution unless the user explicitly asks for one in that implementation session. + +--- + +## File Map + +- Create: `go-backend/internal/http/handler/system_upgrade.go` + Responsibility: system upgrade data types, capability checks, `.env` updates, compose asset selection, helper-container launch, and HTTP handlers for `/api/v1/system/version`, `/api/v1/system/check-updates`, and `/api/v1/system/upgrade`. +- Create: `go-backend/internal/http/handler/system_upgrade_test.go` + Responsibility: helper logic unit tests, method-guard tests, and upgrade lock tests. +- Modify: `go-backend/internal/http/handler/handler.go` + Responsibility: add a dedicated mutex field and register new system upgrade routes. +- Modify: `go-backend/internal/http/handler/upgrade.go` + Responsibility: reuse existing release-channel helpers and GitHub proxy URL builder from system upgrade code. +- Modify: `go-backend/Dockerfile` + Responsibility: copy Docker CLI and compose plugin into the runtime image. +- Modify: `docker-compose-v4.yml` + Responsibility: pass `FLUX_VERSION`, `PANEL_DEPLOY_DIR`, `PANEL_BACKEND_CONTAINER`, mount Docker socket, and mount the deployment directory. +- Modify: `docker-compose-v6.yml` + Responsibility: same runtime wiring as v4 while preserving IPv6 network config. +- Modify: `vite-frontend/src/api/types.ts` + Responsibility: add typed response contracts for system upgrade status, release list, and run result. +- Modify: `vite-frontend/src/api/index.ts` + Responsibility: add `getSystemUpgradeVersion`, `checkSystemUpgrade`, and `runSystemUpgrade` wrappers. +- Modify: `vite-frontend/src/pages/config.tsx` + Responsibility: add upgrade card, loading state, release list, capability reason, confirmation modal, and upgrade action. + +### Task 1: Build the backend helper core with unit tests + +**Files:** +- Create: `go-backend/internal/http/handler/system_upgrade.go` +- Create: `go-backend/internal/http/handler/system_upgrade_test.go` + +- [ ] **Step 1: Write the failing helper tests** + +```go +package handler + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestSelectComposeAssetUsesIPv6Template(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + compose := []byte("networks:\n gost-network:\n enable_ipv6: true\n") + + if got := exec.selectComposeAsset(compose); got != "docker-compose-v6.yml" { + t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v6.yml") + } +} + +func TestSelectComposeAssetFallsBackToIPv4Template(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + compose := []byte("services:\n backend:\n image: test\n") + + if got := exec.selectComposeAsset(compose); got != "docker-compose-v4.yml" { + t.Fatalf("selectComposeAsset() = %q, want %q", got, "docker-compose-v4.yml") + } +} + +func TestUpdateEnvVersionReplacesExistingValue(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("FLUX_VERSION=2.1.8\nJWT_SECRET=test\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil { + t.Fatalf("updateEnvVersion() error = %v", err) + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + want := "FLUX_VERSION=2.1.9\nJWT_SECRET=test\n" + if string(data) != want { + t.Fatalf("env content = %q, want %q", string(data), want) + } +} + +func TestUpdateEnvVersionAppendsMissingValue(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("JWT_SECRET=test\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + exec := &systemUpgradeExecutor{deployDir: dir, backendContainer: "flux-panel-backend"} + if err := exec.updateEnvVersion(envPath, "2.1.9"); err != nil { + t.Fatalf("updateEnvVersion() error = %v", err) + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + want := "JWT_SECRET=test\nFLUX_VERSION=2.1.9\n" + if string(data) != want { + t.Fatalf("env content = %q, want %q", string(data), want) + } +} + +func TestValidateBackendContainerNameRejectsUnsafeValue(t *testing.T) { + if err := validateBackendContainerName("flux-panel-backend;rm -rf /"); err == nil { + t.Fatal("expected unsafe container name to fail validation") + } +} + +func TestBuildHelperRunArgsUsesDetachedContainer(t *testing.T) { + exec := &systemUpgradeExecutor{deployDir: "/opt/flvx-panel", backendContainer: "flux-panel-backend"} + args := exec.buildHelperRunArgs("sha256:abc", "flvx-upgrade-helper") + want := []string{ + "run", "-d", "--rm", "--name", "flvx-upgrade-helper", + "--volumes-from", "flux-panel-backend", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel", + "--entrypoint", "/bin/sh", "sha256:abc", + "-c", exec.helperScript(), + } + + if !reflect.DeepEqual(args, want) { + t.Fatalf("buildHelperRunArgs() = %#v, want %#v", args, want) + } +} +``` + +- [ ] **Step 2: Run the focused backend tests to verify they fail** + +Run: + +```bash +go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1 +``` + +Expected: FAIL with errors such as `undefined: systemUpgradeExecutor`, `undefined: validateBackendContainerName`, and `undefined: (*systemUpgradeExecutor).updateEnvVersion`. + +- [ ] **Step 3: Write the minimal helper implementation** + +Create `go-backend/internal/http/handler/system_upgrade.go` with this initial helper core: + +```go +package handler + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +const ( + panelDeployDirEnv = "PANEL_DEPLOY_DIR" + panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER" + defaultPanelDeployDir = "/opt/flvx-panel" + defaultPanelBackendName = "flux-panel-backend" + dockerSocketPath = "/var/run/docker.sock" + systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启" + systemUpgradeConflictError = "已有面板升级任务执行中" +) + +var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + +type systemUpgradeExecutor struct { + deployDir string + backendContainer string +} + +func newSystemUpgradeExecutor() *systemUpgradeExecutor { + deployDir := strings.TrimSpace(os.Getenv(panelDeployDirEnv)) + if deployDir == "" { + deployDir = defaultPanelDeployDir + } + + backendContainer := strings.TrimSpace(os.Getenv(panelBackendContainerEnv)) + if backendContainer == "" { + backendContainer = defaultPanelBackendName + } + + return &systemUpgradeExecutor{ + deployDir: deployDir, + backendContainer: backendContainer, + } +} + +func validateBackendContainerName(value string) error { + if value == "" { + return fmt.Errorf("backend container name is empty") + } + if !safeBackendContainerPattern.MatchString(value) { + return fmt.Errorf("unsafe backend container name: %s", value) + } + return nil +} + +func (e *systemUpgradeExecutor) composePath() string { + return filepath.Join(e.deployDir, "docker-compose.yml") +} + +func (e *systemUpgradeExecutor) envPath() string { + return filepath.Join(e.deployDir, ".env") +} + +func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string { + if strings.Contains(string(current), "enable_ipv6: true") { + return "docker-compose-v6.yml" + } + return "docker-compose-v4.yml" +} + +func (e *systemUpgradeExecutor) helperScript() string { + return strings.Join([]string{ + "set -eu", + `cd "$PANEL_DEPLOY_DIR"`, + "docker compose pull backend frontend", + "sleep 5", + "docker compose up -d backend frontend", + }, "\n") +} + +func (e *systemUpgradeExecutor) buildHelperRunArgs(imageID, helperName string) []string { + return []string{ + "run", "-d", "--rm", "--name", helperName, + "--volumes-from", e.backendContainer, + "-v", dockerSocketPath + ":" + dockerSocketPath, + "-e", panelDeployDirEnv + "=" + e.deployDir, + "--entrypoint", "/bin/sh", + imageID, + "-c", e.helperScript(), + } +} + +func (e *systemUpgradeExecutor) updateEnvVersion(envPath, version string) error { + data, err := os.ReadFile(envPath) + if err != nil { + return err + } + + lines := strings.Split(string(data), "\n") + replaced := false + for i, line := range lines { + if strings.HasPrefix(line, "FLUX_VERSION=") { + lines[i] = "FLUX_VERSION=" + version + replaced = true + } + } + if !replaced { + trimmed := strings.TrimRight(strings.Join(lines, "\n"), "\n") + if trimmed == "" { + trimmed = "FLUX_VERSION=" + version + } else { + trimmed += "\nFLUX_VERSION=" + version + } + return os.WriteFile(envPath, []byte(trimmed+"\n"), 0o644) + } + + content := strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n" + return os.WriteFile(envPath, []byte(content), 0o644) +} +``` + +- [ ] **Step 4: Run the focused backend tests again** + +Run: + +```bash +go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit only if the user explicitly requested a commit** + +```bash +git add go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go +git commit -m "feat: add panel upgrade helper core" +``` + +### Task 2: Wire backend handlers, capability checks, and upgrade orchestration + +**Files:** +- Modify: `go-backend/internal/http/handler/handler.go` +- Modify: `go-backend/internal/http/handler/upgrade.go` +- Modify: `go-backend/internal/http/handler/system_upgrade.go` +- Modify: `go-backend/internal/http/handler/system_upgrade_test.go` + +- [ ] **Step 1: Add failing handler tests for method guards and the upgrade lock** + +Append these tests to `go-backend/internal/http/handler/system_upgrade_test.go`: + +```go +func TestSystemVersionRejectsWrongMethod(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodGet, "/api/v1/system/version", nil) + rr := httptest.NewRecorder() + + h.systemVersion(rr, req) + + if !strings.Contains(rr.Body.String(), "请求失败") { + t.Fatalf("expected wrong-method response, got %s", rr.Body.String()) + } +} + +func TestSystemUpgradeRejectsConcurrentRequests(t *testing.T) { + h := &Handler{} + h.systemUpgradeMu.Lock() + defer h.systemUpgradeMu.Unlock() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`)) + rr := httptest.NewRecorder() + + h.systemUpgrade(rr, req) + + if !strings.Contains(rr.Body.String(), systemUpgradeConflictError) { + t.Fatalf("expected conflict message, got %s", rr.Body.String()) + } +} +``` + +- [ ] **Step 2: Run the new backend tests to verify they fail** + +Run: + +```bash +go test ./internal/http/handler -run 'Test(SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests)' -count=1 +``` + +Expected: FAIL with errors such as `h.systemVersion undefined`, `h.systemUpgrade undefined`, and `Handler has no field or method systemUpgradeMu`. + +- [ ] **Step 3: Implement the system upgrade handlers and orchestration** + +Update `go-backend/internal/http/handler/handler.go` so `Handler` has a dedicated system-upgrade mutex and route registration: + +```go +type Handler struct { + repo *repo.Repository + jwtSecret string + wsServer *ws.Server + metrics *metrics.IngestionService + healthCheck *health.Checker + + captchaMu sync.Mutex + captchaTokens map[string]int64 + + jobsMu sync.Mutex + jobsCancel context.CancelFunc + jobsStarted bool + jobsWG sync.WaitGroup + + systemUpgradeMu sync.Mutex + upgradeMu sync.Mutex + pendingUpgradeRedeploy map[int64]struct{} + nodeOnlineRedeployAt map[int64]time.Time + nodeOnlineRedeployQueued map[int64]struct{} + nodeOnlineRedeploying map[int64]struct{} + + qualityProber *tunnelQualityProber +} + +func (h *Handler) Register(mux *http.ServeMux) { + // existing handlers... + mux.HandleFunc("/api/v1/system/storage", h.storageSummary) + mux.HandleFunc("/api/v1/system/version", h.systemVersion) + mux.HandleFunc("/api/v1/system/check-updates", h.systemCheckUpdates) + mux.HandleFunc("/api/v1/system/upgrade", h.systemUpgrade) + // existing handlers... +} +``` + +Extend `go-backend/internal/http/handler/system_upgrade.go` with the capability/result types and the minimal orchestration functions: + +```go +type systemUpgradeCapability struct { + Capable bool `json:"capable"` + Reason string `json:"reason,omitempty"` + DeployDir string `json:"deployDir,omitempty"` + ComposeFile string `json:"composeFile,omitempty"` + BackendContainer string `json:"backendContainer,omitempty"` +} + +type systemUpgradeVersionData struct { + CurrentVersion string `json:"currentVersion"` + Channel string `json:"channel"` + LatestVersion string `json:"latestVersion,omitempty"` + HasUpdate bool `json:"hasUpdate"` + Capable bool `json:"capable"` + Reason string `json:"reason,omitempty"` + DeployDir string `json:"deployDir,omitempty"` + ComposeFile string `json:"composeFile,omitempty"` + BackendContainer string `json:"backendContainer,omitempty"` +} + +type systemUpgradeCheckData struct { + CurrentVersion string `json:"currentVersion"` + Channel string `json:"channel"` + LatestVersion string `json:"latestVersion,omitempty"` + HasUpdate bool `json:"hasUpdate"` + Capable bool `json:"capable"` + Reason string `json:"reason,omitempty"` + DeployDir string `json:"deployDir,omitempty"` + ComposeFile string `json:"composeFile,omitempty"` + BackendContainer string `json:"backendContainer,omitempty"` + Releases []systemUpgradeReleaseItem `json:"releases"` +} + +type systemUpgradeReleaseItem struct { + Version string `json:"version"` + Name string `json:"name"` + PublishedAt string `json:"publishedAt"` + Prerelease bool `json:"prerelease"` + Channel string `json:"channel"` +} + +type systemUpgradeRunData struct { + Version string `json:"version"` + Message string `json:"message"` + Commands []string `json:"commands"` + HelperContainerID string `json:"helperContainerId,omitempty"` +} + +func currentPanelVersion() string { + version := strings.TrimSpace(os.Getenv("FLUX_VERSION")) + if version == "" { + return "dev" + } + return version +} + +func (e *systemUpgradeExecutor) checkCapability(ctx context.Context) systemUpgradeCapability { + capability := systemUpgradeCapability{ + DeployDir: e.deployDir, + ComposeFile: e.composePath(), + BackendContainer: e.backendContainer, + } + + if e.deployDir == "" || !filepath.IsAbs(e.deployDir) { + capability.Reason = "PANEL_DEPLOY_DIR 必须是已挂载的绝对路径" + return capability + } + if err := validateBackendContainerName(e.backendContainer); err != nil { + capability.Reason = err.Error() + return capability + } + if _, err := exec.LookPath("docker"); err != nil { + capability.Reason = "docker CLI 不可用" + return capability + } + if _, err := os.Stat(dockerSocketPath); err != nil { + capability.Reason = "未挂载 /var/run/docker.sock" + return capability + } + if _, err := os.Stat(capability.ComposeFile); err != nil { + capability.Reason = "未找到 docker-compose.yml" + return capability + } + if _, err := os.Stat(e.envPath()); err != nil { + capability.Reason = "未找到 .env" + return capability + } + + cmd := exec.CommandContext(ctx, "docker", "compose", "version") + if out, err := cmd.CombinedOutput(); err != nil { + capability.Reason = fmt.Sprintf("docker compose 不可用: %s", strings.TrimSpace(string(out))) + return capability + } + + capability.Capable = true + return capability +} + +func (e *systemUpgradeExecutor) backupFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(path+".upgrade.bak", data, 0o644) +} + +func (e *systemUpgradeExecutor) replaceComposeFile(composeData []byte) error { + tmpPath := e.composePath() + ".tmp" + if err := os.WriteFile(tmpPath, composeData, 0o644); err != nil { + return err + } + return os.Rename(tmpPath, e.composePath()) +} + +func (e *systemUpgradeExecutor) currentBackendImage(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", e.backendContainer) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("inspect backend image failed: %s", strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID string) (string, error) { + helperName := fmt.Sprintf("flvx-upgrade-helper-%d", time.Now().Unix()) + args := e.buildHelperRunArgs(imageID, helperName) + cmd := exec.CommandContext(ctx, "docker", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("start helper failed: %s", strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) { + url := h.buildGithubDownloadURL(version, filename) + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("download %s failed: %w", filename, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("download %s failed: %d %s", filename, resp.StatusCode, strings.TrimSpace(string(body))) + } + return io.ReadAll(resp.Body) +} + +func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + executor := newSystemUpgradeExecutor() + capability := executor.checkCapability(r.Context()) + latestVersion, _ := resolveLatestReleaseByChannel(releaseChannelStable) + currentVersion := currentPanelVersion() + + response.WriteJSON(w, response.OK(systemUpgradeVersionData{ + CurrentVersion: currentVersion, + Channel: releaseChannelStable, + LatestVersion: latestVersion, + HasUpdate: latestVersion != "" && latestVersion != currentVersion, + Capable: capability.Capable, + Reason: capability.Reason, + DeployDir: capability.DeployDir, + ComposeFile: capability.ComposeFile, + BackendContainer: capability.BackendContainer, + })) +} + +func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + Channel string `json:"channel"` + } + if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + channel := normalizeReleaseChannel(req.Channel) + releases, err := fetchGitHubReleases(50) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) + return + } + + items := make([]systemUpgradeReleaseItem, 0, len(releases)) + for _, release := range releases { + if release.Draft { + continue + } + tag := strings.TrimSpace(release.TagName) + if tag == "" || releaseChannelFromTag(tag) != channel { + continue + } + items = append(items, systemUpgradeReleaseItem{ + Version: tag, + Name: release.Name, + PublishedAt: release.PublishedAt, + Prerelease: channel == releaseChannelDev, + Channel: channel, + }) + } + + executor := newSystemUpgradeExecutor() + capability := executor.checkCapability(r.Context()) + latestVersion := "" + if len(items) > 0 { + latestVersion = items[0].Version + } + currentVersion := currentPanelVersion() + + response.WriteJSON(w, response.OK(systemUpgradeCheckData{ + CurrentVersion: currentVersion, + Channel: channel, + LatestVersion: latestVersion, + HasUpdate: latestVersion != "" && latestVersion != currentVersion, + Capable: capability.Capable, + Reason: capability.Reason, + DeployDir: capability.DeployDir, + ComposeFile: capability.ComposeFile, + BackendContainer: capability.BackendContainer, + Releases: items, + })) +} + +func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.systemUpgradeMu.TryLock() { + response.WriteJSON(w, response.Err(-2, systemUpgradeConflictError)) + return + } + defer h.systemUpgradeMu.Unlock() + + var req struct { + Version string `json:"version"` + Channel string `json:"channel"` + } + if err := decodeJSON(r.Body, &req); err != nil && err != io.EOF { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + channel := normalizeReleaseChannel(req.Channel) + version := strings.TrimSpace(req.Version) + if version == "" { + resolved, err := resolveLatestReleaseByChannel(channel) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) + return + } + version = resolved + } + + executor := newSystemUpgradeExecutor() + capability := executor.checkCapability(r.Context()) + if !capability.Capable { + response.WriteJSON(w, response.Err(-2, capability.Reason)) + return + } + + currentCompose, err := os.ReadFile(executor.composePath()) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("读取 compose 文件失败: %v", err))) + return + } + assetName := executor.selectComposeAsset(currentCompose) + composeData, err := h.downloadReleaseAsset(version, assetName) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := executor.backupFile(executor.composePath()); err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("备份 compose 文件失败: %v", err))) + return + } + if err := executor.backupFile(executor.envPath()); err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("备份 .env 失败: %v", err))) + return + } + if err := executor.replaceComposeFile(composeData); err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("更新 compose 文件失败: %v", err))) + return + } + if err := executor.updateEnvVersion(executor.envPath(), version); err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("更新 FLUX_VERSION 失败: %v", err))) + return + } + + helperCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + imageID, err := executor.currentBackendImage(helperCtx) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + helperContainerID, err := executor.startHelper(helperCtx, imageID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(systemUpgradeRunData{ + Version: version, + Message: systemUpgradeMessage, + HelperContainerID: helperContainerID, + Commands: []string{ + "docker run -d --rm --volumes-from flux-panel-backend ...", + "docker compose pull backend frontend", + "docker compose up -d backend frontend", + }, + })) +} +``` + +Leave `go-backend/internal/http/handler/upgrade.go` using the existing release-channel helpers and `buildGithubDownloadURL` as-is; `system_upgrade.go` should call those helpers rather than duplicating them. + +- [ ] **Step 4: Run the focused backend tests again** + +Run: + +```bash +go test ./internal/http/handler -run 'Test(SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests|SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Commit only if the user explicitly requested a commit** + +```bash +git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/upgrade.go go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go +git commit -m "feat: add panel self-upgrade backend" +``` + +### Task 3: Add Docker CLI support and deployment mounts + +**Files:** +- Modify: `go-backend/Dockerfile` +- Modify: `docker-compose-v4.yml` +- Modify: `docker-compose-v6.yml` + +- [ ] **Step 1: Update the Docker Compose templates with runtime env and mounts** + +Change both `docker-compose-v4.yml` and `docker-compose-v6.yml` backend services to include these exact lines: + +```yaml + backend: + image: ghcr.io/sagit-chu/flux-panel-backend:${FLUX_VERSION:-latest} + container_name: flux-panel-backend + restart: unless-stopped + environment: + DB_TYPE: ${DB_TYPE:-sqlite} + DB_PATH: /app/data/gost.db + DATABASE_URL: ${DATABASE_URL:-} + JWT_SECRET: ${JWT_SECRET} + SERVER_ADDR: :6365 + TZ: Asia/Shanghai + FLUX_VERSION: ${FLUX_VERSION:-dev} + PANEL_DEPLOY_DIR: /opt/flvx-panel + PANEL_BACKEND_CONTAINER: flux-panel-backend + volumes: + - sqlite_data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + - ./:/opt/flvx-panel +``` + +- [ ] **Step 2: Validate the two Compose templates render cleanly** + +Run: + +```bash +JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v4.yml config >/tmp/flvx-v4.rendered.yml +JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v6.yml config >/tmp/flvx-v6.rendered.yml +``` + +Expected: both commands exit `0`, and each rendered backend service includes `PANEL_DEPLOY_DIR`, `PANEL_BACKEND_CONTAINER`, and `/var/run/docker.sock:/var/run/docker.sock`. + +- [ ] **Step 3: Update the backend runtime image to include Docker CLI + compose plugin** + +Replace `go-backend/Dockerfile` with this structure: + +```dockerfile +FROM golang:1.25-bookworm AS builder +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld + +FROM docker:27-cli AS dockercli + +FROM debian:bookworm-slim +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/* +COPY --from=builder /out/paneld /app/paneld +COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose + +ENV SERVER_ADDR=:6365 +EXPOSE 6365 +ENTRYPOINT ["/app/paneld"] +``` + +- [ ] **Step 4: Build and smoke-test the backend image** + +Run: + +```bash +docker build -t flvx-backend-system-upgrade-test ./go-backend +docker run --rm flvx-backend-system-upgrade-test docker compose version +``` + +Expected: the image build exits `0`, and the container prints a compose version string and exits `0`. + +- [ ] **Step 5: Commit only if the user explicitly requested a commit** + +```bash +git add go-backend/Dockerfile docker-compose-v4.yml docker-compose-v6.yml +git commit -m "feat: wire docker runtime for panel upgrades" +``` + +### Task 4: Add typed frontend API wrappers for system upgrade + +**Files:** +- Modify: `vite-frontend/src/api/types.ts` +- Modify: `vite-frontend/src/api/index.ts` +- Modify: `vite-frontend/src/pages/config.tsx` + +**Note:** This repo has no frontend unit test runner. Use `pnpm run build` as the failing/passing contract check for the TypeScript surface. + +- [ ] **Step 1: Make the config page import the not-yet-existing API members** + +Update the imports at the top of `vite-frontend/src/pages/config.tsx` to include the missing members before you implement them: + +```tsx +import { + updateConfigs, + activateLicense, + exportBackup, + importBackup, + getAnnouncement, + updateAnnouncement, + getStorageSummary, + getSystemUpgradeVersion, + checkSystemUpgrade, + runSystemUpgrade, + type AnnouncementData, +} from "@/api"; +import type { + NodeReleaseApiItem, + SystemUpgradeCheckApiData, + SystemUpgradeRunApiData, + SystemUpgradeVersionApiData, +} from "@/api/types"; +``` + +- [ ] **Step 2: Run the frontend build to verify it fails** + +Run: + +```bash +pnpm run build +``` + +Expected: FAIL with TypeScript errors such as `Module '"@/api"' has no exported member 'getSystemUpgradeVersion'` and `Module '"@/api/types"' has no exported member 'SystemUpgradeVersionApiData'`. + +- [ ] **Step 3: Add the missing frontend types and API functions** + +Append these interfaces near the existing `StorageSummaryApiData` and monitor types in `vite-frontend/src/api/types.ts`: + +```ts +export interface SystemUpgradeVersionApiData { + currentVersion: string; + channel: "stable" | "dev"; + latestVersion?: string; + hasUpdate: boolean; + capable: boolean; + reason?: string; + deployDir?: string; + composeFile?: string; + backendContainer?: string; +} + +export interface SystemUpgradeCheckApiData + extends SystemUpgradeVersionApiData { + releases: NodeReleaseApiItem[]; +} + +export interface SystemUpgradeRunApiData { + version: string; + message: string; + commands: string[]; + helperContainerId?: string; +} +``` + +Then update the top import block and exports in `vite-frontend/src/api/index.ts`: + +```ts +import type { + BatchOperationResult, + ForwardDiagnosisApiData, + ForwardApiItem, + GroupPermissionApiItem, + NodeReleaseApiItem, + NodeApiItem, + SpeedLimitApiItem, + StorageSummaryApiData, + SystemUpgradeCheckApiData, + SystemUpgradeRunApiData, + SystemUpgradeVersionApiData, + TunnelApiItem, + TunnelBatchDeletePreviewApiData, + TunnelBatchDeleteWithForwardsApiData, + TunnelDeletePreviewApiData, + TunnelDeleteWithForwardsApiData, + TunnelDiagnosisApiData, + TunnelGroupApiItem, + TunnelMetricApiItem, + TunnelQualityApiItem, + UpdatePasswordPayload, + UserApiItem, + UserGroupApiItem, + UserListQuery, + UserMutationPayload, + UserPackageInfoApiData, + UserQuotaResetPayload, + UserTunnelApiItem, + UserTunnelAssignPayload, + UserTunnelListQuery, + UserTunnelPermissionApiItem, + UserTunnelRemovePayload, +} from "./types"; + +export const getSystemUpgradeVersion = () => + Network.post("/system/version"); + +export const checkSystemUpgrade = (channel: ReleaseChannel = "stable") => + Network.post("/system/check-updates", { + channel, + }); + +export const runSystemUpgrade = ( + version?: string, + channel: ReleaseChannel = "stable", +) => + Network.post( + "/system/upgrade", + { version: version || "", channel }, + { timeout: 60 * 1000 }, + ); +``` + +- [ ] **Step 4: Run the frontend build again** + +Run: + +```bash +pnpm run build +``` + +Expected: PASS, because the new imports now exist even though the config page does not render the upgrade UI yet. + +- [ ] **Step 5: Commit only if the user explicitly requested a commit** + +```bash +git add vite-frontend/src/api/index.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/config.tsx +git commit -m "feat: add frontend api surface for panel upgrades" +``` + +### Task 5: Add the settings-page upgrade card and confirmation modal + +**Files:** +- Modify: `vite-frontend/src/pages/config.tsx` + +**Note:** This repo has no frontend unit test runner. Use `pnpm run build` as the failing/passing contract check for the UI state and JSX wiring. + +- [ ] **Step 1: Add the upgrade card and modal markup before defining the state/handlers** + +Insert this JSX block just after the database storage section in `vite-frontend/src/pages/config.tsx`, before the save button row: + +```tsx + + +
+
+
+

+ 面板升级 +

+

+ 升级 backend 与 frontend 容器。操作会短暂中断面板访问,仅管理员可用。 +

+
+
+ + +
+
+ +
+ {systemUpgradeLoading ? ( +

加载升级状态中...

+ ) : ( +
+

当前版本:{systemUpgradeInfo?.currentVersion || "未知"}

+

最新版本:{systemUpgradeInfo?.latestVersion || "未检查"}

+

当前通道:{updateChannel === "stable" ? "稳定版" : "开发版"}

+

+ 升级能力: + {systemUpgradeInfo?.capable ? "可用" : systemUpgradeInfo?.reason || "不可用"} +

+ {systemUpgradeInfo?.deployDir ? ( +

部署目录:{systemUpgradeInfo.deployDir}

+ ) : null} +
+ )} +
+
+ + + + 确认升级面板 + +
+ 该操作会通过 Docker socket 控制宿主机 Docker,并短暂重启当前面板服务。 +
+ +
+ + + + +
+
+``` + +- [ ] **Step 2: Run the frontend build to verify it fails** + +Run: + +```bash +pnpm run build +``` + +Expected: FAIL with TypeScript errors like `Cannot find name 'systemUpgradeInfo'`, `Cannot find name 'handleCheckSystemUpgrade'`, and `Cannot find name 'systemUpgradeReleases'`. + +- [ ] **Step 3: Add the missing state, loaders, handlers, and modal control** + +Add these hooks near the existing config page state in `vite-frontend/src/pages/config.tsx`: + +```tsx + const [systemUpgradeInfo, setSystemUpgradeInfo] = + useState(null); + const [systemUpgradeChecking, setSystemUpgradeChecking] = useState(false); + const [systemUpgradeExecuting, setSystemUpgradeExecuting] = useState(false); + const [systemUpgradeLoading, setSystemUpgradeLoading] = useState(true); + const [systemUpgradeModalOpen, setSystemUpgradeModalOpen] = useState(false); + const [systemUpgradeReleases, setSystemUpgradeReleases] = useState< + NodeReleaseApiItem[] + >([]); + const [systemUpgradeSelectedVersion, setSystemUpgradeSelectedVersion] = + useState(""); +``` + +Add these helpers near `loadStorageSummary` and `handleUpdateChannelChange`: + +```tsx + const loadSystemUpgradeInfo = async () => { + setSystemUpgradeLoading(true); + try { + const response = await getSystemUpgradeVersion(); + + if (response.code === 0 && response.data) { + setSystemUpgradeInfo(response.data); + } else { + setSystemUpgradeInfo(null); + } + } catch { + setSystemUpgradeInfo(null); + } finally { + setSystemUpgradeLoading(false); + } + }; + + const handleCheckSystemUpgrade = async () => { + setSystemUpgradeChecking(true); + try { + const response = await checkSystemUpgrade(updateChannel); + + if (response.code === 0 && response.data) { + const data = response.data as SystemUpgradeCheckApiData; + setSystemUpgradeInfo(data); + setSystemUpgradeReleases(data.releases || []); + toast.success( + data.latestVersion + ? `已检查到最新版本 ${data.latestVersion}` + : "未获取到可用版本", + ); + } else { + toast.error(response.msg || "检查更新失败"); + } + } catch { + toast.error("检查更新失败,请重试"); + } finally { + setSystemUpgradeChecking(false); + } + }; + + const handleOpenSystemUpgradeModal = async () => { + setSystemUpgradeModalOpen(true); + if (systemUpgradeReleases.length === 0) { + await handleCheckSystemUpgrade(); + } + }; + + const handleConfirmSystemUpgrade = async () => { + setSystemUpgradeExecuting(true); + try { + const response = await runSystemUpgrade( + systemUpgradeSelectedVersion || undefined, + updateChannel, + ); + + if (response.code === 0 && response.data) { + const data = response.data as SystemUpgradeRunApiData; + toast.success(data.message || "升级已触发,请稍后刷新页面"); + setSystemUpgradeModalOpen(false); + } else { + toast.error(response.msg || "面板升级失败"); + } + } catch { + toast.error("面板升级失败,请重试"); + } finally { + setSystemUpgradeExecuting(false); + } + }; +``` + +Update the initial `useEffect` so it loads the new status alongside configs and storage: + +```tsx + useEffect(() => { + const timer = setTimeout(() => { + loadConfigs(initialConfigs); + loadAnnouncement(); + loadStorageSummary(); + loadSystemUpgradeInfo(); + }, 100); + + return () => clearTimeout(timer); + }, []); +``` + +Finally, extend `handleUpdateChannelChange` so changing the update channel clears stale release picks and refreshes the displayed status: + +```tsx + const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => { + setUpdateChannel(channel); + setUpdateReleaseChannel(channel); + setSystemUpgradeSelectedVersion(""); + setSystemUpgradeReleases([]); + void loadSystemUpgradeInfo(); + toast.success( + `更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`, + ); + }; +``` + +- [ ] **Step 4: Run the frontend build again** + +Run: + +```bash +pnpm run build +``` + +Expected: PASS. + +- [ ] **Step 5: Commit only if the user explicitly requested a commit** + +```bash +git add vite-frontend/src/pages/config.tsx +git commit -m "feat: add panel self-upgrade settings ui" +``` + +### Task 6: Run the full verification suite and admin QA smoke checks + +**Files:** +- No new files. + +- [ ] **Step 1: Re-run the targeted backend unit tests for the new helper and handlers** + +Run: + +```bash +go test ./internal/http/handler -run 'Test(SelectComposeAsset|UpdateEnvVersion|ValidateBackendContainerName|BuildHelperRunArgs|SystemVersionRejectsWrongMethod|SystemUpgradeRejectsConcurrentRequests)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 2: Run the full backend test suite** + +Run: + +```bash +go test ./... +``` + +Expected: PASS. + +- [ ] **Step 3: Run the frontend production build** + +Run: + +```bash +pnpm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Re-run the Compose template checks** + +Run: + +```bash +JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v4.yml config >/tmp/flvx-v4.rendered.yml +JWT_SECRET=test BACKEND_PORT=6365 FRONTEND_PORT=6366 FLUX_VERSION=dev docker compose -f docker-compose-v6.yml config >/tmp/flvx-v6.rendered.yml +``` + +Expected: PASS for both templates. + +- [ ] **Step 5: Re-run the backend image smoke test** + +Run: + +```bash +docker build -t flvx-backend-system-upgrade-test ./go-backend +docker run --rm flvx-backend-system-upgrade-test docker compose version +``` + +Expected: PASS, with the second command printing a compose version string. + +- [ ] **Step 6: Manually smoke-test the admin flow in the browser** + +Manual checklist: + +```text +1. Log in as an admin user. +2. Open /config. +3. Confirm the “面板升级” card shows current version and capability state. +4. In an environment without /var/run/docker.sock, confirm the card shows the capability reason and disables the upgrade button. +5. In a Docker-enabled environment, click “检查更新” and confirm the latest version + release list populate. +6. Open the confirmation modal and confirm the warning about Docker socket / short restart is visible. +``` + +Expected: all six checks succeed. + +- [ ] **Step 7: Commit only if the user explicitly requested a commit** + +```bash +git add go-backend/internal/http/handler/handler.go go-backend/internal/http/handler/system_upgrade.go go-backend/internal/http/handler/system_upgrade_test.go go-backend/Dockerfile docker-compose-v4.yml docker-compose-v6.yml vite-frontend/src/api/index.ts vite-frontend/src/api/types.ts vite-frontend/src/pages/config.tsx +git commit -m "feat: add panel self-upgrade workflow" +``` diff --git a/docs/superpowers/specs/2026-05-04-panel-self-upgrade-design.md b/docs/superpowers/specs/2026-05-04-panel-self-upgrade-design.md new file mode 100644 index 0000000..cb9c162 --- /dev/null +++ b/docs/superpowers/specs/2026-05-04-panel-self-upgrade-design.md @@ -0,0 +1,295 @@ +# 面板本体一键升级设计 + +**日期**: 2026-05-04 +**状态**: 待审核 +**作者**: AI Assistant + +## 概述 + +在 FLVX 管理面板中增加“面板升级”能力,使管理员可以在网页上检查 GitHub Release 并触发面板本体升级。目标是升级整套面板,而不是只升级转发节点或只替换后端二进制。 + +本设计采用 Docker Compose 整套升级方案:后端容器通过受限的 Docker socket 能力更新宿主机部署目录中的 `docker-compose.yml` 和 `.env`,然后启动独立的升级 helper 容器,由 helper 拉取新版 backend/frontend 镜像并重新启动 `backend` 与 `frontend` 服务。 + +## sub2api 参考结论 + +sub2api 的一键升级不是在宿主机执行 `docker compose pull/up`。它的运行形态是单体 Go 服务:前端构建产物 embed 到后端二进制,容器内只运行 `/app/sub2api`。升级接口下载 GitHub Release 中匹配当前系统和架构的 `sub2api___.tar.gz` 以及 `checksums.txt`,校验后把当前 `os.Executable()` 指向的 `/app/sub2api` 改名为 `/app/sub2api.backup`,再把新二进制原子替换到原路径。重启接口延迟调用 `os.Exit(0)`,依赖 Docker Compose 的 `restart: unless-stopped` 拉起同一个容器。 + +这种方式在 sub2api 的 Docker 部署中可行,是因为它的前后端在同一个二进制里。FLVX 当前是 `flux-panel-backend` 与 `vite-frontend` 两个容器,替换 `/app/paneld` 只能升级后端,不能升级前端页面。因此 FLVX 的“面板本体升级”需要更新 Compose 版本和两个镜像,而不是照搬二进制替换。 + +## 目标 + +1. 管理员可在面板上查看当前版本、最新版本、升级通道和升级能力状态。 +2. 管理员可一键升级整套面板 backend/frontend。 +3. 升级复用现有 GitHub Release 和 `FLUX_VERSION` 版本机制。 +4. 升级复用现有 GitHub 加速配置 `github_proxy_enabled` / `github_proxy_url`。 +5. 升级过程不接受任意命令、任意 URL 或任意 compose 路径。 +6. 环境不满足时清晰提示不可用原因,不静默失败。 + +## 非目标 + +1. 不实现 sub2api 式后端二进制替换作为本次主路径。 +2. 不支持从非本仓库 Release 下载升级资产。 +3. 不支持普通用户触发升级。 +4. 不支持在前端执行 shell 命令。 +5. 不修改 `install.sh` 或 `panel_install.sh` 的本地安装菜单逻辑;发布流程仍可能覆盖这些脚本。 +6. 不引入前端测试框架。 + +## 影响范围 + +### 后端 + +- `go-backend/internal/http/handler/handler.go` +- `go-backend/internal/http/handler/upgrade.go` +- 新增 `go-backend/internal/http/handler/system_upgrade.go` +- 新增 `go-backend/internal/http/handler/system_upgrade_test.go` +- `go-backend/Dockerfile` + +### 部署模板 + +- `docker-compose-v4.yml` +- `docker-compose-v6.yml` + +### 前端 + +- `vite-frontend/src/api/index.ts` +- `vite-frontend/src/api/types.ts` +- `vite-frontend/src/pages/config.tsx` + +## 运行前提 + +升级能力仅在 Docker Compose 部署中可用,并要求后端容器具备以下条件: + +1. 容器内存在 Docker CLI,且支持 `docker compose version`。 +2. `/var/run/docker.sock` 挂载到后端容器。 +3. 宿主部署目录挂载到容器内固定路径,例如 `/opt/flvx-panel`。 +4. 环境变量 `PANEL_DEPLOY_DIR=/opt/flvx-panel`。 +5. 环境变量 `PANEL_BACKEND_CONTAINER=flux-panel-backend`,为空时默认使用 `flux-panel-backend`;值必须匹配容器名安全字符集 `[A-Za-z0-9_.-]+`。 +6. 部署目录内存在 `.env` 和 `docker-compose.yml`。 + +如果任一条件不满足,检查接口返回 `capable=false` 和明确的 `reason`,升级按钮禁用。 + +## 后端设计 + +### API + +新增系统升级接口,路径使用 `/api/v1/system/*`,继续受现有 middleware 管控,仅管理员可访问。 + +| 方法 | 路径 | 用途 | +|------|------|------| +| `POST` | `/api/v1/system/version` | 返回当前版本、升级通道、能力状态和可选最新版本 | +| `POST` | `/api/v1/system/check-updates` | 强制查询 GitHub Release,返回最新版本和候选列表 | +| `POST` | `/api/v1/system/upgrade` | 执行升级 | + +请求体: + +```json +{ + "channel": "stable", + "version": "" +} +``` + +`channel` 使用现有节点升级的通道语义:`stable` 匹配纯数字版本,`dev` 匹配 `alpha` / `beta` / `rc`。`version` 为空时自动选择该通道最新 Release。 + +`/api/v1/system/version` 返回: + +```json +{ + "currentVersion": "2.1.9-beta14", + "channel": "stable", + "latestVersion": "2.1.9", + "hasUpdate": true, + "capable": true, + "reason": "", + "deployDir": "/opt/flvx-panel", + "composeFile": "/opt/flvx-panel/docker-compose.yml", + "backendContainer": "flux-panel-backend" +} +``` + +`/api/v1/system/upgrade` 成功返回: + +```json +{ + "version": "2.1.9", + "message": "升级 helper 已启动,面板服务将短暂重启", + "commands": [ + "docker run -d --rm --volumes-from flux-panel-backend ...", + "docker compose pull backend frontend", + "docker compose up -d backend frontend" + ] +} +``` + +返回的 `commands` 只用于 UI 展示固定步骤,不包含用户输入或 shell 拼接结果。 + +### 版本来源 + +当前版本优先从容器环境变量读取: + +1. `FLUX_VERSION` +2. `VITE_APP_VERSION` 不在后端容器中可靠存在,不作为后端版本来源。 +3. 为空时返回 `dev`。 + +发布流程已经在 `panel_install.sh` 写入 `.env` 的 `FLUX_VERSION`,Compose 模板需要把该变量传给 backend 容器,保证后端可感知当前版本。 + +### Release 查询 + +复用现有 `fetchGitHubReleases`、`resolveLatestReleaseByChannel`、`normalizeReleaseChannel`、`releaseChannelFromTag`、`releaseChannelLabel` 和 GitHub 加速配置能力。新增函数只负责筛选系统升级所需资产: + +- `docker-compose-v4.yml` +- `docker-compose-v6.yml` + +是否下载 v4/v6 compose 文件通过当前部署目录中的 `docker-compose.yml` 判断:如果网络定义包含 `enable_ipv6: true`,选择 `docker-compose-v6.yml`;否则选择 `docker-compose-v4.yml`。 + +### 升级执行器 + +新增 `systemUpgradeExecutor`,职责明确分为可测试的小函数: + +1. `checkSystemUpgradeCapability()` 检查 Docker CLI、Docker socket、部署目录、`.env`、`docker-compose.yml`。 +2. `selectComposeAsset(currentCompose []byte) string` 选择 v4/v6 compose 资产。 +3. `updateEnvVersion(path, version string) error` 原子更新 `.env` 中的 `FLUX_VERSION`。 +4. `downloadCompose(version, assetName, dest string) error` 下载新版 compose 模板到临时文件。 +5. `currentBackendImage(containerName string) (string, error)` 获取当前 backend 容器镜像 ID。 +6. `startSystemUpgradeHelper(version string) error` 启动独立 helper 容器执行固定升级流程。 + +升级流程: + +1. 获取全局升级锁,拒绝并发升级。 +2. 校验目标版本存在且不是 draft。 +3. 检查升级能力。 +4. 备份 `.env` 为 `.env.upgrade.bak`,备份 `docker-compose.yml` 为 `docker-compose.yml.upgrade.bak`。 +5. 下载目标版本的 compose 文件到部署目录临时文件。 +6. 原子替换 `docker-compose.yml`。 +7. 原子更新 `.env` 的 `FLUX_VERSION`。 +8. 通过 Docker socket 查询当前 backend 容器的镜像 ID。 +9. 使用当前 backend 镜像启动一个不属于 Compose 项目的临时 helper 容器。 +10. helper 通过 `--volumes-from flux-panel-backend` 继承部署目录挂载,并显式挂载 `/var/run/docker.sock`。 +11. helper 在 `PANEL_DEPLOY_DIR` 下执行 `docker compose pull backend frontend`。 +12. helper 等待 5 秒,让 SQLite WAL 等文件刷盘。 +13. helper 执行 `docker compose up -d backend frontend`,由 Compose 重建前端和后端。 +14. 后端接口在 helper 成功启动后立即返回;浏览器随后会经历短暂断线。 + +PostgreSQL 模式不主动 pull 或重建 `postgres` 服务,避免无关数据库变动。新版 compose 文件仍保留 postgres 配置供后续手动迁移或重建使用。 + +### 命令安全 + +后端不暴露通用命令执行能力。后端只直接执行 Docker CLI 的固定参数,用于获取当前镜像和启动 helper: + +```go +exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.Image}}", backendContainer) +exec.CommandContext(ctx, "docker", "run", "-d", "--rm", "--name", helperName, + "--volumes-from", backendContainer, + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-e", "PANEL_DEPLOY_DIR=/opt/flvx-panel", + "--entrypoint", "/bin/sh", imageID, + "-c", helperScript) +``` + +`helperScript` 由后端固定生成,不拼接用户输入: + +```sh +cd "$PANEL_DEPLOY_DIR" && docker compose pull backend frontend && sleep 5 && docker compose up -d backend frontend +``` + +工作目录固定为 `PANEL_DEPLOY_DIR`。`PANEL_DEPLOY_DIR` 必须是绝对路径,且必须包含 `.env` 和 `docker-compose.yml`。接口输入只允许影响 `channel` 和已验证的 Release `version`。 + +### 超时和错误处理 + +1. Release 查询超时沿用现有 GitHub API 客户端超时。 +2. 下载 compose 文件使用 60 秒超时。 +3. 启动 helper 使用 30 秒超时,helper 内部命令不受原 HTTP 请求生命周期影响。 +4. 任一步失败时返回错误信息,并尽量保留 `.upgrade.bak` 供人工恢复。 +5. 如果 `.env` 更新后后续步骤失败,不自动回滚镜像或容器,避免误判导致更大破坏;错误信息提示备份文件位置。 + +## 部署模板设计 + +`docker-compose-v4.yml` 和 `docker-compose-v6.yml` 的 backend 服务增加: + +```yaml +environment: + FLUX_VERSION: ${FLUX_VERSION:-dev} + PANEL_DEPLOY_DIR: /opt/flvx-panel + PANEL_BACKEND_CONTAINER: flux-panel-backend +volumes: + - sqlite_data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + - ./:/opt/flvx-panel +``` + +`go-backend/Dockerfile` 的 runtime 镜像通过多阶段构建从官方 `docker:27-cli` 镜像复制 Docker CLI 和 compose 插件到 Debian runtime 镜像,避免依赖 Debian apt 源中的 Docker 包可用性: + +```dockerfile +FROM docker:27-cli AS dockercli +FROM debian:bookworm-slim +COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=dockercli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose +``` + +实现时保留现有 Go builder 和 `/app/paneld` 入口,仅增加 Docker CLI stage 和复制步骤。 + +helper 容器使用当前 backend 容器的镜像 ID 启动,而不是额外依赖 `docker:cli` 镜像。这样不引入新的镜像仓库依赖,并保证 helper 内可用的 Docker CLI 与当前后端一致。 + +## 前端设计 + +在 `vite-frontend/src/pages/config.tsx` 的基本设置或数据库占用附近增加“面板升级”卡片,避免隐藏在节点页导致误解为“节点升级”。 + +展示内容: + +1. 当前版本。 +2. 最新版本。 +3. 更新通道选择,复用现有 `stable` / `dev` 语义和 `UpdateReleaseChannel` 本地存储。 +4. 升级能力状态:可用、不可用原因、Docker socket 高权限提示。 +5. 操作按钮:检查更新、立即升级。 + +交互: + +1. 页面加载时调用 `/system/version`。 +2. 点击“检查更新”调用 `/system/check-updates`。 +3. 点击“立即升级”前弹出确认框,明确提示服务会短暂中断,并提示 Docker socket 具备宿主高权限。 +4. 升级请求只等待 helper 启动,超时设置为 60 秒。 +5. 成功后 toast 显示“升级已触发,面板将在数十秒内重启”,并可提示用户稍后刷新。 + +## 安全边界 + +Docker socket 挂载等同于给后端容器宿主机级别控制能力。这是本设计的主要风险。缓解措施: + +1. 仅 `/api/v1/system/*` 管理员接口可触发。 +2. 不提供任意命令执行接口。 +3. 不允许用户传入下载 URL。 +4. 不允许用户传入 compose 路径。 +5. 只升级本仓库 GitHub Release,且跳过 draft。 +6. 前端明确展示 Docker socket 权限提示。 + +## 测试策略 + +### Go 单测 + +新增 `system_upgrade_test.go` 覆盖: + +1. `selectComposeAsset` 对 v4/v6 compose 内容的判断。 +2. `.env` 中已有 `FLUX_VERSION` 时更新值。 +3. `.env` 中缺少 `FLUX_VERSION` 时追加值。 +4. 缺少部署目录、`.env`、`docker-compose.yml`、Docker socket 时返回不可用原因。 +5. helper 命令构造固定命令序列,不拼接用户输入。 +6. 并发升级锁会拒绝第二个升级请求。 + +### 手动/集成验证 + +1. `go-backend`: `go test ./...` +2. `vite-frontend`: `pnpm run build` +3. 本地容器验证:启动 Compose 后检查设置页升级卡片可显示能力状态。 +4. 在无 Docker socket 的开发环境验证按钮禁用并显示原因。 + +## 回滚与恢复 + +自动升级失败时不做自动容器回滚。后端会保留: + +1. `.env.upgrade.bak` +2. `docker-compose.yml.upgrade.bak` + +人工恢复步骤由错误信息提示:进入部署目录,按需恢复备份文件,再执行 `docker compose up -d backend frontend`。 + +## 决策记录 + +本设计已确定采用 Docker socket 整套升级方案,不再保留二进制替换作为本次实现路径。Docker socket 的权限风险通过管理员限制、命令白名单和前端提示控制。