Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40732c011b | ||
|
|
7dc4313de6 | ||
|
|
8cd1fb137d | ||
|
|
ee52bcf34f |
+13
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Executable
+83
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
Executable
+121
@@ -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"
|
||||
Executable
+37
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user