Compare commits
18
Commits
f7cb72342a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40732c011b | ||
|
|
7dc4313de6 | ||
|
|
8cd1fb137d | ||
|
|
ee52bcf34f | ||
|
|
919a797510 | ||
|
|
9dc01e2411 | ||
|
|
385c509086 | ||
|
|
f31e349409 | ||
|
|
6ee9666b0c | ||
|
|
64bbf5c109 | ||
|
|
2a40ad91c8 | ||
|
|
d54cf2f6ce | ||
|
|
efd843b1b2 | ||
|
|
2a7f5444bf | ||
|
|
5518039792 | ||
|
|
cabee634d4 | ||
|
|
0aebe62f70 | ||
|
|
42ef68857d |
+81
-8
@@ -8,6 +8,13 @@
|
||||
NUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001
|
||||
# 域名访问:NUXT_PUBLIC_BASE_APIURL=/api
|
||||
|
||||
# 后端生成文件、预签名地址和异步任务查询地址时使用的公开 API 根地址。
|
||||
# 留空时启动/升级脚本会根据本节已有配置初始化;手工维护时必须填写完整 http(s) URL。
|
||||
CONFIG_PUBLIC_API_BASE_URL=
|
||||
# 启动/升级脚本的高级覆盖项,适用于已有外部 TLS、特殊端口或手工反向代理。
|
||||
# 示例:https://api.example.com:8443/api
|
||||
DEPLOY_PUBLIC_API_BASE_URL=
|
||||
|
||||
# 默认 WebSocket 地址
|
||||
NUXT_PUBLIC_BASE_SOCKETURL=ws://127.0.0.1:3002
|
||||
# 域名访问:NUXT_PUBLIC_BASE_SOCKETURL=wss://yourwebsite.com/socket.io
|
||||
@@ -23,6 +30,25 @@ 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
|
||||
|
||||
MALLOC_ARENA_MAX=2
|
||||
# 限制 glibc native heap arena,降低 sharp/libvips 场景下 RSS 膨胀
|
||||
MALLOC_TRIM_THRESHOLD_=131072
|
||||
# glibc 更积极 trim 空闲 top chunk,降低 native RSS 高水位
|
||||
MALLOC_MMAP_THRESHOLD_=131072
|
||||
# 较大的 native 分配更倾向 mmap,释放后更容易归还 OS
|
||||
MALLOC_TOP_PAD_=131072
|
||||
# 降低 trim 后额外保留的 heap padding
|
||||
SHARP_CACHE_ENABLED=false
|
||||
# 默认关闭 sharp/libvips 全局 cache;需要开启时再配置 SHARP_CACHE_* 参数
|
||||
SHARP_CONCURRENCY=1
|
||||
# sharp/libvips 并发,8G 单机建议保持 1
|
||||
|
||||
# ws-gateway WebSocket 端口(统一作为对外访问映射端口使用)
|
||||
CONFIG_WS_PORT=3002
|
||||
# ws-gateway 容器内部 TCP 微服务监听地址(供 easyai-server 推送事件)
|
||||
@@ -43,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=
|
||||
@@ -93,7 +116,39 @@ CONFIG_MQ_VHOST=/
|
||||
|
||||
# ========== 8. 鉴权与安全 ==========
|
||||
CONFIG_TOKEN_EXPIRE=1800
|
||||
CONFIG_JWT_SECRET='this is a very secret secret'
|
||||
# JWT 签名密钥。启动/升级脚本发现空值、历史默认值或不足 32 字节时会生成并持久化到 .env。
|
||||
# 也可手动生成:openssl rand -base64 48
|
||||
CONFIG_JWT_SECRET=
|
||||
|
||||
# 后台 JWT 动态配置的 AES-256-GCM 独立主密钥,建议与 JWT 签名密钥分别生成。
|
||||
# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。
|
||||
CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=
|
||||
|
||||
# Canvas CLI / Agent WebSocket 短时票据专用签名密钥。
|
||||
# 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。
|
||||
WS_AUTH_WS_TICKET_SECRET=
|
||||
|
||||
# 验证码 HMAC、设备/指纹哈希和审计完整性密钥,由启动/升级脚本独立生成并持久化。
|
||||
CONFIG_OTP_HASH_SECRET=
|
||||
CONFIG_AUDIT_HASH_PEPPER=
|
||||
CONFIG_AUDIT_INTEGRITY_KEY=
|
||||
|
||||
# 允许访问后端的浏览器 Origin,多个值用英文逗号分隔,生产环境禁止使用 *。
|
||||
# 域名部署示例:https://yourwebsite.com,https://www.yourwebsite.com
|
||||
CONFIG_SECURITY_ORIGIN=http://127.0.0.1:3010,http://localhost:3010
|
||||
|
||||
# 可信反向代理 IP/CIDR,多个值用英文逗号分隔;未使用反向代理时保持为空。
|
||||
# 仅填写实际 Nginx/负载均衡地址,不要使用 0.0.0.0/0。
|
||||
CONFIG_TRUSTED_PROXY=
|
||||
|
||||
# 完成身份冲突扫描和人工处置后才能启用手机号、邮箱和微信身份唯一索引。
|
||||
CONFIG_AUTH_UNIQUE_INDEXES_ENABLED=false
|
||||
|
||||
# 仅在数据库首次创建 admin 账号时使用;启动脚本会生成 20 位字母数字随机值并持久化到 .env。
|
||||
# 首次部署完成页会明确显示登录账号和密码;初始密码没有独立过期时间,首次登录后请立即修改。
|
||||
# admin 创建后不再使用该值;修改 .env 或执行升级不会重置现有管理员密码。
|
||||
CONFIG_INITIAL_ADMIN_PASSWORD=
|
||||
|
||||
CONFIG_TOKEN_SIGN_SK=easyai2025easyai
|
||||
|
||||
# ========== 9. 运维与调试 ==========
|
||||
@@ -162,14 +217,32 @@ 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 票据列表(逗号分隔,适合短时授权)
|
||||
WS_AUTH_WS_TICKETS=
|
||||
|
||||
# ========== 14. 静态资源 CDN(可选) ==========
|
||||
# ========== 14. 静态资源 CDN(可选,默认自动 Brotli) ==========
|
||||
# auto:优先使用验证通过的 CDN Brotli,其次源站 Brotli/Gzip;off:紧急关闭自动协商
|
||||
STATIC_ASSET_COMPRESSION=auto
|
||||
# CDN 公网根地址。保留 NUXT_APP_CDN_URL 兼容旧部署;两者都填写时优先使用 STATIC_CDN_BASE_URL。
|
||||
STATIC_CDN_BASE_URL=
|
||||
NUXT_APP_CDN_URL=
|
||||
# 对象存储中的目录前缀;CDN 根地址应直接指向该目录。根目录部署时留空。
|
||||
STATIC_CDN_ROOT_PATH=
|
||||
# 可选:填写后由 EasyAI 自动发布 canonical、Brotli、Gzip 三套资源。
|
||||
# 不填写时只验证客户已有 CDN;验证失败会自动使用源站,不影响 Web 启动。
|
||||
# 支持 aliyun、tencent、minio、qiniu、s3Compatible。
|
||||
STATIC_CDN_OSS_PROVIDER=
|
||||
STATIC_CDN_OSS_ENDPOINT=
|
||||
STATIC_CDN_OSS_BUCKET=
|
||||
STATIC_CDN_OSS_ACCESS_KEY=
|
||||
STATIC_CDN_OSS_SECRET_KEY=
|
||||
STATIC_CDN_OSS_REGION=
|
||||
STATIC_CDN_OSS_PORT=
|
||||
STATIC_CDN_OSS_USE_SSL=true
|
||||
STATIC_CDN_FORCE_UPLOAD=false
|
||||
|
||||
# ========== 15. 版本 ==========
|
||||
VERSION=latest
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Test deployment public URL (Linux)
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'start.sh'
|
||||
- 'update.sh'
|
||||
- 'docker-compose.yml'
|
||||
- 'easyai-proxy.conf.sample'
|
||||
- '.env.sample'
|
||||
- 'scripts/init-public-api-base-url.sh'
|
||||
- 'scripts/test-public-api-base-url.sh'
|
||||
- 'scripts/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:
|
||||
paths:
|
||||
- 'start.sh'
|
||||
- 'update.sh'
|
||||
- 'docker-compose.yml'
|
||||
- 'easyai-proxy.conf.sample'
|
||||
- '.env.sample'
|
||||
- 'scripts/init-public-api-base-url.sh'
|
||||
- 'scripts/test-public-api-base-url.sh'
|
||||
- 'scripts/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:
|
||||
|
||||
jobs:
|
||||
test-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check shell syntax
|
||||
run: |
|
||||
bash -n start.sh update.sh scripts/init-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
|
||||
@@ -5,11 +5,21 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- 'start.ps1'
|
||||
- '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:
|
||||
paths:
|
||||
- 'start.ps1'
|
||||
- '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:
|
||||
@@ -25,14 +35,37 @@ jobs:
|
||||
DEPLOY_DRY_RUN: "1"
|
||||
DEPLOY_IP: "192.168.1.100"
|
||||
run: |
|
||||
powershell -ExecutionPolicy Bypass -File .\start.ps1
|
||||
(Get-Content .env.sample) -replace '^SERVER_HTTP_PORT=.*$', 'SERVER_HTTP_PORT=4100' | Set-Content .env.sample -Encoding UTF8
|
||||
$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: |
|
||||
$api = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_APIURL=' | ForEach-Object { $_.Line }
|
||||
$socket = Select-String -Path .env -Pattern '^NUXT_PUBLIC_BASE_SOCKETURL=' | ForEach-Object { $_.Line }
|
||||
$sg = Select-String -Path .env -Pattern '^NUXT_PUBLIC_SG_APIURL=' | ForEach-Object { $_.Line }
|
||||
if ($api -ne 'NUXT_PUBLIC_BASE_APIURL=http://192.168.1.100:3001') { exit 1 }
|
||||
$publicApi = Select-String -Path .env -Pattern '^CONFIG_PUBLIC_API_BASE_URL=' | ForEach-Object { $_.Line }
|
||||
$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
|
||||
|
||||
@@ -6,6 +6,94 @@
|
||||
git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && chmod +x start.sh && ./start.sh
|
||||
```
|
||||
|
||||
也可以通过环境变量直接传入部署方式,脚本会跳过对应问答:
|
||||
|
||||
```bash
|
||||
# IP 访问
|
||||
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
|
||||
|
||||
# 域名访问,不申请 HTTPS 证书
|
||||
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
|
||||
|
||||
# 域名访问,并自动申请 HTTPS 证书
|
||||
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true CERTBOT_EMAIL=ops@example.com ./start.sh
|
||||
```
|
||||
|
||||
常用变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DEPLOY_ACCESS` | `ip` 或 `domain`;未传时进入问答选择 |
|
||||
| `DEPLOY_IP` | IP 模式访问地址,如 `10.0.0.8` |
|
||||
| `DEPLOY_DOMAIN` | 域名模式访问地址,可带 `http://` 或 `https://`,脚本会自动清理 |
|
||||
| `DEPLOY_HTTPS` | `true/false`,域名模式下是否申请 HTTPS 证书 |
|
||||
| `DEPLOY_PUBLIC_API_BASE_URL` | 高级覆盖后端公开 API 地址,例如 `https://api.example.com:8443/api` |
|
||||
| `CERTBOT_EMAIL` | 申请 HTTPS 证书使用的邮箱,默认 `wangbo@51easyai.com` |
|
||||
| `DEPLOY_FORCE_RECONFIG` | 非空时即使已有 `.env` 也重新生成部署配置 |
|
||||
| `DEPLOY_DRY_RUN` | `1` 时只生成配置文件,不安装 Docker、不启动服务 |
|
||||
| `DEPLOY_NON_INTERACTIVE` | `1` 时缺少必要变量会直接报错,不再进入问答 |
|
||||
| `DEPLOY_MAC_DOCKER_INSTALL` | macOS 未安装 Docker 时使用,`auto` 自动安装或 `manual` 提示手动安装 |
|
||||
|
||||
#### 安全密钥与 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 地址
|
||||
|
||||
后端返回文件、预签名上传和异步任务查询地址时,按以下优先级确定公开入口:
|
||||
|
||||
1. `.env` 中的 `CONFIG_PUBLIC_API_BASE_URL`。
|
||||
2. 来自可信代理的当前请求地址(仅当请求来源命中 `CONFIG_TRUSTED_PROXY` 时读取转发 Header)。
|
||||
3. 当前请求的直接 `Host`。
|
||||
|
||||
启动和升级脚本会在拉取镜像、重建服务之前校验并持久化 `CONFIG_PUBLIC_API_BASE_URL`:新 IP 部署使用 `http://IP:${SERVER_HTTP_PORT}`;域名部署中 `DEPLOY_HTTPS=false` 使用 `http/ws`,`DEPLOY_HTTPS=true` 使用 `https/wss`,API 路径均为 `/api`。旧环境缺少该配置时,会依次从绝对 `NUXT_PUBLIC_BASE_APIURL`、WebSocket origin、首个 `CONFIG_SECURITY_ORIGIN` 推导。已有合法值保持不变,非法或无法推导时脚本会终止并提示修复。
|
||||
|
||||
已有外部 TLS、手工代理、自定义公开端口或特殊路径时,可在执行脚本前设置高级覆盖:
|
||||
|
||||
```bash
|
||||
DEPLOY_PUBLIC_API_BASE_URL=https://api.example.com:8443/custom-api ./update.sh
|
||||
```
|
||||
|
||||
也可以直接在 `.env` 中维护:
|
||||
|
||||
```dotenv
|
||||
CONFIG_PUBLIC_API_BASE_URL=https://api.example.com:8443/api
|
||||
CONFIG_TRUSTED_PROXY=127.0.0.1,10.0.0.0/8
|
||||
```
|
||||
|
||||
新的 `easyai-proxy.conf.sample` 会为 `/api/` 和 `/mcp` 转发保留客户端 authority 中的显式端口,并补齐 `X-Forwarded-Host`、`X-Forwarded-Port`;`/api/` 继续发送 `X-Original-Prefix: /api`。脚本不会改写已经生成的本地 Nginx 配置,旧部署可先通过显式 `CONFIG_PUBLIC_API_BASE_URL` 生效,再按模板手工同步代理 Header。
|
||||
|
||||
模板同时为 JSON、CSS、`application/javascript` 和 Nuxt 常用的 `text/javascript` 开启 Gzip;`text/event-stream` 未加入压缩类型,SSE 和 WebSocket 保持流式传输。已经生成的域名配置不会随模板自动更新,旧部署需要手工把 `text/javascript` 加入现有 `gzip_types`,执行 `nginx -t` 成功后再无中断 reload。
|
||||
|
||||
前端静态资源默认使用 `STATIC_ASSET_COMPRESSION=auto`。浏览器支持 Brotli 时优先使用经过哈希和响应头验证的 CDN Brotli 资源;客户 CDN 尚未配置编码资源或验证失败时,自动切回 Nuxt 源站的 Brotli/Gzip,不会阻塞 Web 启动。旧部署更新 `docker-compose.yml` 后无需修改 `.env` 即可使用默认值。
|
||||
|
||||
私有化客户可继续只配置 `NUXT_APP_CDN_URL`。如果 CDN 已支持 canonical URL 的 Brotli,EasyAI 会自动识别;如果希望 EasyAI 代为发布三套资源,再填写 `.env.sample` 中的 `STATIC_CDN_OSS_*` 最小读写凭据。上传对象使用 `__easyai_encoding/br` 和 `__easyai_encoding/gzip` 固定前缀,并携带正确的 `Content-Encoding`。也可由客户自己的流水线按该目录协议上传,EasyAI只负责验证。紧急回退可设置 `STATIC_ASSET_COMPRESSION=off`。
|
||||
|
||||
CDN 验证结果保存在 `./data/cdn-init`,并与当前前端构建标识绑定;升级后旧结果会被忽略。任何密钥都只应放在本机 `.env` 或安全配置中心,不要提交到仓库。
|
||||
|
||||
`https.sh` 单独执行时也可以跳过域名选择:
|
||||
|
||||
```bash
|
||||
DEPLOY_DOMAIN=demo.example.com EASYAI_PROXY_CONF=demo.example.com.conf CERTBOT_EMAIL=ops@example.com ./https.sh
|
||||
```
|
||||
|
||||
如需对已有 Nginx 配置追加或替换域名,可设置 `EASYAI_PROXY_DOMAIN_ACTION=add|replace`;未设置时默认追加。
|
||||
|
||||
### Windows
|
||||
|
||||
在 **PowerShell** 中执行:
|
||||
@@ -14,6 +102,24 @@ git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git && cd easyai && c
|
||||
git clone --depth 1 "https://git.51easyai.com/wangbo/easyai.git" "easyai"; Set-Location "easyai"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
|
||||
```
|
||||
|
||||
Windows 也支持变量方式跳过选择:
|
||||
|
||||
```powershell
|
||||
# 本机访问
|
||||
$env:DEPLOY_ACCESS = "local"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
|
||||
|
||||
# 局域网访问
|
||||
$env:DEPLOY_IP = "192.168.1.10"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
|
||||
|
||||
# 仅生成配置,不启动 Docker
|
||||
$env:DEPLOY_IP = "127.0.0.1"
|
||||
$env:DEPLOY_DRY_RUN = "1"
|
||||
$env:DEPLOY_NO_WAIT = "1"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
|
||||
```
|
||||
|
||||
> **Windows 脚本权限说明**:PowerShell 默认禁止运行脚本,直接双击 `start.ps1` 会闪退。
|
||||
>
|
||||
> - **推荐**:使用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"` 执行,无需修改系统策略
|
||||
@@ -48,6 +154,12 @@ mv easyai easyai2
|
||||
```
|
||||
|
||||
3. 重新执行一键部署命令,按脚本提示完成初始化输入。
|
||||
如果希望跳过问答,可直接传入变量:
|
||||
```bash
|
||||
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
|
||||
# 或
|
||||
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
|
||||
```
|
||||
|
||||
4. 当脚本询问是否启用 HTTPS 时:
|
||||
- 如果你之前已经有可用证书,填写 `N`(不生成新证书)。
|
||||
@@ -58,6 +170,7 @@ mv easyai easyai2
|
||||
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
|
||||
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
|
||||
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
|
||||
- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL`
|
||||
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式
|
||||
- 自动拉取最新镜像并重启服务
|
||||
|
||||
@@ -80,6 +193,18 @@ chmod +x update.sh
|
||||
- `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景)
|
||||
- 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。
|
||||
|
||||
- 自动化更新可通过变量跳过选择:
|
||||
```bash
|
||||
# 更新仓库 + 拉取镜像 + 重启(等同默认选 1)
|
||||
UPDATE_MODE=full ./update.sh
|
||||
|
||||
# 仅拉取镜像 + 重启(等同选 2)
|
||||
UPDATE_MODE=image ./update.sh
|
||||
|
||||
# 或使用布尔变量
|
||||
UPDATE_SKIP_REPO_UPDATE=true ./update.sh
|
||||
```
|
||||
|
||||
- **查看帮助**:`./update.sh -h` 或 `./update.sh --help`
|
||||
|
||||
### 更新说明
|
||||
@@ -94,11 +219,64 @@ Windows 下使用 `update.ps1`,功能与 Linux 版一致:
|
||||
```
|
||||
- 执行后会**命令行内选择**:`[1]` 更新并拉取仓库 + 更新镜像(默认);`[2]` 仅更新镜像
|
||||
- 需在 Git 克隆的 easyai 目录下运行
|
||||
- 也可通过变量跳过选择:
|
||||
```powershell
|
||||
$env:UPDATE_MODE = "image"
|
||||
$env:UPDATE_NO_WAIT = "1"
|
||||
.\update.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 重要更新记录:
|
||||
|
||||
### 2026.07.16
|
||||
|
||||
1. **公开 URL 稳定化**:新增 `CONFIG_PUBLIC_API_BASE_URL`,后端文件、预签名和任务查询地址不再依赖 Redis 中缓存的首次请求 Host。
|
||||
2. **新装与升级迁移**:Linux/macOS 和 Windows 启动、升级脚本会在重建服务前初始化并校验公开 API 地址,支持 IP 自定义 `SERVER_HTTP_PORT`、HTTP/HTTPS 域名和 `DEPLOY_PUBLIC_API_BASE_URL` 高级覆盖。
|
||||
3. **代理端口透传**:新 Nginx 模板为 `/api/`、`/mcp` 补齐公开 Host/Port Header;已有生成配置不会自动改写。
|
||||
|
||||
### 2026.07.15
|
||||
|
||||
1. **主服务 Node.js 堆内存上限**:`easyai-server` 默认通过 `NODE_OPTIONS` 将 V8 old-space 堆上限设置为 8192 MiB,可在 `.env` 中使用 `NODE_MAX_OLD_SPACE_SIZE` 覆盖。该值只限制 V8 堆,不包含 Buffer、sharp/libvips 等 native 内存。
|
||||
2. **生效要求**:该参数仅在创建 Node.js 进程时读取,更新部署文件后需要在合适的维护窗口重建 `easyai-server` 容器。
|
||||
|
||||
### 2026.06.24
|
||||
|
||||
1. **主服务 sharp/native 内存护栏**:`easyai-server` 默认关闭 sharp/libvips cache,并将 sharp 并发限制为 1;同时设置 `MALLOC_ARENA_MAX=2`、`MALLOC_TRIM_THRESHOLD_=131072`、`MALLOC_MMAP_THRESHOLD_=131072`、`MALLOC_TOP_PAD_=131072`,降低图片处理和大 Buffer 场景下 native RSS 持续膨胀风险。
|
||||
2. **重 native 操作隔离**:遮罩合并、alpha 反转等 sharp/raw 操作改为一次性子进程执行,处理完成后由 OS 回收子进程 native heap,避免主服务 `[heap]` 被撑大。
|
||||
3. **生图 base64 输出收敛**:模型返回的 base64 图片会先上传到 OSS,返回对象和 raw/debug 响应中不再长期保留大 base64 字符串。
|
||||
4. **重启要求**:该内存护栏必须在新进程启动时生效,更新后需执行 `docker compose up -d easyai-server` 或重建并重启主服务容器。
|
||||
|
||||
### 2026.06.22
|
||||
|
||||
1. **Redis 资源护栏**:Redis 增加 `./data/redis:/data` 持久化挂载、`maxmemory 2gb`、`appendfsync everysec`,并禁用 RDB 快照,避免队列历史膨胀后占满内存和容器写层。
|
||||
2. **Mongo/主服务内存限制**:Mongo 增加 `--wiredTigerCacheSizeGB 1.5` 和容器内存限制,`easyai-server` 增加内存限制,避免 8G 机器上 Redis/Mongo/Node 相互挤压触发 OOM。
|
||||
3. **升级前置要求**:如果线上 Redis 之前没有 `/data` volume,先完成 Redis request-limit 队列历史清理,再迁移 Redis 数据目录,最后重建 Redis 容器。
|
||||
|
||||
#### Redis 数据迁移与重启步骤
|
||||
|
||||
执行前建议先做云盘快照或外部备份。磁盘空间紧张时,不要直接在当前机器上 `BGSAVE` 或复制大体积 Redis 文件。
|
||||
|
||||
```bash
|
||||
cd ~/easyai
|
||||
|
||||
# 1. 先通过后台 ModelRuntime 运维接口或后端启动清理逻辑清理 request-limit 历史:
|
||||
# - trim bull:request_limit_*:events 到小长度
|
||||
# - 只清 completed/failed,不删除 waiting/active/delayed
|
||||
|
||||
# 2. 确认 Redis 内存已经下降后,再准备新的持久化目录
|
||||
mkdir -p ./data/redis
|
||||
|
||||
# 3. 如果旧 Redis 数据在容器写层,先按实际容器内 /data 内容迁移到宿主机目录
|
||||
docker cp redis:/data/. ./data/redis/
|
||||
|
||||
# 4. 更新配置后重建关键容器
|
||||
docker compose up -d redis mongo easyai-server
|
||||
```
|
||||
|
||||
迁移后验收建议:Redis `used_memory_human < 2G`,`bull:request_limit_*:events` 长度维持在小窗口,24 小时内无 OOM。
|
||||
|
||||
### 2026.3.20
|
||||
|
||||
1. **新增 Agent 记忆服务(AMS)模块**:新增 `agent-memory`(Agent 长期记忆)容器,复用 `easyai-pgvector` 数据库(需 pgvector 扩展),用于支持 Agent 对话记忆、向量召回与反馈能力。
|
||||
@@ -296,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
|
||||
|
||||
@@ -423,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. [首次执行,后续无需重复执行]添加脚本的执行权限,命令:
|
||||
@@ -460,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
|
||||
|
||||
+25
-5
@@ -26,8 +26,13 @@ services:
|
||||
# 默认的服务器websocket地址,本地不需要更改,云服务需要修改为云端IP,并放行对应端口
|
||||
- NUXT_PUBLIC_BASE_SOCKETURL=${NUXT_PUBLIC_BASE_SOCKETURL}
|
||||
- NUXT_APP_CDN_URL=${NUXT_APP_CDN_URL}
|
||||
# 默认自动选择 CDN Brotli;CDN 不兼容时安全回退到源站预压缩资源
|
||||
- STATIC_ASSET_COMPRESSION=${STATIC_ASSET_COMPRESSION:-auto}
|
||||
# Agent 服务治理 API 地址(前端直接调用,走 Nginx 代理时配置为 /asg-api)
|
||||
- NUXT_PUBLIC_SG_APIURL=${NUXT_PUBLIC_SG_APIURL}
|
||||
volumes:
|
||||
# 保存与当前前端构建绑定的 CDN 验证结果,旧结果不会跨构建误用
|
||||
- ./data/cdn-init:/tmp/cdn-init
|
||||
# 日志大小设置,避免日志文件过大
|
||||
env_file:
|
||||
- .env
|
||||
@@ -50,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:
|
||||
@@ -68,10 +73,18 @@ services:
|
||||
# - ./data/backend/pm2.config.js:/app/pm2.config.js
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
|
||||
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
|
||||
# MongoDB配置,用户管理,不会配置的话保持默认即可
|
||||
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
|
||||
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
|
||||
- CONFIG_DB_MONGO_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
|
||||
- MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-2}
|
||||
- MALLOC_TRIM_THRESHOLD_=${MALLOC_TRIM_THRESHOLD_:-131072}
|
||||
- MALLOC_MMAP_THRESHOLD_=${MALLOC_MMAP_THRESHOLD_:-131072}
|
||||
- MALLOC_TOP_PAD_=${MALLOC_TOP_PAD_:-131072}
|
||||
- SHARP_CACHE_ENABLED=${SHARP_CACHE_ENABLED:-false}
|
||||
- SHARP_CONCURRENCY=${SHARP_CONCURRENCY:-1}
|
||||
# Redis配置,队列缓存,不会配置的话保持默认即可
|
||||
- CONFIG_COMFYUI_QUENE_REDIS_HOST=172.21.0.4
|
||||
- CONFIG_COMFYUI_QUENE_REDIS_PORT=6379
|
||||
@@ -86,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/留空
|
||||
@@ -147,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
|
||||
@@ -189,6 +206,8 @@ services:
|
||||
# 这里的配置只有首次运行生效。修改后,重启镜像是不会生效的。需要把持久化数据删除再重启,才有效果
|
||||
- MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD}
|
||||
command: [ "mongod", "--wiredTigerCacheSizeGB", "1.5" ]
|
||||
mem_limit: 2500m
|
||||
volumes:
|
||||
# - /usr/local/mongo/data:/data/db
|
||||
- mongo_data:/data/db # 使用docker volume存储数据,提高性能,避免兼容性问题
|
||||
@@ -210,7 +229,11 @@ services:
|
||||
restart: always
|
||||
volumes:
|
||||
- ./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:
|
||||
# - ${REDIS_PORT+${REDIS_PORT}:6379}
|
||||
# read_only: true
|
||||
@@ -323,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:
|
||||
|
||||
+31
-9
@@ -7,6 +7,7 @@
|
||||
- **仅 IP 访问**:不含域名模式与 HTTPS
|
||||
- **本地访问无需放行端口**:选择本地 (127.0.0.1) 时,不涉及防火墙配置
|
||||
- **Docker 未安装时**:用户可选择手动或自动安装 **Docker Desktop for Windows**(winget/Chocolatey)
|
||||
- **支持变量非交互部署**:通过 `DEPLOY_IP` 或 `DEPLOY_ACCESS=local` 可跳过访问方式选择
|
||||
|
||||
## 2. Linux start.sh 流程梳理
|
||||
|
||||
@@ -103,9 +104,13 @@ Windows 版**仅支持 IP 访问**,不包含域名模式与 HTTPS 配置。用
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DEPLOY_ACCESS` | `local` 时直接使用 `127.0.0.1`;`lan`/`ip` 需要同时传 `DEPLOY_IP` |
|
||||
| `DEPLOY_IP` | 访问 IP(本地填 `127.0.0.1`,局域网填实际 IP) |
|
||||
| `DEPLOY_DRY_RUN` | 1 时只生成配置,不安装/启动 Docker |
|
||||
| `DEPLOY_FORCE_RECONFIG` | 非空时强制重新配置 |
|
||||
| `DEPLOY_NON_INTERACTIVE` | 1 时缺少必要变量会直接报错,不进入问答 |
|
||||
| `DEPLOY_DOCKER_INSTALL` | Docker Desktop 未安装时使用,`manual` 打开下载地址,`auto` 使用 winget/choco 自动安装 |
|
||||
| `DEPLOY_NO_WAIT` | 1 时脚本结束或失败不等待按 Enter |
|
||||
|
||||
### 5.4 配置文件修改实现(仅 IP 模式)
|
||||
|
||||
@@ -126,6 +131,7 @@ Set-Content .env -Value $content -Encoding UTF8 -NoNewline
|
||||
- **未安装时**: prompt 让用户选择
|
||||
- `[1] 手动安装`:输出安装说明及 Docker Desktop for Windows 下载链接,退出脚本
|
||||
- `[2] 自动安装`:通过 winget 或 Chocolatey 安装 Docker Desktop for Windows,安装后需用户重启终端/机器再继续
|
||||
- 若设置 `DEPLOY_DOCKER_INSTALL=manual|auto`,脚本会直接执行对应分支,不再提示选择
|
||||
|
||||
### 5.6 执行策略
|
||||
|
||||
@@ -150,19 +156,29 @@ powershell -ExecutionPolicy Bypass -File .\start.ps1
|
||||
- 本地访问:`http://127.0.0.1:3010`
|
||||
- 局域网访问:`http://<LAN_IP>:3010`
|
||||
|
||||
首次安装还会在完成页直接显示管理员登录信息:
|
||||
|
||||
```text
|
||||
登录账号: admin
|
||||
登录密码: <本次部署生成的 20 位随机密码>
|
||||
```
|
||||
|
||||
该密码只在数据库首次创建 `admin` 时使用,创建后修改 `.env` 不会重置现有密码。初始密码没有独立过期时间,首次登录后应立即修改。已有 `.env` 的部署只提示密码沿用规则,不会把可能已经失效的环境变量值显示成当前登录密码。
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现清单
|
||||
|
||||
- [ ] 创建 `start.ps1` 主脚本
|
||||
- [ ] 实现 `Init-ProjectDir`:项目目录校验
|
||||
- [ ] 实现 `Run-DeployQuestions`:本地 / 局域网 IP 选择(无域名/HTTPS)
|
||||
- [ ] 实现 `Setup-EnvFiles`:配置文件生成与替换
|
||||
- [ ] 实现 `Test-Docker`:Docker 检测,未安装时 prompt「手动安装」或「自动安装」
|
||||
- [ ] 实现 `Install-Docker`:自动安装 **Docker Desktop for Windows**(winget / Chocolatey)
|
||||
- [ ] 实现 `Start-Services`:`docker compose` 启动
|
||||
- [ ] 实现 `Main`:主流程串联
|
||||
- [ ] 支持 `DEPLOY_DRY_RUN`、`DEPLOY_IP` 环境变量非交互模式
|
||||
- [x] 创建 `start.ps1` 主脚本
|
||||
- [x] 实现 `Init-ProjectDir`:项目目录校验
|
||||
- [x] 实现 `Run-DeployQuestions`:本地 / 局域网 IP 选择(无域名/HTTPS)
|
||||
- [x] 实现 `Setup-EnvFiles`:配置文件生成与替换
|
||||
- [x] 实现 `Test-Docker`:Docker 检测,未安装时 prompt「手动安装」或「自动安装」
|
||||
- [x] 实现 `Install-Docker`:自动安装 **Docker Desktop for Windows**(winget / Chocolatey)
|
||||
- [x] 实现 `Start-Services`:`docker compose` 启动
|
||||
- [x] 实现 `Main`:主流程串联
|
||||
- [x] 支持 `DEPLOY_DRY_RUN`、`DEPLOY_IP` 环境变量非交互模式
|
||||
- [x] 支持 `DEPLOY_ACCESS=local`、`DEPLOY_NON_INTERACTIVE`、`DEPLOY_DOCKER_INSTALL` 非交互控制
|
||||
- [x] 在 README 或文档中补充 Windows 部署说明与执行策略
|
||||
- [x] 创建 `scripts/test-start-ps1-env.py` 验证 .env 替换逻辑
|
||||
|
||||
@@ -178,6 +194,12 @@ $env:DEPLOY_DRY_RUN = "1"
|
||||
$env:DEPLOY_IP = "127.0.0.1"
|
||||
.\start.ps1
|
||||
|
||||
# 或使用本机访问模式变量
|
||||
$env:DEPLOY_DRY_RUN = "1"
|
||||
$env:DEPLOY_ACCESS = "local"
|
||||
$env:DEPLOY_NO_WAIT = "1"
|
||||
.\start.ps1
|
||||
|
||||
# 检查 .env 是否已正确写入
|
||||
Select-String -Path .env -Pattern "NUXT_PUBLIC_BASE"
|
||||
```
|
||||
|
||||
+64
-22
@@ -3,6 +3,12 @@ map $http_upgrade $connection_upgrade {
|
||||
'' close;
|
||||
}
|
||||
|
||||
# $host 会丢失客户端 authority 中的显式端口;后端公开 URL 推断需保留 $http_host。
|
||||
map $http_host $easyai_forwarded_host {
|
||||
default $http_host;
|
||||
'' $host;
|
||||
}
|
||||
|
||||
# www 域名重定向,使用二级域名注释掉此部分的域名重定向
|
||||
server {
|
||||
listen 80;
|
||||
@@ -32,11 +38,27 @@ server {
|
||||
listen [::]:80;
|
||||
server_name 51easyai.com;
|
||||
|
||||
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 4;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml
|
||||
image/svg+xml;
|
||||
|
||||
# ssl 版本控制
|
||||
ssl_protocols TLSv1.2 TLSv1.3; # 只启用 TLS 1.2 和 TLS 1.3
|
||||
# 通用安全头部
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
# add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
# 用于 certbot 验证的配置
|
||||
location /.well-known/acme-challenge/ {
|
||||
@@ -57,6 +79,29 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# 如果 X-Frame-Options 是上游返回的,隐藏它
|
||||
proxy_hide_header X-Frame-Options;
|
||||
|
||||
# Wujie 会 fetch HTML,必须允许跨源读取
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Accept, Authorization" always;
|
||||
add_header Cross-Origin-Resource-Policy "cross-origin" always;
|
||||
}
|
||||
|
||||
# 规范化 API 根路径,确保 https://example.com/api 不会落到前端 location /。
|
||||
location = /api {
|
||||
return 308 /api/;
|
||||
}
|
||||
|
||||
# Sandbox 只能由 easyai-server 通过 Docker 内网访问,禁止从公网 API 入口绕过。
|
||||
location = /api/sandbox {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location ^~ /api/sandbox/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
@@ -76,7 +121,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $easyai_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Host $easyai_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
location /asg-api/ {
|
||||
@@ -122,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;
|
||||
@@ -147,7 +192,9 @@ server {
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:3001/mcp;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $easyai_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Host $easyai_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -157,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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
if command -v nginx &> /dev/null; then
|
||||
echo "✅ Nginx 已安装,跳过安装步骤"
|
||||
else
|
||||
@@ -104,12 +106,55 @@ else
|
||||
fi
|
||||
|
||||
# ===== 域名配置交互与文件生成 =====
|
||||
sanitize_domain() {
|
||||
local domain=$1
|
||||
domain="${domain#http://}"
|
||||
domain="${domain#https://}"
|
||||
domain="${domain%%/*}"
|
||||
domain="${domain%;}"
|
||||
echo "$domain" | xargs
|
||||
}
|
||||
|
||||
validate_domain_value() {
|
||||
local domain=$1
|
||||
[[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]]
|
||||
}
|
||||
|
||||
get_env_domain() {
|
||||
local domain="${HTTPS_DOMAIN:-${EASYAI_PROXY_DOMAIN:-${DEPLOY_DOMAIN:-}}}"
|
||||
if [ -z "$domain" ]; then
|
||||
return 0
|
||||
fi
|
||||
domain="$(sanitize_domain "$domain")"
|
||||
if ! validate_domain_value "$domain"; then
|
||||
echo "❌ 域名变量格式不正确: $domain" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$domain"
|
||||
}
|
||||
|
||||
is_non_interactive() {
|
||||
[ "${HTTPS_NON_INTERACTIVE:-${DEPLOY_NON_INTERACTIVE:-0}}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]
|
||||
}
|
||||
|
||||
prompt_domain() {
|
||||
local domain
|
||||
domain="$(get_env_domain)"
|
||||
if [ -n "$domain" ]; then
|
||||
echo "$domain"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if is_non_interactive; then
|
||||
echo "❌ 当前为非交互环境,HTTPS 配置需要传入域名变量" >&2
|
||||
echo " 请设置 DEPLOY_DOMAIN=<域名> 或 EASYAI_PROXY_DOMAIN=<域名>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while true; do
|
||||
read -r -p "🌐 请输入域名(例如 demo.example.com): " domain
|
||||
domain=$(echo "$domain" | xargs)
|
||||
if [ -n "$domain" ] && [[ "$domain" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]]; then
|
||||
domain="$(sanitize_domain "$domain")"
|
||||
if [ -n "$domain" ] && validate_domain_value "$domain"; then
|
||||
echo "$domain"
|
||||
return 0
|
||||
fi
|
||||
@@ -140,6 +185,14 @@ create_conf_by_template() {
|
||||
local domain=$1
|
||||
local target_file=$2
|
||||
|
||||
# 始终优先使用仓库维护的完整代理模板,避免仅生成前端 location /。
|
||||
if [ -f "./easyai-proxy.conf.sample" ]; then
|
||||
cp "./easyai-proxy.conf.sample" "./$target_file"
|
||||
sed -i.bak "s/51easyai\.com/$domain/g" "./$target_file"
|
||||
rm -f "./$target_file.bak"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "./demo.51easyai.com.conf" ]; then
|
||||
cp "./demo.51easyai.com.conf" "./$target_file"
|
||||
sed -i.bak "s/www\.demo\.51easyai\.com/www.$domain/g; s/demo\.51easyai\.com/$domain/g" "./$target_file"
|
||||
@@ -180,6 +233,21 @@ server {
|
||||
listen [::]:80;
|
||||
server_name $domain;
|
||||
|
||||
# EasyAI response gzip: compress JSON/text API responses; SSE/WebSocket stay uncompressed by content type.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 4;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml
|
||||
text/css
|
||||
text/plain
|
||||
text/xml
|
||||
image/svg+xml;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
try_files \$uri =404;
|
||||
@@ -196,24 +264,59 @@ server {
|
||||
EOF
|
||||
}
|
||||
|
||||
conf_contains_domain() {
|
||||
local conf_file=$1
|
||||
local domain=$2
|
||||
grep -Eq "server_name[^;]*(^|[[:space:]])(www\\.)?${domain//./\\.}([[:space:];]|$)" "./$conf_file"
|
||||
}
|
||||
|
||||
normalize_domain_action() {
|
||||
local action=$1
|
||||
action="$(printf '%s' "$action" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$action" in
|
||||
1|replace) echo "1" ;;
|
||||
2|add|"") echo "2" ;;
|
||||
*)
|
||||
echo "❌ EASYAI_PROXY_DOMAIN_ACTION 只能是 replace 或 add,当前为: $action" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
upsert_domain_for_conf() {
|
||||
local conf_file=$1
|
||||
local env_domain="${2:-}"
|
||||
local env_action="${3:-}"
|
||||
local mode
|
||||
local new_domain
|
||||
|
||||
echo "ℹ️ 检测到当前目录已有配置文件: $conf_file"
|
||||
while true; do
|
||||
echo "请选择域名处理方式:"
|
||||
echo " 1) 替换当前域名"
|
||||
echo " 2) 新增一个域名"
|
||||
read -r -p "请输入选项 (1/2): " mode
|
||||
case "$mode" in
|
||||
1|2) break ;;
|
||||
*) echo "❌ 无效选项,请输入 1 或 2" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$env_domain" ]; then
|
||||
new_domain="$(sanitize_domain "$env_domain")"
|
||||
if ! validate_domain_value "$new_domain"; then
|
||||
echo "❌ 域名变量格式不正确: $new_domain"
|
||||
exit 1
|
||||
fi
|
||||
if conf_contains_domain "$conf_file" "$new_domain"; then
|
||||
echo "ℹ️ 配置文件已包含域名 $new_domain,无需修改。"
|
||||
return 0
|
||||
fi
|
||||
mode="$(normalize_domain_action "$env_action")"
|
||||
echo "使用变量配置域名: $new_domain, action=$([ "$mode" = "1" ] && echo replace || echo add)"
|
||||
else
|
||||
while true; do
|
||||
echo "请选择域名处理方式:"
|
||||
echo " 1) 替换当前域名"
|
||||
echo " 2) 新增一个域名"
|
||||
read -r -p "请输入选项 (1/2): " mode
|
||||
case "$mode" in
|
||||
1|2) break ;;
|
||||
*) echo "❌ 无效选项,请输入 1 或 2" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
new_domain=$(prompt_domain)
|
||||
new_domain=$(prompt_domain)
|
||||
fi
|
||||
|
||||
if [ "$mode" = "1" ]; then
|
||||
local old_domain
|
||||
@@ -253,7 +356,9 @@ upsert_domain_for_conf() {
|
||||
|
||||
detect_existing_proxy_conf() {
|
||||
local preferred_file=$1
|
||||
local domain_filter="${2:-}"
|
||||
local -a candidates=()
|
||||
local -a matched_candidates=()
|
||||
local conf
|
||||
|
||||
if [ -n "$preferred_file" ] && [ -f "./$preferred_file" ]; then
|
||||
@@ -271,15 +376,36 @@ detect_existing_proxy_conf() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -n "$domain_filter" ]; then
|
||||
for conf in "${candidates[@]}"; do
|
||||
if conf_contains_domain "$conf" "$domain_filter"; then
|
||||
matched_candidates+=("$conf")
|
||||
fi
|
||||
done
|
||||
if [ "${#matched_candidates[@]}" -eq 1 ]; then
|
||||
echo "${matched_candidates[0]}"
|
||||
return 0
|
||||
fi
|
||||
if [ "${#matched_candidates[@]}" -gt 1 ] && is_non_interactive; then
|
||||
echo "❌ 多个配置文件都包含 $domain_filter,请通过 EASYAI_PROXY_CONF 指定文件" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${#candidates[@]}" -eq 1 ]; then
|
||||
echo "${candidates[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "⚠️ 检测到多个可用配置文件,请选择一个:"
|
||||
if is_non_interactive; then
|
||||
echo "❌ 检测到多个可用配置文件,请通过 EASYAI_PROXY_CONF 指定文件" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚠️ 检测到多个可用配置文件,请选择一个:" >&2
|
||||
local i
|
||||
for i in "${!candidates[@]}"; do
|
||||
printf " %s) %s\n" "$((i + 1))" "${candidates[$i]}"
|
||||
printf " %s) %s\n" "$((i + 1))" "${candidates[$i]}" >&2
|
||||
done
|
||||
while true; do
|
||||
read -r -p "请输入序号: " i
|
||||
@@ -295,9 +421,10 @@ echo "🚀 复制当前目录的配置文件到nginx配置文件目录"
|
||||
# 支持 EASYAI_PROXY_CONF 指定配置文件(如 51easyai.com.conf)
|
||||
PREFERRED_CONF_FILE="${EASYAI_PROXY_CONF:-}"
|
||||
DEFAULT_NEW_CONF_FILE="${EASYAI_PROXY_CONF:-easyai-proxy.conf}"
|
||||
CONF_FILE=$(detect_existing_proxy_conf "$PREFERRED_CONF_FILE")
|
||||
HTTPS_DOMAIN_INPUT="$(get_env_domain)"
|
||||
CONF_FILE=$(detect_existing_proxy_conf "$PREFERRED_CONF_FILE" "$HTTPS_DOMAIN_INPUT")
|
||||
if [ -n "$CONF_FILE" ]; then
|
||||
upsert_domain_for_conf "$CONF_FILE"
|
||||
upsert_domain_for_conf "$CONF_FILE" "$HTTPS_DOMAIN_INPUT" "${EASYAI_PROXY_DOMAIN_ACTION:-}"
|
||||
else
|
||||
CONF_FILE="$DEFAULT_NEW_CONF_FILE"
|
||||
echo "ℹ️ 当前目录未找到可用 nginx 配置文件(*.conf 且包含 server_name)"
|
||||
@@ -348,7 +475,7 @@ if [ -n "$DOMAINS" ]; then
|
||||
sudo certbot --nginx \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email wangbo@51easyai.com \
|
||||
--email "${CERTBOT_EMAIL:-wangbo@51easyai.com}" \
|
||||
--rsa-key-size 2048 \
|
||||
--preferred-challenges http \
|
||||
--force-renewal \
|
||||
@@ -357,4 +484,4 @@ if [ -n "$DOMAINS" ]; then
|
||||
# 启动 Nginx 服务
|
||||
echo "启动 Nginx 服务..."
|
||||
sudo nginx
|
||||
fi
|
||||
fi
|
||||
|
||||
+15
-12
@@ -15,16 +15,15 @@ daemonize no
|
||||
#pidfile /data/dockerData/redis/run/redis6379.pid
|
||||
#默认为no,redis持久化,可以改为yes
|
||||
appendonly yes
|
||||
dir /data
|
||||
#当客户端闲置多长时间后关闭连接,如果指定为0,表示关闭该功能 # 增加到300秒,或设为0禁用
|
||||
timeout 300
|
||||
# 服务器系统默认配置参数影响 Redis 的应用
|
||||
maxclients 10000
|
||||
# 减少到60秒,更快检测连接问题
|
||||
tcp-keepalive 60
|
||||
#指定在多长时间内,有多少次更新操作,就将数据同步到数据文件,可以多个条件配合(分别表示900秒(15分钟)内有1个更改,300秒(5分钟)内有10个更改以及60秒内有10000个更改)
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
# 禁用 RDB 快照,避免小内存机器上 fork bgsave 触发 OOM;生产保留 AOF。
|
||||
save ""
|
||||
# 按需求调整 Redis 线程数
|
||||
tcp-backlog 511
|
||||
# 设置Redis副本实例是否为只读模式
|
||||
@@ -32,18 +31,25 @@ tcp-backlog 511
|
||||
replica-read-only no
|
||||
|
||||
# 设置Redis最大内存达到时的数据淘汰策略
|
||||
# noeviction:当内存达到最大限制时,Redis不会自动删除任何键,这可能导致进一步的操作失败,直到手动干预 # 当内存不足时,删除最近最少使用的键
|
||||
# 为 2GB 容器预留约 512MB,供连接缓冲区、模块、AOF 重写和 fork/COW 使用。
|
||||
# noeviction:达到数据内存上限后拒绝写入,避免淘汰队列内部键导致状态不一致。
|
||||
# maxmemory-policy allkeys-lru
|
||||
maxmemory 1536mb
|
||||
maxmemory-policy noeviction
|
||||
# 设置数据库数量,这里设置为16个数据库
|
||||
databases 16
|
||||
# 启用 AOF, AOF常规配置
|
||||
appendonly yes
|
||||
# 改为由操作系统决定何时同步,减少I/O压力
|
||||
appendfsync no
|
||||
# AOF 常规配置
|
||||
appendfsync everysec
|
||||
# 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
|
||||
@@ -69,6 +75,3 @@ list-max-ziplist-value 64
|
||||
set-max-intset-entries 512
|
||||
zset-max-ziplist-entries 128
|
||||
zset-max-ziplist-value 64
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
function Get-PublicEnvValue {
|
||||
param([string]$Content, [string]$Key)
|
||||
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
|
||||
if (-not $match.Success) { return "" }
|
||||
$value = $match.Groups[1].Value.Trim()
|
||||
if ($value.Length -ge 2) {
|
||||
$first = $value.Substring(0, 1)
|
||||
$last = $value.Substring($value.Length - 1, 1)
|
||||
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
|
||||
return $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
}
|
||||
return $value
|
||||
}
|
||||
|
||||
function Set-PublicEnvValue {
|
||||
param([string]$Content, [string]$Key, [string]$Value)
|
||||
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
|
||||
$replacement = "$Key=$Value`r`n"
|
||||
if ([regex]::IsMatch($Content, $pattern)) {
|
||||
return [regex]::Replace($Content, $pattern, $replacement, 1)
|
||||
}
|
||||
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
|
||||
return $Content + $replacement
|
||||
}
|
||||
|
||||
function ConvertTo-PublicApiBaseUrl {
|
||||
param([string]$Value)
|
||||
$normalized = if ($null -eq $Value) { "" } else { $Value.Trim().TrimEnd('/') }
|
||||
if (-not $normalized) { return $null }
|
||||
$uri = $null
|
||||
if (-not [Uri]::TryCreate($normalized, [UriKind]::Absolute, [ref]$uri)) { return $null }
|
||||
if ($uri.Scheme -notin @("http", "https")) { return $null }
|
||||
if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { return $null }
|
||||
if (-not $uri.Host -or $uri.Port -lt 1 -or $uri.Port -gt 65535) { return $null }
|
||||
return $normalized
|
||||
}
|
||||
|
||||
function Get-DerivedPublicApiBaseUrl {
|
||||
param([string]$Content)
|
||||
$apiUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_APIURL"
|
||||
$absoluteApiUrl = ConvertTo-PublicApiBaseUrl $apiUrl
|
||||
if ($absoluteApiUrl) { return $absoluteApiUrl }
|
||||
if (-not $apiUrl.StartsWith('/')) { return $null }
|
||||
|
||||
$origin = $null
|
||||
$socketUrl = Get-PublicEnvValue $Content "NUXT_PUBLIC_BASE_SOCKETURL"
|
||||
$socketUri = $null
|
||||
if ([Uri]::TryCreate($socketUrl, [UriKind]::Absolute, [ref]$socketUri) -and
|
||||
$socketUri.Scheme -in @("ws", "wss")) {
|
||||
$scheme = if ($socketUri.Scheme -eq "wss") { "https" } else { "http" }
|
||||
$origin = "${scheme}://$($socketUri.Authority)"
|
||||
}
|
||||
|
||||
if (-not $origin) {
|
||||
$securityOrigin = (Get-PublicEnvValue $Content "CONFIG_SECURITY_ORIGIN").Split(',')[0].Trim()
|
||||
$securityUri = $null
|
||||
if ([Uri]::TryCreate($securityOrigin, [UriKind]::Absolute, [ref]$securityUri) -and
|
||||
$securityUri.Scheme -in @("http", "https")) {
|
||||
$origin = "$($securityUri.Scheme)://$($securityUri.Authority)"
|
||||
}
|
||||
}
|
||||
if (-not $origin) { return $null }
|
||||
return ConvertTo-PublicApiBaseUrl "$($origin.TrimEnd('/'))$apiUrl"
|
||||
}
|
||||
|
||||
function Initialize-PublicApiBaseUrl {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[ValidateSet("configure", "upgrade")][string]$Mode = "upgrade",
|
||||
[string]$Override = $env:DEPLOY_PUBLIC_API_BASE_URL
|
||||
)
|
||||
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
|
||||
|
||||
$content = Get-Content $Path -Raw -Encoding UTF8
|
||||
if ($null -eq $content) { $content = "" }
|
||||
if (-not $Override) { $Override = Get-PublicEnvValue $content "DEPLOY_PUBLIC_API_BASE_URL" }
|
||||
$current = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
|
||||
|
||||
if ($Override) {
|
||||
$selected = ConvertTo-PublicApiBaseUrl $Override
|
||||
if (-not $selected) { throw "Invalid public API base URL: $Override" }
|
||||
} elseif ($current) {
|
||||
if (-not (ConvertTo-PublicApiBaseUrl $current)) {
|
||||
throw "Invalid CONFIG_PUBLIC_API_BASE_URL: $current"
|
||||
}
|
||||
return
|
||||
} else {
|
||||
$selected = Get-DerivedPublicApiBaseUrl $content
|
||||
if (-not $selected) {
|
||||
throw "Cannot derive CONFIG_PUBLIC_API_BASE_URL. Set a full URL such as https://example.com/api"
|
||||
}
|
||||
}
|
||||
|
||||
$content = Set-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL" $selected
|
||||
[System.IO.File]::WriteAllText(
|
||||
(Resolve-Path $Path),
|
||||
$content,
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
Write-Host " [OK] Public API base URL: $selected" -ForegroundColor Green
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
function New-SecuritySecret {
|
||||
$bytes = New-Object byte[] 48
|
||||
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try {
|
||||
$rng.GetBytes($bytes)
|
||||
} finally {
|
||||
$rng.Dispose()
|
||||
}
|
||||
return [Convert]::ToBase64String($bytes)
|
||||
}
|
||||
|
||||
function New-InitialAdminPassword {
|
||||
$bytes = New-Object byte[] 10
|
||||
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try {
|
||||
$rng.GetBytes($bytes)
|
||||
} finally {
|
||||
$rng.Dispose()
|
||||
}
|
||||
return ([BitConverter]::ToString($bytes) -replace "-", "").ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
param([string]$Content, [string]$Key)
|
||||
$match = [regex]::Match($Content, "(?m)^$([regex]::Escape($Key))=(.*)$")
|
||||
if (-not $match.Success) { return "" }
|
||||
$value = $match.Groups[1].Value.Trim()
|
||||
if ($value.Length -ge 2) {
|
||||
$first = $value.Substring(0, 1)
|
||||
$last = $value.Substring($value.Length - 1, 1)
|
||||
if (($first -eq "'" -and $last -eq "'") -or ($first -eq '"' -and $last -eq '"')) {
|
||||
return $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
}
|
||||
return $value
|
||||
}
|
||||
|
||||
function Set-EnvValue {
|
||||
param([string]$Content, [string]$Key, [string]$Value)
|
||||
$pattern = "(?m)^$([regex]::Escape($Key))=.*(?:\r?\n)?"
|
||||
$replacement = "$Key=$Value`r`n"
|
||||
if ([regex]::IsMatch($Content, $pattern)) {
|
||||
return [regex]::Replace($Content, $pattern, $replacement, 1)
|
||||
}
|
||||
if ($Content -and -not $Content.EndsWith("`n")) { $Content += "`r`n" }
|
||||
return $Content + $replacement
|
||||
}
|
||||
|
||||
function Initialize-SecurityEnv {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[ValidateSet("new", "upgrade")][string]$Mode = "upgrade"
|
||||
)
|
||||
if (-not (Test-Path $Path)) { throw "Environment file not found: $Path" }
|
||||
|
||||
$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 = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 }
|
||||
)
|
||||
if ($Mode -eq "new") {
|
||||
$definitions += @(
|
||||
@{ Key = "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"; Legacy = ""; MinLength = 32 },
|
||||
@{ Key = "CONFIG_OTP_HASH_SECRET"; Legacy = ""; MinLength = 32 },
|
||||
@{ Key = "CONFIG_AUDIT_HASH_PEPPER"; Legacy = ""; MinLength = 32 },
|
||||
@{ Key = "CONFIG_AUDIT_INTEGRITY_KEY"; Legacy = ""; MinLength = 32 }
|
||||
)
|
||||
}
|
||||
|
||||
foreach ($definition in $definitions) {
|
||||
$current = Get-EnvValue $content $definition.Key
|
||||
$needsInitialization = $current.Length -lt $definition.MinLength -or
|
||||
($definition.Legacy -and $current -eq $definition.Legacy)
|
||||
if (-not $needsInitialization) { continue }
|
||||
$content = Set-EnvValue $content $definition.Key (New-SecuritySecret)
|
||||
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) {
|
||||
$content = Set-EnvValue $content "CONFIG_INITIAL_ADMIN_PASSWORD" (New-InitialAdminPassword)
|
||||
Write-Host " [OK] Initialized admin password: CONFIG_INITIAL_ADMIN_PASSWORD" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
[System.IO.File]::WriteAllText(
|
||||
(Resolve-Path $Path),
|
||||
$content,
|
||||
[System.Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
}
|
||||
@@ -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,93 @@
|
||||
#Requires -Version 5.1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. (Join-Path $scriptDir "Initialize-PublicApiBaseUrl.ps1")
|
||||
|
||||
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("easyai-public-url-" + [Guid]::NewGuid())
|
||||
[System.IO.Directory]::CreateDirectory($tempDir) | Out-Null
|
||||
|
||||
function Write-TestEnv {
|
||||
param([string]$Name, [string[]]$Lines)
|
||||
$path = Join-Path $tempDir $Name
|
||||
[System.IO.File]::WriteAllText($path, ($Lines -join "`r`n") + "`r`n", [System.Text.UTF8Encoding]::new($false))
|
||||
return $path
|
||||
}
|
||||
|
||||
function Assert-PublicUrl {
|
||||
param([string]$Path, [string]$Expected)
|
||||
$content = Get-Content $Path -Raw -Encoding UTF8
|
||||
$actual = Get-PublicEnvValue $content "CONFIG_PUBLIC_API_BASE_URL"
|
||||
if ($actual -ne $Expected) { throw "Expected '$Expected', got '$actual'" }
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = Write-TestEnv "existing.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api",
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
)
|
||||
Initialize-PublicApiBaseUrl -Path $existing -Mode upgrade
|
||||
Assert-PublicUrl $existing "https://existing.example.com:8443/api"
|
||||
|
||||
$absolute = Write-TestEnv "absolute.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=",
|
||||
"NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100"
|
||||
)
|
||||
Initialize-PublicApiBaseUrl -Path $absolute -Mode upgrade
|
||||
Assert-PublicUrl $absolute "http://10.0.0.8:4100"
|
||||
|
||||
$websocket = Write-TestEnv "websocket.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=",
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api",
|
||||
"NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io"
|
||||
)
|
||||
Initialize-PublicApiBaseUrl -Path $websocket -Mode upgrade
|
||||
Assert-PublicUrl $websocket "https://demo.example.com/api"
|
||||
|
||||
$securityOrigin = Write-TestEnv "security-origin.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=",
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api",
|
||||
"NUXT_PUBLIC_BASE_SOCKETURL=",
|
||||
"CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com"
|
||||
)
|
||||
Initialize-PublicApiBaseUrl -Path $securityOrigin -Mode upgrade
|
||||
Assert-PublicUrl $securityOrigin "http://demo.example.com/api"
|
||||
|
||||
$fileOverride = Write-TestEnv "file-override.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=",
|
||||
"DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api",
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
)
|
||||
Initialize-PublicApiBaseUrl -Path $fileOverride -Mode upgrade
|
||||
Assert-PublicUrl $fileOverride "https://proxy.example.com:9443/custom-api"
|
||||
|
||||
$explicitOverride = Write-TestEnv "explicit-override.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=",
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
)
|
||||
Initialize-PublicApiBaseUrl `
|
||||
-Path $explicitOverride `
|
||||
-Mode configure `
|
||||
-Override "http://192.168.1.20:4567"
|
||||
Assert-PublicUrl $explicitOverride "http://192.168.1.20:4567"
|
||||
|
||||
$invalid = Write-TestEnv "invalid.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api",
|
||||
"NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api"
|
||||
)
|
||||
$blocked = $false
|
||||
try { Initialize-PublicApiBaseUrl -Path $invalid -Mode upgrade } catch { $blocked = $true }
|
||||
if (-not $blocked) { throw "Invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup" }
|
||||
|
||||
$unresolved = Write-TestEnv "unresolved.env" @(
|
||||
"CONFIG_PUBLIC_API_BASE_URL=",
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
)
|
||||
$blocked = $false
|
||||
try { Initialize-PublicApiBaseUrl -Path $unresolved -Mode upgrade } catch { $blocked = $true }
|
||||
if (-not $blocked) { throw "Unresolved public API URL must block startup" }
|
||||
|
||||
Write-Host "Public API base URL PowerShell tests passed" -ForegroundColor Green
|
||||
} finally {
|
||||
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 初始化并持久化后端对外公开 API 地址。不会 source .env,避免执行其中内容。
|
||||
|
||||
public_url_read_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value
|
||||
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
|
||||
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
case "$value" in
|
||||
\"*\") value="${value#\"}"; value="${value%\"}" ;;
|
||||
\'*\') value="${value#\'}"; value="${value%\'}" ;;
|
||||
esac
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
public_url_write_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp "${file}.tmp.XXXXXX")"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { replaced = 0 }
|
||||
index($0, key "=") == 1 {
|
||||
if (!replaced) {
|
||||
print key "=" value
|
||||
replaced = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!replaced) print key "=" value
|
||||
}
|
||||
' "$file" > "$tmp"
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
public_url_normalize() {
|
||||
local value
|
||||
value="$(printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
while [ "${value%/}" != "$value" ]; do value="${value%/}"; done
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
public_url_validate() {
|
||||
local value rest authority host port
|
||||
value="$(public_url_normalize "$1")"
|
||||
case "$value" in
|
||||
http://*|https://*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "$value" in
|
||||
*[[:space:]@?#]*) return 1 ;;
|
||||
esac
|
||||
|
||||
rest="${value#*://}"
|
||||
authority="${rest%%/*}"
|
||||
[ -n "$authority" ] || return 1
|
||||
if [[ "$authority" == \[* ]]; then
|
||||
[[ "$authority" =~ ^\[[0-9A-Fa-f:.]+\](:[0-9]+)?$ ]] || return 1
|
||||
if [[ "$authority" == *]:* ]]; then port="${authority##*:}"; fi
|
||||
else
|
||||
[[ "$authority" =~ ^[A-Za-z0-9._-]+(:[0-9]+)?$ ]] || return 1
|
||||
if [[ "$authority" == *:* ]]; then
|
||||
host="${authority%:*}"
|
||||
port="${authority##*:}"
|
||||
[ -n "$host" ] || return 1
|
||||
fi
|
||||
fi
|
||||
if [ -n "${port:-}" ]; then
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || return 1
|
||||
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
public_url_derive_from_env() {
|
||||
local file="$1"
|
||||
local api_url socket_url security_origin origin scheme combined
|
||||
api_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_APIURL")"
|
||||
if public_url_validate "$api_url"; then
|
||||
public_url_normalize "$api_url"
|
||||
return 0
|
||||
fi
|
||||
[[ "$api_url" == /* ]] || return 1
|
||||
|
||||
socket_url="$(public_url_read_env_value "$file" "NUXT_PUBLIC_BASE_SOCKETURL")"
|
||||
if [[ "$socket_url" =~ ^(ws|wss)://([^/?#[:space:]]+) ]]; then
|
||||
scheme="http"
|
||||
[ "${BASH_REMATCH[1]}" = "wss" ] && scheme="https"
|
||||
origin="${scheme}://${BASH_REMATCH[2]}"
|
||||
fi
|
||||
|
||||
if [ -z "${origin:-}" ]; then
|
||||
security_origin="$(public_url_read_env_value "$file" "CONFIG_SECURITY_ORIGIN")"
|
||||
security_origin="${security_origin%%,*}"
|
||||
security_origin="$(public_url_normalize "$security_origin")"
|
||||
if [[ "$security_origin" =~ ^(https?://[^/]+) ]]; then
|
||||
origin="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -n "${origin:-}" ] || return 1
|
||||
combined="$(public_url_normalize "${origin}${api_url}")"
|
||||
public_url_validate "$combined" || return 1
|
||||
printf '%s' "$combined"
|
||||
}
|
||||
|
||||
init_public_api_base_url() {
|
||||
local file="${1:-.env}"
|
||||
local mode="${2:-upgrade}"
|
||||
local override="${3:-${DEPLOY_PUBLIC_API_BASE_URL:-}}"
|
||||
local current selected
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ 环境配置文件不存在: $file" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$mode" != "configure" ] && [ "$mode" != "upgrade" ]; then
|
||||
echo "❌ 公开 API 地址初始化模式无效: $mode(仅支持 configure/upgrade)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "$override" ]; then
|
||||
override="$(public_url_read_env_value "$file" "DEPLOY_PUBLIC_API_BASE_URL")"
|
||||
fi
|
||||
current="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
|
||||
if [ -n "$override" ]; then
|
||||
selected="$(public_url_normalize "$override")"
|
||||
elif [ -n "$current" ]; then
|
||||
if ! public_url_validate "$current"; then
|
||||
echo "❌ CONFIG_PUBLIC_API_BASE_URL 配置无效: $current" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
selected="$(public_url_derive_from_env "$file")" || {
|
||||
echo "❌ 无法从现有前端配置推导 CONFIG_PUBLIC_API_BASE_URL" >&2
|
||||
echo " 请设置完整地址,例如 https://example.com/api" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
if ! public_url_validate "$selected"; then
|
||||
echo "❌ 公开 API 地址配置无效: $selected" >&2
|
||||
return 1
|
||||
fi
|
||||
selected="$(public_url_normalize "$selected")"
|
||||
public_url_write_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL" "$selected"
|
||||
echo " ✓ 已配置公开 API 地址: $selected"
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
init_public_api_base_url "${1:-.env}" "${2:-upgrade}" "${3:-}"
|
||||
fi
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 为本地 .env 初始化持久化安全密钥。只处理空值和已知历史默认值,
|
||||
# 已存在的自定义值保持不变;数据库中的 JWT 轮转配置仍由后端优先使用。
|
||||
|
||||
generate_security_secret() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -base64 48 | tr -d '\r\n'
|
||||
return
|
||||
fi
|
||||
if [ -r /dev/urandom ]; then
|
||||
od -An -N48 -tx1 /dev/urandom | tr -d ' \r\n'
|
||||
return
|
||||
fi
|
||||
echo "❌ 无法生成安全随机值:需要 openssl 或 /dev/urandom" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
generate_initial_admin_password() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
# 10 random bytes encoded as hexadecimal: exactly 20 alphanumeric characters.
|
||||
openssl rand -hex 10 | tr -d '\r\n'
|
||||
return
|
||||
fi
|
||||
if [ -r /dev/urandom ]; then
|
||||
od -An -N10 -tx1 /dev/urandom | tr -d ' \r\n'
|
||||
return
|
||||
fi
|
||||
echo "❌ 无法生成初始管理员密码:需要 openssl 或 /dev/urandom" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value
|
||||
value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")"
|
||||
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
case "$value" in
|
||||
\"*\") value="${value#\"}"; value="${value%\"}" ;;
|
||||
\'*\') value="${value#\'}"; value="${value%\'}" ;;
|
||||
esac
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
write_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local tmp
|
||||
tmp="$(mktemp "${file}.tmp.XXXXXX")"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { replaced = 0 }
|
||||
index($0, key "=") == 1 {
|
||||
if (!replaced) {
|
||||
print key "=" value
|
||||
replaced = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!replaced) print key "=" value
|
||||
}
|
||||
' "$file" > "$tmp"
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
ensure_security_secret() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local legacy_value="${3:-}"
|
||||
local minimum_length="${4:-32}"
|
||||
local current
|
||||
current="$(read_env_value "$file" "$key")"
|
||||
if [ "${#current}" -ge "$minimum_length" ] && { [ -z "$legacy_value" ] || [ "$current" != "$legacy_value" ]; }; then
|
||||
return 0
|
||||
fi
|
||||
write_env_value "$file" "$key" "$(generate_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
|
||||
current="$(read_env_value "$file" "CONFIG_INITIAL_ADMIN_PASSWORD")"
|
||||
if [ "${#current}" -ge 12 ]; then
|
||||
return 0
|
||||
fi
|
||||
write_env_value "$file" "CONFIG_INITIAL_ADMIN_PASSWORD" "$(generate_initial_admin_password)"
|
||||
echo " ✓ 已初始化管理员密码: CONFIG_INITIAL_ADMIN_PASSWORD"
|
||||
}
|
||||
|
||||
init_security_env() {
|
||||
local file="${1:-.env}"
|
||||
local mode="${2:-upgrade}"
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ 环境配置文件不存在: $file" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$mode" != "new" ] && [ "$mode" != "upgrade" ]; then
|
||||
echo "❌ 安全环境初始化模式无效: $mode(仅支持 new/upgrade)" >&2
|
||||
return 1
|
||||
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"
|
||||
ensure_security_secret "$file" "CONFIG_AUDIT_HASH_PEPPER"
|
||||
ensure_security_secret "$file" "CONFIG_AUDIT_INTEGRITY_KEY"
|
||||
ensure_initial_admin_password "$file"
|
||||
fi
|
||||
chmod 600 "$file"
|
||||
}
|
||||
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
init_security_env "${1:-.env}" "${2:-upgrade}"
|
||||
fi
|
||||
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
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
cp \
|
||||
"$REPO_ROOT/start.sh" \
|
||||
"$REPO_ROOT/https.sh" \
|
||||
"$REPO_ROOT/docker-compose.yml" \
|
||||
"$REPO_ROOT/.env.sample" \
|
||||
"$REPO_ROOT/.env.tools.sample" \
|
||||
"$REPO_ROOT/.env.ASG.sample" \
|
||||
"$REPO_ROOT/.env.AMS.sample" \
|
||||
"$REPO_ROOT/easyai-proxy.conf.sample" \
|
||||
"$TMP_DIR/"
|
||||
mkdir -p "$TMP_DIR/scripts"
|
||||
cp \
|
||||
"$REPO_ROOT/scripts/init-security-env.sh" \
|
||||
"$REPO_ROOT/scripts/init-public-api-base-url.sh" \
|
||||
"$REPO_ROOT/scripts/init-server-http-bind-ip.sh" \
|
||||
"$TMP_DIR/scripts/"
|
||||
|
||||
cd "$TMP_DIR"
|
||||
|
||||
reset_case() {
|
||||
rm -f \
|
||||
.env \
|
||||
.env.tools \
|
||||
.env.ASG \
|
||||
.env.AMS \
|
||||
easyai-proxy.conf \
|
||||
demo.example.com.conf
|
||||
}
|
||||
|
||||
assert_compression_config() {
|
||||
local config_file="$1"
|
||||
grep -q '^ gzip on;' "$config_file"
|
||||
grep -q '^ gzip_vary on;' "$config_file"
|
||||
grep -q '^ gzip_min_length 1024;' "$config_file"
|
||||
grep -q '^ application/javascript$' "$config_file"
|
||||
grep -q '^ text/css$' "$config_file"
|
||||
grep -q '^ text/javascript$' "$config_file"
|
||||
if grep -q 'text/event-stream' "$config_file"; then
|
||||
echo "Unexpected SSE compression type in $config_file" >&2
|
||||
return 1
|
||||
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 > "$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
|
||||
assert_compose_exposure 0.0.0.0
|
||||
assert_canvas_ws_auth_config
|
||||
fi
|
||||
|
||||
reset_case
|
||||
DEPLOY_NON_INTERACTIVE=1 \
|
||||
DEPLOY_DRY_RUN=1 \
|
||||
DEPLOY_ACCESS=domain \
|
||||
DEPLOY_DOMAIN=demo.example.com \
|
||||
DEPLOY_HTTPS=false \
|
||||
bash start.sh >/dev/null
|
||||
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=ws://demo.example.com/socket.io' .env
|
||||
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=http://demo.example.com/api' .env
|
||||
grep -q 'proxy_set_header X-Forwarded-Host $easyai_forwarded_host;' demo.example.com.conf
|
||||
grep -q 'proxy_set_header X-Forwarded-Port $server_port;' demo.example.com.conf
|
||||
grep -q "proxy_set_header X-Original-Prefix '/api';" demo.example.com.conf
|
||||
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 '
|
||||
/^create_conf_by_template\(\)/ { capture = 1 }
|
||||
/^conf_contains_domain\(\)/ { capture = 0 }
|
||||
capture { print }
|
||||
' https.sh)"
|
||||
create_conf_by_template demo.example.com easyai-proxy.conf
|
||||
grep -q 'server_name demo.example.com;' easyai-proxy.conf
|
||||
grep -q 'location = /api {' easyai-proxy.conf
|
||||
grep -q 'location /api/ {' easyai-proxy.conf
|
||||
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 \
|
||||
DEPLOY_DRY_RUN=1 \
|
||||
DEPLOY_ACCESS=domain \
|
||||
DEPLOY_DOMAIN=demo.example.com \
|
||||
DEPLOY_HTTPS=true \
|
||||
bash start.sh >/dev/null
|
||||
grep -qx 'NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io' .env
|
||||
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://demo.example.com/api' .env
|
||||
|
||||
reset_case
|
||||
DEPLOY_NON_INTERACTIVE=1 \
|
||||
DEPLOY_DRY_RUN=1 \
|
||||
DEPLOY_ACCESS=ip \
|
||||
DEPLOY_IP=10.0.0.8 \
|
||||
DEPLOY_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api \
|
||||
bash start.sh >/dev/null
|
||||
grep -qx 'CONFIG_PUBLIC_API_BASE_URL=https://edge.example.com:8443/custom-api' .env
|
||||
|
||||
echo "Deployment public URL dry-run tests passed"
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/init-public-api-base-url.sh
|
||||
. "${SCRIPT_DIR}/init-public-api-base-url.sh"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_env_value() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local actual
|
||||
actual="$(public_url_read_env_value "$file" "CONFIG_PUBLIC_API_BASE_URL")"
|
||||
[ "$actual" = "$expected" ] || fail "expected $expected, got $actual"
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local file="$1"
|
||||
shift
|
||||
printf '%s\n' "$@" > "$file"
|
||||
}
|
||||
|
||||
existing="$TMP_DIR/existing.env"
|
||||
write_env "$existing" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=https://existing.example.com:8443/api" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
init_public_api_base_url "$existing" upgrade >/dev/null
|
||||
assert_env_value "$existing" "https://existing.example.com:8443/api"
|
||||
|
||||
absolute="$TMP_DIR/absolute.env"
|
||||
write_env "$absolute" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=http://10.0.0.8:4100"
|
||||
init_public_api_base_url "$absolute" upgrade >/dev/null
|
||||
assert_env_value "$absolute" "http://10.0.0.8:4100"
|
||||
|
||||
websocket="$TMP_DIR/websocket.env"
|
||||
write_env "$websocket" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api" \
|
||||
"NUXT_PUBLIC_BASE_SOCKETURL=wss://demo.example.com/socket.io"
|
||||
init_public_api_base_url "$websocket" upgrade >/dev/null
|
||||
assert_env_value "$websocket" "https://demo.example.com/api"
|
||||
|
||||
security_origin="$TMP_DIR/security-origin.env"
|
||||
write_env "$security_origin" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api" \
|
||||
"NUXT_PUBLIC_BASE_SOCKETURL=" \
|
||||
"CONFIG_SECURITY_ORIGIN=http://demo.example.com,https://demo.example.com"
|
||||
init_public_api_base_url "$security_origin" upgrade >/dev/null
|
||||
assert_env_value "$security_origin" "http://demo.example.com/api"
|
||||
|
||||
file_override="$TMP_DIR/file-override.env"
|
||||
write_env "$file_override" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=" \
|
||||
"DEPLOY_PUBLIC_API_BASE_URL=https://proxy.example.com:9443/custom-api" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
init_public_api_base_url "$file_override" upgrade >/dev/null
|
||||
assert_env_value "$file_override" "https://proxy.example.com:9443/custom-api"
|
||||
|
||||
explicit_override="$TMP_DIR/explicit-override.env"
|
||||
write_env "$explicit_override" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
init_public_api_base_url "$explicit_override" configure "http://192.168.1.20:4567" >/dev/null
|
||||
assert_env_value "$explicit_override" "http://192.168.1.20:4567"
|
||||
|
||||
invalid="$TMP_DIR/invalid.env"
|
||||
write_env "$invalid" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=ftp://bad.example.com/api" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=https://good.example.com/api"
|
||||
if init_public_api_base_url "$invalid" upgrade >/dev/null 2>&1; then
|
||||
fail "invalid existing CONFIG_PUBLIC_API_BASE_URL must block startup"
|
||||
fi
|
||||
|
||||
unresolved="$TMP_DIR/unresolved.env"
|
||||
write_env "$unresolved" \
|
||||
"CONFIG_PUBLIC_API_BASE_URL=" \
|
||||
"NUXT_PUBLIC_BASE_APIURL=/api"
|
||||
if init_public_api_base_url "$unresolved" upgrade >/dev/null 2>&1; then
|
||||
fail "unresolved public API URL must block startup"
|
||||
fi
|
||||
|
||||
echo "Public API base URL shell tests passed"
|
||||
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'
|
||||
@@ -22,6 +22,8 @@ $script:LogFile = Join-Path $script:Root "start.ps1.log"
|
||||
$script:DeployDryRun = ($env:DEPLOY_DRY_RUN -eq "1")
|
||||
$script:DeployIP = ""
|
||||
$script:SkipDeployQuestions = $false
|
||||
$script:SecurityEnvMode = "upgrade"
|
||||
$script:PublicApiBaseUrlCandidate = ""
|
||||
$script:DockerDesktopUrl = "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe"
|
||||
|
||||
function Write-Log {
|
||||
@@ -98,6 +100,27 @@ function Run-DeployQuestions {
|
||||
return
|
||||
}
|
||||
|
||||
if ($env:DEPLOY_ACCESS) {
|
||||
$mode = $env:DEPLOY_ACCESS.Trim().ToLowerInvariant()
|
||||
switch ($mode) {
|
||||
{ $_ -in @("local", "localhost", "127.0.0.1") } {
|
||||
$script:DeployIP = "127.0.0.1"
|
||||
Step "Using DEPLOY_ACCESS=$($env:DEPLOY_ACCESS), IP=$($script:DeployIP)"
|
||||
return
|
||||
}
|
||||
{ $_ -in @("lan", "ip") } {
|
||||
Fail "DEPLOY_ACCESS=$($env:DEPLOY_ACCESS) requires DEPLOY_IP, for example: `$env:DEPLOY_IP='192.168.1.10'"
|
||||
}
|
||||
default {
|
||||
Fail "DEPLOY_ACCESS only supports local or lan on Windows, current value: $($env:DEPLOY_ACCESS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
|
||||
Fail "Non-interactive deployment requires DEPLOY_IP or DEPLOY_ACCESS=local."
|
||||
}
|
||||
|
||||
Write-Host "Choose mode:"
|
||||
Write-Host " [1] Local only (127.0.0.1)"
|
||||
Write-Host " [2] LAN access"
|
||||
@@ -138,14 +161,28 @@ function Setup-EnvFiles {
|
||||
Ensure-FileFromSample ".env.tools" ".env.tools.sample"
|
||||
Ensure-FileFromSample ".env.ASG" ".env.ASG.sample"
|
||||
Ensure-FileFromSample ".env.AMS" ".env.AMS.sample"
|
||||
if (-not (Test-Path ".env")) { $script:SecurityEnvMode = "new" }
|
||||
Ensure-FileFromSample ".env" ".env.sample"
|
||||
|
||||
$content = Get-Content ".env" -Raw -Encoding UTF8
|
||||
if (-not $content) { $content = "" }
|
||||
|
||||
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" "http://$($script:DeployIP):3001"
|
||||
$serverPortMatch = [regex]::Match($content, '(?m)^SERVER_HTTP_PORT=(\d+)$')
|
||||
$serverPort = if ($serverPortMatch.Success) { $serverPortMatch.Groups[1].Value } else { "3001" }
|
||||
$publicApiBaseUrl = "http://$($script:DeployIP):$serverPort"
|
||||
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_APIURL" $publicApiBaseUrl
|
||||
$content = Upsert-Env $content "NUXT_PUBLIC_BASE_SOCKETURL" "ws://$($script:DeployIP):3002"
|
||||
$content = Upsert-Env $content "NUXT_PUBLIC_SG_APIURL" "http://$($script:DeployIP):3003"
|
||||
$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"
|
||||
$script:PublicApiBaseUrlCandidate = if ($env:DEPLOY_PUBLIC_API_BASE_URL) {
|
||||
$env:DEPLOY_PUBLIC_API_BASE_URL
|
||||
} else {
|
||||
$publicApiBaseUrl
|
||||
}
|
||||
|
||||
[System.IO.File]::WriteAllText((Join-Path $script:Root ".env"), $content, [System.Text.UTF8Encoding]::new($false))
|
||||
Ok ".env configured for IP=$($script:DeployIP)"
|
||||
@@ -277,6 +314,19 @@ function Test-Docker {
|
||||
}
|
||||
|
||||
Warn "Docker Desktop not detected."
|
||||
$installMode = if ($env:DEPLOY_DOCKER_INSTALL) { $env:DEPLOY_DOCKER_INSTALL.Trim().ToLowerInvariant() } else { "" }
|
||||
if (-not [string]::IsNullOrWhiteSpace($installMode)) {
|
||||
switch ($installMode) {
|
||||
{ $_ -in @("manual", "1") } { Start-Process $script:DockerDesktopUrl; Wait-ForExit; exit 1 }
|
||||
{ $_ -in @("auto", "2") } { Install-DockerDesktop }
|
||||
default { Fail "DEPLOY_DOCKER_INSTALL only supports manual or auto, current value: $($env:DEPLOY_DOCKER_INSTALL)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
|
||||
Fail "Docker Desktop not detected. Set DEPLOY_DOCKER_INSTALL=manual or DEPLOY_DOCKER_INSTALL=auto, or install Docker Desktop first."
|
||||
}
|
||||
|
||||
Write-Host "Choose:"
|
||||
Write-Host " [1] Manual install (open URL and exit)"
|
||||
Write-Host " [2] Auto install (winget/choco)"
|
||||
@@ -308,17 +358,50 @@ 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
|
||||
|
||||
if ((Test-Path ".env") -and -not $env:DEPLOY_FORCE_RECONFIG -and -not $env:DEPLOY_IP) {
|
||||
$answer = (Read-Host "Existing .env found. Reconfigure? [y/N]").Trim().ToLower()
|
||||
$yes = ($answer.Length -gt 0) -and ($answer[0] -eq [char]121)
|
||||
if (-not $yes) {
|
||||
if ((Test-Path ".env") -and -not $env:DEPLOY_FORCE_RECONFIG -and -not $env:DEPLOY_IP -and -not $env:DEPLOY_ACCESS) {
|
||||
if ($env:DEPLOY_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
|
||||
$line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1
|
||||
if ($line -and $line -like "*:3001*") { $script:DeployIP = ($line -replace ".*http://", "" -replace ":3001.*", "").Trim() }
|
||||
if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] }
|
||||
$script:SkipDeployQuestions = $true
|
||||
Step "Using existing env config"
|
||||
} else {
|
||||
$answer = (Read-Host "Existing .env found. Reconfigure? [y/N]").Trim().ToLower()
|
||||
$yes = ($answer.Length -gt 0) -and ($answer[0] -eq [char]121)
|
||||
if (-not $yes) {
|
||||
$line = Get-Content ".env" -Encoding UTF8 | Where-Object { $_ -like "NUXT_PUBLIC_BASE_APIURL=*" } | Select-Object -First 1
|
||||
if ($line -match '^NUXT_PUBLIC_BASE_APIURL=http://([^/:]+)(?::\d+)?(?:/.*)?$') { $script:DeployIP = $Matches[1] }
|
||||
$script:SkipDeployQuestions = $true
|
||||
Step "Using existing env config"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +414,28 @@ 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
|
||||
|
||||
. (Join-Path $script:Root "scripts\Initialize-PublicApiBaseUrl.ps1")
|
||||
$publicUrlMode = if ($script:SkipDeployQuestions) { "upgrade" } else { "configure" }
|
||||
Initialize-PublicApiBaseUrl `
|
||||
-Path (Join-Path $script:Root ".env") `
|
||||
-Mode $publicUrlMode `
|
||||
-Override $script:PublicApiBaseUrlCandidate
|
||||
|
||||
if ($script:DeployDryRun) {
|
||||
Warn "dry-run mode: skip docker and services"
|
||||
} else {
|
||||
@@ -346,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 / 123456(启动服务后用于登录)" -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 "123456" -ForegroundColor White
|
||||
}
|
||||
Show-AdminLoginInfo
|
||||
Write-Host ""
|
||||
Wait-ForExit
|
||||
}
|
||||
|
||||
@@ -23,10 +23,58 @@ init_project_dir() {
|
||||
}
|
||||
|
||||
# ==================== 配置变量(支持环境变量非交互模式) ====================
|
||||
DEPLOY_MODE="" # ip | domain
|
||||
DEPLOY_IP=""
|
||||
DEPLOY_DOMAIN=""
|
||||
DEPLOY_ACCESS="${DEPLOY_ACCESS:-}" # ip | domain
|
||||
DEPLOY_MODE="" # ip | domain | skip
|
||||
DEPLOY_IP="${DEPLOY_IP:-}"
|
||||
DEPLOY_DOMAIN="${DEPLOY_DOMAIN:-}"
|
||||
DEPLOY_HTTPS_ENV="${DEPLOY_HTTPS_INPUT:-${DEPLOY_HTTPS:-}}"
|
||||
DEPLOY_HTTPS=false
|
||||
SECURITY_ENV_MODE="upgrade"
|
||||
PUBLIC_API_BASE_URL_CANDIDATE=""
|
||||
|
||||
is_non_interactive() {
|
||||
[ "${DEPLOY_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]
|
||||
}
|
||||
|
||||
require_interactive() {
|
||||
local missing_hint=$1
|
||||
if is_non_interactive; then
|
||||
echo "❌ 当前为非交互环境,无法继续问答配置"
|
||||
echo " 请通过环境变量传入: ${missing_hint}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_bool() {
|
||||
local value
|
||||
value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$value" in
|
||||
1|true|yes|y|on) echo "true" ;;
|
||||
0|false|no|n|off|"") echo "false" ;;
|
||||
*)
|
||||
echo "❌ 布尔变量值不合法: $1(支持 true/false/1/0/yes/no/y/n/on/off)" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
set_deploy_https_from_env_or_default() {
|
||||
local normalized
|
||||
if ! normalized="$(normalize_bool "${DEPLOY_HTTPS_ENV:-false}")"; then
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_HTTPS="$normalized"
|
||||
}
|
||||
|
||||
infer_deploy_access_from_env() {
|
||||
if [ -z "$DEPLOY_ACCESS" ]; then
|
||||
if [ -n "$DEPLOY_DOMAIN" ]; then
|
||||
DEPLOY_ACCESS="domain"
|
||||
elif [ -n "$DEPLOY_IP" ]; then
|
||||
DEPLOY_ACCESS="ip"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
sanitize_domain() {
|
||||
local domain="$1"
|
||||
@@ -66,6 +114,8 @@ prompt_or_env() {
|
||||
return
|
||||
fi
|
||||
|
||||
require_interactive "$env_name"
|
||||
|
||||
if [ -n "$default" ]; then
|
||||
read -r -p "${prompt_text} [$default]: " input
|
||||
eval "$var_name=\"${input:-$default}\""
|
||||
@@ -82,6 +132,8 @@ run_deploy_questions() {
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
infer_deploy_access_from_env
|
||||
|
||||
# 非交互模式:环境变量已完整设置则直接使用(CI/自动化部署)
|
||||
if [ -n "$DEPLOY_ACCESS" ]; then
|
||||
if [ "$DEPLOY_ACCESS" = "ip" ] && [ -n "$DEPLOY_IP" ]; then
|
||||
@@ -96,14 +148,19 @@ run_deploy_questions() {
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_MODE="domain"
|
||||
DEPLOY_HTTPS="${DEPLOY_HTTPS_INPUT:-false}"
|
||||
set_deploy_https_from_env_or_default
|
||||
echo "使用环境变量: 域名模式, 域名=$DEPLOY_DOMAIN"
|
||||
return
|
||||
fi
|
||||
if [ "$DEPLOY_ACCESS" != "ip" ] && [ "$DEPLOY_ACCESS" != "domain" ]; then
|
||||
echo "❌ DEPLOY_ACCESS 只能是 ip 或 domain,当前为: $DEPLOY_ACCESS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 1. IP 或域名访问
|
||||
if [ -z "$DEPLOY_ACCESS" ]; then
|
||||
require_interactive "DEPLOY_ACCESS=ip DEPLOY_IP=<服务器IP> 或 DEPLOY_ACCESS=domain DEPLOY_DOMAIN=<域名>"
|
||||
echo "1. 通过 IP 地址还是域名访问?"
|
||||
echo " [1] IP 地址(需开放 3001、3002、3003 端口)"
|
||||
echo " [2] 域名"
|
||||
@@ -140,9 +197,10 @@ run_deploy_questions() {
|
||||
fi
|
||||
|
||||
# 3.1 是否启用 HTTPS
|
||||
if [ -n "$DEPLOY_HTTPS_INPUT" ]; then
|
||||
DEPLOY_HTTPS=$DEPLOY_HTTPS_INPUT
|
||||
if [ -n "$DEPLOY_HTTPS_ENV" ]; then
|
||||
set_deploy_https_from_env_or_default
|
||||
else
|
||||
require_interactive "DEPLOY_HTTPS=true 或 DEPLOY_HTTPS=false"
|
||||
read -r -p "3.1 是否签名 HTTPS 证书?原来已经有 HTTPS 证书这里选择 N,第一次部署或者原来没有证书选 Y [y/N]: " https_choice
|
||||
DEPLOY_HTTPS=false
|
||||
if [[ "$https_choice" =~ ^[yY] ]]; then
|
||||
@@ -177,19 +235,37 @@ setup_env_files() {
|
||||
# 4/5. 配置 .env
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.sample .env
|
||||
SECURITY_ENV_MODE="new"
|
||||
fi
|
||||
|
||||
local web_port server_port public_scheme socket_scheme
|
||||
web_port="$(awk -F= '$1 == "WEB_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
|
||||
web_port="${web_port:-3010}"
|
||||
server_port="$(awk -F= '$1 == "SERVER_HTTP_PORT" { print $2; exit }' .env | tr -d '[:space:]')"
|
||||
server_port="${server_port:-3001}"
|
||||
|
||||
if [ "$DEPLOY_MODE" = "ip" ]; then
|
||||
# IP 模式
|
||||
sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:3001|" .env
|
||||
sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=http://${DEPLOY_IP}:${server_port}|" .env
|
||||
sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=ws://${DEPLOY_IP}:3002|" .env
|
||||
sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=http://${DEPLOY_IP}:3003|" .env
|
||||
sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=http://${DEPLOY_IP}:${web_port}|" .env
|
||||
PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-http://${DEPLOY_IP}:${server_port}}"
|
||||
echo " ✓ .env 已配置为 IP 模式 (${DEPLOY_IP})"
|
||||
else
|
||||
# 域名模式
|
||||
public_scheme="http"
|
||||
socket_scheme="ws"
|
||||
if [ "$DEPLOY_HTTPS" = true ]; then
|
||||
public_scheme="https"
|
||||
socket_scheme="wss"
|
||||
fi
|
||||
sed -i.bak "s|^NUXT_PUBLIC_BASE_APIURL=.*|NUXT_PUBLIC_BASE_APIURL=/api|" .env
|
||||
sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=wss://${DEPLOY_DOMAIN}/socket.io|" .env
|
||||
sed -i.bak "s|^NUXT_PUBLIC_BASE_SOCKETURL=.*|NUXT_PUBLIC_BASE_SOCKETURL=${socket_scheme}://${DEPLOY_DOMAIN}/socket.io|" .env
|
||||
sed -i.bak "s|^NUXT_PUBLIC_SG_APIURL=.*|NUXT_PUBLIC_SG_APIURL=/asg-api|" .env
|
||||
# 域名模式同时允许同域 HTTP/HTTPS,兼容首次签证书和复用既有证书两种流程。
|
||||
sed -i.bak "s|^CONFIG_SECURITY_ORIGIN=.*|CONFIG_SECURITY_ORIGIN=https://${DEPLOY_DOMAIN},http://${DEPLOY_DOMAIN}|" .env
|
||||
PUBLIC_API_BASE_URL_CANDIDATE="${DEPLOY_PUBLIC_API_BASE_URL:-${public_scheme}://${DEPLOY_DOMAIN}/api}"
|
||||
echo " ✓ .env 已配置为域名模式 (${DEPLOY_DOMAIN})"
|
||||
|
||||
# 7. Nginx 配置(域名模式)
|
||||
@@ -308,17 +384,22 @@ install_docker() {
|
||||
sudo yum install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
elif [[ "$OS_FAMILY" == "macOS" ]]; then
|
||||
echo "⚠️ 检测到 macOS,未找到 Docker。"
|
||||
echo "请选择安装方式:"
|
||||
echo " [1] 自动安装 Docker Desktop(需要 Homebrew)"
|
||||
echo " [2] 手动安装(默认)"
|
||||
read -r -p "请选择 [1/2]: " mac_install_choice
|
||||
mac_install_choice="${DEPLOY_MAC_DOCKER_INSTALL:-${DEPLOY_DOCKER_INSTALL:-}}"
|
||||
if [ -z "$mac_install_choice" ]; then
|
||||
require_interactive "DEPLOY_MAC_DOCKER_INSTALL=auto 或 DEPLOY_MAC_DOCKER_INSTALL=manual"
|
||||
echo "请选择安装方式:"
|
||||
echo " [1] 自动安装 Docker Desktop(需要 Homebrew)"
|
||||
echo " [2] 手动安装(默认)"
|
||||
read -r -p "请选择 [1/2]: " mac_install_choice
|
||||
fi
|
||||
mac_install_choice="$(printf '%s' "$mac_install_choice" | tr '[:upper:]' '[:lower:]')"
|
||||
case "${mac_install_choice:-2}" in
|
||||
1)
|
||||
1|auto)
|
||||
if ! install_docker_macos; then
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
2|manual)
|
||||
echo " 请手动安装 Docker Desktop: https://www.docker.com/products/docker-desktop/"
|
||||
echo " 安装并启动 Docker Desktop 后,重新运行 ./start.sh"
|
||||
exit 1
|
||||
@@ -376,17 +457,49 @@ 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
|
||||
infer_deploy_access_from_env
|
||||
|
||||
# 检查是否已有 .env 且非强制重新配置
|
||||
if [ -f .env ] && [ -z "$DEPLOY_FORCE_RECONFIG" ] && [ -z "$DEPLOY_ACCESS" ]; then
|
||||
echo "📁 检测到已有 .env 配置"
|
||||
read -r -p "是否重新配置部署方式?[y/N]: " reconfigure
|
||||
if [[ ! "$reconfigure" =~ ^[yY] ]]; then
|
||||
if is_non_interactive; then
|
||||
echo "⏭️ 使用现有配置继续..."
|
||||
DEPLOY_MODE="skip"
|
||||
else
|
||||
read -r -p "是否重新配置部署方式?[y/N]: " reconfigure
|
||||
if [[ ! "$reconfigure" =~ ^[yY] ]]; then
|
||||
echo "⏭️ 使用现有配置继续..."
|
||||
DEPLOY_MODE="skip"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -408,6 +521,35 @@ 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
|
||||
. ./scripts/init-security-env.sh
|
||||
init_security_env .env "$SECURITY_ENV_MODE"
|
||||
|
||||
# 在拉取镜像或重建服务前完成公开地址迁移;非法或无法推导时立即终止。
|
||||
# shellcheck source=scripts/init-public-api-base-url.sh
|
||||
. ./scripts/init-public-api-base-url.sh
|
||||
if [ "$DEPLOY_MODE" = "skip" ]; then
|
||||
init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}"
|
||||
else
|
||||
init_public_api_base_url .env configure "$PUBLIC_API_BASE_URL_CANDIDATE"
|
||||
fi
|
||||
|
||||
if [ "$DEPLOY_DRY_RUN" = "1" ]; then
|
||||
echo ""
|
||||
echo "⚠️ dry-run 模式:跳过 Docker 安装和服务启动"
|
||||
@@ -415,6 +557,7 @@ main() {
|
||||
else
|
||||
install_docker
|
||||
start_services
|
||||
export DEPLOY_DOMAIN
|
||||
run_https_setup
|
||||
fi
|
||||
|
||||
@@ -425,11 +568,16 @@ main() {
|
||||
if [ "$DEPLOY_MODE" = "ip" ] && [ -n "$DEPLOY_IP" ]; then
|
||||
echo "访问地址: http://${DEPLOY_IP}:3010"
|
||||
elif [ "$DEPLOY_MODE" = "domain" ] && [ -n "$DEPLOY_DOMAIN" ]; then
|
||||
echo "访问地址: http://${DEPLOY_DOMAIN} (配置 Nginx 后)"
|
||||
if [ "$DEPLOY_HTTPS" = true ]; then
|
||||
echo "访问地址: https://${DEPLOY_DOMAIN}"
|
||||
else
|
||||
echo "访问地址: http://${DEPLOY_DOMAIN} (配置 Nginx 后)"
|
||||
fi
|
||||
if [ "$DEPLOY_HTTPS" = true ]; then
|
||||
echo "HTTPS 已启用"
|
||||
fi
|
||||
fi
|
||||
show_admin_login_info
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
+80
-16
@@ -33,6 +33,7 @@ trap {
|
||||
|
||||
function Wait-ForExit {
|
||||
if ($env:CI -eq "true") { return }
|
||||
if ($env:UPDATE_NO_WAIT -eq "1") { return }
|
||||
Write-Host ""
|
||||
Read-Host "按 Enter 键退出"
|
||||
}
|
||||
@@ -46,7 +47,17 @@ function Write-Warn { param($Msg) Write-Host "⚠️ $Msg" -ForegroundColor Yell
|
||||
if ($args -contains "-h" -or $args -contains "--help") {
|
||||
Write-Host "用法: .\update.ps1"
|
||||
Write-Host ""
|
||||
Write-Host "脚本将提示选择更新方式,默认拉取仓库并更新镜像。"
|
||||
Write-Host "默认拉取仓库并更新镜像;也可通过环境变量跳过交互选择。"
|
||||
Write-Host ""
|
||||
Write-Host "环境变量:"
|
||||
Write-Host " UPDATE_MODE=full|image"
|
||||
Write-Host " full 更新并拉取仓库(git pull)+ 更新镜像并重启"
|
||||
Write-Host " image 仅更新镜像并重启(跳过 git pull)"
|
||||
Write-Host " UPDATE_SKIP_REPO_UPDATE=true|false"
|
||||
Write-Host " true 等同 UPDATE_MODE=image"
|
||||
Write-Host " false 等同 UPDATE_MODE=full"
|
||||
Write-Host " UPDATE_NO_WAIT=1"
|
||||
Write-Host " 结束或失败时不等待按 Enter"
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -58,24 +69,62 @@ if (-not (Test-Path $composePath)) { Write-Err "未找到 docker-compose.yml,
|
||||
Set-Location $scriptDir
|
||||
Write-Step "📁 项目目录: $scriptDir"
|
||||
|
||||
# ==================== 命令行内选择 ====================
|
||||
Write-Host ""
|
||||
Write-Host "请选择更新方式:"
|
||||
Write-Host " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
|
||||
Write-Host " [2] 仅更新镜像并重启(跳过 git pull)"
|
||||
$choice = Read-Host "请选择 [1/2,回车默认 1]"
|
||||
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
|
||||
|
||||
$skipRepoUpdate = $false
|
||||
switch ($choice) {
|
||||
"2" { $skipRepoUpdate = $true }
|
||||
"1" { }
|
||||
default {
|
||||
Write-Warn "无效选择,将使用默认:更新并拉取仓库"
|
||||
$skipRepoUpdate = $false
|
||||
function ConvertTo-BoolEnv {
|
||||
param([string]$Value)
|
||||
$v = if ($null -eq $Value) { "" } else { $Value.Trim().ToLowerInvariant() }
|
||||
switch ($v) {
|
||||
{ $_ -in @("1", "true", "yes", "y", "on") } { return $true }
|
||||
{ $_ -in @("0", "false", "no", "n", "off", "") } { return $false }
|
||||
default { Write-Err "布尔变量值不合法: $Value(支持 true/false/1/0/yes/no/y/n/on/off)" }
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-UpdateMode {
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SKIP_REPO_UPDATE)) {
|
||||
$skip = ConvertTo-BoolEnv $env:UPDATE_SKIP_REPO_UPDATE
|
||||
Write-Step "使用环境变量: UPDATE_SKIP_REPO_UPDATE=$skip"
|
||||
return $skip
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_MODE)) {
|
||||
$mode = $env:UPDATE_MODE.Trim().ToLowerInvariant()
|
||||
switch ($mode) {
|
||||
{ $_ -in @("full", "repo", "pull", "git", "1") } {
|
||||
Write-Step "使用环境变量: UPDATE_MODE=$($env:UPDATE_MODE)"
|
||||
return $false
|
||||
}
|
||||
{ $_ -in @("image", "images", "docker", "skip-git", "skip_repo", "skip-repo", "2") } {
|
||||
Write-Step "使用环境变量: UPDATE_MODE=$($env:UPDATE_MODE)"
|
||||
return $true
|
||||
}
|
||||
default { Write-Err "UPDATE_MODE 只能是 full 或 image,当前为: $($env:UPDATE_MODE)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:UPDATE_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
|
||||
Write-Step "使用默认更新方式: 更新并拉取仓库(git pull)+ 更新镜像并重启"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "请选择更新方式:"
|
||||
Write-Host " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
|
||||
Write-Host " [2] 仅更新镜像并重启(跳过 git pull)"
|
||||
$choice = Read-Host "请选择 [1/2,回车默认 1]"
|
||||
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
|
||||
|
||||
switch ($choice) {
|
||||
"2" { return $true }
|
||||
"1" { return $false }
|
||||
default {
|
||||
Write-Warn "无效选择,将使用默认:更新并拉取仓库"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$skipRepoUpdate = Resolve-UpdateMode
|
||||
|
||||
# ==================== 拉取仓库 ====================
|
||||
if (-not $skipRepoUpdate) {
|
||||
Write-Host ""
|
||||
@@ -123,6 +172,21 @@ if (-not $skipRepoUpdate) {
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# 空值、历史默认 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"
|
||||
|
||||
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
|
||||
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
|
||||
Initialize-PublicApiBaseUrl `
|
||||
-Path (Join-Path $scriptDir ".env") `
|
||||
-Mode "upgrade" `
|
||||
-Override $env:DEPLOY_PUBLIC_API_BASE_URL
|
||||
|
||||
# ==================== Docker 检查 ====================
|
||||
function Test-DockerInstalled {
|
||||
$docker = Get-Command docker -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -6,7 +6,15 @@ set -e # 发生错误时终止脚本执行
|
||||
if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
|
||||
echo "用法: $0"
|
||||
echo ""
|
||||
echo "脚本将提示选择更新方式,默认拉取仓库并更新镜像。"
|
||||
echo "默认拉取仓库并更新镜像;也可通过环境变量跳过交互选择。"
|
||||
echo ""
|
||||
echo "环境变量:"
|
||||
echo " UPDATE_MODE=full|image"
|
||||
echo " full 更新并拉取仓库(git pull)+ 更新镜像并重启"
|
||||
echo " image 仅更新镜像并重启(跳过 git pull)"
|
||||
echo " UPDATE_SKIP_REPO_UPDATE=true|false"
|
||||
echo " true 等同 UPDATE_MODE=image"
|
||||
echo " false 等同 UPDATE_MODE=full"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -15,18 +23,68 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 命令行内选择更新方式(默认:更新并拉取仓库)
|
||||
echo ""
|
||||
echo "请选择更新方式:"
|
||||
echo " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
|
||||
echo " [2] 仅更新镜像并重启(跳过 git pull)"
|
||||
read -r -p "请选择 [1/2,回车默认 1]: " choice
|
||||
choice="${choice:-1}"
|
||||
SKIP_REPO_UPDATE=false
|
||||
case "$choice" in
|
||||
2) SKIP_REPO_UPDATE=true ;;
|
||||
1) ;;
|
||||
*) echo "❌ 无效选择,将使用默认:更新并拉取仓库"; SKIP_REPO_UPDATE=false ;;
|
||||
esac
|
||||
|
||||
normalize_bool() {
|
||||
local value
|
||||
value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$value" in
|
||||
1|true|yes|y|on) echo "true" ;;
|
||||
0|false|no|n|off|"") echo "false" ;;
|
||||
*)
|
||||
echo "❌ 布尔变量值不合法: $1(支持 true/false/1/0/yes/no/y/n/on/off)" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_update_mode() {
|
||||
local mode
|
||||
if [ -n "${UPDATE_SKIP_REPO_UPDATE:-}" ]; then
|
||||
mode="$(normalize_bool "$UPDATE_SKIP_REPO_UPDATE")" || exit 1
|
||||
SKIP_REPO_UPDATE="$mode"
|
||||
echo "使用环境变量: UPDATE_SKIP_REPO_UPDATE=$SKIP_REPO_UPDATE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "${UPDATE_MODE:-}" ]; then
|
||||
mode="$(printf '%s' "$UPDATE_MODE" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$mode" in
|
||||
full|repo|pull|git|1)
|
||||
SKIP_REPO_UPDATE=false
|
||||
;;
|
||||
image|images|docker|skip-git|skip_repo|skip-repo|2)
|
||||
SKIP_REPO_UPDATE=true
|
||||
;;
|
||||
*)
|
||||
echo "❌ UPDATE_MODE 只能是 full 或 image,当前为: $UPDATE_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "使用环境变量: UPDATE_MODE=$UPDATE_MODE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${UPDATE_NON_INTERACTIVE:-0}" = "1" ] || [ "${CI:-}" = "true" ] || [ ! -t 0 ]; then
|
||||
echo "使用默认更新方式: 更新并拉取仓库(git pull)+ 更新镜像并重启"
|
||||
SKIP_REPO_UPDATE=false
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "请选择更新方式:"
|
||||
echo " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
|
||||
echo " [2] 仅更新镜像并重启(跳过 git pull)"
|
||||
read -r -p "请选择 [1/2,回车默认 1]: " choice
|
||||
choice="${choice:-1}"
|
||||
case "$choice" in
|
||||
2) SKIP_REPO_UPDATE=true ;;
|
||||
1) ;;
|
||||
*) echo "❌ 无效选择,将使用默认:更新并拉取仓库"; SKIP_REPO_UPDATE=false ;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_update_mode
|
||||
|
||||
# 拉取整个仓库更新
|
||||
if [ "$SKIP_REPO_UPDATE" = false ]; then
|
||||
@@ -64,6 +122,27 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# 升级已有部署时,将空值、历史默认 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
|
||||
|
||||
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
|
||||
# shellcheck source=scripts/init-public-api-base-url.sh
|
||||
. ./scripts/init-public-api-base-url.sh
|
||||
init_public_api_base_url .env upgrade "${DEPLOY_PUBLIC_API_BASE_URL:-}"
|
||||
|
||||
echo "==========================="
|
||||
echo "🚀 开始自动安装 Docker 和 Docker Compose"
|
||||
echo "==========================="
|
||||
|
||||
Reference in New Issue
Block a user