diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b7b3a6e..4cc891d 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -22,7 +22,7 @@ jobs: node-version: '20.19.0' - name: Install pnpm - run: npm install -g pnpm + run: npm install -g pnpm@10.28.1 - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/go-backend/internal/runtime/nftables/renderer.go b/go-backend/internal/runtime/nftables/renderer.go index cb816f9..88f4815 100644 --- a/go-backend/internal/runtime/nftables/renderer.go +++ b/go-backend/internal/runtime/nftables/renderer.go @@ -46,7 +46,8 @@ func RenderTable(plan NodePlan) string { } targetHost := strings.Trim(strings.TrimSpace(rule.TargetHost), "[]") for _, protocol := range normalizedProtocols(rule.Protocols) { - b.WriteString(fmt.Sprintf(" ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n", + b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n", + protocol, rule.InPort, family, targetHost, @@ -54,7 +55,8 @@ func RenderTable(plan NodePlan) string { rule.TargetPort, counterComment(rule.ForwardID, CounterDirectionToTarget, protocol), )) - b.WriteString(fmt.Sprintf(" ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n", + b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n", + protocol, rule.InPort, family, targetHost, diff --git a/go-backend/internal/runtime/nftables/renderer_test.go b/go-backend/internal/runtime/nftables/renderer_test.go index 001015f..4238265 100644 --- a/go-backend/internal/runtime/nftables/renderer_test.go +++ b/go-backend/internal/runtime/nftables/renderer_test.go @@ -62,10 +62,10 @@ func TestRenderTableIncludesForwardAccountingCounters(t *testing.T) { wantLines := []string{ `tcp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat tcp"`, `udp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat udp"`, - `ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`, - `ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`, - `ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`, - `ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`, + `meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`, + `meta l4proto tcp ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`, + `meta l4proto udp ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`, + `meta l4proto udp ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`, } for _, want := range wantLines { if !strings.Contains(got, want) { @@ -89,8 +89,8 @@ func TestRenderTableIncludesIPv6ForwardAccountingCounters(t *testing.T) { got := RenderTable(plan) wantLines := []string{ `tcp dport 12346 counter dnat ip6 to [2001:db8::20]:8443 comment "flvx forward:43 dnat tcp"`, - `ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`, - `ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`, + `meta l4proto tcp ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`, + `meta l4proto tcp ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`, } for _, want := range wantLines { if !strings.Contains(got, want) { @@ -110,8 +110,8 @@ func TestRenderTableAccountingCountersIncludeOriginalPort(t *testing.T) { got := RenderTable(plan) wantLines := []string{ - `ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`, - `ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`, + `meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`, + `meta l4proto tcp ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`, } for _, want := range wantLines { if !strings.Contains(got, want) { diff --git a/go-backend/internal/runtime/nftables/runner.go b/go-backend/internal/runtime/nftables/runner.go index 98f7718..9440e22 100644 --- a/go-backend/internal/runtime/nftables/runner.go +++ b/go-backend/internal/runtime/nftables/runner.go @@ -26,23 +26,58 @@ func NewSSHRunner() *SSHRunner { } func (r *SSHRunner) Test(ctx context.Context, cfg SSHConfig) error { - return r.run(ctx, cfg, "command -v nft >/dev/null 2>&1 && nft --version >/dev/null 2>&1") + nft := nftBinary(cfg) + tableName := fmt.Sprintf("flvx_capability_%d", time.Now().UnixNano()) + return r.run(ctx, cfg, buildCapabilityCheckCommand(nft, tableName)) +} + +func buildCapabilityCheckCommand(nft, tableName string) string { + script := RenderTable(NodePlan{ + Rules: []Rule{{ + ForwardID: 1, + InPort: 12345, + TargetHost: "192.0.2.1", + TargetPort: 443, + Protocols: []string{"tcp", "udp"}, + }}, + }) + script = strings.Replace(script, "table inet flvx {", "table inet "+tableName+" {", 1) + return "set -eu\n" + + "command -v nft >/dev/null 2>&1\n" + + nft + " --version >/dev/null 2>&1\n" + + "tmp=$(mktemp /tmp/flvx-nft-capability-XXXXXX.nft)\n" + + "trap 'rm -f \"$tmp\"' EXIT\n" + + "cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" + + "if ! " + nft + " -c -f \"$tmp\"; then\n" + + " echo 'nftables cannot validate the generated FLVX rules' >&2\n" + + " exit 1\n" + + "fi" } func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error { - nft := nftBinary(cfg) - command := "tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft) || exit 1\n" + + command := buildApplyCommand(nftBinary(cfg), script) + return r.run(ctx, cfg, command) +} + +func buildApplyCommand(nft, script string) string { + return "set -eu\n" + + "tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft)\n" + + "batch=$(mktemp /tmp/flvx-nft-batch-XXXXXX.nft) || { rm -f \"$tmp\"; exit 1; }\n" + "cleanup() {\n" + - " rm -f \"$tmp\"\n" + + " rm -f \"$tmp\" \"$batch\"\n" + "}\n" + "trap cleanup EXIT\n" + "cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" + - nft + " -c -f \"$tmp\"\n" + "if " + nft + " list table inet flvx >/dev/null 2>&1; then\n" + - " " + nft + " delete table inet flvx\n" + + " { printf '%s\\n' 'delete table inet flvx'; cat \"$tmp\"; } > \"$batch\"\n" + + "else\n" + + " cp \"$tmp\" \"$batch\"\n" + "fi\n" + - nft + " -f \"$tmp\"" - return r.run(ctx, cfg, command) + "if ! " + nft + " -c -f \"$batch\"; then\n" + + " echo 'nftables rule validation failed; active rules were preserved' >&2\n" + + " exit 1\n" + + "fi\n" + + nft + " -f \"$batch\"" } func (r *SSHRunner) ListTableJSON(ctx context.Context, cfg SSHConfig) ([]byte, error) { diff --git a/go-backend/internal/runtime/nftables/runner_test.go b/go-backend/internal/runtime/nftables/runner_test.go index 673fd2c..dcff79c 100644 --- a/go-backend/internal/runtime/nftables/runner_test.go +++ b/go-backend/internal/runtime/nftables/runner_test.go @@ -5,10 +5,113 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "os" + "os/exec" + "path/filepath" "strings" "testing" ) +func TestBuildApplyCommandStopsAfterValidationFailure(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "calls.log") + applyMarker := filepath.Join(dir, "applied") + nftPath := filepath.Join(dir, "nft") + fake := `#!/bin/sh +echo "$*" >> "` + logPath + `" +if [ "$1" = "list" ]; then + exit 0 +fi +if [ "$1" = "-c" ]; then + exit 1 +fi +touch "` + applyMarker + `" +` + if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil { + t.Fatalf("write fake nft: %v", err) + } + + command := buildApplyCommand(nftPath, "table inet flvx { }") + result := exec.Command("sh", "-c", command) + if err := result.Run(); err == nil { + t.Fatal("expected validation failure") + } + if _, err := os.Stat(applyMarker); !os.IsNotExist(err) { + t.Fatalf("apply ran after validation failure, stat err=%v", err) + } + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read fake nft calls: %v", err) + } + if strings.Count(string(calls), "-f ") != 1 { + t.Fatalf("expected validation only, got calls:\n%s", calls) + } +} + +func TestBuildCapabilityCheckCommandValidatesRenderedRulesWithoutApplying(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "calls.log") + nftPath := filepath.Join(dir, "nft") + fake := `#!/bin/sh +echo "$*" >> "` + logPath + `" +if [ "$1" = "--version" ] || [ "$1" = "-c" ]; then + exit 0 +fi +exit 1 +` + if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil { + t.Fatalf("write fake nft: %v", err) + } + + command := strings.Replace(buildCapabilityCheckCommand(nftPath, "flvx_capability_test"), "command -v nft", "command -v "+nftPath, 1) + result := exec.Command("sh", "-c", command) + if output, err := result.CombinedOutput(); err != nil { + t.Fatalf("capability command failed: %v: %s", err, output) + } + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read fake nft calls: %v", err) + } + if strings.Count(string(calls), "-c -f ") != 1 || strings.Contains(string(calls), "\n-f ") { + t.Fatalf("expected one check-only invocation, got calls:\n%s", calls) + } + if !strings.Contains(command, "table inet flvx_capability_test") || !strings.Contains(command, "meta l4proto tcp ct original proto-dst") { + t.Fatalf("capability check does not contain representative rendered rules:\n%s", command) + } +} + +func TestBuildApplyCommandUsesAtomicReplacementBatch(t *testing.T) { + dir := t.TempDir() + batchPath := filepath.Join(dir, "batch.nft") + nftPath := filepath.Join(dir, "nft") + fake := `#!/bin/sh +if [ "$1" = "list" ]; then + exit 0 +fi +if [ "$1" = "-f" ]; then + cp "$2" "` + batchPath + `" +fi +exit 0 +` + if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil { + t.Fatalf("write fake nft: %v", err) + } + + script := "table inet flvx {\n chain forward { }\n}" + result := exec.Command("sh", "-c", buildApplyCommand(nftPath, script)) + if output, err := result.CombinedOutput(); err != nil { + t.Fatalf("apply command failed: %v: %s", err, output) + } + batch, err := os.ReadFile(batchPath) + if err != nil { + t.Fatalf("read applied batch: %v", err) + } + want := "delete table inet flvx\n" + script + "\n" + if string(batch) != want { + t.Fatalf("atomic batch = %q, want %q", batch, want) + } +} + func TestAuthMethodsDefaultToPrivateKey(t *testing.T) { privateKey := mustGeneratePrivateKey(t) methods, err := authMethods(SSHConfig{PrivateKey: privateKey}) diff --git a/panel_install.sh b/panel_install.sh index 4b47aca..3772ef3 100755 --- a/panel_install.sh +++ b/panel_install.sh @@ -16,6 +16,9 @@ PINNED_VERSION="" # 镜像加速配置(可由面板传入或交互式询问) PROXY_ENABLED="${PROXY_ENABLED:-}" PROXY_URL="${PROXY_URL:-}" +DEFAULT_PANEL_BACKEND_CONTAINER="flux-panel-backend" +DEFAULT_PANEL_POSTGRES_CONTAINER="flux-panel-postgres" +PANEL_SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" # 镜像加速 maybe_proxy_url() { @@ -304,8 +307,8 @@ get_env_var() { get_current_db_type() { local db_type database_url - db_type=$(get_env_var "DB_TYPE") - database_url=$(get_env_var "DATABASE_URL") + db_type=$(get_env_var "DB_TYPE" || true) + database_url=$(get_env_var "DATABASE_URL" || true) if [[ "$db_type" == "sqlite" ]]; then echo "sqlite" @@ -316,13 +319,289 @@ get_current_db_type() { fi } +get_container_compose_label() { + local container="$1" + local label="$2" + local value + + value=$(docker inspect -f "{{ index .Config.Labels \"$label\" }}" "$container" 2>/dev/null || true) + if [[ "$value" == "" ]]; then + value="" + fi + printf '%s' "$value" +} + +resolve_panel_deployment() { + local backend_container="${PANEL_BACKEND_CONTAINER:-$DEFAULT_PANEL_BACKEND_CONTAINER}" + local requested_dir="${PANEL_DEPLOY_DIR:-}" + local label_dir label_project deploy_dir project_name + + label_dir=$(get_container_compose_label "$backend_container" "com.docker.compose.project.working_dir") + label_project=$(get_container_compose_label "$backend_container" "com.docker.compose.project") + + if [[ -n "$requested_dir" ]]; then + deploy_dir="$requested_dir" + elif [[ -n "$label_dir" ]]; then + deploy_dir="$label_dir" + elif [[ -f ".env" && -f "docker-compose.yml" ]]; then + deploy_dir=$(pwd -P) + else + echo "❌ 无法识别面板部署目录:未找到容器 Compose 标签,当前目录也没有完整部署配置" + return 1 + fi + + if [[ ! -d "$deploy_dir" ]]; then + echo "❌ 面板部署目录不存在:$deploy_dir" + return 1 + fi + deploy_dir=$(cd "$deploy_dir" && pwd -P) + if [[ -n "$label_dir" && -d "$label_dir" ]]; then + label_dir=$(cd "$label_dir" && pwd -P) + fi + if [[ -n "$requested_dir" && -n "$label_dir" && "$deploy_dir" != "$label_dir" ]]; then + echo "❌ 指定部署目录与运行中容器标签不一致:$deploy_dir != $label_dir" + return 1 + fi + + project_name="${COMPOSE_PROJECT_NAME:-}" + if [[ -z "$project_name" && -n "$label_project" ]]; then + project_name="$label_project" + fi + if [[ -n "$project_name" && ! "$project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + echo "❌ Compose 项目名不合法:$project_name" + return 1 + fi + + PANEL_DEPLOY_DIR="$deploy_dir" + PANEL_BACKEND_CONTAINER="$backend_container" + PANEL_POSTGRES_CONTAINER="${PANEL_POSTGRES_CONTAINER:-$DEFAULT_PANEL_POSTGRES_CONTAINER}" + PANEL_COMPOSE_PROJECT="$project_name" + export PANEL_DEPLOY_DIR PANEL_BACKEND_CONTAINER PANEL_POSTGRES_CONTAINER PANEL_COMPOSE_PROJECT + if [[ -n "$project_name" ]]; then + COMPOSE_PROJECT_NAME="$project_name" + export COMPOSE_PROJECT_NAME + fi + + cd "$PANEL_DEPLOY_DIR" + echo "📁 部署目录:$PANEL_DEPLOY_DIR" + if [[ -n "$PANEL_COMPOSE_PROJECT" ]]; then + echo "📦 Compose 项目:$PANEL_COMPOSE_PROJECT" + fi +} + +run_panel_compose() { + $DOCKER_CMD "$@" +} + +validate_panel_update_environment() { + local key value backend_port frontend_port configured_db_type db_type database_url postgres_password + + if [[ ! -f ".env" ]]; then + echo "❌ 部署目录缺少 .env,更新终止" + return 1 + fi + if [[ ! -f "docker-compose.yml" ]]; then + echo "❌ 部署目录缺少 docker-compose.yml,更新终止" + return 1 + fi + + for key in JWT_SECRET BACKEND_PORT FRONTEND_PORT; do + value=$(get_env_var "$key" || true) + if [[ -z "${value//[[:space:]]/}" ]]; then + echo "❌ .env 中 $key 缺失或为空,更新终止" + return 1 + fi + done + + backend_port=$(get_env_var "BACKEND_PORT" || true) + frontend_port=$(get_env_var "FRONTEND_PORT" || true) + if [[ ! "$backend_port" =~ ^[0-9]+$ || "$backend_port" -lt 1 || "$backend_port" -gt 65535 ]]; then + echo "❌ BACKEND_PORT 不是有效端口:$backend_port" + return 1 + fi + if [[ ! "$frontend_port" =~ ^[0-9]+$ || "$frontend_port" -lt 1 || "$frontend_port" -gt 65535 ]]; then + echo "❌ FRONTEND_PORT 不是有效端口:$frontend_port" + return 1 + fi + + configured_db_type=$(get_env_var "DB_TYPE" || true) + if [[ -n "$configured_db_type" && "$configured_db_type" != "sqlite" && "$configured_db_type" != "postgres" ]]; then + echo "❌ DB_TYPE 仅支持 sqlite 或 postgres:$configured_db_type" + return 1 + fi + db_type=$(get_current_db_type) + if [[ "$db_type" == "postgres" ]]; then + database_url=$(get_env_var "DATABASE_URL" || true) + postgres_password=$(get_env_var "POSTGRES_PASSWORD" || true) + if [[ -z "${database_url//[[:space:]]/}" || -z "${postgres_password//[[:space:]]/}" ]]; then + echo "❌ PostgreSQL 模式要求 DATABASE_URL 和 POSTGRES_PASSWORD 均非空" + return 1 + fi + fi + + if ! run_panel_compose -f docker-compose.yml config -q; then + echo "❌ 当前 Compose 配置校验失败,更新终止" + return 1 + fi +} + +download_panel_update_compose() { + local compose_url="$1" + + UPDATE_COMPOSE_CANDIDATE=$(mktemp "$PANEL_DEPLOY_DIR/.docker-compose.yml.update.XXXXXX") + if ! curl -fL -o "$UPDATE_COMPOSE_CANDIDATE" "$compose_url"; then + rm -f "$UPDATE_COMPOSE_CANDIDATE" + UPDATE_COMPOSE_CANDIDATE="" + echo "❌ 下载最新 Compose 配置失败,更新终止" + return 1 + fi + if [[ ! -s "$UPDATE_COMPOSE_CANDIDATE" ]]; then + rm -f "$UPDATE_COMPOSE_CANDIDATE" + UPDATE_COMPOSE_CANDIDATE="" + echo "❌ 下载的 Compose 配置为空,更新终止" + return 1 + fi +} + +validate_panel_update_compose() { + if ! FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" config -q; then + echo "❌ 新 Compose 配置校验失败,更新终止" + return 1 + fi +} + +backup_sqlite_for_update() ( + local destination="$1" + local running paused="false" + + cleanup_paused_backend() { + if [[ "$paused" == "true" ]]; then + docker unpause "$PANEL_BACKEND_CONTAINER" >/dev/null 2>&1 || true + fi + } + trap cleanup_paused_backend EXIT + + running=$(docker inspect -f '{{.State.Running}}' "$PANEL_BACKEND_CONTAINER" 2>/dev/null || true) + if [[ "$running" == "true" ]]; then + if ! docker pause "$PANEL_BACKEND_CONTAINER" >/dev/null; then + echo "❌ 暂停后端以创建一致性 SQLite 备份失败" + return 1 + fi + paused="true" + fi + + mkdir -p "$destination" + if ! docker cp "$PANEL_BACKEND_CONTAINER:/app/data/." "$destination"; then + echo "❌ SQLite 数据备份失败" + return 1 + fi + + if [[ "$paused" == "true" ]] && ! docker unpause "$PANEL_BACKEND_CONTAINER" >/dev/null; then + echo "❌ SQLite 备份完成,但恢复后端运行失败" + return 1 + fi + paused="false" +) + +backup_postgres_for_update() { + local destination="$1" + local postgres_db postgres_user + + postgres_db=$(get_env_var "POSTGRES_DB" || true) + postgres_user=$(get_env_var "POSTGRES_USER" || true) + postgres_db=${postgres_db:-flux_panel} + postgres_user=${postgres_user:-flux_panel} + + if ! docker exec "$PANEL_POSTGRES_CONTAINER" pg_dump -U "$postgres_user" "$postgres_db" > "$destination"; then + rm -f "$destination" + echo "❌ PostgreSQL 数据备份失败" + return 1 + fi +} + +create_panel_update_backup() { + local db_type="$1" + local timestamp + + timestamp=$(date '+%Y%m%d-%H%M%S') + if ! (umask 077 && mkdir -p "$PANEL_DEPLOY_DIR/backups"); then + echo "❌ 创建更新备份目录失败" + return 1 + fi + UPDATE_BACKUP_DIR=$(umask 077 && mktemp -d "$PANEL_DEPLOY_DIR/backups/panel-update-$timestamp.XXXXXX") || { + echo "❌ 创建更新备份目录失败" + return 1 + } + if ! cp -p .env docker-compose.yml "$UPDATE_BACKUP_DIR/"; then + echo "❌ 备份面板配置失败" + return 1 + fi + + if [[ "$db_type" == "postgres" ]]; then + backup_postgres_for_update "$UPDATE_BACKUP_DIR/postgres.sql" || return 1 + else + backup_sqlite_for_update "$UPDATE_BACKUP_DIR/sqlite" || return 1 + fi + echo "💾 更新备份:$UPDATE_BACKUP_DIR" +} + +pull_panel_update_images() { + local db_type="$1" + + if [[ "$db_type" == "postgres" ]]; then + FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" pull backend frontend postgres + else + FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" pull backend frontend + fi +} + +activate_panel_update_files() { + if ! chmod 0644 "$UPDATE_COMPOSE_CANDIDATE" || ! mv "$UPDATE_COMPOSE_CANDIDATE" docker-compose.yml; then + echo "❌ 替换 Compose 配置失败" + return 1 + fi + UPDATE_COMPOSE_CANDIDATE="" + if ! upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"; then + echo "❌ 更新版本配置失败" + return 1 + fi +} + +start_panel_after_update() { + local db_type="$1" + + if [[ "$db_type" == "postgres" ]]; then + run_panel_compose up -d postgres || return 1 + wait_for_postgres_healthy || return 1 + fi + run_panel_compose up -d --force-recreate --remove-orphans backend frontend || return 1 + wait_for_backend_healthy +} + +rollback_panel_update() { + local db_type="$1" + + echo "↩️ 正在恢复更新前配置..." + if ! cp -p "$UPDATE_BACKUP_DIR/.env" .env || ! cp -p "$UPDATE_BACKUP_DIR/docker-compose.yml" docker-compose.yml; then + echo "❌ 配置回滚失败,请从 $UPDATE_BACKUP_DIR 手动恢复" + return 1 + fi + if [[ "$db_type" == "postgres" ]]; then + run_panel_compose up -d postgres || return 1 + wait_for_postgres_healthy || return 1 + fi + run_panel_compose up -d --force-recreate --remove-orphans backend frontend || return 1 + wait_for_backend_healthy +} + wait_for_postgres_healthy() { local pg_health + local postgres_container="${PANEL_POSTGRES_CONTAINER:-$DEFAULT_PANEL_POSTGRES_CONTAINER}" echo "🔍 检查 PostgreSQL 服务状态..." for i in {1..90}; do - if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then - pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown") + if docker ps --format "{{.Names}}" | grep -Fxq "$postgres_container"; then + pg_health=$(docker inspect -f '{{.State.Health.Status}}' "$postgres_container" 2>/dev/null || echo "unknown") if [[ "$pg_health" == "healthy" ]]; then echo "✅ PostgreSQL 服务健康检查通过" return 0 @@ -335,7 +614,7 @@ wait_for_postgres_healthy() { if [ $i -eq 90 ]; then echo "❌ PostgreSQL 启动超时(90秒)" - echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')" + echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' "$postgres_container" 2>/dev/null || echo '容器不存在')" return 1 fi @@ -348,11 +627,12 @@ wait_for_postgres_healthy() { wait_for_backend_healthy() { local backend_health + local backend_container="${PANEL_BACKEND_CONTAINER:-$DEFAULT_PANEL_BACKEND_CONTAINER}" echo "🔍 检查后端服务状态..." for i in {1..90}; do - if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then - backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown") + if docker ps --format "{{.Names}}" | grep -Fxq "$backend_container"; then + backend_health=$(docker inspect -f '{{.State.Health.Status}}' "$backend_container" 2>/dev/null || echo "unknown") if [[ "$backend_health" == "healthy" ]]; then echo "✅ 后端服务健康检查通过" return 0 @@ -365,7 +645,7 @@ wait_for_backend_healthy() { if [ $i -eq 90 ]; then echo "❌ 后端服务启动超时(90秒)" - echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')" + echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' "$backend_container" 2>/dev/null || echo '容器不存在')" return 1 fi @@ -380,9 +660,8 @@ wait_for_backend_healthy() { delete_self() { echo "" echo "🗑️ 操作已完成,正在清理脚本文件..." - SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")" sleep 1 - rm -f "$SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败" + rm -f "$PANEL_SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败" } @@ -486,12 +765,11 @@ EOF # 更新功能 update_panel() { echo "🔄 开始更新面板..." - ask_proxy_config check_docker + resolve_panel_deployment || return 1 + ask_proxy_config + validate_panel_update_environment || return 1 - if [[ ! -f ".env" ]]; then - echo "⚠️ 未找到 .env,默认按 SQLite 模式更新" - fi CURRENT_DB_TYPE=$(get_current_db_type) echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE" @@ -502,52 +780,45 @@ update_panel() { } echo "🆕 最新版本:$LATEST_VERSION" set_compose_urls_by_version "$LATEST_VERSION" - upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION" echo "🔽 下载最新配置文件..." DOCKER_COMPOSE_URL=$(get_docker_compose_url) echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")" - curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL" - echo "✅ 下载完成" - - # 自动检测并配置 IPv6 支持 - if check_ipv6_support; then - echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..." - configure_docker_ipv6 + download_panel_update_compose "$DOCKER_COMPOSE_URL" || return 1 + if ! validate_panel_update_compose; then + rm -f "$UPDATE_COMPOSE_CANDIDATE" + UPDATE_COMPOSE_CANDIDATE="" + return 1 fi - # 先发送 SIGTERM 信号,让应用优雅关闭 - docker stop -t 30 flux-panel-backend 2>/dev/null || true - docker stop -t 10 vite-frontend 2>/dev/null || true - - # 等待 WAL 文件同步 - echo "⏳ 等待数据同步..." - sleep 5 - - # 然后再完全停止 - $DOCKER_CMD down + echo "💾 备份当前配置和数据库..." + if ! create_panel_update_backup "$CURRENT_DB_TYPE"; then + rm -f "$UPDATE_COMPOSE_CANDIDATE" + UPDATE_COMPOSE_CANDIDATE="" + return 1 + fi echo "⬇️ 拉取最新镜像..." - if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then - $DOCKER_CMD pull backend frontend postgres - else - $DOCKER_CMD pull backend frontend + if ! pull_panel_update_images "$CURRENT_DB_TYPE"; then + rm -f "$UPDATE_COMPOSE_CANDIDATE" + UPDATE_COMPOSE_CANDIDATE="" + echo "❌ 镜像拉取失败,现有服务保持运行" + return 1 fi - echo "🚀 启动更新后的服务..." - if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then - $DOCKER_CMD up -d postgres - wait_for_postgres_healthy - $DOCKER_CMD up -d backend frontend - else - $DOCKER_CMD up -d backend frontend + if ! activate_panel_update_files; then + rollback_panel_update "$CURRENT_DB_TYPE" || true + return 1 fi - # 等待服务启动 - echo "⏳ 等待服务启动..." - - if ! wait_for_backend_healthy; then - echo "🛑 更新终止" + echo "🚀 原地重建更新后的服务..." + if ! start_panel_after_update "$CURRENT_DB_TYPE"; then + echo "🛑 新版本启动失败" + if rollback_panel_update "$CURRENT_DB_TYPE"; then + echo "✅ 已恢复更新前版本" + else + echo "❌ 自动回滚失败,请从 $UPDATE_BACKUP_DIR 手动恢复" + fi return 1 fi diff --git a/test-install-scripts-proxy.sh b/test-install-scripts-proxy.sh index 587166a..069823a 100644 --- a/test-install-scripts-proxy.sh +++ b/test-install-scripts-proxy.sh @@ -558,6 +558,7 @@ test_update_panel_asks_for_proxy_config() ( load_script_without_main "$ROOT_DIR/panel_install.sh" local ask_called="0" + local calls="" ask_proxy_config() { ask_called="1" @@ -570,6 +571,15 @@ test_update_panel_asks_for_proxy_config() ( DOCKER_CMD="true" } + resolve_panel_deployment() { + calls+=" resolve" + PANEL_DEPLOY_DIR=$(mktemp -d) + } + + validate_panel_update_environment() { + calls+=" validate-current" + } + get_current_db_type() { echo "sqlite" } @@ -578,13 +588,33 @@ test_update_panel_asks_for_proxy_config() ( echo "v-test" } - upsert_env_var() { :; } + download_panel_update_compose() { + calls+=" download" + UPDATE_COMPOSE_CANDIDATE="candidate" + } + + validate_panel_update_compose() { + calls+=" validate-new" + } + + create_panel_update_backup() { + calls+=" backup" + UPDATE_BACKUP_DIR="backup" + } + + pull_panel_update_images() { + calls+=" pull" + } + + activate_panel_update_files() { + calls+=" activate" + } + + start_panel_after_update() { + calls+=" start" + } + check_ipv6_support() { return 1; } - configure_docker_ipv6() { :; } - docker() { return 0; } - wait_for_backend_healthy() { return 0; } - sleep() { :; } - curl() { :; } update_panel >/dev/null @@ -593,6 +623,75 @@ test_update_panel_asks_for_proxy_config() ( "https://github.com/${REPO}/releases/download/v-test/docker-compose-v4.yml" \ "$DOCKER_COMPOSEV4_URL" \ "update_panel should honor the prompted proxy choice" + assert_equals \ + " resolve validate-current download validate-new backup pull activate start" \ + "$calls" \ + "update_panel should validate, back up, and pull before activating the update" +) + +test_resolve_panel_deployment_uses_container_labels() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/panel_install.sh" + + local expected_dir + expected_dir=$(mktemp -d) + expected_dir=$(cd "$expected_dir" && pwd -P) + printf 'JWT_SECRET=test\nBACKEND_PORT=6365\nFRONTEND_PORT=6366\nDB_TYPE=sqlite\n' > "$expected_dir/.env" + printf 'services: {}\n' > "$expected_dir/docker-compose.yml" + + unset PANEL_DEPLOY_DIR COMPOSE_PROJECT_NAME + docker() { + if [[ "$1" == "inspect" && "$2" == "-f" ]]; then + case "$3" in + *working_dir*) printf '%s\n' "$expected_dir" ;; + *) printf '%s\n' "flvx-panel" ;; + esac + fi + } + + resolve_panel_deployment >/dev/null + + assert_equals "$expected_dir" "$PANEL_DEPLOY_DIR" "update should discover the Compose working directory from container labels" + assert_equals "flvx-panel" "$COMPOSE_PROJECT_NAME" "update should preserve the existing Compose project name" + assert_equals "$expected_dir" "$(pwd -P)" "update should run from the discovered deployment directory" +) + +test_validate_panel_update_environment_rejects_empty_required_values() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/panel_install.sh" + + local deploy_dir rc="0" + deploy_dir=$(mktemp -d) + printf 'JWT_SECRET=\nBACKEND_PORT=6365\nFRONTEND_PORT=6366\nDB_TYPE=sqlite\n' > "$deploy_dir/.env" + printf 'services: {}\n' > "$deploy_dir/docker-compose.yml" + cd "$deploy_dir" + DOCKER_CMD="true" + + validate_panel_update_environment >/dev/null || rc="$?" + + assert_equals "1" "$rc" "update should reject an empty JWT secret before touching the running service" +) + +test_backup_sqlite_for_update_pauses_copies_and_unpauses() ( + set -euo pipefail + load_script_without_main "$ROOT_DIR/panel_install.sh" + + local destination calls log_path + destination=$(mktemp -d)/sqlite + log_path=$(mktemp) + PANEL_BACKEND_CONTAINER="flux-panel-backend" + docker() { + if [[ "$1" == "inspect" ]]; then + printf 'true\n' + return 0 + fi + printf ' %s' "$1" >> "$log_path" + } + + backup_sqlite_for_update "$destination" >/dev/null + calls=$(cat "$log_path") + + assert_equals " pause cp unpause" "$calls" "SQLite backup should pause writes, copy the database volume, then resume the backend" ) test_panel_install_script_accepts_proxy_url_env_without_prompt() ( @@ -663,6 +762,9 @@ test_install_script_accepts_proxy_url_env_without_prompt test_panel_install_script_can_disable_proxy test_panel_install_script_recomputes_compose_urls_after_prompt test_update_panel_asks_for_proxy_config +test_resolve_panel_deployment_uses_container_labels +test_validate_panel_update_environment_rejects_empty_required_values +test_backup_sqlite_for_update_pauses_copies_and_unpauses test_panel_install_script_uses_default_proxy test_panel_install_script_accepts_proxy_url_env_without_prompt test_panel_install_script_defaults_proxy_on_eof diff --git a/vite-frontend/package.json b/vite-frontend/package.json index de4a6e7..bb7c878 100644 --- a/vite-frontend/package.json +++ b/vite-frontend/package.json @@ -2,6 +2,7 @@ "name": "flvx", "private": true, "version": "0.0.0", + "packageManager": "pnpm@10.28.1", "type": "module", "scripts": { "dev": "vite", @@ -81,12 +82,5 @@ "vite": "npm:rolldown-vite@^7.3.1", "vite-plugin-pwa": "^1.1.0", "vite-tsconfig-paths": "^6.0.5" - }, - "pnpm": { - "overrides": { - "@babel/plugin-transform-modules-systemjs": "7.29.4", - "fast-uri": "3.1.2", - "serialize-javascript": "7.0.5" - } } } diff --git a/vite-frontend/pnpm-workspace.yaml b/vite-frontend/pnpm-workspace.yaml index 14aa49a..b31b528 100644 --- a/vite-frontend/pnpm-workspace.yaml +++ b/vite-frontend/pnpm-workspace.yaml @@ -1,2 +1,10 @@ +packages: + - '.' + +overrides: + '@babel/plugin-transform-modules-systemjs': 7.29.4 + fast-uri: 3.1.2 + serialize-javascript: 7.0.5 + allowBuilds: '@tailwindcss/oxide': true