forked from wangbo/easyai
fix(deploy): 加固 Redis 持久化与旧环境启动配置
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
Executable
+24
@@ -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
|
||||
Executable
+67
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+35
@@ -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'
|
||||
Executable
+48
@@ -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'
|
||||
Reference in New Issue
Block a user