diff --git a/.env.sample b/.env.sample index 31d4595..63c2d37 100644 --- a/.env.sample +++ b/.env.sample @@ -138,6 +138,7 @@ CONFIG_AUDIT_HASH_PEPPER= CONFIG_AUDIT_INTEGRITY_KEY= # 允许访问后端的浏览器 Origin,多个值用英文逗号分隔,生产环境禁止使用 *。 +# 启动/升级脚本会从已校验的公开 API 地址补齐缺失值或下方样例值;自定义值会保留并校验。 # 域名部署示例:https://yourwebsite.com,https://www.yourwebsite.com CONFIG_SECURITY_ORIGIN=http://127.0.0.1,http://localhost diff --git a/.github/workflows/test-deployment-public-url.yml b/.github/workflows/test-deployment-public-url.yml index 70c3fd1..08f09a5 100644 --- a/.github/workflows/test-deployment-public-url.yml +++ b/.github/workflows/test-deployment-public-url.yml @@ -13,6 +13,10 @@ on: - 'scripts/init-server-http-bind-ip.sh' - 'scripts/test-server-http-bind-ip.sh' - 'scripts/init-security-env.sh' + - 'scripts/init-security-origin.sh' + - 'scripts/check-redis-persistence.sh' + - 'scripts/test-security-origin.sh' + - 'scripts/test-redis-persistence-guard.sh' - 'scripts/test-deployment-public-url.sh' - '.github/workflows/test-deployment-public-url.yml' pull_request: @@ -27,6 +31,10 @@ on: - 'scripts/init-server-http-bind-ip.sh' - 'scripts/test-server-http-bind-ip.sh' - 'scripts/init-security-env.sh' + - 'scripts/init-security-origin.sh' + - 'scripts/check-redis-persistence.sh' + - 'scripts/test-security-origin.sh' + - 'scripts/test-redis-persistence-guard.sh' - 'scripts/test-deployment-public-url.sh' - '.github/workflows/test-deployment-public-url.yml' workflow_dispatch: @@ -39,7 +47,7 @@ jobs: - name: Check shell syntax run: | - bash -n start.sh update.sh scripts/init-security-env.sh scripts/init-public-api-base-url.sh scripts/init-server-http-bind-ip.sh scripts/test-public-api-base-url.sh scripts/test-server-http-bind-ip.sh scripts/test-deployment-public-url.sh + bash -n start.sh update.sh scripts/init-security-env.sh scripts/init-security-origin.sh scripts/check-redis-persistence.sh scripts/init-public-api-base-url.sh scripts/init-server-http-bind-ip.sh scripts/test-security-origin.sh scripts/test-redis-persistence-guard.sh scripts/test-public-api-base-url.sh scripts/test-server-http-bind-ip.sh scripts/test-deployment-public-url.sh - name: Test migration helper run: bash scripts/test-public-api-base-url.sh @@ -47,5 +55,10 @@ jobs: - name: Test server HTTP bind migration run: bash scripts/test-server-http-bind-ip.sh + - name: Test startup recovery guards + run: | + bash scripts/test-security-origin.sh + bash scripts/test-redis-persistence-guard.sh + - name: Test deployment dry-run matrix run: bash scripts/test-deployment-public-url.sh diff --git a/.github/workflows/test-start-ps1.yml b/.github/workflows/test-start-ps1.yml index 518de26..7ced14f 100644 --- a/.github/workflows/test-start-ps1.yml +++ b/.github/workflows/test-start-ps1.yml @@ -10,6 +10,10 @@ on: - 'scripts/Test-PublicApiBaseUrl.ps1' - 'scripts/Initialize-ServerHttpBindIp.ps1' - 'scripts/Test-ServerHttpBindIp.ps1' + - 'scripts/Initialize-SecurityOrigin.ps1' + - 'scripts/Test-SecurityOrigin.ps1' + - 'scripts/Assert-RedisPersistence.ps1' + - 'scripts/Test-RedisPersistence.ps1' - '.env.sample' - '.github/workflows/test-start-ps1.yml' pull_request: @@ -20,6 +24,10 @@ on: - 'scripts/Test-PublicApiBaseUrl.ps1' - 'scripts/Initialize-ServerHttpBindIp.ps1' - 'scripts/Test-ServerHttpBindIp.ps1' + - 'scripts/Initialize-SecurityOrigin.ps1' + - 'scripts/Test-SecurityOrigin.ps1' + - 'scripts/Assert-RedisPersistence.ps1' + - 'scripts/Test-RedisPersistence.ps1' - '.env.sample' - '.github/workflows/test-start-ps1.yml' workflow_dispatch: @@ -69,3 +77,8 @@ jobs: - name: Test server HTTP bind migration helper run: | powershell -ExecutionPolicy Bypass -File .\scripts\Test-ServerHttpBindIp.ps1 + + - name: Test startup recovery guards + run: | + powershell -ExecutionPolicy Bypass -File .\scripts\Test-SecurityOrigin.ps1 + powershell -ExecutionPolicy Bypass -File .\scripts\Test-RedisPersistence.ps1 diff --git a/README.md b/README.md index 228bf0e..ea23830 100644 --- a/README.md +++ b/README.md @@ -254,30 +254,34 @@ $env:UPDATE_NO_WAIT = "1" 1. **Redis 资源护栏**:Redis 增加 `./data/redis:/data` 持久化挂载、`maxmemory 2gb`、`appendfsync everysec`,并禁用 RDB 快照,避免队列历史膨胀后占满内存和容器写层。 2. **Mongo/主服务内存限制**:Mongo 增加 `--wiredTigerCacheSizeGB 1.5` 和容器内存限制,`easyai-server` 增加内存限制,避免 8G 机器上 Redis/Mongo/Node 相互挤压触发 OOM。 -3. **升级前置要求**:如果线上 Redis 之前没有 `/data` volume,先完成 Redis request-limit 队列历史清理,再迁移 Redis 数据目录,最后重建 Redis 容器。 +3. **升级前置要求**:如果线上 Redis 之前没有 `/data` 持久化挂载,必须先迁移数据,再重建容器。Linux 和 Windows 的启动、升级脚本都会在拉取镜像前检查现有 Redis 的挂载位置,不一致时停止部署。 #### Redis 数据迁移与重启步骤 -执行前建议先做云盘快照或外部备份。磁盘空间紧张时,不要直接在当前机器上 `BGSAVE` 或复制大体积 Redis 文件。 +执行前先做云盘快照或外部备份,并确认可用磁盘空间。不要删除或重建旧 Redis 容器,直到数据已经迁出并校验。 ```bash cd ~/easyai -# 1. 先通过后台 ModelRuntime 运维接口或后端启动清理逻辑清理 request-limit 历史: -# - trim bull:request_limit_*:events 到小长度 -# - 只清 completed/failed,不删除 waiting/active/delayed +# 1. 确认旧容器的 /data 未挂载到当前项目的 data/redis,并备份现有配置。 +docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' redis -# 2. 确认 Redis 内存已经下降后,再准备新的持久化目录 +# 2. 停止 Redis 后复制一致的数据目录;保留旧容器,直到新容器验收完成。 +docker stop --timeout 60 redis mkdir -p ./data/redis - -# 3. 如果旧 Redis 数据在容器写层,先按实际容器内 /data 内容迁移到宿主机目录 docker cp redis:/data/. ./data/redis/ -# 4. 更新配置后重建关键容器 -docker compose up -d redis mongo easyai-server +# 3. 将 ./data/redis 再备份到其他磁盘或安全位置,并校验 AOF 文件与 manifest。 +# 若目标目录原本非空,先核对来源,不要覆盖。 + +# 4. 确认 docker-compose.yml 已包含 ./data/redis:/data,检查配置并只重建 Redis。 +docker compose config --quiet +docker compose up -d --no-deps redis +docker exec redis redis-cli ping +docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' redis ``` -迁移后验收建议:Redis `used_memory_human < 2G`,`bull:request_limit_*:events` 长度维持在小窗口,24 小时内无 OOM。 +迁移后核对 Redis 数据库键数、`aof_last_write_status:ok`、后端 `/content` 和网站首页;后端若未自动恢复,再单独重启后端容器。队列历史清理应单独评估,不作为迁移的默认步骤。 ### 2026.3.20 diff --git a/docker-compose.yml b/docker-compose.yml index 7a35459..6976293 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,9 +62,12 @@ services: ipv4_address: 172.21.0.6 sandbox-network: depends_on: - - mongo - - redis - - rabbitmq + mongo: + condition: service_started + redis: + condition: service_healthy + rabbitmq: + condition: service_started volumes: - ./data/upload:/app/upload - ./data/backups:/app/backups @@ -233,6 +236,12 @@ services: - ./data/redis:/data # 持久化 Redis AOF/RDB,避免落到容器写层 command: [ "redis-server", "/etc/redis/redis.conf" ] # 让 Redis 读取配置文件 stop_grace_period: 1m # 给 Redis 足够时间在 SIGTERM 后完成 AOF fsync + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s mem_limit: 2g memswap_limit: 2g # ports: diff --git a/scripts/Assert-RedisPersistence.ps1 b/scripts/Assert-RedisPersistence.ps1 new file mode 100644 index 0000000..6d7c98e --- /dev/null +++ b/scripts/Assert-RedisPersistence.ps1 @@ -0,0 +1,29 @@ +# 在 Compose 重建 Redis 前,确认现有 /data 已位于当前项目的数据目录。 +function Assert-RedisPersistence { + param([Parameter(Mandatory = $true)][string]$ProjectRoot) + + $null = & docker info 2>&1 + if ($LASTEXITCODE -ne 0) { throw "Cannot connect to Docker; Redis data location was not checked" } + + $null = & docker container inspect redis 2>&1 + if ($LASTEXITCODE -ne 0) { return } + + $mount = (& docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Source}}{{end}}{{end}}' redis).Trim() + if ($LASTEXITCODE -ne 0 -or -not $mount) { + throw "Existing Redis /data is not persisted; migrate AOF/RDB before updating (see README)" + } + $parts = $mount.Split('|', 2) + if ($parts.Count -ne 2 -or $parts[0] -ne 'bind') { + throw "Existing Redis /data uses a different storage type; migrate it before updating (see README)" + } + + $source = $parts[1] + # Docker Desktop 可将 Windows C:\path 显示为 /run/desktop/mnt/host/c/path。 + $source = $source -replace '^/run/desktop/mnt/host/([A-Za-z])/', '$1:/' + $source = $source -replace '^/host_mnt/([A-Za-z])/', '$1:/' + $expected = [System.IO.Path]::GetFullPath((Join-Path $ProjectRoot 'data/redis')) + $actual = [System.IO.Path]::GetFullPath($source) + if (-not [string]::Equals($actual.TrimEnd('\', '/'), $expected.TrimEnd('\', '/'), [StringComparison]::OrdinalIgnoreCase)) { + throw "Existing Redis /data uses another directory; migrate it before updating (see README)" + } +} diff --git a/scripts/Initialize-SecurityOrigin.ps1 b/scripts/Initialize-SecurityOrigin.ps1 new file mode 100644 index 0000000..f1ab7a6 --- /dev/null +++ b/scripts/Initialize-SecurityOrigin.ps1 @@ -0,0 +1,43 @@ +# 升级旧部署时从公开 API 地址补齐浏览器来源,并校验显式配置。 +. (Join-Path $PSScriptRoot "Initialize-PublicApiBaseUrl.ps1") + +function Initialize-SecurityOrigin { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" } + + $content = Get-Content $Path -Raw -Encoding UTF8 + if ($null -eq $content) { $content = "" } + $apiUrl = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" + if (-not (ConvertTo-PublicApiBaseUrl $apiUrl)) { + throw "Configure a valid CONFIG_PUBLIC_API_BASE_URL first" + } + $apiUri = [Uri]$apiUrl + $derived = $apiUri.GetLeftPart([UriPartial]::Authority) + $current = Get-PublicEnvValue $content "CONFIG_SECURITY_ORIGIN" + + if (-not $current -or + ($current -eq "http://127.0.0.1,http://localhost" -and $derived -ne "http://127.0.0.1")) { + $content = Set-PublicEnvValue $content "CONFIG_SECURITY_ORIGIN" $derived + [System.IO.File]::WriteAllText( + (Resolve-Path $Path), + $content, + [System.Text.UTF8Encoding]::new($false) + ) + Write-Host " [OK] Browser origin: $derived" -ForegroundColor Green + return + } + + if ($current.StartsWith(',') -or $current.EndsWith(',') -or $current.Contains(',,')) { + throw "CONFIG_SECURITY_ORIGIN contains an empty origin" + } + foreach ($value in $current.Split(',')) { + $origin = $value.Trim() + $uri = $null + if (-not [Uri]::TryCreate($origin, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -notin @('http', 'https') -or + $uri.UserInfo -or $uri.Query -or $uri.Fragment -or + $uri.AbsolutePath -ne '/' -or -not $uri.Host) { + throw "CONFIG_SECURITY_ORIGIN must contain only http(s) origins without paths or wildcards" + } + } +} diff --git a/scripts/Test-RedisPersistence.ps1 b/scripts/Test-RedisPersistence.ps1 new file mode 100644 index 0000000..4fb0d34 --- /dev/null +++ b/scripts/Test-RedisPersistence.ps1 @@ -0,0 +1,37 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Assert-RedisPersistence.ps1') + +$projectRoot = Split-Path $PSScriptRoot -Parent +$expected = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'data/redis')) +$script:state = 'absent' +function docker { + $global:LASTEXITCODE = 0 + switch ($args[0]) { + 'info' { + if ($script:state -eq 'unavailable') { $global:LASTEXITCODE = 1 } + return + } + 'container' { + if ($script:state -eq 'absent') { $global:LASTEXITCODE = 1 } + return + } + 'inspect' { + switch ($script:state) { + 'mounted' { return "bind|$expected" } + 'wrong_mount' { return 'bind|/other/redis' } + default { return '' } + } + } + } +} + +Assert-RedisPersistence -ProjectRoot $projectRoot +$script:state = 'mounted' +Assert-RedisPersistence -ProjectRoot $projectRoot +foreach ($unsafe in @('unavailable', 'no_mount', 'wrong_mount')) { + $script:state = $unsafe + $rejected = $false + try { Assert-RedisPersistence -ProjectRoot $projectRoot } catch { $rejected = $true } + if (-not $rejected) { throw "Unsafe Redis state accepted: $unsafe" } +} +Write-Host 'Redis persistence PowerShell tests passed' diff --git a/scripts/Test-SecurityOrigin.ps1 b/scripts/Test-SecurityOrigin.ps1 new file mode 100644 index 0000000..ea9e997 --- /dev/null +++ b/scripts/Test-SecurityOrigin.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Initialize-SecurityOrigin.ps1') + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-origin-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tempDir | Out-Null +try { + $path = Join-Path $tempDir 'test.env' + [System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`n") + Initialize-SecurityOrigin -Path $path + $content = Get-Content $path -Raw + if ((Get-PublicEnvValue $content 'CONFIG_SECURITY_ORIGIN') -ne 'https://zaowua.com') { throw 'Missing origin was not initialized' } + $before = $content + Initialize-SecurityOrigin -Path $path + if ((Get-Content $path -Raw) -ne $before) { throw 'Repeated initialization changed the environment' } + + [System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`nCONFIG_SECURITY_ORIGIN=http://127.0.0.1,http://localhost`n") + Initialize-SecurityOrigin -Path $path + if ((Get-PublicEnvValue (Get-Content $path -Raw) 'CONFIG_SECURITY_ORIGIN') -ne 'https://zaowua.com') { throw 'Sample origin was not migrated' } + + [System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`nCONFIG_SECURITY_ORIGIN=https://zaowua.com,https://www.zaowua.com`n") + $before = Get-Content $path -Raw + Initialize-SecurityOrigin -Path $path + if ((Get-Content $path -Raw) -ne $before) { throw 'Custom origins were not preserved' } + + foreach ($invalid in @('*', 'https://zaowua.com/api', 'https://zaowua.com,', 'https://user:pass@zaowua.com')) { + [System.IO.File]::WriteAllText($path, "CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api`nCONFIG_SECURITY_ORIGIN=$invalid`n") + $rejected = $false + try { Initialize-SecurityOrigin -Path $path } catch { $rejected = $true } + if (-not $rejected) { throw "Accepted invalid origin: $invalid" } + } + Write-Host 'Security origin PowerShell tests passed' +} finally { + Remove-Item -Recurse -Force $tempDir +} diff --git a/scripts/check-redis-persistence.sh b/scripts/check-redis-persistence.sh new file mode 100755 index 0000000..6993e5b --- /dev/null +++ b/scripts/check-redis-persistence.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# 在 Compose 可能重建 Redis 前,阻止把容器写层或旧 volume 中的数据静默替换为空目录。 +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +expected_dir="$(cd "${project_dir}/data" && pwd -P)/redis" +container="${REDIS_CONTAINER_NAME:-redis}" + +if ! docker info >/dev/null 2>&1; then + echo "❌ 无法连接 Docker,未检查 Redis 数据位置,停止部署" >&2 + exit 1 +fi + +if ! docker container inspect "$container" >/dev/null 2>&1; then + exit 0 +fi + +mount="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Source}}{{end}}{{end}}' "$container")" +if [ "$mount" != "bind|$expected_dir" ]; then + echo "❌ 现有 Redis 的 /data 未挂载到 ${expected_dir},停止部署以保护 AOF/RDB 数据" >&2 + echo " 请按 README 的 Redis 数据迁移步骤先备份、停机、复制并校验,再重建 Redis" >&2 + exit 1 +fi diff --git a/scripts/init-security-origin.sh b/scripts/init-security-origin.sh new file mode 100755 index 0000000..773d09d --- /dev/null +++ b/scripts/init-security-origin.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# 升级旧部署时补齐浏览器来源;显式配置始终保留并校验。 +# 不 source .env,避免执行环境文件中的内容。 + +security_origin_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/init-public-api-base-url.sh +. "${security_origin_script_dir}/init-public-api-base-url.sh" + +security_origin_valid() { + local value rest + value="$(public_url_normalize "$1")" + public_url_validate "$value" || return 1 + rest="${value#*://}" + [[ "$rest" != */* ]] +} + +init_security_origin() { + local file="${1:-.env}" + local api_url origin current item scheme rest authority + [ -f "$file" ] || { + echo "❌ 环境配置文件不存在: $file" >&2 + return 1 + } + + api_url="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")" + public_url_validate "$api_url" || { + echo "❌ 请先配置有效的 CONFIG_PUBLIC_API_BASE_URL" >&2 + return 1 + } + scheme="${api_url%%://*}" + rest="${api_url#*://}" + authority="${rest%%/*}" + origin="${scheme}://${authority}" + security_origin_valid "$origin" || return 1 + + current="$(public_url_read_env_value "$file" "CONFIG_SECURITY_ORIGIN")" + if [ -z "$current" ] || { + [ "$current" = 'http://127.0.0.1,http://localhost' ] && + [ "$origin" != 'http://127.0.0.1' ]; + }; then + public_url_write_env_value "$file" "CONFIG_SECURITY_ORIGIN" "$origin" + echo " ✓ 已配置浏览器来源: $origin" + return 0 + fi + + local -a origins + case "$current" in + ,*|*,|*,,*) + echo "❌ CONFIG_SECURITY_ORIGIN 包含空的来源地址" >&2 + return 1 + ;; + esac + IFS=',' read -r -a origins <<< "$current" + [ "${#origins[@]}" -gt 0 ] || return 1 + for item in "${origins[@]}"; do + item="$(printf '%s' "$item" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + if ! security_origin_valid "$item"; then + echo "❌ CONFIG_SECURITY_ORIGIN 包含无效或非浏览器来源,请填写 http(s) Origin,不能使用通配符或路径" >&2 + return 1 + fi + done +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + init_security_origin "${1:-.env}" +fi diff --git a/scripts/test-deployment-public-url.sh b/scripts/test-deployment-public-url.sh index f25c05f..709461a 100755 --- a/scripts/test-deployment-public-url.sh +++ b/scripts/test-deployment-public-url.sh @@ -19,8 +19,10 @@ cp \ mkdir -p "$TMP_DIR/scripts" cp \ "$REPO_ROOT/scripts/init-security-env.sh" \ + "$REPO_ROOT/scripts/init-security-origin.sh" \ "$REPO_ROOT/scripts/init-public-api-base-url.sh" \ "$REPO_ROOT/scripts/init-server-http-bind-ip.sh" \ + "$REPO_ROOT/scripts/check-redis-persistence.sh" \ "$TMP_DIR/scripts/" cd "$TMP_DIR" @@ -112,6 +114,17 @@ assert "ws_ticket" in methods, f"ws-gateway does not advertise ws_ticket: {sorte ' } +assert_redis_startup_contract() { + docker compose config --format json | python3 -c ' +import json, sys +services = json.load(sys.stdin)["services"] +redis = services["redis"] +assert any(volume.get("target") == "/data" for volume in redis.get("volumes") or []), redis.get("volumes") +assert redis["healthcheck"]["test"] == ["CMD", "redis-cli", "ping"] +assert services["easyai-server"]["depends_on"]["redis"]["condition"] == "service_healthy" +' +} + assert_compose_security() { docker compose config --format json | python3 -c ' import json, sys @@ -188,6 +201,7 @@ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; assert_compose_exposure 127.0.0.1 127.0.0.1 assert_canvas_ws_auth_config assert_compose_security + assert_redis_startup_contract fi reset_case diff --git a/scripts/test-redis-persistence-guard.sh b/scripts/test-redis-persistence-guard.sh new file mode 100755 index 0000000..223f478 --- /dev/null +++ b/scripts/test-redis-persistence-guard.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +cat > "$tmp_dir/docker" <<'SH' +#!/usr/bin/env bash +case "$1" in + info) [ "${DOCKER_STUB_STATE:-}" != unavailable ] ;; + container) [ "${DOCKER_STUB_STATE:-}" != absent ] ;; + inspect) printf '%s\n' "${DOCKER_STUB_MOUNT:-}" ;; + *) exit 2 ;; +esac +SH +chmod +x "$tmp_dir/docker" +export PATH="$tmp_dir:$PATH" + +DOCKER_STUB_STATE=absent "$script_dir/check-redis-persistence.sh" +DOCKER_STUB_MOUNT="bind|$(cd "$script_dir/../data" && pwd -P)/redis" "$script_dir/check-redis-persistence.sh" + +for state in unavailable no_mount wrong_mount; do + case "$state" in + unavailable) export DOCKER_STUB_STATE=unavailable DOCKER_STUB_MOUNT='' ;; + no_mount) export DOCKER_STUB_STATE=present DOCKER_STUB_MOUNT='' ;; + wrong_mount) export DOCKER_STUB_STATE=present DOCKER_STUB_MOUNT='bind|/other/redis' ;; + esac + if "$script_dir/check-redis-persistence.sh" > /dev/null 2>&1; then + echo "unsafe Redis state accepted: $state" >&2 + exit 1 + fi +done + +echo 'Redis persistence guard tests passed' diff --git a/scripts/test-security-origin.sh b/scripts/test-security-origin.sh new file mode 100755 index 0000000..d191bfb --- /dev/null +++ b/scripts/test-security-origin.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/init-security-origin.sh +. "${script_dir}/init-security-origin.sh" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +assert_value() { + local actual + actual="$(public_url_read_env_value "$1" CONFIG_SECURITY_ORIGIN)" + [ "$actual" = "$2" ] || { echo "unexpected origin: $actual" >&2; exit 1; } +} + +cat > "$tmp_dir/missing.env" <<'ENV' +CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api +ENV +init_security_origin "$tmp_dir/missing.env" > /dev/null +assert_value "$tmp_dir/missing.env" 'https://zaowua.com' +first_hash="$(shasum -a 256 "$tmp_dir/missing.env" | cut -d ' ' -f 1)" +init_security_origin "$tmp_dir/missing.env" > /dev/null +[ "$first_hash" = "$(shasum -a 256 "$tmp_dir/missing.env" | cut -d ' ' -f 1)" ] + +cat > "$tmp_dir/sample.env" <<'ENV' +CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api +CONFIG_SECURITY_ORIGIN=http://127.0.0.1,http://localhost +ENV +init_security_origin "$tmp_dir/sample.env" > /dev/null +assert_value "$tmp_dir/sample.env" 'https://zaowua.com' + +cat > "$tmp_dir/preserved.env" <<'ENV' +CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api +CONFIG_SECURITY_ORIGIN=https://zaowua.com,https://www.zaowua.com +ENV +before="$(shasum -a 256 "$tmp_dir/preserved.env" | cut -d ' ' -f 1)" +init_security_origin "$tmp_dir/preserved.env" > /dev/null +[ "$before" = "$(shasum -a 256 "$tmp_dir/preserved.env" | cut -d ' ' -f 1)" ] + +for invalid in '*' 'https://zaowua.com/api' 'https://zaowua.com,' 'https://user:pass@zaowua.com'; do + printf 'CONFIG_PUBLIC_API_BASE_URL=https://zaowua.com/api\nCONFIG_SECURITY_ORIGIN=%s\n' "$invalid" > "$tmp_dir/invalid.env" + if init_security_origin "$tmp_dir/invalid.env" > /dev/null 2>&1; then + echo "accepted invalid origin: $invalid" >&2 + exit 1 + fi +done + +echo 'Security origin migration tests passed' diff --git a/start.ps1 b/start.ps1 index 7984cf8..c35acf4 100644 --- a/start.ps1 +++ b/start.ps1 @@ -343,6 +343,8 @@ function Test-Docker { function Start-Services { Write-Host "" Step "Starting EasyAI services..." + . (Join-Path $script:Root "scripts\Assert-RedisPersistence.ps1") + Assert-RedisPersistence -ProjectRoot $script:Root $useComposeV2 = $false try { & docker compose version *> $null; if ($LASTEXITCODE -eq 0) { $useComposeV2 = $true } } catch { } @@ -437,6 +439,8 @@ function Main { -Path (Join-Path $script:Root ".env") ` -Mode $publicUrlMode ` -Override $script:PublicApiBaseUrlCandidate + . (Join-Path $script:Root "scripts\Initialize-SecurityOrigin.ps1") + Initialize-SecurityOrigin -Path (Join-Path $script:Root ".env") if ($script:DeployDryRun) { Warn "dry-run mode: skip docker and services" diff --git a/start.sh b/start.sh index c6c85ac..c0658a6 100755 --- a/start.sh +++ b/start.sh @@ -547,6 +547,7 @@ configure_http_nginx_proxy() { start_services() { echo "" echo "🚀 启动 EasyAI 服务..." + sudo ./scripts/check-redis-persistence.sh if docker compose version &>/dev/null; then sudo docker compose pull && sudo docker compose up -d else @@ -659,6 +660,9 @@ main() { else init_public_api_base_url .env configure "$PUBLIC_API_BASE_URL_CANDIDATE" fi + # shellcheck source=scripts/init-security-origin.sh + . ./scripts/init-security-origin.sh + init_security_origin .env if [ "$DEPLOY_DRY_RUN" = "1" ]; then echo "" diff --git a/update.ps1 b/update.ps1 index d48be2c..5e38e80 100644 --- a/update.ps1 +++ b/update.ps1 @@ -186,6 +186,8 @@ Initialize-PublicApiBaseUrl ` -Path (Join-Path $scriptDir ".env") ` -Mode "upgrade" ` -Override $env:DEPLOY_PUBLIC_API_BASE_URL +. (Join-Path $scriptDir "scripts\Initialize-SecurityOrigin.ps1") +Initialize-SecurityOrigin -Path (Join-Path $scriptDir ".env") # ==================== Docker 检查 ==================== function Test-DockerInstalled { @@ -236,6 +238,8 @@ if (-not (Ensure-DockerRunning)) { } # 检测 docker compose +. (Join-Path $scriptDir "scripts\Assert-RedisPersistence.ps1") +Assert-RedisPersistence -ProjectRoot $scriptDir $hasComposeV2 = $false try { $null = docker compose version 2>&1; $hasComposeV2 = $true } catch { } diff --git a/update.sh b/update.sh index 2124511..714e3fb 100755 --- a/update.sh +++ b/update.sh @@ -143,6 +143,11 @@ init_security_env .env upgrade . ./scripts/init-public-api-base-url.sh init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}" +# 旧部署可能缺少浏览器 Origin;在重建主服务前从已校验的公开 API 地址补齐。 +# shellcheck source=scripts/init-security-origin.sh +. ./scripts/init-security-origin.sh +init_security_origin .env + echo "===========================" echo "🚀 开始自动安装 Docker 和 Docker Compose" echo "===========================" @@ -198,6 +203,8 @@ $DOCKER_COMPOSE_CMD version echo "🎉 Docker 和 Docker Compose 已就绪!" +# 旧 Redis 若仍把 AOF/RDB 留在容器写层,直接 up 会丢失数据。 +sudo ./scripts/check-redis-persistence.sh echo "🚀 重新启动EasyAI" sudo $DOCKER_COMPOSE_CMD pull && sudo $DOCKER_COMPOSE_CMD up -d