From 312c9a9c5c416330a7cd570ebe85cc8dc72defcd Mon Sep 17 00:00:00 2001 From: sagitchu Date: Fri, 15 May 2026 23:16:14 +0800 Subject: [PATCH] fix: harden panel self-upgrade --- .../internal/http/handler/system_upgrade.go | 97 ++++++++++++++++--- .../http/handler/system_upgrade_test.go | 81 ++++++++++++---- 2 files changed, 141 insertions(+), 37 deletions(-) diff --git a/go-backend/internal/http/handler/system_upgrade.go b/go-backend/internal/http/handler/system_upgrade.go index 04c53de..905c56c 100644 --- a/go-backend/internal/http/handler/system_upgrade.go +++ b/go-backend/internal/http/handler/system_upgrade.go @@ -31,6 +31,10 @@ const ( var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`) var systemUpgradeReleaseBaseURL = githubHTMLBase +var systemUpgradeAPIBaseURL = githubAPIBase +var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) { + return client.Get(url) +} type systemUpgradeExecutor struct { deployDir string @@ -199,7 +203,7 @@ if [ ! -f .env ]; then fi log "拉取新镜像..." -if ! docker compose pull backend frontend 2>&1 | tee -a "$LOGFILE"; then +if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then log "错误: 拉取镜像失败" exit 1 fi @@ -208,7 +212,7 @@ log "等待旧容器释放资源..." sleep 3 log "重启服务(force-recreate)..." -if ! docker compose up -d --force-recreate --remove-orphans backend frontend 2>&1 | tee -a "$LOGFILE"; then +if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then log "错误: 重启服务失败" exit 1 fi @@ -317,6 +321,68 @@ func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error { return writeFileWithMode(path, data, mode) } +func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string { + enabled, proxyURL := h.getGithubProxyConfig() + base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename) + if enabled { + return fmt.Sprintf("%s/%s", proxyURL, base) + } + return base +} + +func (h *Handler) fetchSystemUpgradeReleases(perPage int) ([]githubRelease, error) { + if perPage <= 0 { + perPage = 20 + } + + client := &http.Client{Timeout: 15 * time.Second} + url := fmt.Sprintf("%s/repos/%s/releases?per_page=%d", strings.TrimRight(systemUpgradeAPIBaseURL, "/"), githubRepo, perPage) + if enabled, proxyURL := h.getGithubProxyConfig(); enabled { + url = fmt.Sprintf("%s/%s", proxyURL, url) + } + + resp, err := systemUpgradeHTTPGet(client, url) + if err != nil { + return nil, fmt.Errorf("请求GitHub API失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body)) + } + + var releases []githubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("解析GitHub API响应失败: %v", err) + } + + return releases, nil +} + +func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (string, error) { + normalizedChannel := normalizeReleaseChannel(channel) + releases, err := h.fetchSystemUpgradeReleases(50) + if err != nil { + return "", err + } + + for _, r := range releases { + if r.Draft { + continue + } + tag := strings.TrimSpace(r.TagName) + if tag == "" { + continue + } + if releaseChannelFromTag(tag) == normalizedChannel { + return tag, nil + } + } + + return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel)) +} + func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) { info, err := os.Stat(path) if err != nil { @@ -367,9 +433,9 @@ func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helper } func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) { - url := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename) + url := h.buildSystemUpgradeDownloadURL(version, filename) client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Get(url) + resp, err := systemUpgradeHTTPGet(client, url) if err != nil { return nil, fmt.Errorf("下载%s失败: %v", filename, err) } @@ -457,7 +523,7 @@ func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) { current := currentPanelVersion() exec := newSystemUpgradeExecutor() capability := exec.capability(r.Context()) - latest, err := resolveLatestReleaseByChannel(channel) + latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel) response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability))) } @@ -477,7 +543,7 @@ func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) { exec := newSystemUpgradeExecutor() capability := exec.capability(r.Context()) - githubReleases, err := fetchGitHubReleases(50) + githubReleases, err := h.fetchSystemUpgradeReleases(50) if err != nil { response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) return @@ -514,22 +580,21 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) { return } channel := normalizeReleaseChannel(req.Channel) - version := strings.TrimSpace(req.Version) - if version == "" { - var err error - version, err = resolveLatestReleaseByChannel(channel) - if err != nil { - response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) - return - } - } - exec := newSystemUpgradeExecutor() capability := exec.capability(r.Context()) if !capability.Capable { response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; "))) return } + version := strings.TrimSpace(req.Version) + if version == "" { + var err error + version, err = h.resolveSystemUpgradeLatestReleaseByChannel(channel) + if err != nil { + response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err))) + return + } + } imageID, err := exec.currentBackendImage(r.Context()) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) diff --git a/go-backend/internal/http/handler/system_upgrade_test.go b/go-backend/internal/http/handler/system_upgrade_test.go index 8222c54..3eff141 100644 --- a/go-backend/internal/http/handler/system_upgrade_test.go +++ b/go-backend/internal/http/handler/system_upgrade_test.go @@ -3,6 +3,7 @@ package handler import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -10,6 +11,9 @@ import ( "reflect" "strings" "testing" + "time" + + "go-backend/internal/store/repo" ) func TestSelectComposeAssetUsesIPv6Template(t *testing.T) { @@ -21,19 +25,39 @@ func TestSelectComposeAssetUsesIPv6Template(t *testing.T) { } } -func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) { - var gotPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - _, _ = w.Write([]byte("services:\n backend:\n image: test\n")) - })) - defer server.Close() +func TestDownloadReleaseAssetUsesGithubProxyWhenEnabled(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + repoStore, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("repo.Open() error = %v", err) + } + defer repoStore.Close() + h := &Handler{repo: repoStore} originalBase := systemUpgradeReleaseBaseURL - systemUpgradeReleaseBaseURL = server.URL + systemUpgradeReleaseBaseURL = "https://example.invalid" t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase }) - h := &Handler{} + originalGet := systemUpgradeHTTPGet + defer func() { systemUpgradeHTTPGet = originalGet }() + + var gotURL string + systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) { + gotURL = url + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("services:\n backend:\n image: test\n")), + }, nil + } + + now := time.Now().UnixMilli() + if err := repoStore.UpsertConfig("github_proxy_enabled", "true", now); err != nil { + t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err) + } + if err := repoStore.UpsertConfig("github_proxy_url", "https://proxy.example.com", now); err != nil { + t.Fatalf("UpsertConfig() github_proxy_url error = %v", err) + } + data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") if err != nil { t.Fatalf("downloadReleaseAsset() error = %v", err) @@ -42,24 +66,39 @@ func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) { t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data)) } - wantPath := "/" + githubRepo + "/releases/download/2.1.9/docker-compose-v4.yml" - if gotPath != wantPath { - t.Fatalf("download path = %q, want %q", gotPath, wantPath) + wantURL := "https://proxy.example.com/https://example.invalid/Sagit-chu/flvx/releases/download/2.1.9/docker-compose-v4.yml" + if gotURL != wantURL { + t.Fatalf("download URL = %q, want %q", gotURL, wantURL) } } func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1)) - })) - defer server.Close() + dbPath := filepath.Join(t.TempDir(), "test.db") + repoStore, err := repo.Open(dbPath) + if err != nil { + t.Fatalf("repo.Open() error = %v", err) + } + defer repoStore.Close() + now := time.Now().UnixMilli() + if err := repoStore.UpsertConfig("github_proxy_enabled", "false", now); err != nil { + t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err) + } originalBase := systemUpgradeReleaseBaseURL - systemUpgradeReleaseBaseURL = server.URL + systemUpgradeReleaseBaseURL = "https://example.invalid" t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase }) - h := &Handler{} - _, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") + originalGet := systemUpgradeHTTPGet + defer func() { systemUpgradeHTTPGet = originalGet }() + systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))), + }, nil + } + + h := &Handler{repo: repoStore} + _, err = h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") if err == nil || !strings.Contains(err.Error(), "过大") { t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err) } @@ -272,7 +311,7 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) { fakeDockerDir := t.TempDir() fakeDockerPath := filepath.Join(fakeDockerDir, "docker") - fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;\n *)\n exit 0\n ;;\n esac\n" + fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;&\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;&\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;&\n *)\n exit 0\n ;;&\n esac\n" if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil { t.Fatalf("WriteFile() fake docker error = %v", err) } @@ -281,7 +320,7 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) { t.Setenv(panelBackendContainerEnv, "flux-panel-backend") h := &Handler{} - req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable","version":"3.0.0"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`)) rr := httptest.NewRecorder() h.systemUpgrade(rr, req)