From 9ca4940935776e49ee6e1539830c8217e703a5c2 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 30 May 2026 18:25:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7=E9=A6=96?= =?UTF-8?q?=E8=A3=85=E5=8D=87=E7=BA=A7=E5=85=B3=E9=94=AE=E5=9D=91=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 +- README.md | 9 +- deploy-registry.ps1 | 9 +- docker-compose.prod.yml | 22 +- docs/README.md | 2 +- docs/deployment/部署说明.md | 55 ++++- docs/其他项目接入许可证系统说明.md | 28 ++- docs/初次部署与出厂设置指南.md | 6 +- docs/参考源码功能吸收整理_20260513.md | 2 +- docs/商业化地基8项收口记录_20260530.md | 2 +- docs/客户首装升级坑位排查_20260530.md | 116 +++++----- docs/许可证链路安全评估_20260312.md | 4 + flnxi-agent-go/internal/api/handlers.go | 149 +++++++++++-- flnxi-agent-go/internal/api/handlers_test.go | 199 ++++++++++++++++++ flnxi-agent-go/internal/docker/client.go | 25 ++- flnxi-agent-go/internal/docker/client_test.go | 68 ++++++ license-server/.env.example | 9 +- .../lib/__tests__/env-example.test.ts | 16 ++ license-server/scripts/generate-keys.ts | 16 +- .../__tests__/deploy-registry-script.test.ts | 2 + scripts/__tests__/docker-entrypoint.test.ts | 65 ++++++ scripts/docker-entrypoint.sh | 38 ++++ 22 files changed, 731 insertions(+), 128 deletions(-) create mode 100644 flnxi-agent-go/internal/api/handlers_test.go create mode 100644 flnxi-agent-go/internal/docker/client_test.go create mode 100644 license-server/lib/__tests__/env-example.test.ts diff --git a/.env.example b/.env.example index e984815..1caa99b 100644 --- a/.env.example +++ b/.env.example @@ -38,19 +38,24 @@ MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 # Database settings -DATABASE_URL=postgresql://username:password@host:5432/database +# Leave DATABASE_URL empty when using the PostgreSQL service from docker-compose.prod.yml. +# Fill it only when using an external PostgreSQL database, for example: +# DATABASE_URL=postgresql://flnxi:@192.168.1.10:5432/flnxi?sslmode=disable +DATABASE_URL= POSTGRES_USER=postgres POSTGRES_PASSWORD=replace-with-strong-postgres-password POSTGRES_DB=flnxi # Object storage (S3 compatible) STORAGE_PROVIDER=s3 -S3_ENDPOINT=https://s3.example.com -S3_BUCKET=your-bucket -S3_ACCESS_KEY_ID=your-access-key -S3_SECRET_ACCESS_KEY=your-secret-key +# Default values below match the built-in MinIO service in docker-compose.prod.yml. +# For external S3/MinIO, replace S3_ENDPOINT, S3_PUBLIC_DOMAIN, access key, and secret key. +S3_ENDPOINT=http://minio:9000 +S3_BUCKET=flnxi-materials +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= S3_REGION=us-east-1 -S3_PUBLIC_DOMAIN=https://s3.example.com/your-bucket +S3_PUBLIC_DOMAIN=http://192.168.x.x:9000/flnxi-materials S3_SET_ACL=0 S3_FILE_PATH=materials/ S3_ENV_PREFIX= diff --git a/README.md b/README.md index c10553e..3bf5b4c 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ cp .env.example .env 至少需要确认。下面只表示变量形状,不要把示例占位值用于客户环境: ```env -DATABASE_URL=postgresql://:@:5432/flnxi NEXTAUTH_SECRET= +APP_PUBLIC_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000 S3_ENDPOINT=http://localhost:9000 S3_BUCKET=flnxi-materials @@ -120,9 +120,12 @@ S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= LICENSE_SERVER_URL=https://your-license-server.example.com LICENSE_PROJECT_CODE=flnxi-sucai +LICENSE_REQUEST_SIGNING_SECRET= +LICENSE_PUBLIC_KEY= +LOCAL_ENCRYPTION_KEY= ``` -`LICENSE_PROJECT_CODE` 必须和 `license-server` 中的项目编码一致。 +内置 PostgreSQL 部署时 `DATABASE_URL` 留空,由 Compose 自动指向内置数据库;只有外部 PostgreSQL 才填写真实连接串。`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 中的项目配置一致。 ### 初始化 Prisma @@ -204,7 +207,7 @@ docker compose -f docker-compose.prod.yml up -d - `发布管理.ps1` - `license-server/deploy.ps1` -这些脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。 +这些脚本执行前必须先读内容和参数,不应把内部固定服务器或固定镜像仓库当成客户默认交付配置。客户交付时要显式写入版本化 `APP_IMAGE` / `IMAGE_TAG`,`latest` 只保留给内部测试或兼容场景。当前新 Registry 发布脚本是主线,旧远程部署脚本只作历史或内部维护参考。AI、钉钉、内网穿透等可选能力依赖真实外部服务和密钥,不能只凭页面入口当成客户验收已完成。 ## 10. 文档入口 diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 index 59899ca..feaf1b6 100644 --- a/deploy-registry.ps1 +++ b/deploy-registry.ps1 @@ -597,6 +597,7 @@ rollback() { fi echo "[ROLLBACK] 部署失败,开始恢复旧版本" + echo "[ROLLBACK] 自动回滚只恢复应用镜像和 .env,不自动恢复数据库;如本次升级包含数据库迁移,请使用升级前数据库备份人工确认恢复" if [ -f "$env_backup" ]; then cp "$env_backup" .env echo "[ROLLBACK] 已恢复升级前 .env" @@ -685,6 +686,10 @@ verify_running_image() { backup_postgres '@) + if (-not $NoRollback) { + $lines.Add("trap rollback ERR") + } + if ($UpdateRemoteEnv) { $lines.Add("upsert_env APP_IMAGE $quotedRegistryImage") $lines.Add("upsert_env NEXTAUTH_URL $quotedNextAuthUrl") @@ -695,10 +700,6 @@ backup_postgres $lines.Add("upsert_env MINIO_MC_IMAGE $quotedMinioMcImage") } - if (-not $NoRollback) { - $lines.Add("trap rollback ERR") - } - if (-not $SkipRegistryLogin) { $lines.Add('printf ''%s'' "$REGISTRY_PASSWORD" | docker login ' + "$quotedRegistryHost -u $quotedRegistryUser --password-stdin") } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d4a44b4..3b7c6d7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -106,33 +106,35 @@ services: - .env environment: # 注意: - # - SERVER_IP 必须在安装脚本或 .env 中配置为「本机在局域网中的访问 IP」,例如:192.168.41.144 - # - 对于一键安装用户,install.sh 会自动生成 .env.production 并写入 SERVER_IP,这里直接引用即可 + # - SERVER_IP 必须在 .env 中配置为「本机在局域网中的访问 IP」,例如:192.168.41.144 - NODE_ENV=production + - APP_PUBLIC_URL=${APP_PUBLIC_URL:?APP_PUBLIC_URL is required} # DATABASE_URL 从 .env 文件自动读取;未配置时按同一份 .env 中的数据库变量拼接 - DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-flnxi}} - DATABASE_DRIVER=node - STORAGE_PROVIDER=s3 - - S3_ENDPOINT=http://minio:9000 + - S3_ENDPOINT=${S3_ENDPOINT:-http://minio:9000} - S3_BUCKET=${S3_BUCKET:-flnxi-materials} - - S3_ACCESS_KEY_ID=${MINIO_ROOT_USER} - - S3_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} - - S3_REGION=us-east-1 + - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-${MINIO_ROOT_USER}} + - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-${MINIO_ROOT_PASSWORD}} + - S3_REGION=${S3_REGION:-us-east-1} # 对外访问 MinIO 的地址(供前端和分享链接使用),必须是局域网 / 公网可访问的 IP 或域名 # 这里不再默认 127.0.0.1,避免在 NAS 等环境下写死 localhost - - S3_PUBLIC_DOMAIN=http://${SERVER_IP}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-flnxi-materials} - - S3_ENABLE_PATH_STYLE=1 + - S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN:-http://${SERVER_IP}:${MINIO_API_PORT:-9000}/${S3_BUCKET:-flnxi-materials}} + - S3_ENABLE_PATH_STYLE=${S3_ENABLE_PATH_STYLE:-1} - S3_SET_ACL=${S3_SET_ACL:-0} - S3_PREVIEW_URL_EXPIRE_IN=7200 - S3_FILE_PATH=materials/ - NEXT_PUBLIC_S3_FILE_PATH=materials/ - # NextAuth 的外部访问地址,从 .env(.production) 中注入(不在这里拼接,以免 SERVER_IP 为空时产生 http://:4320) - # install.sh / 部署脚本会写入 NEXTAUTH_URL,例如:http://192.168.41.144:4320 + # NextAuth 的外部访问地址,从 .env 中注入(不在这里拼接,以免 SERVER_IP 为空时产生 http://:4320) + # 客户首装或部署脚本应写入 NEXTAUTH_URL,例如:http://192.168.41.144:4320 - NEXTAUTH_URL=${NEXTAUTH_URL} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required} - LICENSE_SERVER_URL=${LICENSE_SERVER_URL:?LICENSE_SERVER_URL is required} - LICENSE_PROJECT_CODE=${LICENSE_PROJECT_CODE:?LICENSE_PROJECT_CODE is required} - LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required} + - LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:?LICENSE_PUBLIC_KEY is required} + - LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:?LOCAL_ENCRYPTION_KEY is required} # 初始管理员账号(供 /api/system/init 使用) - INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi} - INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-} diff --git a/docs/README.md b/docs/README.md index bcece5b..16b1d1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ - `商业化地基8项收口记录_20260530.md` 本轮按商业化私有交付目标收口的 8 项事实记录,作为后续继续推进的短入口。 - `许可证链路安全评估_20260312.md` - 历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口。 + 历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。 ## 6. 使用原则 diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index ab4af15..b864d67 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -50,9 +50,11 @@ docker-compose.prod.yml - CPU:4 核及以上 - 内存:8GB 及以上 - 磁盘:100GB 可用空间 -- 系统:Ubuntu 22.04 LTS、群晖 DSM、飞牛 fnOS 或其他支持 Docker / Podman 的 Linux 环境 +- 系统:Ubuntu 22.04 LTS、群晖 DSM、飞牛 fnOS 或其他支持 Docker 的 Linux 环境 - Docker:Docker 24+ 和 Docker Compose V2 +当前主线安装命令按 Docker Compose 验证。Podman / 图形化容器套件属于目标兼容方向,但不同 NAS 的网络、卷权限和健康检查差异很大,客户交付前必须单独实机验证,不能直接把本文的 Docker 命令等同为 Podman 已完整支持。 + ### 2.2 必填环境变量 部署前至少准备: @@ -63,12 +65,15 @@ MINIO_ROOT_USER= MINIO_ROOT_PASSWORD= APP_IMAGE=registry.example.com/flnxi/material/flnxi-material:1.0.1 SERVER_IP=192.168.x.x +APP_PUBLIC_URL=http://192.168.x.x:4320 NEXTAUTH_URL=http://192.168.x.x:4320 NEXTAUTH_SECRET= S3_BUCKET=flnxi-materials LICENSE_SERVER_URL=https://your-license-server.example.com LICENSE_PROJECT_CODE=flnxi-sucai LICENSE_REQUEST_SIGNING_SECRET= +LICENSE_PUBLIC_KEY= +LOCAL_ENCRYPTION_KEY= MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 @@ -77,14 +82,36 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 说明: - `SERVER_IP` 必须是用户浏览器能访问到的 NAS / 服务器地址。 +- `APP_PUBLIC_URL` 必须和最终访问入口一致,用于生成公开访问、分享和回调相关地址。 - `NEXTAUTH_URL` 必须和最终访问入口一致。 - `APP_IMAGE` 必须写完整镜像地址和版本号,不要只写 `latest`。 - `POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 建议同样固定版本;离线或受控网络交付时,应写成私有 Registry 里的固定版本镜像。 - `LICENSE_PROJECT_CODE` 必须和 `license-server` 中项目表的 `projects.code` 一致。 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 +- `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。 +- `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。 - `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。 +数据库有两种模式: + +- 内置 PostgreSQL:`DATABASE_URL` 留空或不写,由 `docker-compose.prod.yml` 按 `POSTGRES_USER`、`POSTGRES_PASSWORD`、`POSTGRES_DB` 自动拼接到 `postgres` 服务。 +- 外部 PostgreSQL:才填写真实 `DATABASE_URL`,例如 `postgresql://flnxi:@192.168.1.10:5432/flnxi?sslmode=disable`。 + +对象存储也有两种模式: + +- 内置 MinIO:`S3_ENDPOINT=http://minio:9000`,`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` 可不填,Compose 会使用 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`。`S3_PUBLIC_DOMAIN` 必须写成用户浏览器能访问的地址,例如 `http://192.168.1.20:9000/flnxi-materials`。 +- 外部 S3 / 外部 MinIO:需要填写真实 `S3_ENDPOINT`、`S3_ACCESS_KEY_ID`、`S3_SECRET_ACCESS_KEY`、`S3_BUCKET`、`S3_PUBLIC_DOMAIN`,并确认 bucket 已存在或当前账号有创建和读写权限。 + +NAS 或 Linux 服务器上建议提前准备挂载目录: + +```bash +mkdir -p uploads data +chmod 775 uploads data +``` + +如果客户系统启用了更严格的用户权限,需让运行主应用容器的用户可以写入这两个目录;当前镜像内应用用户通常按 UID `1001` 理解。最简单的验收标准是:容器启动后能上传素材,`uploads/` 和 `data/` 没有持续出现权限拒绝日志。 + ### 2.3 HTTP Registry 前置检查 如果 Registry 使用 `http://`,本机 Docker 和目标机器 Docker 都要先允许这个 Registry。否则会出现 `HTTP response to HTTPS client`,表现为登录、推送或拉取镜像失败。 @@ -126,7 +153,7 @@ Docker Desktop 需要在设置里的 Docker Engine 配置同样的 `insecure-reg cp .env.example .env ``` -再按实际环境补齐数据库、MinIO、NextAuth、许可证和访问地址。 +再按实际环境补齐数据库、MinIO、NextAuth、许可证和访问地址。首装使用内置 PostgreSQL 时,不要给 `DATABASE_URL` 填占位连接串;外部 PostgreSQL 才填写真实连接串。 ### 3.3 启动服务 @@ -193,6 +220,8 @@ docker logs flnxi-material --tail 100 只有在已有其它备份和回滚体系时,才考虑使用 `-NoRemoteDbBackup` 或 `-NoRollback`。 +必须注意:脚本的自动回滚只恢复应用镜像和 `.env`,不自动恢复数据库。只要本次升级包含数据库迁移,升级前的数据库备份就必须保留,必要时由人工确认后恢复。 + 典型用法: ```powershell @@ -215,16 +244,18 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString ## 5. 部署前检查清单 -- [ ] 已确认目标机器品牌、系统、Docker / Podman 支持情况 +- [ ] 已确认目标机器品牌、系统和 Docker Compose 支持情况;如使用 Podman,已单独实机验证 - [ ] 已确认端口、域名、证书、反向代理方案 - [ ] 已确认数据目录、对象存储目录和备份位置 -- [ ] `.env` 已填写真实环境变量 +- [ ] `.env` 已填写真实环境变量;内置 PostgreSQL 模式下 `DATABASE_URL` 没有错误占位值 - [ ] `APP_IMAGE` 已固定到完整版本镜像,不使用 `latest` - [ ] 如果 Registry 是 HTTP,本机和目标机 Docker 都已配置 `insecure-registries` - [ ] `NEXTAUTH_URL` 与最终访问地址一致 - [ ] `S3_BUCKET`、`S3_PUBLIC_DOMAIN` 口径正确 - [ ] `LICENSE_SERVER_URL` 和 `LICENSE_PROJECT_CODE` 已确认 - [ ] `LICENSE_REQUEST_SIGNING_SECRET` 已配置且与许可证服务一致 +- [ ] `LICENSE_PUBLIC_KEY`、`LOCAL_ENCRYPTION_KEY` 已配置为真实值 +- [ ] `uploads/`、`data/` 目录对应用容器可写 - [ ] 已执行与本次改动相关的测试或命令 - [ ] 涉及数据库变更时已有备份和回滚方案 @@ -275,14 +306,25 @@ curl http://:4320/api/health ### 7.3 手动恢复数据库 -恢复前必须确认已停止会写入数据库的应用容器,并确认备份文件正确。 +恢复前必须确认已停止会写入数据库的应用容器,并确认备份文件正确。下面给两种常见方式,二选一即可。 + +```bash +docker cp ./flnxi-backup-YYYYMMDD-HHMMSS.dump flnxi-postgres:/tmp/flnxi-restore.dump +docker exec flnxi-postgres pg_restore \ + -U postgres \ + -d flnxi \ + -c \ + /tmp/flnxi-restore.dump +``` + +或者直接从宿主机标准输入恢复: ```bash docker exec -i flnxi-postgres pg_restore \ -U postgres \ -d flnxi \ -c \ - /tmp/flnxi-backup-YYYYMMDD-HHMMSS.dump + < ./flnxi-backup-YYYYMMDD-HHMMSS.dump ``` ## 8. 常见问题 @@ -307,6 +349,7 @@ docker exec -i flnxi-postgres pg_restore \ - MinIO 控制台是否能看到桶和对象 - `S3_SET_ACL` 是否仍为默认 `0`。私有化交付默认不公开素材桶;只有兼容旧公开对象模式时才允许显式改成 `1`。 - 当前生产 Compose 的 `minio-init` 会默认执行 `mc anonymous set none`,用于关闭 MinIO 桶匿名下载。如果客户历史环境曾经公开过桶,升级后应重跑 `minio-init` 或手动执行同等命令确认桶已恢复私有。 +- 外部 S3 或历史环境可能存在旧 bucket policy、旧对象级 ACL 或历史公开链接,这些不一定会被当前 `minio-init` 自动收回,升级前后要单独做只读审计。 ### 许可证异常 diff --git a/docs/其他项目接入许可证系统说明.md b/docs/其他项目接入许可证系统说明.md index 6f10b0b..97231b7 100644 --- a/docs/其他项目接入许可证系统说明.md +++ b/docs/其他项目接入许可证系统说明.md @@ -11,7 +11,7 @@ - 当前仓库自己的主项目,确实主要面向 Docker / NAS / Linux 私有化部署 - 但“其他项目接入许可证系统”这件事,本身不要求项目一定跑在 Docker 里 - 只要项目侧有一个可信执行层可以承接许可证逻辑,就可以接入 -- 对外发送时,默认只发这一份文档即可;它已经按“单文件接入说明”组织,不要求对方再翻第二份文档 +- 对外沟通时可先发本文档作为接入主说明;涉及 FLNXI 主项目部署、当前生产配置或安全评估结论时,必须回到当前代码和对应部署文档复核,不能把本文当成全部事实来源 ## 1. 先说结论 @@ -86,6 +86,7 @@ ```env LICENSE_SERVER_URL=https://license.example.com LICENSE_PROJECT_CODE=your-project-code +LICENSE_REQUEST_SIGNING_SECRET=shared-secret-at-least-32-chars ``` 如果项目需要后台管理,还会给: @@ -104,6 +105,7 @@ LICENSE_PROJECT_CODE=your-project-code - 当前项目的 `LICENSE_PROJECT_CODE` - 后台入口和后台账号(如需要) - 测试许可证或测试发证方式 +- 请求签名共享密钥 `LICENSE_REQUEST_SIGNING_SECRET` 不要把历史联调地址、个人账号或服务器登录方式写进项目代码、`.env.example` 或对外文档。 @@ -124,6 +126,22 @@ GET /api/version/{projectCode}/check GET /api/version/{projectCode}/agent ``` +### 5.1.1 请求签名要求 + +`activate`、`verify`、`heartbeat`、`register-free`、`premium-modules` 和 `premium-modules/{moduleId}` 当前不是“只带项目编码就能访问”。项目侧可信执行层必须使用 `LICENSE_REQUEST_SIGNING_SECRET` 对请求体做 HMAC 签名,并把下面三个字段放进请求体: + +```json +{ + "timestamp": 1779753600000, + "nonce": "32位随机十六进制字符串", + "signature": "sha256-hmac-hex" +} +``` + +签名计算口径以当前代码 `lib/license/request-signature.ts` 和 `license-server/lib/request-signature.ts` 为准:签名内容包含 `action`、`projectCode` 和按 key 排序后的请求体,且签名时会排除 `signature` 字段本身。远端会拒绝缺签名、签名错误、时间超出窗口和重复 `nonce` 的请求。 + +这和“验证许可证返回值的 RSA 签名”是两件事:请求签名用于防止外部伪造请求;许可证返回签名用于确认许可证内容没有被篡改。 + ### 5.2 禁止再使用的旧接口 以下旧接口已经废弃,当前统一返回 `410`: @@ -517,11 +535,12 @@ POST /api/license/{projectCode}/generate ## 8. 项目配置要求 -项目侧至少要能提供这两个配置项: +项目侧至少要能提供这三个配置项: ```env LICENSE_SERVER_URL=https://license.example.com LICENSE_PROJECT_CODE=your-project-code +LICENSE_REQUEST_SIGNING_SECRET=shared-secret-at-least-32-chars ``` 这两个值不一定非要写在 `.env` 里,也可以放在: @@ -533,11 +552,12 @@ LICENSE_PROJECT_CODE=your-project-code - Windows 服务配置 - 桌面程序本地安全配置 -核心不是“必须用环境变量”,而是“运行时必须能稳定、安全地读到这两个值”。 +核心不是“必须用环境变量”,而是“运行时必须能稳定、安全地读到这些值”。 要求: - `LICENSE_PROJECT_CODE` 必须和验证系统中登记的项目编码完全一致 +- `LICENSE_REQUEST_SIGNING_SECRET` 必须和许可证服务中同项目使用的共享密钥一致,至少 32 字符,不能放到浏览器端 - 不要在多个地方写不同的项目编码 - 不要继续写死旧的无项目接口地址 @@ -687,7 +707,7 @@ LICENSE_PROJECT_CODE=your-project-code - 按统一接口把自己项目接上去 - 把联调和验收跑通 -对外发送时,默认只发本文件即可。 +对外接入可先看本文件;涉及部署、线上地址、安全结论或 FLNXI 主项目差异时,必须同步引用当前代码和对应部署说明,不能只发旧快照或内部台账。 下面这些文档属于当前项目内部补充资料,不是对方接入时的必读项: diff --git a/docs/初次部署与出厂设置指南.md b/docs/初次部署与出厂设置指南.md index 60f8a74..20ad974 100644 --- a/docs/初次部署与出厂设置指南.md +++ b/docs/初次部署与出厂设置指南.md @@ -4,7 +4,7 @@ ### 1. 环境准备 - 服务器/NAS:4C8G 以上、100GB 可用空间,建议启用 SSD/NVMe。 -- 系统:Ubuntu 22.04 LTS 或群晖 DSM(需支持 Docker / Podman)。 +- 系统:Ubuntu 22.04 LTS、群晖 DSM 或飞牛 fnOS 等 Linux / NAS 环境;当前主线按 Docker Compose 验证,Podman 需交付前单独实机验证。 - 依赖:Docker 24+、Docker Compose V2;Node.js 20 仅本地调试需要,主应用生产镜像当前基于 `node:18-alpine`。 - 包管理:当前仓库使用 `package-lock.json`,本地开发以 `npm` 为准。 - 网络:可访问对象存储(MinIO/S3),按实际部署放通 80/443、默认应用端口 4320、数据库和对象存储端口等。 @@ -17,7 +17,7 @@ cd flnxi_sucai cp .env.example .env npm install ``` -- 如现场已有 `config-bianliang.env`,可作为现场补充变量文件;正式新装优先按 `.env.example` 和部署说明填写 `.env`,保证 `DATABASE_URL`、S3、域名、NEXTAUTH_SECRET 等关键变量存在。 +- 如现场已有 `config-bianliang.env`,可作为现场补充变量文件;正式新装优先按 `.env.example` 和部署说明填写 `.env`。使用内置 PostgreSQL 时 `DATABASE_URL` 留空即可;只有外部 PostgreSQL 才填写真实连接串。S3、域名、NextAuth、许可证密钥等关键变量必须填真实值。 - 当前仓库没有 `pnpm-lock.yaml`,不要把 `pnpm` 当作默认安装方式。 - 不要把 `config-bianliang.env` 当成通用必填项;现场没有它时,直接按 `.env.example` 和部署文档要求的 `.env` 走。 - 如果启用许可证服务,必须同时填写 `LICENSE_SERVER_URL` 和 `LICENSE_PROJECT_CODE`,且 `LICENSE_PROJECT_CODE` 必须和 `license-server` 项目编码一致。 @@ -76,7 +76,7 @@ npx prisma migrate reset --force --skip-seed curl -X POST https://your-domain.com/api/system/init \ -H "x-system-init-token: your-init-token" ``` -可将上述两条命令收录到自定义脚本,例如 `scripts/reset-to-factory.ps1/.sh`,方便演示或客户验收前重置。 +可将上述两条命令收录到自定义脚本,例如 `scripts/reset-to-factory.ps1/.sh`,只用于内部测试或演示环境验收前重置。正式客户环境禁止直接执行恢复出厂,除非已确认备份、停机窗口和数据删除授权。 ### 9. 常见问题 - **`prisma migrate reset` 卡在输入确认**:加上 `--force --skip-generate`/`--skip-seed` 使其无交互运行。 diff --git a/docs/参考源码功能吸收整理_20260513.md b/docs/参考源码功能吸收整理_20260513.md index 85ef5ea..ce7ef80 100644 --- a/docs/参考源码功能吸收整理_20260513.md +++ b/docs/参考源码功能吸收整理_20260513.md @@ -208,7 +208,7 @@ D:\AI Code\sucaiku\flnxi_sucai\gongxiangp2-source-reference-old-style-20260513-1 - 外部 API / API Key / 外部导入 / 外部下载 - old-style 整套旧 UI - 绑定特定公网域名、固定服务器、固定外部平台的部署假设 -- 强绑定钉钉或 AI 生图平台的默认流程 +- 强绑定钉钉或 AI 生图平台的默认流程;这类能力必须依赖真实外部服务、密钥和回调地址,不作为默认客户验收项 - old-style 的完整角色体系和超级管理员体系 ## 7. 后续落地建议 diff --git a/docs/商业化地基8项收口记录_20260530.md b/docs/商业化地基8项收口记录_20260530.md index 745d925..3c9d25f 100644 --- a/docs/商业化地基8项收口记录_20260530.md +++ b/docs/商业化地基8项收口记录_20260530.md @@ -2,7 +2,7 @@ 更新时间:2026-05-30 -本文件记录本轮按“商业化私有交付”目标推进的 8 项地基工作。它不是销售文案,也不是旧参考源码搬运清单;后续判断以当前代码、配置和本文档记录为准。 +本文件记录本轮按“商业化私有交付”目标推进的 8 项地基工作。它不是销售文案,也不是旧参考源码搬运清单;本文档只是 2026-05-30 的阶段快照,后续判断以当前代码、配置和部署文件为准。 ## 1. 客户安装和升级演练 diff --git a/docs/客户首装升级坑位排查_20260530.md b/docs/客户首装升级坑位排查_20260530.md index a858fda..9c8acdb 100644 --- a/docs/客户首装升级坑位排查_20260530.md +++ b/docs/客户首装升级坑位排查_20260530.md @@ -2,18 +2,19 @@ 更新时间:2026-05-30 -本文件记录本轮针对客户真实首装、升级、回滚、许可证服务、对象存储和文档真相的只读排查结果。它不是整改完成记录,后续修复时要以当前代码、配置和实际验证结果为准。 +本文件记录本轮针对客户真实首装、升级、回滚、许可证服务、对象存储和文档真相的排查与修复状态。它是 2026-05-30 的阶段记录,后续仍以当前代码、配置和实际验证结果为准。 ## 1. 结论 -当前项目已经有比较清楚的主线:`docker-compose.prod.yml + .env` 做客户首装,`deploy-registry.ps1` 做版本化发布和已有客户升级。但离“客户能按步骤稳定安装和升级”还有几个硬坑: +当前项目已经有比较清楚的主线:`docker-compose.prod.yml + .env` 做客户首装,`deploy-registry.ps1` 做版本化发布和已有客户升级。本轮已先处理会直接卡住客户首装、升级和回滚判断的 7 类硬坑: -- 首装照 `.env.example` 复制后,主应用可能直接连到错误数据库地址。 -- 许可证服务的 `docker compose config` 目前用示例环境文件会直接失败。 -- 主应用许可证激活依赖的关键变量没有进入部署必填清单。 -- Go Agent 的一键升级链路存在失败后仍像成功、失败后不恢复旧容器的问题。 -- `deploy-registry.ps1` 的自动回滚只退应用镜像和 `.env`,不退数据库;涉及迁移时不能把它当完整回滚。 -- 外部 S3 / MinIO、NAS 目录权限、历史对象 ACL 还需要专项演练和审计。 +- `.env.example` 不再给内置 PostgreSQL 模式写错误 `DATABASE_URL` 占位值。 +- 主应用许可证激活依赖的 `LICENSE_PUBLIC_KEY`、`LOCAL_ENCRYPTION_KEY` 已进入生产必填和启动前检查。 +- `license-server/.env.example` 已补 `LICENSE_REQUEST_SIGNING_SECRET`,示例数据库地址已改为 Compose 服务名 `postgres`。 +- Go Agent 健康检查超时会返回失败,升级健康失败后会尽力恢复旧容器。 +- `deploy-registry.ps1` 的回滚保护已提前到远端 `.env` 写入前,并明确提示“自动回滚不恢复数据库”。 +- 部署文档已补内置/外部 PostgreSQL、内置/外部 MinIO、NAS 挂载目录权限、历史 ACL 审计和 Podman 口径。 +- 许可证外部接入文档和历史安全评估已补当前签名要求和历史快照提示。 ## 2. P0:会直接卡首装、升级或回滚 @@ -28,12 +29,11 @@ - `.env.example:41` 写入了占位 `DATABASE_URL=postgresql://username:password@host:5432/database`。 - `docker-compose.prod.yml:113` 会优先使用 `.env` 里的 `DATABASE_URL`。 - `docs/deployment/部署说明.md:60` 到 `75` 的必填变量清单没有提醒要删除占位 `DATABASE_URL` 或改成 Compose 内部地址。 -- 本轮只读验证:`docker compose -f docker-compose.prod.yml --env-file .env.example config` 输出的 `DATABASE_URL` 仍是占位地址。 +- 修复前只读验证:`docker compose -f docker-compose.prod.yml --env-file .env.example config` 输出的 `DATABASE_URL` 仍是占位地址;本轮修复后已改为内置 PostgreSQL 展开结果。 -建议: +当前处理: -- 首装用内置 PostgreSQL 时,`.env.example` 不应给一个会被直接使用的错误 `DATABASE_URL`。 -- 部署说明必须明确两种模式:内置 PostgreSQL 不填 `DATABASE_URL`;外部 PostgreSQL 才填写真实连接串。 +- 已修复:`.env.example` 中 `DATABASE_URL` 现在默认留空,部署说明明确内置 PostgreSQL 留空,外部 PostgreSQL 才填写真实连接串。 ### P0-02 主应用许可证激活缺少真正必填变量 @@ -48,10 +48,9 @@ - `lib/license/crypto-service.ts` 会检查许可证公钥配置。 - `lib/license/license-service.ts` 保存本地许可证时依赖本地加密能力。 -建议: +当前处理: -- 把 `LICENSE_PUBLIC_KEY`、`LOCAL_ENCRYPTION_KEY` 加入部署必填清单。 -- 首装脚本或检查脚本应在启动前发现缺失,而不是让客户到激活页才失败。 +- 已修复:`docker-compose.prod.yml` 强制要求这两个变量,`scripts/docker-entrypoint.sh` 也会在启动前拦截空值和明显占位值;部署说明同步加入必填清单。 ### P0-03 许可证服务示例环境文件无法启动 Compose @@ -65,10 +64,9 @@ - `license-server/.env.example:1` 到 `27` 没有这个变量。 - 本轮只读验证:`docker compose -f license-server/docker-compose.yml --env-file license-server/.env.example config` 直接报缺少 `LICENSE_REQUEST_SIGNING_SECRET`。 -建议: +当前处理: -- 补齐 `license-server/.env.example`。 -- `license-server/scripts/generate-keys.ts` 也要同步生成或提示这个变量。 +- 已修复:`license-server/.env.example` 已补签名密钥;`license-server/scripts/generate-keys.ts` 会自动生成并提示同步到素材系统。 ### P0-04 许可证服务示例数据库地址不适配容器 @@ -81,10 +79,9 @@ - `license-server/.env.example:8` 和 `13` 使用 `localhost:5432`。 - `license-server/docker-compose.yml:38` 的数据库服务名是 `postgres`。 -建议: +当前处理: -- Compose 场景下示例地址应使用 `postgres:5432`。 -- 本地裸机开发和 Compose 部署要分开给示例。 +- 已修复:Compose 示例地址已改为 `postgres:5432`,文案说明裸机本地调试可改回 `localhost` 或用 `LICENSE_DB_HOST=localhost` 生成。 ### P0-05 Go Agent 健康检查超时后仍返回成功 @@ -97,10 +94,9 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 - `flnxi-agent-go/internal/docker/client.go:195` 开始等待健康状态。 - `flnxi-agent-go/internal/docker/client.go:216` 超时后仍 `return nil`。 -建议: +当前处理: -- 超时必须返回错误。 -- 前端和接口要把失败状态明确展示出来。 +- 已修复:`WaitForHealthy` 超时会返回错误,并支持上下文取消;新增 Go 测试覆盖超时失败。 ### P0-06 Go Agent 升级失败没有恢复旧容器 @@ -114,10 +110,10 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 - `flnxi-agent-go/internal/api/handlers.go:511` 到 `514` 删除旧容器。 - `flnxi-agent-go/internal/api/handlers.go:537` 到 `540` 健康失败只记录错误,没有恢复旧容器。 -建议: +当前处理: -- Agent 升级前要保存旧镜像、旧容器配置和数据备份状态。 -- 新容器失败时至少自动恢复旧容器;涉及数据库迁移时必须进入人工确认流程。 +- 已修复第一层保护:Go Agent 更新失败会尽力删除失败的新容器,并用旧容器配置和旧镜像重建旧版本容器;直接传入 `image` 时也禁止无固定 tag 或 `latest`。 +- 仍需注意:Agent 级别恢复不等于数据库回滚,涉及数据库迁移时仍要走人工确认和备份恢复。 ### P0-07 发布脚本自动回滚不等于数据库回滚 @@ -131,10 +127,9 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 - `deploy-registry.ps1:621` 到 `637` 会做 `pg_dump` 备份。 - `deploy-registry.ps1:591` 到 `618` 的 `rollback()` 只恢复 `.env` 和旧应用镜像。 -建议: +当前处理: -- 文档和脚本输出必须明确:自动回滚只是应用层回滚,不是完整数据回滚。 -- 涉及数据库结构变更时,要有单独的恢复演练和人工确认步骤。 +- 已修复:发布脚本回滚输出和部署文档都明确“自动回滚只恢复应用镜像和 `.env`,不自动恢复数据库”;手动数据库恢复命令已补 `docker cp` 和标准输入两条可执行路径。 ## 3. P1:会造成交付不稳或现场误操作 @@ -149,9 +144,9 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 如果 `.env` 写入中途失败,可能留下半新半旧配置,下次手工 `docker compose up` 会继续踩坑。 -建议: +当前处理: -- 在任何远端 `.env` 写入前就启用回滚保护。 +- 已修复:`trap rollback ERR` 已提前到 `upsert_env APP_IMAGE` 之前。 ### P1-02 手动数据库恢复文档不可直接照做 @@ -165,9 +160,9 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 真出事故时,照文档恢复大概率找不到备份文件。 -建议: +当前处理: -- 改成一条可执行路径:要么 `docker cp` 到容器后恢复,要么用宿主机备份文件通过 stdin 管道恢复。 +- 已修复:部署文档已给 `docker cp` 和宿主机标准输入恢复两种路径。 ### P1-03 示例密钥非空,Compose 不会拦截弱默认 @@ -180,9 +175,9 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 客户漏改时系统可能照样启动,带着可猜密钥上线。 -建议: +当前处理: -- 增加启动前环境检查,发现 `replace-with`、`your-`、空公钥等占位值时直接阻断。 +- 已修复:主应用入口脚本会在连接数据库前检查关键生产变量,发现空值、`replace-with`、`your-`、示例域名和旧错误数据库占位值会直接停止。 ### P1-04 NAS 挂载目录权限容易卡上传和激活 @@ -195,10 +190,10 @@ Agent 升级时,新容器如果一直不健康,代码仍可能把结果当 NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextjs` 可能没有写权限。客户会在上传素材或保存许可证实例时才发现失败。 -建议: +当前处理: -- 部署文档给出创建目录和授权步骤。 -- 更稳的方式是安装脚本自动处理目录权限。 +- 已修复文档层:部署说明已加入 `uploads/`、`data/` 目录创建和可写权限说明。 +- 后续可继续做安装脚本自动处理。 ### P1-05 外部 S3 / MinIO 口径容易误导 @@ -211,10 +206,9 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 客户以为填 `.env` 就能接外部对象存储,实际仍会走内置 MinIO。 -建议: +当前处理: -- 明确区分“内置 MinIO 模式”和“外部对象存储模式”。 -- 外部对象存储模式需要单独 compose 覆盖文件或明确删改环境覆盖。 +- 已修复主线配置和文档:`docker-compose.prod.yml` 现在允许 `.env` 覆盖 S3 变量;部署说明区分内置 MinIO 和外部 S3 / MinIO。 ### P1-06 历史对象权限还没有审计闭环 @@ -228,9 +222,10 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 新上传默认更安全,但旧 bucket、外部 S3、历史对象 ACL 可能仍然公开。 -建议: +当前处理: -- 做一份只读审计脚本或 SOP,检查 bucket policy、对象 ACL、历史公开链接。 +- 已补文档提醒:部署说明和坑位清单明确外部 S3、历史 bucket policy、对象级 ACL、历史公开链接需单独只读审计。 +- 后续仍建议补自动审计脚本或 SOP。 ### P1-07 上传预览图和 PSD 展示图仍有大图内存风险 @@ -260,10 +255,9 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 其他项目照旧文档接入,会缺签名参数,联调失败。 -建议: +当前处理: -- 更新对外接入文档,补齐签名密钥和签名示例。 -- 修完前不要单独对外发送旧文档。 +- 已修复:`docs/其他项目接入许可证系统说明.md` 已补 `LICENSE_REQUEST_SIGNING_SECRET`、`timestamp`、`nonce`、`signature` 和当前签名口径,并取消“默认只发本文件即可”的过度承诺。 ## 4. P2:容易误导、但不一定马上阻断 @@ -278,25 +272,24 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 实施人员容易分不清 Compose 首装和 Agent 安装。 -建议: +当前处理: -- 删除或改写这段注释。 +- 已修复:`docker-compose.prod.yml` 中误导性的客户首装脚本口径已改成当前 `.env` / Compose 真相。 ### P2-02 Podman 只写目标,没有可执行路径 证据: -- `docs/deployment/部署说明.md:53` 到 `54` 写支持 Docker / Podman。 +- 修复前 `docs/deployment/部署说明.md:53` 到 `54` 写支持 Docker / Podman。 - 实际命令主要是 `docker compose`,例如 `docs/deployment/部署说明.md:136`。 影响: 只提供 Podman 或图形容器入口的 NAS,客户仍不知道该怎么装。 -建议: +当前处理: -- 明确当前客户主线是 Docker Compose。 -- Podman 另开适配文档或验证清单,不要混在主线步骤里。 +- 已修复文档口径:部署说明和初次部署指南已明确当前主线按 Docker Compose 验证,Podman 需单独实机验证。 ### P2-03 历史安全评估仍在讲已修复问题 @@ -309,9 +302,9 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 后续排查会被旧结论带偏。 -建议: +当前处理: -- 移入归档目录,或在顶部明确“历史快照,不作为当前事实来源”。 +- 已修复:历史安全评估顶部已加历史快照提示,并补充主链路 HMAC 签名结论已过期;`docs/README.md` 也要求引用前按当前代码复核。 ### P2-04 商业化长清单仍混合旧状态和当前状态 @@ -324,10 +317,9 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj 后续接手时容易把旧状态当当前真相。 -建议: +当前处理: -- 新增“当前开放项”短文档。 -- 长清单改成内部审计流水,不作为默认事实入口。 +- 已修复阶段性记录口径:`docs/商业化地基8项收口记录_20260530.md` 已改为 2026-05-30 阶段快照,不再高于当前代码和部署文件。 ### P2-05 旧构建脚本仍保留 `latest` 口径 @@ -348,9 +340,11 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj ## 5. 已做验证 - 本轮使用 multi-agent 做只读并行排查,分别覆盖首装入口、升级回滚、文档真相。 -- 本地执行 `docker compose -f docker-compose.prod.yml --env-file .env.example config`,确认 `DATABASE_URL` 占位值会进入最终配置。 -- 本地执行 `docker compose -f license-server/docker-compose.yml --env-file license-server/.env.example config`,确认因缺少 `LICENSE_REQUEST_SIGNING_SECRET` 失败。 -- 子 agent 执行 `deploy-registry.ps1` PowerShell 语法解析,并运行 `npm test -- --runTestsByPath scripts/__tests__/deploy-registry-script.test.ts scripts/__tests__/docker-entrypoint.test.ts --runInBand`,测试通过。 +- 本轮修复后执行 `docker compose -f docker-compose.prod.yml --env-file .env.example config`,在补入真实必填密钥的前提下确认 `DATABASE_URL` 会展开到内置 `postgres` 服务,`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` 会从 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` 回填。 +- 本轮修复后执行 `docker compose -f license-server/docker-compose.yml --env-file license-server/.env.example config`,确认示例环境文件已包含 `LICENSE_REQUEST_SIGNING_SECRET`,并展开到 `@postgres:5432`。 +- 执行 `npm test -- --runTestsByPath scripts/__tests__/docker-entrypoint.test.ts scripts/__tests__/deploy-registry-script.test.ts license-server/lib/__tests__/env-example.test.ts --runInBand`,3 个测试套件、9 个测试通过。 +- 执行 `npm run check`,`type-check`、`next lint`、`prisma validate` 通过。 +- 本机没有直接安装 Go,已使用 Docker `golang:1.22-alpine` 执行 `go test ./...`,Go Agent 全量测试通过。 - 本轮没有启动客户环境容器,没有写入服务器,没有修改旧参考源码目录。 ## 6. 建议修复顺序 diff --git a/docs/许可证链路安全评估_20260312.md b/docs/许可证链路安全评估_20260312.md index 8b08b7e..a17c5c2 100644 --- a/docs/许可证链路安全评估_20260312.md +++ b/docs/许可证链路安全评估_20260312.md @@ -1,5 +1,7 @@ # 许可证链路安全评估 +> 历史快照提示:本文是 2026-03-12 的安全评估和后续局部复核记录,不代表当前代码、部署脚本和许可证服务的完整最新状态。当前接入和部署以 `docs/许可证服务接入说明.md`、`docs/deployment/部署说明.md`、`docker-compose.prod.yml` 和实际代码为准。 + 评估日期:2026-03-12 复核更新:2026-05-13。本文件仍是安全评估快照,不是操作入口;执行整改前必须按当前代码、部署文件和实际环境复核。 @@ -72,6 +74,8 @@ - 私有化部署场景下,本地宽限期和本地缓存仍只能视为容灾,不应再被当作“硬防破解” - 历史旧入口目前仍保留壳路由,虽不再承载真实逻辑,但后续仍建议物理删除 +2026-05-30 补充:主应用到 `license-server` 的 `activate / verify / heartbeat / register-free / premium-modules` 主链路已要求 `LICENSE_REQUEST_SIGNING_SECRET`,请求体携带 `timestamp`、`nonce` 和 HMAC `signature`。本文早期关于“主应用请求缺少签名/重放防护”的旧结论已过期,只能作为历史问题来源,不再作为当前事实。 + ## 3. 链路概览 当前大致链路如下: diff --git a/flnxi-agent-go/internal/api/handlers.go b/flnxi-agent-go/internal/api/handlers.go index 5cae5a4..5d85ebd 100644 --- a/flnxi-agent-go/internal/api/handlers.go +++ b/flnxi-agent-go/internal/api/handlers.go @@ -120,6 +120,9 @@ func resolveInstallImages(req InstallRequest) (string, string, string, error) { func resolveUpdateImage(req UpdateRequest) (string, error) { if image := firstNonEmpty(req.Image); image != "" { + if err := validateFixedImageTag(image); err != nil { + return "", err + } return image, nil } @@ -139,12 +142,36 @@ func resolveUpdateImage(req UpdateRequest) (string, error) { if version == "" { return "", fmt.Errorf("version is required") } - if version == "latest" { + if strings.EqualFold(version, "latest") { return "", fmt.Errorf("latest is not allowed for customer updates; use a fixed version tag") } return repository + ":" + version, nil } +func validateFixedImageTag(image string) error { + image = strings.TrimSpace(image) + if image == "" { + return fmt.Errorf("image is required") + } + + imageName := image + if at := strings.Index(imageName, "@"); at >= 0 { + imageName = imageName[:at] + } + + lastSlash := strings.LastIndex(imageName, "/") + lastColon := strings.LastIndex(imageName, ":") + if lastColon <= lastSlash || lastColon == len(imageName)-1 { + return fmt.Errorf("image must include a fixed version tag") + } + + tag := strings.TrimSpace(imageName[lastColon+1:]) + if strings.EqualFold(tag, "latest") { + return fmt.Errorf("latest is not allowed for customer updates; use a fixed version tag") + } + return nil +} + const ( envMaterialImage = "FLNXI_MATERIAL_IMAGE" envMaterialImageCompat = "APP_IMAGE" @@ -460,7 +487,19 @@ func GetUpdateStatus(dc *docker.Client) gin.HandlerFunc { } } -func runUpdate(dc *docker.Client, req UpdateRequest) { +type updateDockerClient interface { + GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) + PullImage(ctx context.Context, image string, authStr string) error + StopContainer(ctx context.Context, containerID string) error + RemoveContainer(ctx context.Context, containerID string) error + StartContainer(ctx context.Context, containerID string) error + CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) + WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error + RemoveImage(ctx context.Context, imageID string) error + PruneImages(ctx context.Context) error +} + +func runUpdate(dc updateDockerClient, req UpdateRequest) { ctx := context.Background() setStep := func(step string, progress int, msg string) { statusMutex.Lock() @@ -472,6 +511,15 @@ func runUpdate(dc *docker.Client, req UpdateRequest) { updateStatus.Status, updateStatus.Error = "failed", e statusMutex.Unlock() } + failWithRollback := func(prefix string, err error, old *docker.ContainerInfo) { + setStep("回滚旧版本", 85, "更新失败,正在恢复旧版本...") + rollbackErr := restoreMaterialContainer(ctx, dc, old) + if rollbackErr != nil { + setErr(fmt.Sprintf("%s: %v;旧版本恢复失败: %v", prefix, err, rollbackErr)) + return + } + setErr(fmt.Sprintf("%s: %v;已恢复旧版本容器", prefix, err)) + } authStr := "" if req.AuthToken != "" { b, _ := json.Marshal(map[string]string{"username": "token", "password": req.AuthToken}) @@ -487,6 +535,7 @@ func runUpdate(dc *docker.Client, req UpdateRequest) { // 记录旧镜像 ID,用于后续清理 oldImageID := cont.ImageID + oldConfig := cont image, err := resolveUpdateImage(req) if err != nil { @@ -505,38 +554,27 @@ func runUpdate(dc *docker.Client, req UpdateRequest) { return } - // 保存旧容器的配置信息,用于创建新容器 - oldConfig := cont - setStep("清理旧容器", 50, "删除旧容器...") if err := dc.RemoveContainer(ctx, cont.ID); err != nil { - setErr("删除容器失败: " + err.Error()) + failWithRollback("删除容器失败", err, oldConfig) return } setStep("启动新版本", 60, "创建新容器...") - // 使用旧容器的配置创建新容器 - cfg := &container.Config{ - Image: image, - User: "0", - Env: oldConfig.Config.Env, - ExposedPorts: oldConfig.Config.ExposedPorts, - } - hcfg := &container.HostConfig{ - PortBindings: oldConfig.HostConfig.PortBindings, - Binds: oldConfig.HostConfig.Binds, - NetworkMode: oldConfig.HostConfig.NetworkMode, - RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + cfg, hcfg, err := materialContainerConfig(oldConfig, image) + if err != nil { + failWithRollback("生成新容器配置失败", err, oldConfig) + return } newID, err := dc.CreateAndStartContainer(ctx, cfg, hcfg, "flnxi-material") if err != nil { - setErr("启动新容器失败: " + err.Error()) + failWithRollback("启动新容器失败", err, oldConfig) return } setStep("健康检查", 80, "等待服务就绪...") if err := dc.WaitForHealthy(ctx, newID, 60*time.Second); err != nil { - setErr("健康检查失败: " + err.Error()) + failWithRollback("健康检查失败", err, oldConfig) return } @@ -556,6 +594,77 @@ func runUpdate(dc *docker.Client, req UpdateRequest) { statusMutex.Unlock() } +func materialContainerConfig(old *docker.ContainerInfo, image string) (*container.Config, *container.HostConfig, error) { + if old == nil || old.Config == nil { + return nil, nil, fmt.Errorf("旧容器配置不存在") + } + + cfg := *old.Config + cfg.Image = image + + hcfg := &container.HostConfig{} + if old.HostConfig != nil { + copied := *old.HostConfig + hcfg = &copied + } + if hcfg.RestartPolicy.Name == "" { + hcfg.RestartPolicy = container.RestartPolicy{Name: "unless-stopped"} + } + return &cfg, hcfg, nil +} + +func oldMaterialImage(old *docker.ContainerInfo) string { + if old == nil || old.Config == nil { + return "" + } + image := strings.TrimSpace(old.Config.Image) + if image != "" { + return image + } + return strings.TrimSpace(old.ImageID) +} + +func restoreMaterialContainer(ctx context.Context, dc updateDockerClient, old *docker.ContainerInfo) error { + if old == nil { + return fmt.Errorf("旧容器信息不存在") + } + + current, err := dc.GetContainer(ctx, "flnxi-material") + if err != nil { + return fmt.Errorf("查询当前容器失败: %w", err) + } + if current != nil && current.ID == old.ID { + if current.State != "running" { + if err := dc.StartContainer(ctx, current.ID); err != nil { + return fmt.Errorf("启动旧容器失败: %w", err) + } + } + return nil + } + if current != nil { + if current.State == "running" { + _ = dc.StopContainer(ctx, current.ID) + } + if err := dc.RemoveContainer(ctx, current.ID); err != nil { + return fmt.Errorf("删除失败的新容器失败: %w", err) + } + } + + image := oldMaterialImage(old) + if image == "" { + return fmt.Errorf("旧镜像信息不存在") + } + + cfg, hcfg, err := materialContainerConfig(old, image) + if err != nil { + return err + } + if _, err := dc.CreateAndStartContainer(ctx, cfg, hcfg, "flnxi-material"); err != nil { + return fmt.Errorf("重建旧容器失败: %w", err) + } + return nil +} + func ListContainers(dc *docker.Client) gin.HandlerFunc { return func(c *gin.Context) { containers, err := dc.ListContainers(context.Background(), "flnxi") diff --git a/flnxi-agent-go/internal/api/handlers_test.go b/flnxi-agent-go/internal/api/handlers_test.go new file mode 100644 index 0000000..786848b --- /dev/null +++ b/flnxi-agent-go/internal/api/handlers_test.go @@ -0,0 +1,199 @@ +package api + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "flnxi-agent/internal/docker" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" +) + +func TestResolveUpdateImageRejectsDirectImageWithoutFixedTag(t *testing.T) { + tests := []struct { + name string + image string + wantErr string + }{ + {name: "no tag", image: "registry.local:5000/material/flnxi-material", wantErr: "fixed version tag"}, + {name: "latest", image: "registry.local:5000/material/flnxi-material:latest", wantErr: "latest is not allowed"}, + {name: "empty tag", image: "registry.local:5000/material/flnxi-material:", wantErr: "fixed version tag"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolveUpdateImage(UpdateRequest{Image: tt.image}) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("resolveUpdateImage() error = %v, want contains %q", err, tt.wantErr) + } + }) + } +} + +func TestResolveUpdateImageAcceptsDirectImageWithFixedTag(t *testing.T) { + image := "registry.local:5000/material/flnxi-material:1.2.3" + got, err := resolveUpdateImage(UpdateRequest{Image: image}) + if err != nil { + t.Fatalf("resolveUpdateImage() unexpected error: %v", err) + } + if got != image { + t.Fatalf("resolveUpdateImage() = %q, want %q", got, image) + } +} + +func TestResolveUpdateImageRejectsLatestVersion(t *testing.T) { + t.Setenv(envMaterialImageRepository, "registry.local:5000/material/flnxi-material") + + _, err := resolveUpdateImage(UpdateRequest{Version: "LATEST"}) + if err == nil || !strings.Contains(err.Error(), "latest is not allowed") { + t.Fatalf("resolveUpdateImage() error = %v, want latest rejection", err) + } +} + +func TestRunUpdateRestoresOldContainerAfterHealthCheckFailure(t *testing.T) { + resetUpdateStatusForTest() + + old := &docker.ContainerInfo{ + ID: "old-container", + ImageID: "sha256:old", + State: "running", + Config: &container.Config{ + Image: "registry.local:5000/material/flnxi-material:1.0.0", + User: "1000", + Env: []string{"A=B"}, + ExposedPorts: nat.PortSet{"3000/tcp": struct{}{}}, + }, + HostConfig: &container.HostConfig{ + Binds: []string{"/data/material:/app/data"}, + NetworkMode: "flnxi-network", + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + PortBindings: nat.PortMap{"3000/tcp": []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "3000"}}}, + }, + } + fake := &fakeUpdateDocker{container: old, healthErr: errors.New("not healthy")} + + runUpdate(fake, UpdateRequest{Image: "registry.local:5000/material/flnxi-material:2.0.0"}) + + statusMutex.RLock() + status := *updateStatus + statusMutex.RUnlock() + if status.Status != "failed" { + t.Fatalf("update status = %q, want failed", status.Status) + } + if !strings.Contains(status.Error, "已恢复旧版本容器") { + t.Fatalf("update error = %q, want rollback success message", status.Error) + } + if fake.container == nil { + t.Fatal("container was not restored") + } + if fake.container.Config.Image != old.Config.Image { + t.Fatalf("restored image = %q, want %q", fake.container.Config.Image, old.Config.Image) + } + if fake.container.Config.User != old.Config.User { + t.Fatalf("restored user = %q, want %q", fake.container.Config.User, old.Config.User) + } + if len(fake.createdImages) != 2 { + t.Fatalf("created images = %v, want new image then old image", fake.createdImages) + } + if fake.createdImages[0] != "registry.local:5000/material/flnxi-material:2.0.0" { + t.Fatalf("first created image = %q, want new image", fake.createdImages[0]) + } + if fake.createdImages[1] != old.Config.Image { + t.Fatalf("second created image = %q, want old image", fake.createdImages[1]) + } + if !contains(fake.removedIDs, "old-container") || !contains(fake.removedIDs, "new-container") { + t.Fatalf("removed IDs = %v, want old and failed new containers removed", fake.removedIDs) + } +} + +func resetUpdateStatusForTest() { + statusMutex.Lock() + updateStatus = &UpdateStatus{Status: "running", Step: "初始化", Progress: 0} + statusMutex.Unlock() +} + +type fakeUpdateDocker struct { + container *docker.ContainerInfo + healthErr error + pulledImages []string + createdImages []string + removedIDs []string + stoppedIDs []string + startedIDs []string +} + +func (f *fakeUpdateDocker) GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) { + return f.container, nil +} + +func (f *fakeUpdateDocker) PullImage(ctx context.Context, image string, authStr string) error { + f.pulledImages = append(f.pulledImages, image) + return nil +} + +func (f *fakeUpdateDocker) StopContainer(ctx context.Context, containerID string) error { + f.stoppedIDs = append(f.stoppedIDs, containerID) + if f.container != nil && f.container.ID == containerID { + f.container.State = "exited" + } + return nil +} + +func (f *fakeUpdateDocker) RemoveContainer(ctx context.Context, containerID string) error { + f.removedIDs = append(f.removedIDs, containerID) + if f.container != nil && f.container.ID == containerID { + f.container = nil + } + return nil +} + +func (f *fakeUpdateDocker) StartContainer(ctx context.Context, containerID string) error { + f.startedIDs = append(f.startedIDs, containerID) + if f.container != nil && f.container.ID == containerID { + f.container.State = "running" + } + return nil +} + +func (f *fakeUpdateDocker) CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) { + f.createdImages = append(f.createdImages, config.Image) + id := "new-container" + if strings.Contains(config.Image, ":1.0.0") { + id = "restored-container" + } + f.container = &docker.ContainerInfo{ + ID: id, + ImageID: "sha256:" + id, + State: "running", + Config: config, + HostConfig: hostConfig, + } + return id, nil +} + +func (f *fakeUpdateDocker) WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error { + return f.healthErr +} + +func (f *fakeUpdateDocker) RemoveImage(ctx context.Context, imageID string) error { + return nil +} + +func (f *fakeUpdateDocker) PruneImages(ctx context.Context) error { + return nil +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +var _ updateDockerClient = (*fakeUpdateDocker)(nil) diff --git a/flnxi-agent-go/internal/docker/client.go b/flnxi-agent-go/internal/docker/client.go index 94498c1..d3b3833 100644 --- a/flnxi-agent-go/internal/docker/client.go +++ b/flnxi-agent-go/internal/docker/client.go @@ -193,8 +193,12 @@ func (c *Client) CreateAndStartContainer(ctx context.Context, config *container. // WaitForHealthy 等待容器健康 func (c *Client) WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error { + if timeout <= 0 { + return fmt.Errorf("等待容器 %s 健康超时: %s", containerID, timeout) + } + deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { + for { inspect, err := c.cli.ContainerInspect(ctx, containerID) if err != nil { return err @@ -211,9 +215,24 @@ func (c *Client) WaitForHealthy(ctx context.Context, containerID string, timeout } } - time.Sleep(2 * time.Second) + remaining := time.Until(deadline) + if remaining <= 0 { + return fmt.Errorf("等待容器 %s 健康超时: %s", containerID, timeout) + } + + sleepFor := 2 * time.Second + if remaining < sleepFor { + sleepFor = remaining + } + + timer := time.NewTimer(sleepFor) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } } - return nil } // GetDockerInfo 获取 Docker 信息 diff --git a/flnxi-agent-go/internal/docker/client_test.go b/flnxi-agent-go/internal/docker/client_test.go new file mode 100644 index 0000000..bc87972 --- /dev/null +++ b/flnxi-agent-go/internal/docker/client_test.go @@ -0,0 +1,68 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + dockerclient "github.com/docker/docker/client" +) + +func TestWaitForHealthyReturnsErrorOnTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/containers/test-container/json") { + t.Fatalf("unexpected Docker API path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "State": map[string]any{ + "Running": true, + "Health": map[string]any{ + "Status": "starting", + }, + }, + }) + })) + defer server.Close() + + cli, err := dockerclient.NewClientWithOpts(dockerclient.WithHost(server.URL), dockerclient.WithVersion("1.43")) + if err != nil { + t.Fatalf("NewClientWithOpts() error: %v", err) + } + client := &Client{cli: cli} + + err = client.WaitForHealthy(context.Background(), "test-container", time.Millisecond) + if err == nil { + t.Fatal("WaitForHealthy() error = nil, want timeout error") + } + if !strings.Contains(err.Error(), "超时") { + t.Fatalf("WaitForHealthy() error = %v, want timeout error", err) + } +} + +func TestWaitForHealthyAcceptsRunningContainerWithoutHealthcheck(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/containers/test-container/json") { + t.Fatalf("unexpected Docker API path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "State": map[string]any{ + "Running": true, + }, + }) + })) + defer server.Close() + + cli, err := dockerclient.NewClientWithOpts(dockerclient.WithHost(server.URL), dockerclient.WithVersion("1.43")) + if err != nil { + t.Fatalf("NewClientWithOpts() error: %v", err) + } + client := &Client{cli: cli} + + if err := client.WaitForHealthy(context.Background(), "test-container", time.Second); err != nil { + t.Fatalf("WaitForHealthy() unexpected error: %v", err) + } +} diff --git a/license-server/.env.example b/license-server/.env.example index c3a0a2b..9147930 100644 --- a/license-server/.env.example +++ b/license-server/.env.example @@ -4,13 +4,15 @@ POSTGRES_PASSWORD="replace-with-strong-postgres-password" POSTGRES_DB="license_db" # 数据库连接 +# Docker Compose 部署时,服务容器内应使用 postgres 作为数据库主机名。 +# 裸机本地调试时,可把 postgres 改为 localhost。 # 主数据库(管理项目和用户) -MAIN_DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@localhost:5432/license_db?schema=main" +MAIN_DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@postgres:5432/license_db?schema=main" # 主库加密密钥(用于加密项目数据库密码,至少 32 字节) MAIN_DB_ENCRYPTION_KEY="replace-with-32-byte-or-longer-encryption-key" # 项目数据库(默认项目,兼容旧配置) -DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@localhost:5432/license_db?schema=public" +DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@postgres:5432/license_db?schema=public" # RSA 密钥(生产环境必须使用 generate-keys 脚本生成,不要使用默认值) RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" @@ -23,5 +25,8 @@ AES_SECRET_KEY="replace-with-64-char-hex-key" # 警告:未配置或长度不足将导致管理员认证功能不可用 JWT_SECRET="your-jwt-secret-key-at-least-32-bytes-long" +# 许可证请求签名密钥(生产环境必须配置,最少 32 字节;素材系统需使用同一值) +LICENSE_REQUEST_SIGNING_SECRET="replace-with-32-byte-or-longer-request-signing-secret" + # 管理员初始密码(只作为初始化入口,不要保留弱默认值) ADMIN_INITIAL_PASSWORD="replace-with-strong-initial-admin-password" diff --git a/license-server/lib/__tests__/env-example.test.ts b/license-server/lib/__tests__/env-example.test.ts new file mode 100644 index 0000000..ae9a0a2 --- /dev/null +++ b/license-server/lib/__tests__/env-example.test.ts @@ -0,0 +1,16 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +describe('license-server 首装环境示例', () => { + const envExample = fs.readFileSync(path.join(__dirname, '..', '..', '.env.example'), 'utf8'); + + it('应该包含许可证请求签名密钥,避免 compose 首装时缺变量失败', () => { + expect(envExample).toContain('LICENSE_REQUEST_SIGNING_SECRET='); + }); + + it('Compose 示例数据库地址应该使用 postgres 服务名', () => { + expect(envExample).toContain('@postgres:5432/license_db?schema=main'); + expect(envExample).toContain('@postgres:5432/license_db?schema=public'); + expect(envExample).not.toContain('@localhost:5432/license_db?schema='); + }); +}); diff --git a/license-server/scripts/generate-keys.ts b/license-server/scripts/generate-keys.ts index fc58ddb..c179363 100644 --- a/license-server/scripts/generate-keys.ts +++ b/license-server/scripts/generate-keys.ts @@ -21,15 +21,19 @@ const aesKey = crypto.randomBytes(32).toString('hex'); // 生成 JWT 密钥 const jwtSecret = crypto.randomBytes(32).toString('hex'); +// 生成许可证请求签名密钥 +const licenseRequestSigningSecret = crypto.randomBytes(32).toString('hex'); + // 主库加密密钥 const mainDbEncryptionKey = crypto.randomBytes(32).toString('hex'); -// Docker / 本地默认数据库参数 +// Docker Compose 默认数据库参数;裸机本地调试可用 LICENSE_DB_HOST=localhost 覆盖 const postgresUser = 'license'; const postgresPassword = crypto.randomBytes(18).toString('base64url'); const postgresDb = 'license_db'; -const mainDatabaseUrl = `postgresql://${postgresUser}:${postgresPassword}@localhost:5432/${postgresDb}?schema=main`; -const projectDatabaseUrl = `postgresql://${postgresUser}:${postgresPassword}@localhost:5432/${postgresDb}?schema=public`; +const databaseHost = process.env.LICENSE_DB_HOST?.trim() || 'postgres'; +const mainDatabaseUrl = `postgresql://${postgresUser}:${postgresPassword}@${databaseHost}:5432/${postgresDb}?schema=main`; +const projectDatabaseUrl = `postgresql://${postgresUser}:${postgresPassword}@${databaseHost}:5432/${postgresDb}?schema=public`; const adminInitialPassword = crypto.randomBytes(18).toString('base64url'); // 创建 keys 目录 @@ -44,6 +48,8 @@ fs.writeFileSync(path.join(keysDir, 'public.key'), publicKey); // 生成 .env 内容 const envContent = `# 数据库连接 +# Docker Compose 部署默认使用 postgres 作为数据库主机名。 +# 裸机本地调试可先设置 LICENSE_DB_HOST=localhost 再运行 generate-keys。 POSTGRES_USER="${postgresUser}" POSTGRES_PASSWORD="${postgresPassword}" POSTGRES_DB="${postgresDb}" @@ -64,6 +70,9 @@ AES_SECRET_KEY="${aesKey}" # JWT 密钥 JWT_SECRET="${jwtSecret}" +# 许可证请求签名密钥(素材系统需使用同一值) +LICENSE_REQUEST_SIGNING_SECRET="${licenseRequestSigningSecret}" + # 管理员初始密码 ADMIN_INITIAL_PASSWORD="${adminInitialPassword}" `; @@ -84,3 +93,4 @@ console.log('─'.repeat(50)); console.log(publicKey); console.log(''); console.log('📋 许可证服务初始管理员密码已写入 license-server/.env 的 ADMIN_INITIAL_PASSWORD,不在终端日志中输出明文。'); +console.log('📋 许可证请求签名密钥已写入 license-server/.env 的 LICENSE_REQUEST_SIGNING_SECRET,请在素材系统中配置同一值。'); diff --git a/scripts/__tests__/deploy-registry-script.test.ts b/scripts/__tests__/deploy-registry-script.test.ts index 5da4861..bab09ab 100644 --- a/scripts/__tests__/deploy-registry-script.test.ts +++ b/scripts/__tests__/deploy-registry-script.test.ts @@ -18,8 +18,10 @@ describe('deploy-registry.ps1', () => { expect(script).toContain('[switch]$NoRollback') expect(script).toContain('rollback()') expect(script).toContain('trap rollback ERR') + expect(script.indexOf('trap rollback ERR')).toBeLessThan(script.indexOf('upsert_env APP_IMAGE')) expect(script).toContain('rollback_image="$old_container_image"') expect(script).toContain('docker compose -f "$compose_file" up -d --no-deps --pull never --force-recreate "$compose_service" || true') + expect(script).toContain('自动回滚只恢复应用镜像和 .env,不自动恢复数据库') }) it('部署后校验运行镜像、版本文件和数据库迁移状态', () => { diff --git a/scripts/__tests__/docker-entrypoint.test.ts b/scripts/__tests__/docker-entrypoint.test.ts index 3957a17..4b2f9dd 100644 --- a/scripts/__tests__/docker-entrypoint.test.ts +++ b/scripts/__tests__/docker-entrypoint.test.ts @@ -10,12 +10,63 @@ describe('scripts/docker-entrypoint.sh', () => { ) it('数据库迁移失败时必须停止容器启动', () => { + expect(entrypoint).toContain('require_real_env "NEXTAUTH_SECRET"') + expect(entrypoint).toContain('require_real_env "APP_PUBLIC_URL"') + expect(entrypoint).toContain('require_real_env "LICENSE_PUBLIC_KEY"') + expect(entrypoint).toContain('require_real_env "LOCAL_ENCRYPTION_KEY"') + expect(entrypoint).toContain('require_real_env "S3_PUBLIC_DOMAIN"') + expect(entrypoint).toContain('45.249.208.118') + expect(entrypoint).toContain('postgresql://username:password@host:5432/database') expect(entrypoint).toContain('npx prisma migrate deploy') expect(entrypoint).toContain('数据库迁移失败') expect(entrypoint).toMatch(/数据库迁移失败[\s\S]*exit 1/) expect(entrypoint).not.toContain('数据库迁移失败,尝试继续启动') }) + it('生产占位环境变量必须在连接数据库前被拦截', () => { + if (process.platform !== 'linux') { + return + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flnxi-entrypoint-env-')) + const binDir = path.join(tmpDir, 'bin') + fs.mkdirSync(binDir) + + fs.writeFileSync( + path.join(binDir, 'npx'), + ['#!/bin/sh', 'echo npx-should-not-run', 'exit 99', ''].join('\n'), + { mode: 0o755 } + ) + + const scriptPath = path.join(process.cwd(), 'scripts/docker-entrypoint.sh') + const result = spawnSync('sh', [scriptPath], { + cwd: tmpDir, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + DATABASE_URL: 'postgresql://postgres:strong-password@postgres:5432/flnxi', + APP_PUBLIC_URL: 'http://192.168.1.20:4320', + NEXTAUTH_URL: 'http://192.168.1.20:4320', + NEXTAUTH_SECRET: 'replace-with-strong-secret', + LICENSE_SERVER_URL: 'https://license.example.test', + LICENSE_PROJECT_CODE: 'flnxi-sucai', + LICENSE_REQUEST_SIGNING_SECRET: 'a'.repeat(32), + LICENSE_PUBLIC_KEY: '-----BEGIN PUBLIC KEY-----\\nabc\\n-----END PUBLIC KEY-----', + LOCAL_ENCRYPTION_KEY: 'b'.repeat(32), + S3_ENDPOINT: 'http://minio:9000', + S3_BUCKET: 'flnxi-materials', + S3_ACCESS_KEY_ID: 'minioadmin', + S3_SECRET_ACCESS_KEY: 'minio-secret', + S3_PUBLIC_DOMAIN: 'http://192.168.1.20:9000/flnxi-materials', + }, + encoding: 'utf8', + }) + + expect(result.status).toBe(1) + expect(`${result.stdout}\n${result.stderr}`).toContain('NEXTAUTH_SECRET 未配置为真实值') + expect(`${result.stdout}\n${result.stderr}`).not.toContain('npx-should-not-run') + }) + it('迁移命令真实失败时不会继续启动 server.js', () => { if (process.platform !== 'linux') { return @@ -58,6 +109,20 @@ describe('scripts/docker-entrypoint.sh', () => { env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + DATABASE_URL: 'postgresql://postgres:strong-password@postgres:5432/flnxi', + APP_PUBLIC_URL: 'http://192.168.1.20:4320', + NEXTAUTH_URL: 'http://192.168.1.20:4320', + NEXTAUTH_SECRET: 'strong-nextauth-secret', + LICENSE_SERVER_URL: 'https://license.example.test', + LICENSE_PROJECT_CODE: 'flnxi-sucai', + LICENSE_REQUEST_SIGNING_SECRET: 'a'.repeat(32), + LICENSE_PUBLIC_KEY: '-----BEGIN PUBLIC KEY-----\\nabc\\n-----END PUBLIC KEY-----', + LOCAL_ENCRYPTION_KEY: 'b'.repeat(32), + S3_ENDPOINT: 'http://minio:9000', + S3_BUCKET: 'flnxi-materials', + S3_ACCESS_KEY_ID: 'minioadmin', + S3_SECRET_ACCESS_KEY: 'minio-secret', + S3_PUBLIC_DOMAIN: 'http://192.168.1.20:9000/flnxi-materials', }, encoding: 'utf8', }) diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 2188a66..90c0f2f 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -10,10 +10,48 @@ set -e +is_placeholder_value() { + value="$1" + case "$value" in + ""|replace-with-*|your-*|*example.com*|*x.x*|*45.249.208.118*|*"<"*">"*|postgresql://username:password@host:5432/database) + return 0 + ;; + *) + return 1 + ;; + esac +} + +require_real_env() { + name="$1" + eval "value=\${$name:-}" + if is_placeholder_value "$value"; then + echo "✗ 环境变量 $name 未配置为真实值,请先修正 .env 后再启动" + exit 1 + fi +} + echo "==========================================" echo "FLNXI 素材库系统启动中..." echo "==========================================" +echo "[0/3] 检查生产环境变量..." +require_real_env "DATABASE_URL" +require_real_env "APP_PUBLIC_URL" +require_real_env "NEXTAUTH_URL" +require_real_env "NEXTAUTH_SECRET" +require_real_env "LICENSE_SERVER_URL" +require_real_env "LICENSE_PROJECT_CODE" +require_real_env "LICENSE_REQUEST_SIGNING_SECRET" +require_real_env "LICENSE_PUBLIC_KEY" +require_real_env "LOCAL_ENCRYPTION_KEY" +require_real_env "S3_ENDPOINT" +require_real_env "S3_BUCKET" +require_real_env "S3_ACCESS_KEY_ID" +require_real_env "S3_SECRET_ACCESS_KEY" +require_real_env "S3_PUBLIC_DOMAIN" +echo "✓ 生产环境变量检查通过" + # 1. 等待数据库就绪 echo "[1/3] 检查数据库连接..." MAX_RETRIES=30