diff --git a/.env.sample b/.env.sample index 84eb585..9f373e8 100644 --- a/.env.sample +++ b/.env.sample @@ -124,6 +124,10 @@ CONFIG_JWT_SECRET= # 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。 CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY= +# Canvas CLI / Agent WebSocket 短时票据专用签名密钥。 +# 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。 +WS_AUTH_WS_TICKET_SECRET= + # 验证码 HMAC、设备/指纹哈希和审计完整性密钥,由启动/升级脚本独立生成并持久化。 CONFIG_OTP_HASH_SECRET= CONFIG_AUDIT_HASH_PEPPER= @@ -212,7 +216,7 @@ WS_AUTH_REQUIRED=false # 鉴权阶段超时时间(毫秒) WS_AUTH_TIMEOUT_MS=6000 # 可用鉴权方法(逗号分隔):none/bearer/ws_ticket -WS_AUTH_METHODS=none,bearer +WS_AUTH_METHODS=none,bearer,ws_ticket # bearer 令牌列表(逗号分隔,生产环境请使用安全配置中心) WS_AUTH_BEARER_TOKENS= # ws_ticket 票据列表(逗号分隔,适合短时授权) diff --git a/README.md b/README.md index 4200c21..23a4322 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true CERTBOT_EM | `DEPLOY_NON_INTERACTIVE` | `1` 时缺少必要变量会直接报错,不再进入问答 | | `DEPLOY_MAC_DOCKER_INSTALL` | macOS 未安装 Docker 时使用,`auto` 自动安装或 `manual` 提示手动安装 | +#### 安全密钥与 Canvas CLI 实时协作 + +`start.sh` / `start.ps1` 和 `update.sh` / `update.ps1` 会自动初始化并持久化鉴权安全密钥。首次安装或存量升级发现 `WS_AUTH_WS_TICKET_SECRET` 缺失、过短时,会生成新的高强度随机值,并确保 `WS_AUTH_METHODS` 包含 `ws_ticket`;Compose 会把同一密钥同时注入 `easyai-server` 与 `ws-gateway`。已有合格密钥会原样保留,重复启动或升级不会轮换,用户无需手动填写或复制密钥。 + +生成结果仅保存在本机 `.env`,脚本日志只显示变量名而不会输出密钥内容。不要把 `.env` 提交到 Git;集群部署时,各 `easyai-server` / `ws-gateway` 实例必须使用同一份持久化配置。 + #### 后端公开 API 地址 后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口: diff --git a/docker-compose.yml b/docker-compose.yml index 70e78fc..f045e24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,7 @@ services: #Token过期时间,单位秒 - CONFIG_TOKEN_EXPIRE=${CONFIG_TOKEN_EXPIRE} - CONFIG_JWT_SECRET=${CONFIG_JWT_SECRET} + - WS_AUTH_WS_TICKET_SECRET=${WS_AUTH_WS_TICKET_SECRET} # 代理服务器 - CONFIG_PROXY_URL=${CONFIG_PROXY_URL} #禁用文档 true/false/留空 @@ -160,6 +161,9 @@ services: - ./data/wsgateway/.pm2:/app/.pm2 restart: unless-stopped environment: + # Canvas CLI / Agent 使用短时 ws_ticket;密钥由启动/升级脚本自动生成并与主服务共享。 + - WS_AUTH_WS_TICKET_SECRET=${WS_AUTH_WS_TICKET_SECRET} + - WS_AUTH_METHODS=${WS_AUTH_METHODS:-none,bearer,ws_ticket} # Redis配置,队列缓存,不会配置的话保持默认即可 - CONFIG_COMFYUI_QUENE_REDIS_HOST=172.21.0.4 - CONFIG_COMFYUI_QUENE_REDIS_PORT=6379 diff --git a/scripts/Initialize-SecurityEnv.ps1 b/scripts/Initialize-SecurityEnv.ps1 index c51da8b..528ac17 100644 --- a/scripts/Initialize-SecurityEnv.ps1 +++ b/scripts/Initialize-SecurityEnv.ps1 @@ -56,7 +56,8 @@ function Initialize-SecurityEnv { $content = Get-Content $Path -Raw -Encoding UTF8 if ($null -eq $content) { $content = "" } $definitions = @( - @{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 } + @{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 }, + @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 } ) if ($Mode -eq "new") { $definitions += @( @@ -76,6 +77,14 @@ function Initialize-SecurityEnv { Write-Host " [OK] Initialized security secret: $($definition.Key)" -ForegroundColor Green } + $authMethods = Get-EnvValue $content "WS_AUTH_METHODS" + if ([string]::IsNullOrWhiteSpace($authMethods)) { $authMethods = "none,bearer" } + $hasWsTicket = @($authMethods.Split(",") | ForEach-Object { $_.Trim() }) -contains "ws_ticket" + if (-not $hasWsTicket) { + $content = Set-EnvValue $content "WS_AUTH_METHODS" ($authMethods + ",ws_ticket") + Write-Host " [OK] Added security config item: WS_AUTH_METHODS" -ForegroundColor Green + } + if ($Mode -eq "new") { $adminPassword = Get-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD" if ($adminPassword.Length -lt 12) { diff --git a/scripts/Test-SecurityEnv.ps1 b/scripts/Test-SecurityEnv.ps1 new file mode 100644 index 0000000..4585a31 --- /dev/null +++ b/scripts/Test-SecurityEnv.ps1 @@ -0,0 +1,86 @@ +#Requires -Version 5.1 + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir "Initialize-SecurityEnv.ps1") + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-security-env-" + [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-MinLength { + param([string]$Content, [string]$Key, [int]$Minimum) + $value = Get-EnvValue $Content $Key + if ($value.Length -lt $Minimum) { throw "$Key must contain at least $Minimum characters" } +} + +function Assert-Value { + param([string]$Content, [string]$Key, [string]$Expected) + $actual = Get-EnvValue $Content $Key + if ($actual -ne $Expected) { throw "$Key expected '$Expected', got '$actual'" } +} + +try { + $upgrade = Write-TestEnv "upgrade.env" @( + "CONFIG_JWT_SECRET='this is a very secret secret'", + "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=", + "WS_AUTH_WS_TICKET_SECRET=", + "WS_AUTH_METHODS=none,bearer" + ) + Initialize-SecurityEnv -Path $upgrade -Mode upgrade + $upgradeContent = Get-Content $upgrade -Raw -Encoding UTF8 + Assert-MinLength $upgradeContent "CONFIG_JWT_SECRET" 32 + Assert-MinLength $upgradeContent "WS_AUTH_WS_TICKET_SECRET" 32 + Assert-Value $upgradeContent "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" "" + Assert-Value $upgradeContent "WS_AUTH_METHODS" "none,bearer,ws_ticket" + $upgradeBeforeRepeat = $upgradeContent + Initialize-SecurityEnv -Path $upgrade -Mode upgrade + $upgradeAfterRepeat = Get-Content $upgrade -Raw -Encoding UTF8 + if ($upgradeAfterRepeat -cne $upgradeBeforeRepeat) { throw "Repeated upgrade must preserve generated values byte-for-byte" } + + $preservedSecret = "existing-ws-ticket-secret-that-is-long-enough" + $preserved = Write-TestEnv "preserved.env" @( + "CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough", + "WS_AUTH_WS_TICKET_SECRET=$preservedSecret", + "WS_AUTH_METHODS=bearer,ws_ticket" + ) + Initialize-SecurityEnv -Path $preserved -Mode upgrade + $preservedContent = Get-Content $preserved -Raw -Encoding UTF8 + Assert-Value $preservedContent "WS_AUTH_WS_TICKET_SECRET" $preservedSecret + Assert-Value $preservedContent "WS_AUTH_METHODS" "bearer,ws_ticket" + + $fresh = Write-TestEnv "new.env" @( + "CONFIG_JWT_SECRET=", + "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=", + "CONFIG_OTP_HASH_SECRET=", + "CONFIG_AUDIT_HASH_PEPPER=", + "CONFIG_AUDIT_INTEGRITY_KEY=", + "CONFIG_INITIAL_ADMIN_PASSWORD=", + "WS_AUTH_WS_TICKET_SECRET=", + "WS_AUTH_METHODS=" + ) + Initialize-SecurityEnv -Path $fresh -Mode new + $freshContent = Get-Content $fresh -Raw -Encoding UTF8 + foreach ($key in @( + "CONFIG_JWT_SECRET", + "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY", + "CONFIG_OTP_HASH_SECRET", + "CONFIG_AUDIT_HASH_PEPPER", + "CONFIG_AUDIT_INTEGRITY_KEY", + "WS_AUTH_WS_TICKET_SECRET" + )) { + Assert-MinLength $freshContent $key 32 + } + Assert-MinLength $freshContent "CONFIG_INITIAL_ADMIN_PASSWORD" 12 + Assert-Value $freshContent "WS_AUTH_METHODS" "none,bearer,ws_ticket" + + Write-Host "Security environment PowerShell tests passed" -ForegroundColor Green +} finally { + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/init-security-env.sh b/scripts/init-security-env.sh index 9a7ed6c..e6b27db 100644 --- a/scripts/init-security-env.sh +++ b/scripts/init-security-env.sh @@ -81,6 +81,29 @@ ensure_security_secret() { echo " ✓ 已初始化安全密钥: ${key}" } +ensure_env_list_item() { + local file="$1" + local key="$2" + local required_item="$3" + local default_value="${4:-}" + local current normalized + current="$(read_env_value "$file" "$key")" + if [ -z "$current" ]; then + current="$default_value" + fi + normalized="$(printf '%s' "$current" | tr -d '[:space:]')" + case ",${normalized}," in + *",${required_item},"*) return 0 ;; + esac + if [ -n "$current" ]; then + current="${current},${required_item}" + else + current="$required_item" + fi + write_env_value "$file" "$key" "$current" + echo " ✓ 已补充安全配置项: ${key}" +} + ensure_initial_admin_password() { local file="$1" local current @@ -105,6 +128,8 @@ init_security_env() { fi ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret" + ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET" + ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer" if [ "$mode" = "new" ]; then ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" ensure_security_secret "$file" "CONFIG_OTP_HASH_SECRET" diff --git a/scripts/test-deployment-public-url.sh b/scripts/test-deployment-public-url.sh index f77d9ac..1d391c7 100755 --- a/scripts/test-deployment-public-url.sh +++ b/scripts/test-deployment-public-url.sh @@ -77,6 +77,21 @@ assert server_ports[0].get("host_ip") == expected, server_ports ' "$expected_server_host_ip" } +assert_canvas_ws_auth_config() { + docker compose config --format json | python3 -c ' +import json, sys +config = json.load(sys.stdin) +server_env = config["services"]["easyai-server"].get("environment") or {} +gateway_env = config["services"]["ws-gateway"].get("environment") or {} +server_secret = str(server_env.get("WS_AUTH_WS_TICKET_SECRET") or "") +gateway_secret = str(gateway_env.get("WS_AUTH_WS_TICKET_SECRET") or "") +assert len(server_secret.encode()) >= 32, "server-main WS ticket secret is missing or too short" +assert server_secret == gateway_secret, "server-main and ws-gateway WS ticket secrets differ" +methods = {item.strip() for item in str(gateway_env.get("WS_AUTH_METHODS") or "").split(",") if item.strip()} +assert "ws_ticket" in methods, f"ws-gateway does not advertise ws_ticket: {sorted(methods)}" +' +} + sed -i.bak 's/^SERVER_HTTP_PORT=.*/SERVER_HTTP_PORT=4100/' .env.sample rm -f .env.sample.bak DEPLOY_NON_INTERACTIVE=1 \ @@ -89,6 +104,7 @@ grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://10.0.0.8:4100' .env grep -qx 'SERVER_HTTP_BIND_IP=0.0.0.0' .env if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then assert_compose_exposure 0.0.0.0 + assert_canvas_ws_auth_config fi reset_case diff --git a/scripts/test-security-env.sh b/scripts/test-security-env.sh new file mode 100755 index 0000000..a249a85 --- /dev/null +++ b/scripts/test-security-env.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/init-security-env.sh +. "${SCRIPT_DIR}/init-security-env.sh" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +write_env() { + local file="$1" + shift + printf '%s\n' "$@" > "$file" +} + +assert_min_length() { + local file="$1" + local key="$2" + local minimum="$3" + local value + value="$(read_env_value "$file" "$key")" + [ "${#value}" -ge "$minimum" ] || fail "$key must contain at least $minimum characters" +} + +assert_value() { + local file="$1" + local key="$2" + local expected="$3" + local actual + actual="$(read_env_value "$file" "$key")" + [ "$actual" = "$expected" ] || fail "$key expected '$expected', got '$actual'" +} + +assert_single_key() { + local file="$1" + local key="$2" + local count + count="$(grep -c "^${key}=" "$file" || true)" + [ "$count" -eq 1 ] || fail "$key must appear exactly once" +} + +file_sha256() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + else + shasum -a 256 "$file" | awk '{print $1}' + fi +} + +upgrade_env="$TMP_DIR/upgrade.env" +upgrade_log="$TMP_DIR/upgrade.log" +write_env "$upgrade_env" \ + "CONFIG_JWT_SECRET='this is a very secret secret'" \ + "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=" \ + "WS_AUTH_WS_TICKET_SECRET=" \ + "WS_AUTH_METHODS=none,bearer" +init_security_env "$upgrade_env" upgrade > "$upgrade_log" + +assert_min_length "$upgrade_env" "CONFIG_JWT_SECRET" 32 +assert_min_length "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET" 32 +assert_value "$upgrade_env" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" "" +assert_value "$upgrade_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket" +assert_single_key "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET" +assert_single_key "$upgrade_env" "WS_AUTH_METHODS" + +upgrade_jwt="$(read_env_value "$upgrade_env" "CONFIG_JWT_SECRET")" +upgrade_ws="$(read_env_value "$upgrade_env" "WS_AUTH_WS_TICKET_SECRET")" +if grep -Fq "$upgrade_jwt" "$upgrade_log" || grep -Fq "$upgrade_ws" "$upgrade_log"; then + fail "security initialization output must not reveal generated secrets" +fi + +before_repeat="$(file_sha256 "$upgrade_env")" +init_security_env "$upgrade_env" upgrade > "$TMP_DIR/repeat.log" +after_repeat="$(file_sha256 "$upgrade_env")" +[ "$before_repeat" = "$after_repeat" ] || fail "repeated upgrade must preserve generated values byte-for-byte" + +preserved_secret="existing-ws-ticket-secret-that-is-long-enough" +preserved_env="$TMP_DIR/preserved.env" +write_env "$preserved_env" \ + "CONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough" \ + "WS_AUTH_WS_TICKET_SECRET=$preserved_secret" \ + "WS_AUTH_METHODS=bearer,ws_ticket" +init_security_env "$preserved_env" upgrade > "$TMP_DIR/preserved.log" +assert_value "$preserved_env" "WS_AUTH_WS_TICKET_SECRET" "$preserved_secret" +assert_value "$preserved_env" "WS_AUTH_METHODS" "bearer,ws_ticket" + +new_env="$TMP_DIR/new.env" +write_env "$new_env" \ + "CONFIG_JWT_SECRET=" \ + "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=" \ + "CONFIG_OTP_HASH_SECRET=" \ + "CONFIG_AUDIT_HASH_PEPPER=" \ + "CONFIG_AUDIT_INTEGRITY_KEY=" \ + "CONFIG_INITIAL_ADMIN_PASSWORD=" \ + "WS_AUTH_WS_TICKET_SECRET=" \ + "WS_AUTH_METHODS=" +init_security_env "$new_env" new > "$TMP_DIR/new.log" +for key in \ + CONFIG_JWT_SECRET \ + CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY \ + CONFIG_OTP_HASH_SECRET \ + CONFIG_AUDIT_HASH_PEPPER \ + CONFIG_AUDIT_INTEGRITY_KEY \ + WS_AUTH_WS_TICKET_SECRET; do + assert_min_length "$new_env" "$key" 32 +done +assert_min_length "$new_env" "CONFIG_INITIAL_ADMIN_PASSWORD" 12 +assert_value "$new_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket" + +mode="$(stat -c '%a' "$new_env" 2>/dev/null || stat -f '%Lp' "$new_env")" +[ "$mode" = "600" ] || fail "generated environment file mode must be 600, got $mode" + +echo "Security environment shell tests passed" diff --git a/update.ps1 b/update.ps1 index 3647e8b..d48be2c 100644 --- a/update.ps1 +++ b/update.ps1 @@ -172,7 +172,7 @@ if (-not $skipRepoUpdate) { Write-Host "" } -# 空值或历史默认值只初始化一次并写回 .env;已有自定义值及数据库轮转配置不受影响。 +# 空值、历史默认 JWT 密钥和缺失的 WS ticket 密钥只初始化一次并写回 .env;已有强密钥保持不变。 if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps1 完成初始化" } . (Join-Path $scriptDir "scripts\Initialize-ServerHttpBindIp.ps1") Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env") diff --git a/update.sh b/update.sh index 4803860..2124511 100755 --- a/update.sh +++ b/update.sh @@ -122,8 +122,8 @@ else echo "" fi -# 升级已有部署时,将空值或历史默认 JWT 密钥一次性替换为持久化随机值。 -# 已存在的自定义值保持不变,数据库 JWT 轮转配置仍由后端优先使用。 +# 升级已有部署时,将空值、历史默认 JWT 密钥和缺失的 WS ticket 密钥 +# 一次性替换为持久化随机值,并确保网关声明 ws_ticket;已有强密钥保持不变。 if [ ! -f .env ]; then echo "❌ 未找到 .env,请先执行 start.sh 完成初始化" exit 1