mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-11 03:36:37 +08:00
fix: harden panel self-upgrade
This commit is contained in:
@@ -31,6 +31,10 @@ const (
|
|||||||
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||||
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
|
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
|
||||||
var systemUpgradeReleaseBaseURL = githubHTMLBase
|
var systemUpgradeReleaseBaseURL = githubHTMLBase
|
||||||
|
var systemUpgradeAPIBaseURL = githubAPIBase
|
||||||
|
var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
|
||||||
|
return client.Get(url)
|
||||||
|
}
|
||||||
|
|
||||||
type systemUpgradeExecutor struct {
|
type systemUpgradeExecutor struct {
|
||||||
deployDir string
|
deployDir string
|
||||||
@@ -199,7 +203,7 @@ if [ ! -f .env ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "拉取新镜像..."
|
log "拉取新镜像..."
|
||||||
if ! docker compose pull backend frontend 2>&1 | tee -a "$LOGFILE"; then
|
if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
|
||||||
log "错误: 拉取镜像失败"
|
log "错误: 拉取镜像失败"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -208,7 +212,7 @@ log "等待旧容器释放资源..."
|
|||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
log "重启服务(force-recreate)..."
|
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 "错误: 重启服务失败"
|
log "错误: 重启服务失败"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -317,6 +321,68 @@ func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
|
|||||||
return writeFileWithMode(path, data, mode)
|
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) {
|
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
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) {
|
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}
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
resp, err := client.Get(url)
|
resp, err := systemUpgradeHTTPGet(client, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
|
return nil, fmt.Errorf("下载%s失败: %v", filename, err)
|
||||||
}
|
}
|
||||||
@@ -457,7 +523,7 @@ func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
current := currentPanelVersion()
|
current := currentPanelVersion()
|
||||||
exec := newSystemUpgradeExecutor()
|
exec := newSystemUpgradeExecutor()
|
||||||
capability := exec.capability(r.Context())
|
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)))
|
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()
|
exec := newSystemUpgradeExecutor()
|
||||||
capability := exec.capability(r.Context())
|
capability := exec.capability(r.Context())
|
||||||
|
|
||||||
githubReleases, err := fetchGitHubReleases(50)
|
githubReleases, err := h.fetchSystemUpgradeReleases(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
|
||||||
return
|
return
|
||||||
@@ -514,22 +580,21 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
channel := normalizeReleaseChannel(req.Channel)
|
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()
|
exec := newSystemUpgradeExecutor()
|
||||||
capability := exec.capability(r.Context())
|
capability := exec.capability(r.Context())
|
||||||
if !capability.Capable {
|
if !capability.Capable {
|
||||||
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
|
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
|
||||||
return
|
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())
|
imageID, err := exec.currentBackendImage(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -10,6 +11,9 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
|
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
|
||||||
@@ -21,19 +25,39 @@ func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) {
|
func TestDownloadReleaseAssetUsesGithubProxyWhenEnabled(t *testing.T) {
|
||||||
var gotPath string
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
repoStore, err := repo.Open(dbPath)
|
||||||
gotPath = r.URL.Path
|
if err != nil {
|
||||||
_, _ = w.Write([]byte("services:\n backend:\n image: test\n"))
|
t.Fatalf("repo.Open() error = %v", err)
|
||||||
}))
|
}
|
||||||
defer server.Close()
|
defer repoStore.Close()
|
||||||
|
|
||||||
|
h := &Handler{repo: repoStore}
|
||||||
originalBase := systemUpgradeReleaseBaseURL
|
originalBase := systemUpgradeReleaseBaseURL
|
||||||
systemUpgradeReleaseBaseURL = server.URL
|
systemUpgradeReleaseBaseURL = "https://example.invalid"
|
||||||
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
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")
|
data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("downloadReleaseAsset() error = %v", err)
|
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))
|
t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
wantPath := "/" + githubRepo + "/releases/download/2.1.9/docker-compose-v4.yml"
|
wantURL := "https://proxy.example.com/https://example.invalid/Sagit-chu/flvx/releases/download/2.1.9/docker-compose-v4.yml"
|
||||||
if gotPath != wantPath {
|
if gotURL != wantURL {
|
||||||
t.Fatalf("download path = %q, want %q", gotPath, wantPath)
|
t.Fatalf("download URL = %q, want %q", gotURL, wantURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) {
|
func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
_, _ = w.Write(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))
|
repoStore, err := repo.Open(dbPath)
|
||||||
}))
|
if err != nil {
|
||||||
defer server.Close()
|
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
|
originalBase := systemUpgradeReleaseBaseURL
|
||||||
systemUpgradeReleaseBaseURL = server.URL
|
systemUpgradeReleaseBaseURL = "https://example.invalid"
|
||||||
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
|
||||||
|
|
||||||
h := &Handler{}
|
originalGet := systemUpgradeHTTPGet
|
||||||
_, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
|
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(), "过大") {
|
if err == nil || !strings.Contains(err.Error(), "过大") {
|
||||||
t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err)
|
t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +311,7 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
|||||||
|
|
||||||
fakeDockerDir := t.TempDir()
|
fakeDockerDir := t.TempDir()
|
||||||
fakeDockerPath := filepath.Join(fakeDockerDir, "docker")
|
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 {
|
if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil {
|
||||||
t.Fatalf("WriteFile() fake docker error = %v", err)
|
t.Fatalf("WriteFile() fake docker error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -281,7 +320,7 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
|
|||||||
t.Setenv(panelBackendContainerEnv, "flux-panel-backend")
|
t.Setenv(panelBackendContainerEnv, "flux-panel-backend")
|
||||||
|
|
||||||
h := &Handler{}
|
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()
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
h.systemUpgrade(rr, req)
|
h.systemUpgrade(rr, req)
|
||||||
|
|||||||
Reference in New Issue
Block a user