fix: harden panel updates and nftables deployment (#551)

Fix panel update deployment discovery and rollback safety, add nftables compatibility and atomic replacement, and restore reproducible frontend CI installs.
This commit is contained in:
sagit
2026-09-01 15:37:33 +08:00
committed by GitHub
parent f26014579b
commit da7bef88f1
9 changed files with 595 additions and 80 deletions
+1 -1
View File
@@ -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
@@ -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,
@@ -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) {
+43 -8
View File
@@ -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) {
@@ -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})
+319 -48
View File
@@ -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" == "<no 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
+108 -6
View File
@@ -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
+1 -7
View File
@@ -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"
}
}
}
+8
View File
@@ -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