Compare commits

..
4 Commits
Author SHA1 Message Date
wangbo 40732c011b perf(redis): 增强持久化与优雅停止
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
2026-08-25 16:24:36 +08:00
wangbo 7dc4313de6 fix(deploy): 明确提示初始管理员密码 2026-08-25 16:22:46 +08:00
wangbo 8cd1fb137d feat(deploy): 自动初始化画布协作认证密钥 2026-08-19 14:16:20 +08:00
wangbo ee52bcf34f fix(deploy): 默认关闭沙箱公网访问 2026-08-17 10:19:10 +08:00
21 changed files with 732 additions and 53 deletions
+13 -8
View File
@@ -30,6 +30,9 @@ WEB_PORT=3010
SERVER_HTTP_PORT=3001
# easyai-server 后端 HTTP 端口
SERVER_HTTP_BIND_IP=127.0.0.1
# easyai-server 宿主机绑定地址。域名/本机部署保持 127.0.0.1;仅 IP/LAN 直连模式使用 0.0.0.0
NODE_MAX_OLD_SPACE_SIZE=8192
# easyai-server 的 Node.js V8 old-space 堆上限,单位 MiB
@@ -66,14 +69,11 @@ MEMORY_TCP_PORT=4004
# 记忆整理相关参数在 .env.AMS 中配置(MEMORY_MAINTENANCE_*
# ========== 4. 沙箱环境 ==========
SANDBOX_PORT=8081
# 不建议对外暴露
SANDBOX_JUPYTERLAB_PORT=8888
SANDBOX_JUPYTER_TOKEN=easyaiisbest
# 建议设置 token
# Sandbox API 与 JupyterLab 默认不映射宿主机端口;token 仅用于容器内 Jupyter 认证
SANDBOX_SERVICE_BASE_URL=
# 留空时 easyai-server 通过 Docker 内网 http://sandbox:8000 访问
# ========== 5. Redis ==========
REDIS_PORT=
@@ -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=
@@ -140,8 +144,9 @@ CONFIG_TRUSTED_PROXY=
# 完成身份冲突扫描和人工处置后才能启用手机号、邮箱和微信身份唯一索引。
CONFIG_AUTH_UNIQUE_INDEXES_ENABLED=false
# 仅在首次创建 admin 账号时使用;启动脚本会生成 20 位字母数字随机值并持久化到 .env。
# 已存在 admin 的升级环境不会使用或修改现有管理员密码
# 仅在数据库首次创建 admin 账号时使用;启动脚本会生成 20 位字母数字随机值并持久化到 .env。
# 首次部署完成页会明确显示登录账号和密码;初始密码没有独立过期时间,首次登录后请立即修改
# admin 创建后不再使用该值;修改 .env 或执行升级不会重置现有管理员密码。
CONFIG_INITIAL_ADMIN_PASSWORD=
CONFIG_TOKEN_SIGN_SK=easyai2025easyai
@@ -212,7 +217,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 票据列表(逗号分隔,适合短时授权)
@@ -10,6 +10,9 @@ on:
- '.env.sample'
- 'scripts/init-public-api-base-url.sh'
- 'scripts/test-public-api-base-url.sh'
- 'scripts/init-server-http-bind-ip.sh'
- 'scripts/test-server-http-bind-ip.sh'
- 'scripts/init-security-env.sh'
- 'scripts/test-deployment-public-url.sh'
- '.github/workflows/test-deployment-public-url.yml'
pull_request:
@@ -21,6 +24,9 @@ on:
- '.env.sample'
- 'scripts/init-public-api-base-url.sh'
- 'scripts/test-public-api-base-url.sh'
- 'scripts/init-server-http-bind-ip.sh'
- 'scripts/test-server-http-bind-ip.sh'
- 'scripts/init-security-env.sh'
- 'scripts/test-deployment-public-url.sh'
- '.github/workflows/test-deployment-public-url.yml'
workflow_dispatch:
@@ -33,10 +39,13 @@ jobs:
- 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
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
- name: Test migration helper
run: bash scripts/test-public-api-base-url.sh
- name: Test server HTTP bind migration
run: bash scripts/test-server-http-bind-ip.sh
- name: Test deployment dry-run matrix
run: bash scripts/test-deployment-public-url.sh
+21 -1
View File
@@ -8,6 +8,8 @@ on:
- 'update.ps1'
- 'scripts/Initialize-PublicApiBaseUrl.ps1'
- 'scripts/Test-PublicApiBaseUrl.ps1'
- 'scripts/Initialize-ServerHttpBindIp.ps1'
- 'scripts/Test-ServerHttpBindIp.ps1'
- '.env.sample'
- '.github/workflows/test-start-ps1.yml'
pull_request:
@@ -16,6 +18,8 @@ on:
- 'update.ps1'
- 'scripts/Initialize-PublicApiBaseUrl.ps1'
- 'scripts/Test-PublicApiBaseUrl.ps1'
- 'scripts/Initialize-ServerHttpBindIp.ps1'
- 'scripts/Test-ServerHttpBindIp.ps1'
- '.env.sample'
- '.github/workflows/test-start-ps1.yml'
workflow_dispatch:
@@ -32,7 +36,17 @@ jobs:
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
$output = powershell -ExecutionPolicy Bypass -File .\start.ps1 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Host $output; exit $LASTEXITCODE }
$adminPassword = (Select-String -Path .env -Pattern '^CONFIG_INITIAL_ADMIN_PASSWORD=' | ForEach-Object { $_.Line.Substring('CONFIG_INITIAL_ADMIN_PASSWORD='.Length) })
if ([string]::IsNullOrWhiteSpace($adminPassword)) { throw 'Initial admin password was not generated' }
if (-not $output.Contains('登录账号: admin')) { throw 'Deployment output does not show the admin username' }
if (-not $output.Contains("登录密码: $adminPassword")) { throw 'Deployment output does not show the generated admin password' }
if (-not $output.Contains('初始密码没有独立过期时间')) { throw 'Deployment output does not explain the initial password lifetime' }
$existingOutput = powershell -ExecutionPolicy Bypass -File .\start.ps1 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Host $existingOutput; exit $LASTEXITCODE }
if (-not $existingOutput.Contains('本次部署不会生成或重置已有密码')) { throw 'Existing-env output does not explain password preservation' }
if ($existingOutput.Contains("登录密码: $adminPassword")) { throw 'Existing-env output must not report the env value as the current password' }
- name: Verify .env
run: |
@@ -40,12 +54,18 @@ jobs:
$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 }
$publicApi = Select-String -Path .env -Pattern '^CONFIG_PUBLIC_API_BASE_URL=' | ForEach-Object { $_.Line }
$serverBindIp = Select-String -Path .env -Pattern '^SERVER_HTTP_BIND_IP=' | 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 }
if ($serverBindIp -ne 'SERVER_HTTP_BIND_IP=0.0.0.0') { exit 1 }
Write-Host "OK: .env 配置正确"
- name: Test public API URL migration helper
run: |
powershell -ExecutionPolicy Bypass -File .\scripts\Test-PublicApiBaseUrl.ps1
- name: Test server HTTP bind migration helper
run: |
powershell -ExecutionPolicy Bypass -File .\scripts\Test-ServerHttpBindIp.ps1
+24 -2
View File
@@ -34,6 +34,25 @@ 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` 实例必须使用同一份持久化配置。
#### 初始管理员密码生命周期
首次安装时,部署脚本会生成 20 位随机密码,写入 `.env``CONFIG_INITIAL_ADMIN_PASSWORD`,并在部署完成页直接显示:
```text
登录账号: admin
登录密码: <本次部署生成的随机密码>
```
后端只在数据库中不存在 `admin` 时读取该值并创建管理员。创建成功后,`.env` 中的值不再用于登录校验,修改该变量或执行升级都不会重置现有管理员密码。初始密码没有独立的自动过期时间,在管理员主动修改前持续有效,因此首次登录后应立即修改密码。
如果部署脚本检测到已有 `.env`,完成页不会把其中的值误报成当前密码:数据库已有 `admin` 时继续使用现有密码;数据库尚未创建 `admin` 时,可查看 `.env -> CONFIG_INITIAL_ADMIN_PASSWORD`
#### 后端公开 API 地址
后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口:
@@ -455,10 +474,11 @@ curl https://<你的域名>/asg-api/health
1. 增加脚本沙箱环境容器,需要更新`docker-compose.yml`,用以支持SKILL中的脚本运行
2. 增加环境变量配置
```dotenv
SANDBOX_PORT=8081 #对外暴露沙箱环境的端口,不建议暴露,权限较高
SANDBOX_SERVICE_BASE_URL= #脚本运行环境使用的服务地址,默认为通过内网直接访问http://sandbox:8000,不需要配置。当将sandbox部署在其他外部网络才需要配置
SANDBOX_SERVICE_BASE_URL= #脚本运行环境使用的服务地址,默认通过 Docker 内网访问 http://sandbox:8000,不需要配置
```
> 安全默认值:Sandbox API 与 JupyterLab 不映射宿主机端口,Nginx 模板对 `/api/sandbox`、`/sandbox`、`/jupyterlab` 返回 404。不要在公网重新开放这些高权限入口。
### 2025.1.29
@@ -582,6 +602,7 @@ cd easyai
```bash
NUXT_PUBLIC_BASE_APIURL=http://<你的服务器公网IP或者局域网地址>:3001
NUXT_PUBLIC_BASE_SOCKETURL=ws://<你的服务器公网IP或者局域网地址>:3002
SERVER_HTTP_BIND_IP=0.0.0.0
```
3. 开放服务器的3001、3002和3010端口
4. [首次执行,后续无需重复执行]添加脚本的执行权限,命令:
@@ -619,6 +640,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
```bash
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<你的域名>/socket.io
SERVER_HTTP_BIND_IP=127.0.0.1
```
3. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
+7 -5
View File
@@ -55,7 +55,7 @@ services:
labels:
- "com.centurylinklabs.watchtower.enable=true"
ports:
- "${SERVER_HTTP_PORT}:3001" #http端口
- "${SERVER_HTTP_BIND_IP:-127.0.0.1}:${SERVER_HTTP_PORT}:3001" # 域名/本机部署默认仅回环;IP/LAN 模式由启动脚本显式设为 0.0.0.0
# read_only: true
networks:
easyai:
@@ -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
@@ -227,6 +231,7 @@ services:
- ./redis.conf:/etc/redis/redis.conf # 挂载本地 redis.conf
- ./data/redis:/data # 持久化 Redis AOF/RDB,避免落到容器写层
command: [ "redis-server", "/etc/redis/redis.conf" ] # 让 Redis 读取配置文件
stop_grace_period: 1m # 给 Redis 足够时间在 SIGTERM 后完成 AOF fsync
mem_limit: 2g
memswap_limit: 2g
# ports:
@@ -341,10 +346,7 @@ services:
container_name: sandbox
networks:
- easyai
#沙箱环境默认不对外暴露
ports:
# - "${SANDBOX_PORT}:8000"
- "${SANDBOX_JUPYTERLAB_PORT:-8888}:8888"
# 不映射宿主机端口,仅允许 Docker 内网中的 easyai-server 访问
labels:
- "com.centurylinklabs.watchtower.enable=true"
volumes:
+9
View File
@@ -156,6 +156,15 @@ powershell -ExecutionPolicy Bypass -File .\start.ps1
- 本地访问:`http://127.0.0.1:3010`
- 局域网访问:`http://<LAN_IP>:3010`
首次安装还会在完成页直接显示管理员登录信息:
```text
登录账号: admin
登录密码: <本次部署生成的 20 位随机密码>
```
该密码只在数据库首次创建 `admin` 时使用,创建后修改 `.env` 不会重置现有密码。初始密码没有独立过期时间,首次登录后应立即修改。已有 `.env` 的部署只提示密码沿用规则,不会把可能已经失效的环境变量值显示成当前登录密码。
---
## 7. 实现清单
+21 -19
View File
@@ -95,6 +95,15 @@ server {
return 308 /api/;
}
# Sandbox 只能由 easyai-server 通过 Docker 内网访问,禁止从公网 API 入口绕过。
location = /api/sandbox {
return 404;
}
location ^~ /api/sandbox/ {
return 404;
}
location /api/ {
proxy_pass http://127.0.0.1:3001/;
proxy_read_timeout 1200s; # 您已设置,适合长连接
@@ -160,14 +169,12 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
location /jupyterlab/ {
proxy_pass http://127.0.0.1:8888/lab/;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/lab';
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;
location = /jupyterlab {
return 404;
}
location ^~ /jupyterlab/ {
return 404;
}
location /socket.io {
proxy_pass http://127.0.0.1:3002;
@@ -197,17 +204,12 @@ server {
proxy_buffering off; # 对于 WebSocket 连接禁用缓冲
}
# 沙箱环境 API(脚本执行、下载、安装依赖等),需在 docker-compose 中取消 SANDBOX_PORT 映射
location /sandbox/ {
proxy_pass http://127.0.0.1:8081/;
proxy_read_timeout 300s;
client_max_body_size 50M;
proxy_redirect off;
proxy_set_header X-Original-Prefix '/sandbox';
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;
location = /sandbox {
return 404;
}
location ^~ /sandbox/ {
return 404;
}
}
+11 -5
View File
@@ -31,17 +31,25 @@ tcp-backlog 511
replica-read-only no
# 设置Redis最大内存达到时的数据淘汰策略
# noeviction:当内存达到最大限制时,Redis不会自动删除任何键,这可能导致进一步的操作失败,直到手动干预 # 当内存不足时,删除最近最少使用的键
# 为 2GB 容器预留约 512MB,供连接缓冲区、模块、AOF 重写和 fork/COW 使用。
# noeviction:达到数据内存上限后拒绝写入,避免淘汰队列内部键导致状态不一致。
# maxmemory-policy allkeys-lru
maxmemory 2gb
maxmemory 1536mb
maxmemory-policy noeviction
# 设置数据库数量,这里设置为16个数据库
databases 16
# AOF 常规配置
appendfsync everysec
no-appendfsync-on-rewrite yes
# AOF/RDB 重写期间继续 fsync,优先保证异常退出后的数据完整性。
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 自动修复意外 EOF;Redis 8.4+ 可额外截断小范围格式损坏尾部。
aof-load-truncated yes
aof-load-corrupt-tail-max-size 4096
# 使用 RDB 作为 AOF 基础文件并增量刷盘,降低重写和恢复开销。
aof-use-rdb-preamble yes
aof-rewrite-incremental-fsync yes
# 慢查询阈值
slowlog-log-slower-than 10000
slowlog-max-len 128
@@ -67,5 +75,3 @@ list-max-ziplist-value 64
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
+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) {
+47
View File
@@ -0,0 +1,47 @@
function Get-ServerHttpBindEnvValue {
param([string]$Content, [string]$Key)
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=([^\r\n]*)$")
if ($match.Success) { return $match.Groups[1].Value.Trim() }
return ""
}
function Set-ServerHttpBindEnvValue {
param([string]$Content, [string]$Key, [string]$Value)
$line = "$Key=$Value"
$pattern = "(?m)^$([regex]::Escape($Key))=.*$"
if ($Content -match $pattern) { return ($Content -replace $pattern, $line) }
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`n" }
return ($Content + $line + "`n")
}
function Initialize-ServerHttpBindIp {
param(
[Parameter(Mandatory = $true)][string]$Path,
[string]$Override = ""
)
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
$content = Get-Content $Path -Raw -Encoding UTF8
if (-not $content) { $content = "" }
$current = Get-ServerHttpBindEnvValue $content "SERVER_HTTP_BIND_IP"
$target = if (-not [string]::IsNullOrWhiteSpace($Override)) { $Override.Trim() } else { $current }
if ([string]::IsNullOrWhiteSpace($target)) {
$publicApiUrl = Get-ServerHttpBindEnvValue $content "NUXT_PUBLIC_BASE_APIURL"
$target = if (
$publicApiUrl -eq "/api" -or
$publicApiUrl.StartsWith("/api/") -or
$publicApiUrl -match '^https?://(127\.0\.0\.1|localhost)(:\d+)?(?:/|$)'
) { "127.0.0.1" } elseif ($publicApiUrl -match '^https?://') { "0.0.0.0" } else { "127.0.0.1" }
}
if ($target -notin @("127.0.0.1", "0.0.0.0")) {
throw "SERVER_HTTP_BIND_IP only supports 127.0.0.1 or 0.0.0.0, current value: $target"
}
if ($current -ne $target) {
$content = Set-ServerHttpBindEnvValue $content "SERVER_HTTP_BIND_IP" $target
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
Write-Host " ✓ SERVER_HTTP_BIND_IP=$target"
}
}
+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
}
+40
View File
@@ -0,0 +1,40 @@
$ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "Initialize-ServerHttpBindIp.ps1")
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-bind-test-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $tempDir | Out-Null
$envPath = Join-Path $tempDir ".env"
function Assert-BindIp {
param([string]$PublicApiUrl, [string]$Expected)
[System.IO.File]::WriteAllText($envPath, "NUXT_PUBLIC_BASE_APIURL=$PublicApiUrl`n", [System.Text.UTF8Encoding]::new($false))
Initialize-ServerHttpBindIp -Path $envPath
$content = Get-Content $envPath -Raw -Encoding UTF8
if ($content -notmatch "(?m)^SERVER_HTTP_BIND_IP=$([regex]::Escape($Expected))$") {
throw "Expected SERVER_HTTP_BIND_IP=$Expected for $PublicApiUrl"
}
}
try {
Assert-BindIp "/api" "127.0.0.1"
Assert-BindIp "http://10.0.0.8:3001" "0.0.0.0"
Assert-BindIp "http://127.0.0.1:3001" "127.0.0.1"
[System.IO.File]::WriteAllText($envPath, "NUXT_PUBLIC_BASE_APIURL=/api`nSERVER_HTTP_BIND_IP=0.0.0.0`n", [System.Text.UTF8Encoding]::new($false))
Initialize-ServerHttpBindIp -Path $envPath
if ((Get-Content $envPath -Raw -Encoding UTF8) -notmatch '(?m)^SERVER_HTTP_BIND_IP=0\.0\.0\.0$') {
throw "Existing explicit bind value was not preserved"
}
$invalidAccepted = $false
try {
Initialize-ServerHttpBindIp -Path $envPath -Override "192.168.1.8"
$invalidAccepted = $true
} catch { }
if ($invalidAccepted) { throw "Invalid SERVER_HTTP_BIND_IP unexpectedly accepted" }
Write-Host "Server HTTP bind IP PowerShell tests passed"
} finally {
Remove-Item -Recurse -Force $tempDir -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"
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
server_bind_read_env_value() {
local file="$1"
local key="$2"
awk -F= -v key="$key" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$file" | tr -d '\r'
}
server_bind_write_env_value() {
local file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file="$(mktemp "${file}.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
$0 ~ "^" key "=" {
if (!replaced) {
print key "=" value
replaced = 1
}
next
}
{ print }
END {
if (!replaced) print key "=" value
}
' "$file" > "$tmp_file"
chmod --reference="$file" "$tmp_file" 2>/dev/null || true
mv "$tmp_file" "$file"
}
infer_server_http_bind_ip() {
local file="$1"
local public_api_url
public_api_url="$(server_bind_read_env_value "$file" "NUXT_PUBLIC_BASE_APIURL")"
case "$public_api_url" in
/api|/api/*|http://127.0.0.1:*|https://127.0.0.1:*|http://localhost:*|https://localhost:*)
printf '%s\n' "127.0.0.1"
;;
http://*|https://*)
printf '%s\n' "0.0.0.0"
;;
*)
printf '%s\n' "127.0.0.1"
;;
esac
}
init_server_http_bind_ip() {
local file="${1:-.env}"
local override="${2:-}"
local current target
if [ ! -f "$file" ]; then
echo "❌ 未找到环境文件: $file" >&2
return 1
fi
current="$(server_bind_read_env_value "$file" "SERVER_HTTP_BIND_IP")"
target="${override:-$current}"
if [ -z "$target" ]; then
target="$(infer_server_http_bind_ip "$file")"
fi
case "$target" in
127.0.0.1|0.0.0.0) ;;
*)
echo "❌ SERVER_HTTP_BIND_IP 仅支持 127.0.0.1 或 0.0.0.0,当前为: $target" >&2
return 1
;;
esac
if [ "$current" != "$target" ]; then
server_bind_write_env_value "$file" "SERVER_HTTP_BIND_IP" "$target"
echo " ✓ SERVER_HTTP_BIND_IP=$target"
fi
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
init_server_http_bind_ip "${1:-.env}" "${2:-}"
fi
+73 -2
View File
@@ -20,6 +20,7 @@ mkdir -p "$TMP_DIR/scripts"
cp \
"$REPO_ROOT/scripts/init-security-env.sh" \
"$REPO_ROOT/scripts/init-public-api-base-url.sh" \
"$REPO_ROOT/scripts/init-server-http-bind-ip.sh" \
"$TMP_DIR/scripts/"
cd "$TMP_DIR"
@@ -48,17 +49,81 @@ assert_compression_config() {
fi
}
assert_sandbox_public_access_disabled() {
local config_file="$1"
grep -q '^ location = /api/sandbox {' "$config_file"
grep -q '^ location \^~ /api/sandbox/ {' "$config_file"
grep -q '^ location = /jupyterlab {' "$config_file"
grep -q '^ location \^~ /jupyterlab/ {' "$config_file"
grep -q '^ location = /sandbox {' "$config_file"
grep -q '^ location \^~ /sandbox/ {' "$config_file"
if grep -Eq 'proxy_pass http://127\.0\.0\.1:(8081|8888)' "$config_file"; then
echo "Unexpected public Sandbox/Jupyter proxy in $config_file" >&2
return 1
fi
}
assert_compose_exposure() {
local expected_server_host_ip="$1"
docker compose config --format json | python3 -c '
import json, sys
expected = sys.argv[1]
config = json.load(sys.stdin)
sandbox_ports = config["services"]["sandbox"].get("ports") or []
assert sandbox_ports == [], f"sandbox ports published: {sandbox_ports}"
server_ports = config["services"]["easyai-server"].get("ports") or []
assert len(server_ports) == 1, server_ports
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 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
bash start.sh >/dev/null
bash start.sh > "$TMP_DIR/first-install.log"
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
grep -qx 'SERVER_HTTP_BIND_IP=0.0.0.0' .env
initial_admin_password="$(awk -F= '$1 == "CONFIG_INITIAL_ADMIN_PASSWORD" { print $2; exit }' .env)"
[ "${#initial_admin_password}" -ge 12 ] || {
echo "Initial admin password was not generated" >&2
exit 1
}
grep -Fq '登录账号: admin' "$TMP_DIR/first-install.log"
grep -Fq "登录密码: ${initial_admin_password}" "$TMP_DIR/first-install.log"
grep -Fq '生命周期: 仅在数据库首次创建 admin 时使用;创建后修改 .env 不会重置密码。' "$TMP_DIR/first-install.log"
grep -Fq '有效期: 初始密码没有独立过期时间,在管理员修改密码前持续有效。' "$TMP_DIR/first-install.log"
DEPLOY_NON_INTERACTIVE=1 \
DEPLOY_DRY_RUN=1 \
DEPLOY_ACCESS=ip \
DEPLOY_IP=10.0.0.8 \
bash start.sh > "$TMP_DIR/existing-env.log"
grep -Fq '管理员账号: admin(本次部署不会生成或重置已有密码)' "$TMP_DIR/existing-env.log"
if grep -Fq "登录密码: ${initial_admin_password}" "$TMP_DIR/existing-env.log"; then
echo "Existing-env deployment must not report CONFIG_INITIAL_ADMIN_PASSWORD as the current login password" >&2
exit 1
fi
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
docker compose config --quiet
assert_compose_exposure 0.0.0.0
assert_canvas_ws_auth_config
fi
reset_case
@@ -75,6 +140,11 @@ 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
grep -q 'location = /api {' demo.example.com.conf
assert_compression_config demo.example.com.conf
assert_sandbox_public_access_disabled demo.example.com.conf
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' .env
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
assert_compose_exposure 127.0.0.1
fi
# 直接验证 https.sh 的缺省配置生成函数会采用完整模板,而不是只代理 3010。
eval "$(awk '
@@ -90,6 +160,7 @@ grep -q 'proxy_pass http://127.0.0.1:3001/;' easyai-proxy.conf
grep -q 'location /socket.io {' easyai-proxy.conf
grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' easyai-proxy.conf
assert_compression_config easyai-proxy.conf
assert_sandbox_public_access_disabled easyai-proxy.conf
reset_case
DEPLOY_NON_INTERACTIVE=1 \
+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"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
# shellcheck source=init-server-http-bind-ip.sh
. "$REPO_ROOT/scripts/init-server-http-bind-ip.sh"
write_env() {
printf '%s\n' "$1" > "$TMP_DIR/.env"
}
write_env 'NUXT_PUBLIC_BASE_APIURL=/api'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' "$TMP_DIR/.env"
write_env 'NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:3001'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=0.0.0.0' "$TMP_DIR/.env"
write_env 'NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=127.0.0.1' "$TMP_DIR/.env"
write_env $'NUXT_PUBLIC_BASE_APIURL=/api\nSERVER_HTTP_BIND_IP=0.0.0.0'
init_server_http_bind_ip "$TMP_DIR/.env" >/dev/null
grep -qx 'SERVER_HTTP_BIND_IP=0.0.0.0' "$TMP_DIR/.env"
if init_server_http_bind_ip "$TMP_DIR/.env" '192.168.1.8' >/dev/null 2>&1; then
echo 'Invalid SERVER_HTTP_BIND_IP unexpectedly accepted' >&2
exit 1
fi
echo 'Server HTTP bind IP shell tests passed'
+41 -6
View File
@@ -173,6 +173,8 @@ function Setup-EnvFiles {
$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"
$serverBindIp = if ($script:DeployIP -eq "127.0.0.1" -or $script:DeployIP -eq "localhost") { "127.0.0.1" } else { "0.0.0.0" }
$content = Upsert-Env $content "SERVER_HTTP_BIND_IP" $serverBindIp
$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"
@@ -356,6 +358,32 @@ function Start-Services {
Ok "EasyAI started"
}
function Show-AdminLoginInfo {
Write-Host ""
if ($script:SecurityEnvMode -ne "new") {
Write-Host " 管理员账号: admin(本次部署不会生成或重置已有密码)" -ForegroundColor Yellow
Write-Host " 登录提示: 若数据库尚未创建 admin,请查看 .env -> CONFIG_INITIAL_ADMIN_PASSWORD;若 admin 已存在,请使用当前密码。" -ForegroundColor DarkGray
return
}
$content = Get-Content (Join-Path $script:Root ".env") -Raw -Encoding UTF8
$initialAdminPassword = Get-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD"
if ([string]::IsNullOrWhiteSpace($initialAdminPassword)) {
Fail "Cannot read initial admin password from .env -> CONFIG_INITIAL_ADMIN_PASSWORD"
}
$heading = if ($script:DeployDryRun) {
" -------- 初始管理员(Docker 首次启动后生效)--------"
} else {
" -------- 初始管理员(首次登录后请立即修改密码)--------"
}
Write-Host $heading -ForegroundColor Yellow
Write-Host " 登录账号: " -NoNewline; Write-Host "admin" -ForegroundColor White
Write-Host " 登录密码: " -NoNewline; Write-Host $initialAdminPassword -ForegroundColor White
Write-Host " 生命周期: 仅在数据库首次创建 admin 时使用;创建后修改 .env 不会重置密码。" -ForegroundColor DarkGray
Write-Host " 有效期: 初始密码没有独立过期时间,在管理员修改密码前持续有效。" -ForegroundColor DarkGray
}
function Main {
Init-ProjectDir
@@ -386,6 +414,18 @@ function Main {
Ensure-FileFromSample ".env.AMS" ".env.AMS.sample"
}
. (Join-Path $script:Root "scripts\Initialize-ServerHttpBindIp.ps1")
$serverBindOverride = if ($script:SkipDeployQuestions) {
""
} elseif ($script:DeployIP -eq "127.0.0.1" -or $script:DeployIP -eq "localhost") {
"127.0.0.1"
} else {
"0.0.0.0"
}
Initialize-ServerHttpBindIp `
-Path (Join-Path $script:Root ".env") `
-Override $serverBindOverride
. (Join-Path $script:Root "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $script:Root ".env") -Mode $script:SecurityEnvMode
@@ -411,19 +451,14 @@ function Main {
Write-Host "================================" -ForegroundColor Yellow
Write-Host ""
Write-Host " 预期访问: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan
Write-Host ""
Write-Host " 初始管理员: admin;随机密码保存在 .env 的 CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor DarkGray
} else {
Write-Host "================================" -ForegroundColor Green
Write-Host " 部署成功" -ForegroundColor Green
Write-Host "================================" -ForegroundColor Green
Write-Host ""
Write-Host " 访问地址: " -NoNewline; Write-Host "http://${ip}:3010" -ForegroundColor Cyan
Write-Host ""
Write-Host " -------- 初始管理员(首次登录后请修改密码)--------" -ForegroundColor Yellow
Write-Host " 账号: " -NoNewline; Write-Host "admin" -ForegroundColor White
Write-Host " 密码位置: " -NoNewline; Write-Host ".env -> CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor White
}
Show-AdminLoginInfo
Write-Host ""
Wait-ForExit
}
+41
View File
@@ -457,6 +457,32 @@ run_https_setup() {
fi
}
show_admin_login_info() {
echo ""
if [ "$SECURITY_ENV_MODE" != "new" ]; then
echo "管理员账号: admin(本次部署不会生成或重置已有密码)"
echo "登录提示: 若数据库尚未创建 admin,请查看 .env -> CONFIG_INITIAL_ADMIN_PASSWORD;若 admin 已存在,请使用当前密码。"
return 0
fi
local initial_admin_password
initial_admin_password="$(read_env_value .env "CONFIG_INITIAL_ADMIN_PASSWORD")"
if [ -z "$initial_admin_password" ]; then
echo "❌ 未能读取初始管理员密码: .env -> CONFIG_INITIAL_ADMIN_PASSWORD" >&2
return 1
fi
if [ "$DEPLOY_DRY_RUN" = "1" ]; then
echo "-------- 初始管理员(Docker 首次启动后生效)--------"
else
echo "-------- 初始管理员(首次登录后请立即修改密码)--------"
fi
echo "登录账号: admin"
echo "登录密码: ${initial_admin_password}"
echo "生命周期: 仅在数据库首次创建 admin 时使用;创建后修改 .env 不会重置密码。"
echo "有效期: 初始密码没有独立过期时间,在管理员修改密码前持续有效。"
}
# ==================== 主流程 ====================
main() {
init_project_dir
@@ -495,6 +521,20 @@ main() {
fi
fi
# 域名和本机部署默认仅绑定回环;只有明确的 IP/LAN 直连模式才开放后端宿主机端口。
# shellcheck source=scripts/init-server-http-bind-ip.sh
. ./scripts/init-server-http-bind-ip.sh
local server_bind_override=""
if [ "$DEPLOY_MODE" = "domain" ]; then
server_bind_override="127.0.0.1"
elif [ "$DEPLOY_MODE" = "ip" ]; then
case "$DEPLOY_IP" in
127.0.0.1|localhost) server_bind_override="127.0.0.1" ;;
*) server_bind_override="0.0.0.0" ;;
esac
fi
init_server_http_bind_ip .env "$server_bind_override"
# 空值或历史默认值只在首次发现时生成并写回 .env,后续启动保持稳定。
# 数据库中的后台 JWT 轮转配置由后端优先使用,不受此处影响。
# shellcheck source=scripts/init-security-env.sh
@@ -537,6 +577,7 @@ main() {
echo "HTTPS 已启用"
fi
fi
show_admin_login_info
echo ""
}
+4 -1
View File
@@ -172,8 +172,11 @@ 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")
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
+8 -2
View File
@@ -122,12 +122,18 @@ else
echo ""
fi
# 升级已有部署时,将空值历史默认 JWT 密钥一次性替换为持久化随机值。
# 已存在的自定义值保持不变,数据库 JWT 轮转配置仍由后端优先使用
# 升级已有部署时,将空值历史默认 JWT 密钥和缺失的 WS ticket 密钥
# 一次性替换为持久化随机值,并确保网关声明 ws_ticket;已有强密钥保持不变
if [ ! -f .env ]; then
echo "❌ 未找到 .env,请先执行 start.sh 完成初始化"
exit 1
fi
# 老部署缺少绑定配置时,根据现有公开 API 地址迁移:域名/本机保持回环,IP 直连保持可用。
# shellcheck source=scripts/init-server-http-bind-ip.sh
. ./scripts/init-server-http-bind-ip.sh
init_server_http_bind_ip .env
# shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh
init_security_env .env upgrade