feat(deploy): 自动初始化画布协作认证密钥

This commit is contained in:
2026-08-19 14:16:20 +08:00
parent ee52bcf34f
commit 8cd1fb137d
10 changed files with 276 additions and 5 deletions
+5 -1
View File
@@ -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 票据列表(逗号分隔,适合短时授权)
+6
View File
@@ -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 地址
后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口:
+4
View File
@@ -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
+10 -1
View File
@@ -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) {
+86
View File
@@ -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
}
+25
View File
@@ -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"
+16
View File
@@ -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
+121
View File
@@ -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"
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -122,8 +122,8 @@ else
echo ""
fi
# 升级已有部署时,将空值历史默认 JWT 密钥一次性替换为持久化随机值。
# 已存在的自定义值保持不变,数据库 JWT 轮转配置仍由后端优先使用
# 升级已有部署时,将空值历史默认 JWT 密钥和缺失的 WS ticket 密钥
# 一次性替换为持久化随机值,并确保网关声明 ws_ticket;已有强密钥保持不变
if [ ! -f .env ]; then
echo "❌ 未找到 .env,请先执行 start.sh 完成初始化"
exit 1