package handler import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "go-backend/internal/http/response" "go-backend/internal/panelupgrade" ) const ( panelDeployDirEnv = "PANEL_DEPLOY_DIR" panelBackendContainerEnv = "PANEL_BACKEND_CONTAINER" defaultPanelDeployDir = "/opt/flvx-panel" defaultPanelBackendName = "flux-panel-backend" dockerSocketPath = "/var/run/docker.sock" maxSystemUpgradeComposeAssetBytes = 1 << 20 systemUpgradeMessage = "升级 helper 已启动,面板服务将短暂重启" systemUpgradeConflictError = "已有面板升级任务执行中" ) 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 backendContainer string } type systemUpgradeCapabilityData struct { Capable bool `json:"capable"` Reasons []string `json:"reasons"` DeployDir string `json:"deployDir"` BackendContainer string `json:"backendContainer"` } type systemUpgradeReleaseData struct { Version string `json:"version"` Name string `json:"name"` PublishedAt string `json:"publishedAt"` Prerelease bool `json:"prerelease"` Channel string `json:"channel"` } type systemUpgradeVersionData struct { CurrentVersion string `json:"currentVersion"` LatestVersion string `json:"latestVersion"` HasUpdate bool `json:"hasUpdate"` Channel string `json:"channel"` Reason string `json:"reason,omitempty"` Capability systemUpgradeCapabilityData `json:"capability"` } type systemUpgradeCheckData struct { CurrentVersion string `json:"currentVersion"` LatestVersion string `json:"latestVersion"` HasUpdate bool `json:"hasUpdate"` Channel string `json:"channel"` Capability systemUpgradeCapabilityData `json:"capability"` Releases []systemUpgradeReleaseData `json:"releases"` } type systemUpgradeRunData struct { JobID string `json:"jobId"` Version string `json:"version"` Channel string `json:"channel"` ComposeAsset string `json:"composeAsset"` HelperContainer string `json:"helperContainer"` BackendImageID string `json:"backendImageId"` Message string `json:"message"` } type systemUpgradeRequest struct { Version string `json:"version"` Channel string `json:"channel"` } 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 currentPanelVersion() string { version := strings.TrimSpace(os.Getenv("FLUX_VERSION")) if version == "" { return "dev" } return version } 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 validateUpgradeVersion(value string) error { return panelupgrade.ValidateVersion(value) } 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) capability(ctx context.Context) systemUpgradeCapabilityData { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() reasons := make([]string, 0) if !filepath.IsAbs(e.deployDir) { reasons = append(reasons, "部署目录必须是绝对路径") } if err := validateBackendContainerName(e.backendContainer); err != nil { reasons = append(reasons, err.Error()) } if err := exec.CommandContext(ctx, "docker", "--version").Run(); err != nil { reasons = append(reasons, "docker CLI不可用") } if info, err := os.Stat(dockerSocketPath); err != nil { reasons = append(reasons, "docker socket不可用: "+err.Error()) } else if info.IsDir() { reasons = append(reasons, "docker socket路径不是文件") } if info, err := os.Stat(e.composePath()); err != nil { reasons = append(reasons, "部署docker-compose.yml不可用: "+err.Error()) } else if info.IsDir() { reasons = append(reasons, "部署docker-compose.yml不是文件") } if info, err := os.Stat(e.envPath()); err != nil { reasons = append(reasons, "部署.env不可用: "+err.Error()) } else if info.IsDir() { reasons = append(reasons, "部署.env不是文件") } if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err != nil { reasons = append(reasons, "docker compose不可用") } if _, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Identity(ctx); err != nil { reasons = append(reasons, err.Error()) } return systemUpgradeCapabilityData{ Capable: len(reasons) == 0, Reasons: reasons, DeployDir: e.deployDir, BackendContainer: e.backendContainer, } } func (e *systemUpgradeExecutor) selectComposeAsset(current []byte) string { if enableIPv6ComposePattern.Match(current) { return "docker-compose-v6.yml" } return "docker-compose-v4.yml" } 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 (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) { url := h.buildSystemUpgradeDownloadURL(version, filename) client := &http.Client{Timeout: 60 * time.Second} resp, err := systemUpgradeHTTPGet(client, url) if err != nil { return nil, fmt.Errorf("下载%s失败: %v", filename, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("下载%s返回 %d: %s", filename, resp.StatusCode, strings.TrimSpace(string(body))) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxSystemUpgradeComposeAssetBytes+1)) if err != nil { return nil, fmt.Errorf("读取%s失败: %v", filename, err) } if len(body) > maxSystemUpgradeComposeAssetBytes { return nil, fmt.Errorf("下载%s过大", filename) } if len(bytes.TrimSpace(body)) == 0 { return nil, fmt.Errorf("下载%s内容为空", filename) } return body, nil } func releasesForChannel(releases []githubRelease, channel string) []systemUpgradeReleaseData { channel = normalizeReleaseChannel(channel) items := make([]systemUpgradeReleaseData, 0, len(releases)) for _, r := range releases { if r.Draft { continue } tag := strings.TrimSpace(r.TagName) if tag == "" { continue } itemChannel := releaseChannelFromTag(tag) if itemChannel != channel { continue } items = append(items, systemUpgradeReleaseData{ Version: tag, Name: r.Name, PublishedAt: r.PublishedAt, Prerelease: itemChannel == releaseChannelDev, Channel: itemChannel, }) } return items } func decodeSystemUpgradeRequest(r *http.Request, req *systemUpgradeRequest) error { defer r.Body.Close() body, err := io.ReadAll(io.LimitReader(r.Body, 4097)) if err != nil { return err } if len(body) > 4096 { return fmt.Errorf("request too large") } if len(bytes.TrimSpace(body)) == 0 { return nil } decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() if err := decoder.Decode(req); err != nil { return err } var extra any if decoder.Decode(&extra) != io.EOF { return fmt.Errorf("unexpected trailing JSON") } return nil } func systemUpgradeVersionResponse(current, channel, latest string, lookupErr error, capability systemUpgradeCapabilityData) systemUpgradeVersionData { data := systemUpgradeVersionData{ CurrentVersion: current, LatestVersion: latest, HasUpdate: latest != "" && latest != current, Channel: channel, Capability: capability, } if lookupErr != nil { data.LatestVersion = "" data.HasUpdate = false data.Reason = lookupErr.Error() } return data } func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) return } channel := releaseChannelStable current := currentPanelVersion() exec := newSystemUpgradeExecutor() capability := exec.capability(r.Context()) latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel) response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability))) } func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) return } var req systemUpgradeRequest if err := decodeSystemUpgradeRequest(r, &req); err != nil { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } channel := normalizeReleaseChannel(req.Channel) current := currentPanelVersion() exec := newSystemUpgradeExecutor() capability := exec.capability(r.Context()) githubReleases, err := h.fetchSystemUpgradeReleases(50) if err != nil { response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) return } releases := releasesForChannel(githubReleases, channel) latest := "" if len(releases) > 0 { latest = releases[0].Version } response.WriteJSON(w, response.OK(systemUpgradeCheckData{ CurrentVersion: current, LatestVersion: latest, HasUpdate: latest != "" && latest != current, Channel: channel, Capability: capability, Releases: releases, })) } 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.ErrDefault(systemUpgradeConflictError)) return } defer h.systemUpgradeMu.Unlock() var req systemUpgradeRequest if err := decodeSystemUpgradeRequest(r, &req); err != nil { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } channel := normalizeReleaseChannel(req.Channel) 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 } } if err := validateUpgradeVersion(version); err != nil { response.WriteJSON(w, response.ErrDefault("版本号格式无效")) return } composeData, err := os.ReadFile(exec.composePath()) if err != nil { response.WriteJSON(w, response.ErrDefault("读取部署配置失败")) return } composeAsset := exec.selectComposeAsset(composeData) newCompose, err := h.downloadReleaseAsset(version, composeAsset) if err != nil { response.WriteJSON(w, response.ErrDefault("下载目标 Compose 失败")) return } // A detached helper must not be canceled when the browser disconnects. ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() job, imageID, err := (panelupgrade.Manager{Dir: exec.deployDir, Backend: exec.backendContainer}).Start(ctx, version, newCompose) if err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) return } response.WriteJSON(w, response.OK(systemUpgradeRunData{JobID: job.ID, Version: version, Channel: channel, ComposeAsset: composeAsset, HelperContainer: job.HelperContainer, BackendImageID: imageID, Message: systemUpgradeMessage})) } func (h *Handler) systemUpgradeStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { response.WriteJSON(w, response.ErrDefault("请求失败")) return } var req struct { JobID string `json:"jobId"` } defer r.Body.Close() decoder := json.NewDecoder(io.LimitReader(r.Body, 4097)) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil && err != io.EOF { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } var extra any if decoder.Decode(&extra) != io.EOF { response.WriteJSON(w, response.ErrDefault("请求参数错误")) return } e := newSystemUpgradeExecutor() ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() job, err := (panelupgrade.Manager{Dir: e.deployDir, Backend: e.backendContainer}).Status(ctx, req.JobID) if err != nil { response.WriteJSON(w, response.ErrDefault("无法读取升级任务状态")) return } response.WriteJSON(w, response.OK(struct { CurrentVersion string `json:"currentVersion"` Job *panelupgrade.Job `json:"job"` }{currentPanelVersion(), job})) }