diff --git a/.env.sample b/.env.sample index abb8fed..83bcd4f 100644 --- a/.env.sample +++ b/.env.sample @@ -8,6 +8,13 @@ NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001 # 域名访问:NUXT_PUBLIC_BASE_APIURL=/api +# 后端生成文件、预签名地址和异步任务查询地址时使用的公开 API 根地址。 +# 留空时启动/升级脚本会根据本节已有配置初始化;手工维护时必须填写完整 http(s) URL。 +CONFIG_PUBLIC_API_BASE_URL= +# 启动/升级脚本的高级覆盖项,适用于已有外部 TLS、特殊端口或手工反向代理。 +# 示例:https://api.example.com:8443/api +DEPLOY_PUBLIC_API_BASE_URL= + # 默认 WebSocket 地址 NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1:3002 # 域名访问:NUXT_PUBLIC_BASE_SOCKETURL=wss://yourwebsite.com/socket.io diff --git a/.github/workflows/test-deployment-public-url.yml b/.github/workflows/test-deployment-public-url.yml new file mode 100644 index 0000000..c170067 --- /dev/null +++ b/.github/workflows/test-deployment-public-url.yml @@ -0,0 +1,42 @@ +name: Test deployment public URL (Linux) + +on: + push: + paths: + - 'start.sh' + - 'update.sh' + - 'docker-compose.yml' + - 'easyai-proxy.conf.sample' + - '.env.sample' + - 'scripts/init-public-api-base-url.sh' + - 'scripts/test-public-api-base-url.sh' + - 'scripts/test-deployment-public-url.sh' + - '.github/workflows/test-deployment-public-url.yml' + pull_request: + paths: + - 'start.sh' + - 'update.sh' + - 'docker-compose.yml' + - 'easyai-proxy.conf.sample' + - '.env.sample' + - 'scripts/init-public-api-base-url.sh' + - 'scripts/test-public-api-base-url.sh' + - 'scripts/test-deployment-public-url.sh' + - '.github/workflows/test-deployment-public-url.yml' + workflow_dispatch: + +jobs: + test-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check shell syntax + run: | + bash -n start.sh update.sh scripts/init-public-api-base-url.sh scripts/test-public-api-base-url.sh scripts/test-deployment-public-url.sh + + - name: Test migration helper + run: bash scripts/test-public-api-base-url.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 a81af06..353131b 100644 --- a/.github/workflows/test-start-ps1.yml +++ b/.github/workflows/test-start-ps1.yml @@ -5,11 +5,17 @@ on: push: paths: - 'start.ps1' + - 'update.ps1' + - 'scripts/Initialize-PublicApiBaseUrl.ps1' + - 'scripts/Test-PublicApiBaseUrl.ps1' - '.env.sample' - '.github/workflows/test-start-ps1.yml' pull_request: paths: - 'start.ps1' + - 'update.ps1' + - 'scripts/Initialize-PublicApiBaseUrl.ps1' + - 'scripts/Test-PublicApiBaseUrl.ps1' - '.env.sample' - '.github/workflows/test-start-ps1.yml' workflow_dispatch: @@ -25,6 +31,7 @@ jobs: DEPLOY_DRY_RUN: "1" DEPLOY_IP: "192.168.1.100" run: | + (Get-Content .env.sample) -replace '^SERVER_HTTP_PORT=.*$', 'SERVER_HTTP_PORT=4100' | Set-Content .env.sample -Encoding UTF8 powershell -ExecutionPolicy Bypass -File .\start.ps1 - name: Verify .env @@ -32,7 +39,13 @@ jobs: $api = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_APIURL=' | ForEach-Object { $_.Line } $socket = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_SOCKETURL=' | ForEach-Object { $_.Line } $sg = Select-String -Path .env -Pattern '^NUXT_PUBLIC_SG_APIURL=' | ForEach-Object { $_.Line } - if ($api -ne 'NUXT_PUBLIC_BASE_APIURL=http://192.168.1.100:3001') { exit 1 } + $publicApi = Select-String -Path .env -Pattern '^CONFIG_PUBLIC_API_BASE_URL=' | ForEach-Object { $_.Line } + if ($api -ne 'NUXT_PUBLIC_BASE_APIURL=http://192.168.1.100:4100') { exit 1 } if ($socket -ne 'NUXT_PUBLIC_BASE_SOCKETURL=ws://192.168.1.100:3002') { exit 1 } if ($sg -ne 'NUXT_PUBLIC_SG_APIURL=http://192.168.1.100:3003') { exit 1 } + if ($publicApi -ne 'CONFIG_PUBLIC_API_BASE_URL=http://192.168.1.100:4100') { exit 1 } Write-Host "OK: .env 配置正确" + + - name: Test public API URL migration helper + run: | + powershell -ExecutionPolicy Bypass -File .\scripts\Test-PublicApiBaseUrl.ps1 diff --git a/README.md b/README.md index cc20488..7a20aaa 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,38 @@ DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true CERTBOT_EM | `DEPLOY_IP` | IP 模式访问地址,如 `10.0.0.8` | | `DEPLOY_DOMAIN` | 域名模式访问地址,可带 `http://` 或 `https://`,脚本会自动清理 | | `DEPLOY_HTTPS` | `true/false`,域名模式下是否申请 HTTPS 证书 | +| `DEPLOY_PUBLIC_API_BASE_URL` | 高级覆盖后端公开 API 地址,例如 `https://api.example.com:8443/api` | | `CERTBOT_EMAIL` | 申请 HTTPS 证书使用的邮箱,默认 `wangbo@51easyai.com` | | `DEPLOY_FORCE_RECONFIG` | 非空时即使已有 `.env` 也重新生成部署配置 | | `DEPLOY_DRY_RUN` | `1` 时只生成配置文件,不安装 Docker、不启动服务 | | `DEPLOY_NON_INTERACTIVE` | `1` 时缺少必要变量会直接报错,不再进入问答 | | `DEPLOY_MAC_DOCKER_INSTALL` | macOS 未安装 Docker 时使用,`auto` 自动安装或 `manual` 提示手动安装 | +#### 后端公开 API 地址 + +后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口: + +1. `.env` 中的 `CONFIG_PUBLIC_API_BASE_URL`。 +2. 来自可信代理的当前请求地址(仅当请求来源命中 `CONFIG_TRUSTED_PROXY` 时读取转发 Header)。 +3. 当前请求的直接 `Host`。 + +启动和升级脚本会在拉取镜像、重建服务之前校验并持久化 `CONFIG_PUBLIC_API_BASE_URL`:新 IP 部署使用 `http://IP:${SERVER_HTTP_PORT}`;域名部署中 `DEPLOY_HTTPS=false` 使用 `http/ws`,`DEPLOY_HTTPS=true` 使用 `https/wss`,API 路径均为 `/api`。旧环境缺少该配置时,会依次从绝对 `NUXT_PUBLIC_BASE_APIURL`、WebSocket origin、首个 `CONFIG_SECURITY_ORIGIN` 推导。已有合法值保持不变,非法或无法推导时脚本会终止并提示修复。 + +已有外部 TLS、手工代理、自定义公开端口或特殊路径时,可在执行脚本前设置高级覆盖: + +```bash +DEPLOY_PUBLIC_API_BASE_URL=https://api.example.com:8443/custom-api ./update.sh +``` + +也可以直接在 `.env` 中维护: + +```dotenv +CONFIG_PUBLIC_API_BASE_URL=https://api.example.com:8443/api +CONFIG_TRUSTED_PROXY=127.0.0.1,10.0.0.0/8 +``` + +新的 `easyai-proxy.conf.sample` 会为 `/api/` 和 `/mcp` 转发保留客户端 authority 中的显式端口,并补齐 `X-Forwarded-Host`、`X-Forwarded-Port`;`/api/` 继续发送 `X-Original-Prefix: /api`。脚本不会改写已经生成的本地 Nginx 配置,旧部署可先通过显式 `CONFIG_PUBLIC_API_BASE_URL` 生效,再按模板手工同步代理 Header。 + `https.sh` 单独执行时也可以跳过域名选择: ```bash @@ -117,6 +143,7 @@ mv easyai easyai2 update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能: - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) +- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL` - 兼容 `docker compose` 和 `docker-compose` 两种命令格式 - 自动拉取最新镜像并重启服务 @@ -176,6 +203,12 @@ $env:UPDATE_NO_WAIT = "1" ## 重要更新记录: +### 2026.07.16 + +1. **公开 URL 稳定化**:新增 `CONFIG_PUBLIC_API_BASE_URL`,后端文件、预签名和任务查询地址不再依赖 Redis 中缓存的首次请求 Host。 +2. **新装与升级迁移**:Linux/macOS 和 Windows 启动、升级脚本会在重建服务前初始化并校验公开 API 地址,支持 IP 自定义 `SERVER_HTTP_PORT`、HTTP/HTTPS 域名和 `DEPLOY_PUBLIC_API_BASE_URL` 高级覆盖。 +3. **代理端口透传**:新 Nginx 模板为 `/api/`、`/mcp` 补齐公开 Host/Port Header;已有生成配置不会自动改写。 + ### 2026.06.24 1. **主服务 sharp/native 内存护栏**:`easyai-server` 默认关闭 sharp/libvips cache,并将 sharp 并发限制为 1;同时设置 `MALLOC_ARENA_MAX=2`、`MALLOC_TRIM_THRESHOLD_=131072`、`MALLOC_MMAP_THRESHOLD_=131072`、`MALLOC_TOP_PAD_=131072`,降低图片处理和大 Buffer 场景下 native RSS 持续膨胀风险。 diff --git a/easyai-proxy.conf.sample b/easyai-proxy.conf.sample index 72d16c0..8e09488 100644 --- a/easyai-proxy.conf.sample +++ b/easyai-proxy.conf.sample @@ -3,6 +3,12 @@ map $http_upgrade $connection_upgrade { '' close; } +# $host 会丢失客户端 authority 中的显式端口;后端公开 URL 推断需保留 $http_host。 +map $http_host $easyai_forwarded_host { + default $http_host; + '' $host; +} + # www 域名重定向,使用二级域名注释掉此部分的域名重定向 server { listen 80; @@ -100,7 +106,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $host; + proxy_set_header Host $easyai_forwarded_host; + proxy_set_header X-Forwarded-Host $easyai_forwarded_host; + proxy_set_header X-Forwarded-Port $server_port; } location /asg-api/ { @@ -171,7 +179,9 @@ server { location /mcp { proxy_pass http://127.0.0.1:3001/mcp; proxy_read_timeout 300s; - proxy_set_header Host $host; + proxy_set_header Host $easyai_forwarded_host; + proxy_set_header X-Forwarded-Host $easyai_forwarded_host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/scripts/Initialize-PublicApiBaseUrl.ps1 b/scripts/Initialize-PublicApiBaseUrl.ps1 new file mode 100644 index 0000000..818ca22 --- /dev/null +++ b/scripts/Initialize-PublicApiBaseUrl.ps1 @@ -0,0 +1,102 @@ +function Get-PublicEnvValue { + param([string]$Content, [string]$Key) + $match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$") + if (-not $match.Success) { return "" } + $value = $match.Groups[1].Value.Trim() + if ($value.Length -ge 2) { + $first = $value.Substring(0, 1) + $last = $value.Substring($value.Length - 1, 1) + if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) { + return $value.Substring(1, $value.Length - 2) + } + } + return $value +} + +function Set-PublicEnvValue { + param([string]$Content, [string]$Key, [string]$Value) + $pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?" + $replacement = "$Key=$Value`r`n" + if ([regex]::IsMatch($Content, $pattern)) { + return [regex]::Replace($Content, $pattern, $replacement, 1) + } + if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" } + return $Content + $replacement +} + +function ConvertTo-PublicApiBaseUrl { + param([string]$Value) + $normalized = if ($null -eq $Value) { "" } else { $Value.Trim().TrimEnd('/') } + if (-not $normalized) { return $null } + $uri = $null + if (-not [Uri]::TryCreate($normalized, [UriKind]::Absolute, [ref]$uri)) { return $null } + if ($uri.Scheme -notin @("http", "https")) { return $null } + if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { return $null } + if (-not $uri.Host -or $uri.Port -lt 1 -or $uri.Port -gt 65535) { return $null } + return $normalized +} + +function Get-DerivedPublicApiBaseUrl { + param([string]$Content) + $apiUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_APIURL" + $absoluteApiUrl = ConvertTo-PublicApiBaseUrl $apiUrl + if ($absoluteApiUrl) { return $absoluteApiUrl } + if (-not $apiUrl.StartsWith('/')) { return $null } + + $origin = $null + $socketUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_SOCKETURL" + $socketUri = $null + if ([Uri]::TryCreate($socketUrl, [UriKind]::Absolute, [ref]$socketUri) -and + $socketUri.Scheme -in @("ws", "wss")) { + $scheme = if ($socketUri.Scheme -eq "wss") { "https" } else { "http" } + $origin = "${scheme}://$($socketUri.Authority)" + } + + if (-not $origin) { + $securityOrigin = (Get-PublicEnvValue $Content "CONFIG_SECURITY_ORIGIN").Split(',')[0].Trim() + $securityUri = $null + if ([Uri]::TryCreate($securityOrigin, [UriKind]::Absolute, [ref]$securityUri) -and + $securityUri.Scheme -in @("http", "https")) { + $origin = "$($securityUri.Scheme)://$($securityUri.Authority)" + } + } + if (-not $origin) { return $null } + return ConvertTo-PublicApiBaseUrl "$($origin.TrimEnd('/'))$apiUrl" +} + +function Initialize-PublicApiBaseUrl { + param( + [Parameter(Mandatory = $true)][string]$Path, + [ValidateSet("configure", "upgrade")][string]$Mode = "upgrade", + [string]$Override = $env:DEPLOY_PUBLIC_API_BASE_URL + ) + if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" } + + $content = Get-Content $Path -Raw -Encoding UTF8 + if ($null -eq $content) { $content = "" } + if (-not $Override) { $Override = Get-PublicEnvValue $content "DEPLOY_PUBLIC_API_BASE_URL" } + $current = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" + + if ($Override) { + $selected = ConvertTo-PublicApiBaseUrl $Override + if (-not $selected) { throw "Invalid public API base URL: $Override" } + } elseif ($current) { + if (-not (ConvertTo-PublicApiBaseUrl $current)) { + throw "Invalid CONFIG_PUBLIC_API_BASE_URL: $current" + } + return + } else { + $selected = Get-DerivedPublicApiBaseUrl $content + if (-not $selected) { + throw "Cannot derive CONFIG_PUBLIC_API_BASE_URL. Set a full URL such as https://example.com/api" + } + } + + $content = Set-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" $selected + [System.IO.File]::WriteAllText( + (Resolve-Path $Path), + $content, + [System.Text.UTF8Encoding]::new($false) + ) + Write-Host " [OK] Public API base URL: $selected" -ForegroundColor Green +} diff --git a/scripts/Test-PublicApiBaseUrl.ps1 b/scripts/Test-PublicApiBaseUrl.ps1 new file mode 100644 index 0000000..1d5a4b8 --- /dev/null +++ b/scripts/Test-PublicApiBaseUrl.ps1 @@ -0,0 +1,93 @@ +#Requires -Version 5.1 + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir "Initialize-PublicApiBaseUrl.ps1") + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-public-url-" + [Guid]::NewGuid()) +[System.IO.Directory]::CreateDirectory($tempDir) | Out-Null + +function Write-TestEnv { + param([string]$Name, [string[]]$Lines) + $path = Join-Path $tempDir $Name + [System.IO.File]::WriteAllText($path, ($Lines -join "`r`n") + "`r`n", [System.Text.UTF8Encoding]::new($false)) + return $path +} + +function Assert-PublicUrl { + param([string]$Path, [string]$Expected) + $content = Get-Content $Path -Raw -Encoding UTF8 + $actual = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" + if ($actual -ne $Expected) { throw "Expected '$Expected', got '$actual'" } +} + +try { + $existing = Write-TestEnv "existing.env" @( + "CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api", + "NUXT_PUBLIC_BASE_APIURL=/api" + ) + Initialize-PublicApiBaseUrl -Path $existing -Mode upgrade + Assert-PublicUrl $existing "https://existing.example.com:8443/api" + + $absolute = Write-TestEnv "absolute.env" @( + "CONFIG_PUBLIC_API_BASE_URL=", + "NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100" + ) + Initialize-PublicApiBaseUrl -Path $absolute -Mode upgrade + Assert-PublicUrl $absolute "http://10.0.0.8:4100" + + $websocket = Write-TestEnv "websocket.env" @( + "CONFIG_PUBLIC_API_BASE_URL=", + "NUXT_PUBLIC_BASE_APIURL=/api", + "NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io" + ) + Initialize-PublicApiBaseUrl -Path $websocket -Mode upgrade + Assert-PublicUrl $websocket "https://demo.example.com/api" + + $securityOrigin = Write-TestEnv "security-origin.env" @( + "CONFIG_PUBLIC_API_BASE_URL=", + "NUXT_PUBLIC_BASE_APIURL=/api", + "NUXT_PUBLIC_BASE_SOCKETURL=", + "CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com" + ) + Initialize-PublicApiBaseUrl -Path $securityOrigin -Mode upgrade + Assert-PublicUrl $securityOrigin "http://demo.example.com/api" + + $fileOverride = Write-TestEnv "file-override.env" @( + "CONFIG_PUBLIC_API_BASE_URL=", + "DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api", + "NUXT_PUBLIC_BASE_APIURL=/api" + ) + Initialize-PublicApiBaseUrl -Path $fileOverride -Mode upgrade + Assert-PublicUrl $fileOverride "https://proxy.example.com:9443/custom-api" + + $explicitOverride = Write-TestEnv "explicit-override.env" @( + "CONFIG_PUBLIC_API_BASE_URL=", + "NUXT_PUBLIC_BASE_APIURL=/api" + ) + Initialize-PublicApiBaseUrl ` + -Path $explicitOverride ` + -Mode configure ` + -Override "http://192.168.1.20:4567" + Assert-PublicUrl $explicitOverride "http://192.168.1.20:4567" + + $invalid = Write-TestEnv "invalid.env" @( + "CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api", + "NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api" + ) + $blocked = $false + try { Initialize-PublicApiBaseUrl -Path $invalid -Mode upgrade } catch { $blocked = $true } + if (-not $blocked) { throw "Invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup" } + + $unresolved = Write-TestEnv "unresolved.env" @( + "CONFIG_PUBLIC_API_BASE_URL=", + "NUXT_PUBLIC_BASE_APIURL=/api" + ) + $blocked = $false + try { Initialize-PublicApiBaseUrl -Path $unresolved -Mode upgrade } catch { $blocked = $true } + if (-not $blocked) { throw "Unresolved public API URL must block startup" } + + Write-Host "Public API base URL PowerShell tests passed" -ForegroundColor Green +} finally { + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/init-public-api-base-url.sh b/scripts/init-public-api-base-url.sh new file mode 100755 index 0000000..ac4f1bb --- /dev/null +++ b/scripts/init-public-api-base-url.sh @@ -0,0 +1,157 @@ +#!/bin/bash + +# 初始化并持久化后端对外公开 API 地址。不会 source .env,避免执行其中内容。 + +public_url_read_env_value() { + local file="$1" + local key="$2" + local value + value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} + +public_url_write_env_value() { + local file="$1" + local key="$2" + local value="$3" + local tmp + tmp="$(mktemp "${file}.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { replaced = 0 } + index($0, key "=") == 1 { + if (!replaced) { + print key "=" value + replaced = 1 + } + next + } + { print } + END { + if (!replaced) print key "=" value + } + ' "$file" > "$tmp" + chmod 600 "$tmp" + mv "$tmp" "$file" +} + +public_url_normalize() { + local value + value="$(printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + while [ "${value%/}" != "$value" ]; do value="${value%/}"; done + printf '%s' "$value" +} + +public_url_validate() { + local value rest authority host port + value="$(public_url_normalize "$1")" + case "$value" in + http://*|https://*) ;; + *) return 1 ;; + esac + case "$value" in + *[[:space:]@?#]*) return 1 ;; + esac + + rest="${value#*://}" + authority="${rest%%/*}" + [ -n "$authority" ] || return 1 + if [[ "$authority" == \[* ]]; then + [[ "$authority" =~ ^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$ ]] || return 1 + if [[ "$authority" == *]:* ]]; then port="${authority##*:}"; fi + else + [[ "$authority" =~ ^[A-Za-z0-9._-]+(:[0-9]+)?$ ]] || return 1 + if [[ "$authority" == *:* ]]; then + host="${authority%:*}" + port="${authority##*:}" + [ -n "$host" ] || return 1 + fi + fi + if [ -n "${port:-}" ]; then + [[ "$port" =~ ^[0-9]+$ ]] || return 1 + [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1 + fi +} + +public_url_derive_from_env() { + local file="$1" + local api_url socket_url security_origin origin scheme combined + api_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_APIURL")" + if public_url_validate "$api_url"; then + public_url_normalize "$api_url" + return 0 + fi + [[ "$api_url" == /* ]] || return 1 + + socket_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_SOCKETURL")" + if [[ "$socket_url" =~ ^(ws|wss)://([^/?#[:space:]]+) ]]; then + scheme="http" + [ "${BASH_REMATCH[1]}" = "wss" ] && scheme="https" + origin="${scheme}://${BASH_REMATCH[2]}" + fi + + if [ -z "${origin:-}" ]; then + security_origin="$(public_url_read_env_value "$file" "CONFIG_SECURITY_ORIGIN")" + security_origin="${security_origin%%,*}" + security_origin="$(public_url_normalize "$security_origin")" + if [[ "$security_origin" =~ ^(https?://[^/]+) ]]; then + origin="${BASH_REMATCH[1]}" + fi + fi + + [ -n "${origin:-}" ] || return 1 + combined="$(public_url_normalize "${origin}${api_url}")" + public_url_validate "$combined" || return 1 + printf '%s' "$combined" +} + +init_public_api_base_url() { + local file="${1:-.env}" + local mode="${2:-upgrade}" + local override="${3:-${DEPLOY_PUBLIC_API_BASE_URL:-}}" + local current selected + if [ ! -f "$file" ]; then + echo "❌ 环境配置文件不存在: $file" >&2 + return 1 + fi + if [ "$mode" != "configure" ] && [ "$mode" != "upgrade" ]; then + echo "❌ 公开 API 地址初始化模式无效: $mode(仅支持 configure/upgrade)" >&2 + return 1 + fi + + if [ -z "$override" ]; then + override="$(public_url_read_env_value "$file" "DEPLOY_PUBLIC_API_BASE_URL")" + fi + current="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")" + if [ -n "$override" ]; then + selected="$(public_url_normalize "$override")" + elif [ -n "$current" ]; then + if ! public_url_validate "$current"; then + echo "❌ CONFIG_PUBLIC_API_BASE_URL 配置无效: $current" >&2 + return 1 + fi + return 0 + else + selected="$(public_url_derive_from_env "$file")" || { + echo "❌ 无法从现有前端配置推导 CONFIG_PUBLIC_API_BASE_URL" >&2 + echo " 请设置完整地址,例如 https://example.com/api" >&2 + return 1 + } + fi + + if ! public_url_validate "$selected"; then + echo "❌ 公开 API 地址配置无效: $selected" >&2 + return 1 + fi + selected="$(public_url_normalize "$selected")" + public_url_write_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL" "$selected" + echo " ✓ 已配置公开 API 地址: $selected" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + init_public_api_base_url "${1:-.env}" "${2:-upgrade}" "${3:-}" +fi diff --git a/scripts/test-deployment-public-url.sh b/scripts/test-deployment-public-url.sh new file mode 100755 index 0000000..2932d04 --- /dev/null +++ b/scripts/test-deployment-public-url.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +cp \ + "$REPO_ROOT/start.sh" \ + "$REPO_ROOT/docker-compose.yml" \ + "$REPO_ROOT/.env.sample" \ + "$REPO_ROOT/.env.tools.sample" \ + "$REPO_ROOT/.env.ASG.sample" \ + "$REPO_ROOT/.env.AMS.sample" \ + "$REPO_ROOT/easyai-proxy.conf.sample" \ + "$TMP_DIR/" +mkdir -p "$TMP_DIR/scripts" +cp \ + "$REPO_ROOT/scripts/init-security-env.sh" \ + "$REPO_ROOT/scripts/init-public-api-base-url.sh" \ + "$TMP_DIR/scripts/" + +cd "$TMP_DIR" + +reset_case() { + rm -f \ + .env \ + .env.tools \ + .env.ASG \ + .env.AMS \ + demo.example.com.conf +} + +sed -i.bak 's/^SERVER_HTTP_PORT=.*/SERVER_HTTP_PORT=4100/' .env.sample +rm -f .env.sample.bak +DEPLOY_NON_INTERACTIVE=1 \ + DEPLOY_DRY_RUN=1 \ + DEPLOY_ACCESS=ip \ + DEPLOY_IP=10.0.0.8 \ + bash start.sh >/dev/null +grep -qx 'NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100' .env +grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://10.0.0.8:4100' .env +if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + docker compose config --quiet +fi + +reset_case +DEPLOY_NON_INTERACTIVE=1 \ + DEPLOY_DRY_RUN=1 \ + DEPLOY_ACCESS=domain \ + DEPLOY_DOMAIN=demo.example.com \ + DEPLOY_HTTPS=false \ + bash start.sh >/dev/null +grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=ws://demo.example.com/socket.io' .env +grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://demo.example.com/api' .env +grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' demo.example.com.conf +grep -q 'proxy_set_header X-Forwarded-Port $server_port;' demo.example.com.conf +grep -q "proxy_set_header X-Original-Prefix '/api';" demo.example.com.conf + +reset_case +DEPLOY_NON_INTERACTIVE=1 \ + DEPLOY_DRY_RUN=1 \ + DEPLOY_ACCESS=domain \ + DEPLOY_DOMAIN=demo.example.com \ + DEPLOY_HTTPS=true \ + bash start.sh >/dev/null +grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io' .env +grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://demo.example.com/api' .env + +reset_case +DEPLOY_NON_INTERACTIVE=1 \ + DEPLOY_DRY_RUN=1 \ + DEPLOY_ACCESS=ip \ + DEPLOY_IP=10.0.0.8 \ + DEPLOY_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api \ + bash start.sh >/dev/null +grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api' .env + +echo "Deployment public URL dry-run tests passed" diff --git a/scripts/test-public-api-base-url.sh b/scripts/test-public-api-base-url.sh new file mode 100755 index 0000000..76a6553 --- /dev/null +++ b/scripts/test-public-api-base-url.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/init-public-api-base-url.sh +. "${SCRIPT_DIR}/init-public-api-base-url.sh" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_env_value() { + local file="$1" + local expected="$2" + local actual + actual="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")" + [ "$actual" = "$expected" ] || fail "expected $expected, got $actual" +} + +write_env() { + local file="$1" + shift + printf '%s\n' "$@" > "$file" +} + +existing="$TMP_DIR/existing.env" +write_env "$existing" \ + "CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api" \ + "NUXT_PUBLIC_BASE_APIURL=/api" +init_public_api_base_url "$existing" upgrade >/dev/null +assert_env_value "$existing" "https://existing.example.com:8443/api" + +absolute="$TMP_DIR/absolute.env" +write_env "$absolute" \ + "CONFIG_PUBLIC_API_BASE_URL=" \ + "NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100" +init_public_api_base_url "$absolute" upgrade >/dev/null +assert_env_value "$absolute" "http://10.0.0.8:4100" + +websocket="$TMP_DIR/websocket.env" +write_env "$websocket" \ + "CONFIG_PUBLIC_API_BASE_URL=" \ + "NUXT_PUBLIC_BASE_APIURL=/api" \ + "NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io" +init_public_api_base_url "$websocket" upgrade >/dev/null +assert_env_value "$websocket" "https://demo.example.com/api" + +security_origin="$TMP_DIR/security-origin.env" +write_env "$security_origin" \ + "CONFIG_PUBLIC_API_BASE_URL=" \ + "NUXT_PUBLIC_BASE_APIURL=/api" \ + "NUXT_PUBLIC_BASE_SOCKETURL=" \ + "CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com" +init_public_api_base_url "$security_origin" upgrade >/dev/null +assert_env_value "$security_origin" "http://demo.example.com/api" + +file_override="$TMP_DIR/file-override.env" +write_env "$file_override" \ + "CONFIG_PUBLIC_API_BASE_URL=" \ + "DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api" \ + "NUXT_PUBLIC_BASE_APIURL=/api" +init_public_api_base_url "$file_override" upgrade >/dev/null +assert_env_value "$file_override" "https://proxy.example.com:9443/custom-api" + +explicit_override="$TMP_DIR/explicit-override.env" +write_env "$explicit_override" \ + "CONFIG_PUBLIC_API_BASE_URL=" \ + "NUXT_PUBLIC_BASE_APIURL=/api" +init_public_api_base_url "$explicit_override" configure "http://192.168.1.20:4567" >/dev/null +assert_env_value "$explicit_override" "http://192.168.1.20:4567" + +invalid="$TMP_DIR/invalid.env" +write_env "$invalid" \ + "CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api" \ + "NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api" +if init_public_api_base_url "$invalid" upgrade >/dev/null 2>&1; then + fail "invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup" +fi + +unresolved="$TMP_DIR/unresolved.env" +write_env "$unresolved" \ + "CONFIG_PUBLIC_API_BASE_URL=" \ + "NUXT_PUBLIC_BASE_APIURL=/api" +if init_public_api_base_url "$unresolved" upgrade >/dev/null 2>&1; then + fail "unresolved public API URL must block startup" +fi + +echo "Public API base URL shell tests passed" diff --git a/start.ps1 b/start.ps1 index d23eb1c..2edec15 100644 --- a/start.ps1 +++ b/start.ps1 @@ -23,6 +23,7 @@ $script:DeployDryRun = ($env:DEPLOY_DRY_RUN -eq "1") $script:DeployIP = "" $script:SkipDeployQuestions = $false $script:SecurityEnvMode = "upgrade" +$script:PublicApiBaseUrlCandidate = "" $script:DockerDesktopUrl = "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe" function Write-Log { @@ -166,12 +167,20 @@ function Setup-EnvFiles { $content = Get-Content ".env" -Raw -Encoding UTF8 if (-not $content) { $content = "" } - $content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" "http://$($script:DeployIP):3001" + $serverPortMatch = [regex]::Match($content, '(?m)^SERVER_HTTP_PORT=(\d+)$') + $serverPort = if ($serverPortMatch.Success) { $serverPortMatch.Groups[1].Value } else { "3001" } + $publicApiBaseUrl = "http://$($script:DeployIP):$serverPort" + $content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" $publicApiBaseUrl $content = Upsert-Env $content "NUXT_PUBLIC_BASE_SOCKETURL" "ws://$($script:DeployIP):3002" $content = Upsert-Env $content "NUXT_PUBLIC_SG_APIURL" "http://$($script:DeployIP):3003" $webPortMatch = [regex]::Match($content, '(?m)^WEB_PORT=(\d+)$') $webPort = if ($webPortMatch.Success) { $webPortMatch.Groups[1].Value } else { "3010" } $content = Upsert-Env $content "CONFIG_SECURITY_ORIGIN" "http://$($script:DeployIP):$webPort" + $script:PublicApiBaseUrlCandidate = if ($env:DEPLOY_PUBLIC_API_BASE_URL) { + $env:DEPLOY_PUBLIC_API_BASE_URL + } else { + $publicApiBaseUrl + } [System.IO.File]::WriteAllText((Join-Path $script:Root ".env"), $content, [System.Text.UTF8Encoding]::new($false)) Ok ".env configured for IP=$($script:DeployIP)" @@ -353,7 +362,7 @@ function Main { if ((Test-Path ".env") -and -not $env:DEPLOY_FORCE_RECONFIG -and -not $env:DEPLOY_IP -and -not $env:DEPLOY_ACCESS) { if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") { $line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1 - if ($line -and $line -like "*:3001*") { $script:DeployIP = ($line -replace ".*http://", "" -replace ":3001.*", "").Trim() } + if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] } $script:SkipDeployQuestions = $true Step "Using existing env config" } else { @@ -361,7 +370,7 @@ function Main { $yes = ($answer.Length -gt 0) -and ($answer[0] -eq [char]121) if (-not $yes) { $line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1 - if ($line -and $line -like "*:3001*") { $script:DeployIP = ($line -replace ".*http://", "" -replace ":3001.*", "").Trim() } + if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] } $script:SkipDeployQuestions = $true Step "Using existing env config" } @@ -380,6 +389,13 @@ function Main { . (Join-Path $script:Root "scripts\Initialize-SecurityEnv.ps1") Initialize-SecurityEnv -Path (Join-Path $script:Root ".env") -Mode $script:SecurityEnvMode + . (Join-Path $script:Root "scripts\Initialize-PublicApiBaseUrl.ps1") + $publicUrlMode = if ($script:SkipDeployQuestions) { "upgrade" } else { "configure" } + Initialize-PublicApiBaseUrl ` + -Path (Join-Path $script:Root ".env") ` + -Mode $publicUrlMode ` + -Override $script:PublicApiBaseUrlCandidate + if ($script:DeployDryRun) { Warn "dry-run mode: skip docker and services" } else { diff --git a/start.sh b/start.sh index 18d118c..8c3683b 100755 --- a/start.sh +++ b/start.sh @@ -30,6 +30,7 @@ DEPLOY_DOMAIN="${DEPLOY_DOMAIN:-}" DEPLOY_HTTPS_ENV="${DEPLOY_HTTPS_INPUT:-${DEPLOY_HTTPS:-}}" DEPLOY_HTTPS=false SECURITY_ENV_MODE="upgrade" +PUBLIC_API_BASE_URL_CANDIDATE="" is_non_interactive() { [ "${DEPLOY_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ] @@ -237,24 +238,34 @@ setup_env_files() { SECURITY_ENV_MODE="new" fi - local web_port + local web_port server_port public_scheme socket_scheme web_port="$(awk -F= '$1 == "WEB_PORT" { print $2; exit }' .env | tr -d '[:space:]')" web_port="${web_port:-3010}" + server_port="$(awk -F= '$1 == "SERVER_HTTP_PORT" { print $2; exit }' .env | tr -d '[:space:]')" + server_port="${server_port:-3001}" if [ "$DEPLOY_MODE" = "ip" ]; then # IP 模式 - sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:3001|" .env + sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:${server_port}|" .env sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=ws://${DEPLOY_IP}:3002|" .env sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=http://${DEPLOY_IP}:3003|" .env sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=http://${DEPLOY_IP}:${web_port}|" .env + PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-http://${DEPLOY_IP}:${server_port}}" echo " ✓ .env 已配置为 IP 模式 (${DEPLOY_IP})" else # 域名模式 + public_scheme="http" + socket_scheme="ws" + if [ "$DEPLOY_HTTPS" = true ]; then + public_scheme="https" + socket_scheme="wss" + fi sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=/api|" .env - sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=wss://${DEPLOY_DOMAIN}/socket.io|" .env + sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=${socket_scheme}://${DEPLOY_DOMAIN}/socket.io|" .env sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=/asg-api|" .env # 域名模式同时允许同域 HTTP/HTTPS,兼容首次签证书和复用既有证书两种流程。 sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=https://${DEPLOY_DOMAIN},http://${DEPLOY_DOMAIN}|" .env + PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-${public_scheme}://${DEPLOY_DOMAIN}/api}" echo " ✓ .env 已配置为域名模式 (${DEPLOY_DOMAIN})" # 7. Nginx 配置(域名模式) @@ -490,6 +501,15 @@ main() { . ./scripts/init-security-env.sh init_security_env .env "$SECURITY_ENV_MODE" + # 在拉取镜像或重建服务前完成公开地址迁移;非法或无法推导时立即终止。 + # shellcheck source=scripts/init-public-api-base-url.sh + . ./scripts/init-public-api-base-url.sh + if [ "$DEPLOY_MODE" = "skip" ]; then + init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}" + else + init_public_api_base_url .env configure "$PUBLIC_API_BASE_URL_CANDIDATE" + fi + if [ "$DEPLOY_DRY_RUN" = "1" ]; then echo "" echo "⚠️ dry-run 模式:跳过 Docker 安装和服务启动" @@ -508,7 +528,11 @@ main() { if [ "$DEPLOY_MODE" = "ip" ] && [ -n "$DEPLOY_IP" ]; then echo "访问地址: http://${DEPLOY_IP}:3010" elif [ "$DEPLOY_MODE" = "domain" ] && [ -n "$DEPLOY_DOMAIN" ]; then - echo "访问地址: http://${DEPLOY_DOMAIN} (配置 Nginx 后)" + if [ "$DEPLOY_HTTPS" = true ]; then + echo "访问地址: https://${DEPLOY_DOMAIN}" + else + echo "访问地址: http://${DEPLOY_DOMAIN} (配置 Nginx 后)" + fi if [ "$DEPLOY_HTTPS" = true ]; then echo "HTTPS 已启用" fi diff --git a/update.ps1 b/update.ps1 index 133124f..98dad21 100644 --- a/update.ps1 +++ b/update.ps1 @@ -177,6 +177,13 @@ if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps . (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1") Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade" +# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。 +. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1") +Initialize-PublicApiBaseUrl ` + -Path (Join-Path $scriptDir ".env") ` + -Mode "upgrade" ` + -Override $env:DEPLOY_PUBLIC_API_BASE_URL + # ==================== Docker 检查 ==================== function Test-DockerInstalled { $docker = Get-Command docker -ErrorAction SilentlyContinue diff --git a/update.sh b/update.sh index c505933..5f3dc02 100755 --- a/update.sh +++ b/update.sh @@ -132,6 +132,11 @@ fi . ./scripts/init-security-env.sh init_security_env .env upgrade +# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。 +# shellcheck source=scripts/init-public-api-base-url.sh +. ./scripts/init-public-api-base-url.sh +init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}" + echo "===========================" echo "🚀 开始自动安装 Docker 和 Docker Compose" echo "==========================="