diff --git a/README.md b/README.md index 2db4644..df94d5d 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ LOCAL_ENCRYPTION_KEY= 内置 PostgreSQL 部署时 `DATABASE_URL` 留空,由 Compose 自动指向内置数据库;只有外部 PostgreSQL 才填写真实连接串。`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 中的项目配置一致。 +付费模块采用“镜像站放模块核心本体,验证服务器发授权清单和实例凭证”的分工;部署 `license-server` 时还要确认 `PREMIUM_MODULE_REGISTRY_BASE` 和模块版本指向当前可用镜像站。 + ### 初始化 Prisma ```bash diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index d52b194..3fee0f3 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -90,6 +90,7 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 - `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。 - `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。 +- 付费模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单。 - `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。 diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index 0860796..e5573b2 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -455,9 +455,9 @@ - `license-server/docker-compose.yml`、`license-server/nginx/nginx.conf`:许可证服务默认 Compose 已改为由 Nginx 对外暴露 `9527`,应用容器只在内部网络暴露 `3000`;现有登录和 API 限流不再只是孤立配置。 - `license-server/lib/abuse-detection.ts`、`license-server/app/api/license/[projectCode]/register-free/route.ts`、`activate/verify/heartbeat`:免费版新增同设备重复注册、多实例/多指纹复用阻断;付费版多设备疑似复用先告警,不直接误伤客户。 - `license-server/app/api/license/[projectCode]/register-free/route.ts`:免费注册复用已有激活前会检查许可证禁用和过期状态;首条心跳记录统一写入硬件指纹哈希,避免后续被误判为多设备。 -- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts`、`license-server/lib/premium-module-manifest.ts`:新增付费模块清单和单模块下载接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并下发绑定当前实例的加密模块包;硬件指纹明显不匹配时拒绝并告警。 -- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,再逐个下载模块包,本地校验 RSA 签名、租约、当前许可证类型、包大小和 SHA256 后加密缓存,避免降级后继续使用旧 PRO 模块包。 -- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单 + 本地有效模块包”三重门槛;没有验证服务器下发并校验通过的模块包时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。 +- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts`、`license-server/lib/premium-module-manifest.ts`:付费模块链路已调整为“镜像站放模块核心本体,验证服务器发签名清单和实例绑定授权凭证”。免费版拒绝,付费版按许可证类型返回带 Registry 镜像地址的签名 manifest;硬件指纹明显不匹配时拒绝并告警。 +- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,再获取小型授权凭证,本地校验 RSA 签名、实例、租约、当前许可证类型、镜像地址、大小和 SHA256 后加密缓存,避免降级后继续使用旧 PRO 模块凭证。 +- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单 + 本地有效授权凭证”三重门槛;没有验证服务器下发并校验通过的授权凭证时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。 - `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖 `hasPremiumModule()`。 - `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期、在线租约过期、高级功能不可用、数量限制不放行、本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效,以及旧许可证缺少 `leaseExpiresAt` 时的兼容行为。 - `lib/__tests__/license-types.test.ts`、`app/api/license/check/__tests__/route.test.ts`:新增测试锁定默认租约天数、租约截断逻辑,以及租约过期时不恢复激活 cookie。 @@ -487,8 +487,8 @@ - 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。 - 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。 - 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。 -- 已完成:付费模块 manifest 和实例绑定加密模块包链路已落地,适合低配置验证服务器和约 5M 带宽;自定义模板和内网穿透已经接入本地有效模块包门槛,验证服务器不承担重计算,只做轻量签名、加密包生成和下载。 -- 后续增强:如果要做到更强的“核心业务实现完全不在镜像里”,还需要继续把自定义模板/内网穿透内部服务端实现逐步移动到模块加载层;当前本轮已经完成的是清单、下载、hash 校验、加密缓存和服务端入口强制依赖模块包的闭环。 +- 已完成:付费模块 manifest 和实例绑定授权凭证链路已落地,模块核心本体位置由 Registry 镜像地址承载;自定义模板和内网穿透已经接入本地有效授权凭证门槛,验证服务器不承担大文件下载站职责。 +- 后续增强:如果要做到更强的“核心业务实现完全不在主应用镜像里”,还需要继续把自定义模板/内网穿透内部服务端实现逐步移动到独立模块镜像或模块加载层;当前本轮完成的是清单、镜像地址、授权凭证、hash 校验、加密缓存和服务端入口强制依赖凭证的闭环。 ### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复) diff --git a/docs/客户首装升级坑位排查_20260530.md b/docs/客户首装升级坑位排查_20260530.md index 40135eb..285cea6 100644 --- a/docs/客户首装升级坑位排查_20260530.md +++ b/docs/客户首装升级坑位排查_20260530.md @@ -62,7 +62,7 @@ 证据: -- `license-server/docker-compose.yml:29` 强制要求 `LICENSE_REQUEST_SIGNING_SECRET`。 +- `license-server/docker-compose.yml` 强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,并要求配置 `PREMIUM_MODULE_REGISTRY_BASE`,用于把付费模块核心本体指向镜像站。 - `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`。 @@ -385,7 +385,7 @@ NAS 上目录常由宿主机 root 或图形界面用户创建,容器内 `nextj - 使用同一隔离环境完成备份、恢复和恢复后健康验收:`scripts/backup-restore.ps1 -Action Backup` 生成 `postgres.sql` 和 `minio-data.tar.gz`,`-Action Restore -ConfirmRestore RESTORE_FLNXI_DATA` 恢复成功。 - 使用临时本机许可证模拟服务完成免费版激活,并用登录后的管理员会话上传一条“材质”素材,接口返回 `201 Created`。 - 本轮修复后执行 `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`。 +- 本轮修复后执行 `docker compose -f license-server/docker-compose.yml --env-file license-server/.env.example config`,确认示例环境文件已包含 `LICENSE_REQUEST_SIGNING_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`,并展开到 `@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 全量测试通过。 diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index c5bd731..2d76ffd 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -36,6 +36,15 @@ LICENSE_PROJECT_CODE=flnxi-sucai Go Agent 目前走的是另一条现状链路,文档和脚本还没有和 Node Agent 完全统一;接入时不要默认它也已经接好了同一套 `LICENSE_SERVER_URL` / `LICENSE_PROJECT_CODE` 版本检查流程。 +`license-server` 自身还需要配置付费模块的镜像站位置: + +```env +PREMIUM_MODULE_REGISTRY_BASE=registry.example.com/flnxi/premium +PREMIUM_MODULE_VERSION=1.0.0 +``` + +如某个模块要单独指定镜像,可使用 `PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE`、`PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE` 这类变量覆盖。原则是:模块核心本体在镜像站,验证服务器只发授权清单和小型凭证。 + ## 2. 联调入口规则 历史文档里曾记录过内部联调 IP、测试机连通性和临时域名状态。那些信息属于一次性现场快照,不是当前项目默认事实来源,也不应写入客户交付环境。 @@ -97,7 +106,7 @@ GET /api/version/{projectCode}/agent - `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。 - `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。 -- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块清单和对应模块包;免费版会跳过,不访问远端模块接口。 +- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块授权清单和对应授权凭证;免费版会跳过,不访问远端模块接口。 - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 @@ -113,10 +122,11 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... - 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。 - 远端 `POST /api/license/{projectCode}/premium-modules` 是付费模块清单接口,要求同一套 HMAC 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。 -- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个付费模块包下载接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块权限。 -- `premium-modules` 返回签名 manifest(模块清单),每个模块带真实 `sizeBytes` 和 `sha256`;主应用随后下载实例绑定的加密模块包,校验大小和 SHA256 后再本地加密缓存。 -- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应模块包;许可证功能开关开启但没有有效模块包时,服务端仍会拒绝。 -- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId}` 当前会校验请求 HMAC 签名;返回的许可证、付费模块清单和模块包必须通过签名、hash、大小、租约和许可证类型检查后才能缓存或放行。 +- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个付费模块授权凭证接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块权限。 +- `premium-modules` 返回签名 manifest(模块清单),每个模块会带镜像站上的模块本体地址、实例绑定信息、授权凭证大小和 SHA256;主应用随后只获取实例绑定的小型授权凭证,校验大小和 SHA256 后再本地加密缓存。 +- 付费模块核心本体应放在镜像站或等价的私有制品仓库,验证服务器只负责授权判断、签名清单和授权凭证,不承担大文件下载站职责。 +- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应授权凭证;许可证功能开关开启但没有有效授权凭证时,服务端仍会拒绝。 +- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId}` 当前会校验请求 HMAC 签名;返回的许可证、付费模块清单和授权凭证必须通过签名、hash、大小、租约、实例和许可证类型检查后才能缓存或放行。 ## 5. 后台管理入口 diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts index bdf9bc7..f660d4f 100644 --- a/lib/__tests__/premium-module-service.test.ts +++ b/lib/__tests__/premium-module-service.test.ts @@ -24,7 +24,7 @@ jest.mock('../license/license-service', () => ({ jest.mock('../license/crypto-service', () => ({ encryptLocal: jest.fn((data) => `encrypted:${data}`), decryptLocal: jest.fn((data) => data.replace('encrypted:', '')), - hashData: jest.fn((data) => (data === 'module-package' ? 'hash' : 'unknown-hash')), + hashData: jest.fn((data) => (data === 'module-entitlement' ? 'hash' : 'unknown-hash')), verifySignature: jest.fn(), })); @@ -54,6 +54,25 @@ const mockVerifySignature = verifySignature as jest.MockedFunction; const mockIsLicenseConfigExpired = isLicenseConfigExpired as jest.MockedFunction; +const moduleArtifact = { + type: 'registry-image' as const, + image: 'registry.test/flnxi/premium/intranet-penetration-core:1.0.0', + digest: null, + version: '1.0.0', +}; + +function mockManifestModule(overrides: Record = {}) { + return { + id: 'intranet-penetration-core', + minLicenseType: 'PRO', + artifact: moduleArtifact, + sizeBytes: 18, + sha256: 'hash', + entitlementVersion: 1, + ...overrides, + }; +} + function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') { mockGetLocalLicense.mockResolvedValue({ instanceId: 'instance-1', @@ -119,7 +138,8 @@ describe('premium-module-service', () => { manifest: { version: 1, licenseType: 'PRO', - modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }], + instanceId: 'instance-1', + modules: [mockManifestModule()], issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), }, @@ -130,12 +150,13 @@ describe('premium-module-service', () => { ok: true, json: async () => ({ success: true, - package: { + entitlement: { moduleId: 'intranet-penetration-core', - contentBase64: 'module-package', + entitlementBase64: 'module-entitlement', sha256: 'hash', - sizeBytes: 14, + sizeBytes: 18, expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: moduleArtifact, }, }), }); @@ -176,7 +197,8 @@ describe('premium-module-service', () => { manifest: { version: 1, licenseType: 'PRO', - modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }], + instanceId: 'instance-1', + modules: [mockManifestModule({ sizeBytes: 4096 })], issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), }, @@ -190,7 +212,7 @@ describe('premium-module-service', () => { await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); }); - it('只有清单但缺少本地模块包时不应视为可用模块', async () => { + it('只有清单但缺少本地授权凭证时不应视为可用模块', async () => { mockPaidLicense('PRO'); (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { if (where.key === 'premium_module_manifest') { @@ -199,7 +221,8 @@ describe('premium-module-service', () => { manifest: { version: 1, licenseType: 'PRO', - modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }], + instanceId: 'instance-1', + modules: [mockManifestModule()], issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), }, @@ -217,7 +240,7 @@ describe('premium-module-service', () => { await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); }); - it('清单和本地模块包都有效时应视为可用模块', async () => { + it('清单和本地授权凭证都有效时应视为可用模块', async () => { mockPaidLicense('PRO'); (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { if (where.key === 'premium_module_manifest') { @@ -226,7 +249,8 @@ describe('premium-module-service', () => { manifest: { version: 1, licenseType: 'PRO', - modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }], + instanceId: 'instance-1', + modules: [mockManifestModule()], issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), }, @@ -236,15 +260,16 @@ describe('premium-module-service', () => { }); } - if (where.key === 'premium_module_packages') { + if (where.key === 'premium_module_entitlements') { return Promise.resolve({ value: `encrypted:${JSON.stringify({ - packages: { + entitlements: { 'intranet-penetration-core': { - contentBase64: 'module-package', + entitlementBase64: 'module-entitlement', sha256: 'hash', - sizeBytes: 14, + sizeBytes: 18, expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: moduleArtifact, syncedAt: new Date().toISOString(), }, }, @@ -267,7 +292,8 @@ describe('premium-module-service', () => { manifest: { version: 1, licenseType: 'PRO', - modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }], + instanceId: 'instance-1', + modules: [mockManifestModule({ sizeBytes: 4096 })], issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), }, diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts index 1a65641..cb30b65 100644 --- a/lib/license/premium-module-service.ts +++ b/lib/license/premium-module-service.ts @@ -9,12 +9,19 @@ import type { LicenseType } from './types'; export interface PremiumModuleManifest { version: number; licenseType: Exclude; + instanceId: string; modules: Array<{ id: string; minLicenseType: Exclude; + artifact: { + type: 'registry-image'; + image: string; + digest?: string | null; + version: string; + }; sizeBytes: number; sha256: string; - packageVersion?: number; + entitlementVersion?: number; }>; issuedAt: string; expiresAt: string; @@ -33,33 +40,35 @@ interface CachedPremiumModuleManifest { syncedAt: string; } -interface PremiumModulePackageResponse { +interface PremiumModuleEntitlementResponse { success: boolean; - package?: { + entitlement?: { moduleId: string; - contentBase64: string; + entitlementBase64: string; sha256: string; sizeBytes: number; expiresAt: string; + artifact: PremiumModuleManifest['modules'][number]['artifact']; }; error?: string; } -interface CachedPremiumModulePackages { - packages: Record< +interface CachedPremiumModuleEntitlements { + entitlements: Record< string, { - contentBase64: string; + entitlementBase64: string; sha256: string; sizeBytes: number; expiresAt: string; + artifact: PremiumModuleManifest['modules'][number]['artifact']; syncedAt: string; } >; } const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest'; -const PREMIUM_MODULE_PACKAGES_CONFIG_KEY = 'premium_module_packages'; +const PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY = 'premium_module_entitlements'; export const PREMIUM_MODULE_IDS = { CUSTOM_TEMPLATES_CORE: 'custom-templates-core', @@ -96,29 +105,29 @@ async function savePremiumModuleManifest( }); } -async function getCachedPremiumModulePackages(): Promise { +async function getCachedPremiumModuleEntitlements(): Promise { try { const config = await prisma.systemConfig.findUnique({ - where: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY }, + where: { key: PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY }, }); - if (!config) return { packages: {} }; + if (!config) return { entitlements: {} }; - const cached = JSON.parse(decryptLocal(config.value)) as CachedPremiumModulePackages; - if (!cached.packages || typeof cached.packages !== 'object') return { packages: {} }; + const cached = JSON.parse(decryptLocal(config.value)) as CachedPremiumModuleEntitlements; + if (!cached.entitlements || typeof cached.entitlements !== 'object') return { entitlements: {} }; return cached; } catch (error) { - console.warn('[License] 读取付费模块包缓存失败:', error); - return { packages: {} }; + console.warn('[License] 读取付费模块授权凭证缓存失败:', error); + return { entitlements: {} }; } } -async function savePremiumModulePackages(cached: CachedPremiumModulePackages): Promise { +async function savePremiumModuleEntitlements(cached: CachedPremiumModuleEntitlements): Promise { await prisma.systemConfig.upsert({ - where: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY }, + where: { key: PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY }, update: { value: encryptLocal(JSON.stringify(cached)) }, - create: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY, value: encryptLocal(JSON.stringify(cached)) }, + create: { key: PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY, value: encryptLocal(JSON.stringify(cached)) }, }); } @@ -140,6 +149,7 @@ export async function getCachedPremiumModuleManifest(): Promise Date.now(); + if (!left || !right) return false; + return ( + left.type === right.type && + left.image === right.image && + (left.digest || null) === (right.digest || null) && + left.version === right.version + ); } -async function downloadPremiumModulePackage( +function isCachedEntitlementValid( + cached: CachedPremiumModuleEntitlements, + module: PremiumModuleManifest['modules'][number] +): boolean { + const entitlement = cached.entitlements[module.id]; + if (!entitlement) return false; + if (!isSameArtifact(entitlement.artifact, module.artifact)) return false; + if (entitlement.sha256 !== module.sha256) return false; + if (entitlement.sizeBytes !== module.sizeBytes) return false; + if (hashData(entitlement.entitlementBase64) !== module.sha256) return false; + + const entitlementExpiresAt = new Date(entitlement.expiresAt).getTime(); + return !Number.isNaN(entitlementExpiresAt) && entitlementExpiresAt > Date.now(); +} + +async function downloadPremiumModuleEntitlement( module: PremiumModuleManifest['modules'][number], fingerprint: Awaited> -): Promise { +): Promise { const signedRequest = buildSignedLicenseRequest('premium-module-download', { moduleId: module.id, instanceId: fingerprint.instanceId, @@ -183,25 +207,29 @@ async function downloadPremiumModulePackage( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signedRequest), }); - const data = (await response.json()) as PremiumModulePackageResponse; + const data = (await response.json()) as PremiumModuleEntitlementResponse; - if (!response.ok || !data.success || !data.package) { - throw new Error(data.error || `付费模块 ${module.id} 下载失败`); + if (!response.ok || !data.success || !data.entitlement) { + throw new Error(data.error || `付费模块 ${module.id} 授权凭证获取失败`); } - if (data.package.moduleId !== module.id) { + if (data.entitlement.moduleId !== module.id) { throw new Error(`付费模块 ${module.id} 返回内容不匹配`); } - if (data.package.sha256 !== module.sha256 || hashData(data.package.contentBase64) !== module.sha256) { - throw new Error(`付费模块 ${module.id} 哈希校验失败`); + if (!isSameArtifact(data.entitlement.artifact, module.artifact)) { + throw new Error(`付费模块 ${module.id} 镜像地址校验失败`); } - if (data.package.sizeBytes !== module.sizeBytes) { + if (data.entitlement.sha256 !== module.sha256 || hashData(data.entitlement.entitlementBase64) !== module.sha256) { + throw new Error(`付费模块 ${module.id} 授权凭证哈希校验失败`); + } + + if (data.entitlement.sizeBytes !== module.sizeBytes) { throw new Error(`付费模块 ${module.id} 大小校验失败`); } - return data.package; + return data.entitlement; } export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: boolean; error?: string }> { @@ -243,28 +271,29 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: return { synced: false, error: '付费模块清单签名验证失败' }; } - const cachedPackages = await getCachedPremiumModulePackages(); + const cachedEntitlements = await getCachedPremiumModuleEntitlements(); for (const moduleInfo of data.manifest.modules) { - if (isCachedPackageValid(cachedPackages, moduleInfo)) { + if (isCachedEntitlementValid(cachedEntitlements, moduleInfo)) { continue; } - const modulePackage = await downloadPremiumModulePackage(moduleInfo, fingerprint); - if (!modulePackage) { - throw new Error(`付费模块 ${moduleInfo.id} 下载结果为空`); + const entitlement = await downloadPremiumModuleEntitlement(moduleInfo, fingerprint); + if (!entitlement) { + throw new Error(`付费模块 ${moduleInfo.id} 授权凭证为空`); } - cachedPackages.packages[moduleInfo.id] = { - contentBase64: modulePackage.contentBase64, - sha256: modulePackage.sha256, - sizeBytes: modulePackage.sizeBytes, - expiresAt: modulePackage.expiresAt, + cachedEntitlements.entitlements[moduleInfo.id] = { + entitlementBase64: entitlement.entitlementBase64, + sha256: entitlement.sha256, + sizeBytes: entitlement.sizeBytes, + expiresAt: entitlement.expiresAt, + artifact: entitlement.artifact, syncedAt: new Date().toISOString(), }; } await savePremiumModuleManifest(data.manifest, data.signature); - await savePremiumModulePackages(cachedPackages); + await savePremiumModuleEntitlements(cachedEntitlements); return { synced: true }; } catch (error) { const message = error instanceof Error ? error.message : '付费模块清单同步异常'; @@ -279,6 +308,6 @@ export async function hasPremiumModule(moduleId: string): Promise { const moduleInfo = cached.manifest.modules.find((item) => item.id === moduleId); if (!moduleInfo) return false; - const cachedPackages = await getCachedPremiumModulePackages(); - return isCachedPackageValid(cachedPackages, moduleInfo); + const cachedEntitlements = await getCachedPremiumModuleEntitlements(); + return isCachedEntitlementValid(cachedEntitlements, moduleInfo); } diff --git a/license-server/.env.example b/license-server/.env.example index 9147930..fd565e7 100644 --- a/license-server/.env.example +++ b/license-server/.env.example @@ -28,5 +28,13 @@ 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" +# 付费模块分发 +# 模块核心本体放在镜像站,验证服务器只下发授权清单和实例绑定凭证。 +PREMIUM_MODULE_REGISTRY_BASE="registry.example.com/flnxi/premium" +PREMIUM_MODULE_VERSION="1.0.0" +# 如需单独覆盖某个模块镜像,可使用: +# PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE="registry.example.com/flnxi/premium/custom-templates-core:1.0.0" +# PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE="registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0" + # 管理员初始密码(只作为初始化入口,不要保留弱默认值) ADMIN_INITIAL_PASSWORD="replace-with-strong-initial-admin-password" diff --git a/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts b/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts index 54019ea..1d071cf 100644 --- a/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts +++ b/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPublicProjectDb } from '@/lib/project-auth'; import { verifyLicenseRequestSignature } from '@/lib/request-signature'; -import { buildPremiumModulePackage } from '@/lib/premium-module-manifest'; +import { buildPremiumModuleEntitlement } from '@/lib/premium-module-manifest'; import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection'; import { z } from 'zod'; @@ -78,7 +78,7 @@ export async function POST( const { license } = activation; if (license.licenseType === 'FREE') { return NextResponse.json( - { success: false, error: '免费版不提供付费模块包' }, + { success: false, error: '免费版不提供付费模块授权凭证' }, { status: 403 } ); } @@ -107,14 +107,14 @@ export async function POST( }); return NextResponse.json( - { success: false, error: '硬件指纹不匹配,无法下载付费模块包' }, + { success: false, error: '硬件指纹不匹配,无法获取付费模块授权凭证' }, { status: 403 } ); } const config = generateLicenseConfig(license.licenseType, license.expiresAt); const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now()); - const modulePackage = buildPremiumModulePackage({ + const entitlement = buildPremiumModuleEntitlement({ moduleId, licenseType: license.licenseType, instanceId, @@ -123,10 +123,10 @@ export async function POST( return NextResponse.json({ success: true, - package: modulePackage, + entitlement, }); } catch (error) { - console.error('付费模块包下载失败:', error); + console.error('付费模块授权凭证生成失败:', error); return NextResponse.json( { success: false, error: '服务器内部错误' }, { status: 500 } diff --git a/license-server/app/api/license/[projectCode]/premium-modules/route.ts b/license-server/app/api/license/[projectCode]/premium-modules/route.ts index a0e3371..2d8526e 100644 --- a/license-server/app/api/license/[projectCode]/premium-modules/route.ts +++ b/license-server/app/api/license/[projectCode]/premium-modules/route.ts @@ -75,7 +75,7 @@ export async function POST( const { license } = activation; if (license.licenseType === 'FREE') { return NextResponse.json( - { success: false, error: '免费版不提供付费模块包' }, + { success: false, error: '免费版不提供付费模块授权清单' }, { status: 403 } ); } @@ -103,7 +103,7 @@ export async function POST( }); return NextResponse.json( - { success: false, error: '硬件指纹不匹配,无法下载付费模块包' }, + { success: false, error: '硬件指纹不匹配,无法获取付费模块授权清单' }, { status: 403 } ); } @@ -121,7 +121,7 @@ export async function POST( ...signedManifest, }); } catch (error) { - console.error('付费模块包清单生成失败:', error); + console.error('付费模块授权清单生成失败:', error); return NextResponse.json( { success: false, error: '服务器内部错误' }, { status: 500 } diff --git a/license-server/deploy.ps1 b/license-server/deploy.ps1 index 1a74a6f..8864987 100644 --- a/license-server/deploy.ps1 +++ b/license-server/deploy.ps1 @@ -6,6 +6,8 @@ param( [string]$User = "root", [string]$SshKey = "", [string]$RemotePath = "/opt/license-server", + [string]$PremiumModuleRegistryBase = "", + [string]$PremiumModuleVersion = "1.0.0", [switch]$SkipBuild = $false, [switch]$AcknowledgeLegacyInternalDeploy ) @@ -86,6 +88,9 @@ function Test-EnvConfig { "RSA_PRIVATE_KEY", "RSA_PUBLIC_KEY", "AES_SECRET_KEY", + "LICENSE_REQUEST_SIGNING_SECRET", + "PREMIUM_MODULE_REGISTRY_BASE", + "PREMIUM_MODULE_VERSION", "JWT_SECRET", "ADMIN_INITIAL_PASSWORD" ) @@ -103,6 +108,7 @@ function Test-EnvConfig { "your-main-db-encryption-key", "your-32-byte-hex-key", "replace-with", + "registry.example.com", "admin123", "license_secret" ) @@ -200,12 +206,17 @@ Write-Host "4. 检查环境变量配置..." -ForegroundColor White $envExists = ssh -i $SshKey "$User@$Server" "test -f $RemotePath/.env && echo 'exists'" if ($envExists -ne "exists") { Write-Host " 生成安全 .env 配置..." -ForegroundColor Yellow + if ([string]::IsNullOrWhiteSpace($PremiumModuleRegistryBase)) { + throw "请通过 -PremiumModuleRegistryBase 指定付费模块镜像站路径,例如 registry.example.com/flnxi/premium" + } + $postgresUser = "license" $postgresPassword = New-RandomBase64Url $postgresDb = "license_db" $jwtSecret = New-RandomHex $mainDbEncryptionKey = New-RandomHex $aesSecretKey = New-RandomHex + $licenseRequestSigningSecret = New-RandomHex $adminInitialPassword = New-RandomBase64Url $rsaKeys = New-RsaKeyPair $mainDatabaseUrl = "postgresql://${postgresUser}:${postgresPassword}@postgres:5432/${postgresDb}?schema=main" @@ -223,6 +234,9 @@ RSA_PRIVATE_KEY=$(Convert-ToEscapedEnvMultiline $rsaKeys.PrivateKey) RSA_PUBLIC_KEY=$(Convert-ToEscapedEnvMultiline $rsaKeys.PublicKey) AES_SECRET_KEY=$aesSecretKey JWT_SECRET=$jwtSecret +LICENSE_REQUEST_SIGNING_SECRET=$licenseRequestSigningSecret +PREMIUM_MODULE_REGISTRY_BASE=$PremiumModuleRegistryBase +PREMIUM_MODULE_VERSION=$PremiumModuleVersion ADMIN_INITIAL_PASSWORD=$adminInitialPassword "@ ssh -i $SshKey "$User@$Server" "cat > $RemotePath/.env << 'EOF' diff --git a/license-server/docker-compose.yml b/license-server/docker-compose.yml index 45570ff..56faf77 100644 --- a/license-server/docker-compose.yml +++ b/license-server/docker-compose.yml @@ -27,6 +27,14 @@ services: - RSA_PUBLIC_KEY=${RSA_PUBLIC_KEY:?RSA_PUBLIC_KEY is required} - AES_SECRET_KEY=${AES_SECRET_KEY:?AES_SECRET_KEY is required} - LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required} + - PREMIUM_MODULE_REGISTRY_BASE=${PREMIUM_MODULE_REGISTRY_BASE:?PREMIUM_MODULE_REGISTRY_BASE is required} + - PREMIUM_MODULE_VERSION=${PREMIUM_MODULE_VERSION:-1.0.0} + - PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE=${PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE:-} + - PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_TAG=${PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_TAG:-} + - PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST=${PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST:-} + - PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE=${PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE:-} + - PREMIUM_MODULE_INTRANET_PENETRATION_CORE_TAG=${PREMIUM_MODULE_INTRANET_PENETRATION_CORE_TAG:-} + - PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST=${PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST:-} - ADMIN_INITIAL_PASSWORD=${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD is required} - NODE_ENV=production depends_on: diff --git a/license-server/lib/__tests__/env-example.test.ts b/license-server/lib/__tests__/env-example.test.ts index ae9a0a2..454a690 100644 --- a/license-server/lib/__tests__/env-example.test.ts +++ b/license-server/lib/__tests__/env-example.test.ts @@ -8,6 +8,11 @@ describe('license-server 首装环境示例', () => { expect(envExample).toContain('LICENSE_REQUEST_SIGNING_SECRET='); }); + it('应该包含付费模块镜像站配置,避免验证服务器承担模块本体下载', () => { + expect(envExample).toContain('PREMIUM_MODULE_REGISTRY_BASE='); + expect(envExample).toContain('PREMIUM_MODULE_VERSION='); + }); + it('Compose 示例数据库地址应该使用 postgres 服务名', () => { expect(envExample).toContain('@postgres:5432/license_db?schema=main'); expect(envExample).toContain('@postgres:5432/license_db?schema=public'); diff --git a/license-server/lib/__tests__/premium-module-manifest.test.ts b/license-server/lib/__tests__/premium-module-manifest.test.ts index f5c3c6d..7aa2ceb 100644 --- a/license-server/lib/__tests__/premium-module-manifest.test.ts +++ b/license-server/lib/__tests__/premium-module-manifest.test.ts @@ -7,10 +7,15 @@ describe('premium-module-manifest', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z')); process.env.PREMIUM_MODULE_PACKAGE_SECRET = 'p'.repeat(32); + process.env.PREMIUM_MODULE_REGISTRY_BASE = 'registry.test/flnxi/premium'; + process.env.PREMIUM_MODULE_VERSION = '1.0.0'; }); afterEach(() => { jest.useRealTimers(); + delete process.env.PREMIUM_MODULE_PACKAGE_SECRET; + delete process.env.PREMIUM_MODULE_REGISTRY_BASE; + delete process.env.PREMIUM_MODULE_VERSION; }); it('应为标准版生成自定义模板核心模块清单', async () => { @@ -18,7 +23,14 @@ describe('premium-module-manifest', () => { const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'), 'instance-1'); expect(result.manifest.licenseType).toBe('STANDARD'); + expect(result.manifest.instanceId).toBe('instance-1'); expect(result.manifest.modules.map((module) => module.id)).toEqual(['custom-templates-core']); + expect(result.manifest.modules[0].artifact).toEqual({ + type: 'registry-image', + image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + digest: null, + version: '1.0.0', + }); expect(result.manifest.modules[0].sizeBytes).toBeGreaterThan(0); expect(result.manifest.modules[0].sha256).toMatch(/^[a-f0-9]{64}$/); expect(result.signature).toContain('signed:'); @@ -34,11 +46,11 @@ describe('premium-module-manifest', () => { ]); }); - it('应生成和清单 hash 匹配的实例绑定模块包', async () => { - const { buildPremiumModulePackage, buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); + it('应生成和清单 hash 匹配的实例绑定授权凭证', async () => { + const { buildPremiumModuleEntitlement, buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); const leaseExpiresAt = new Date('2026-07-25T00:00:00.000Z'); const manifest = buildSignedPremiumModuleManifest('PRO', leaseExpiresAt, 'instance-1').manifest; - const modulePackage = buildPremiumModulePackage({ + const entitlement = buildPremiumModuleEntitlement({ moduleId: 'intranet-penetration-core', licenseType: 'PRO', instanceId: 'instance-1', @@ -46,8 +58,18 @@ describe('premium-module-manifest', () => { }); const manifestModule = manifest.modules.find((module) => module.id === 'intranet-penetration-core'); - expect(modulePackage.sha256).toBe(manifestModule?.sha256); - expect(modulePackage.sizeBytes).toBe(manifestModule?.sizeBytes); - expect(modulePackage.contentBase64).not.toContain('intranetPenetration'); + expect(entitlement.sha256).toBe(manifestModule?.sha256); + expect(entitlement.sizeBytes).toBe(manifestModule?.sizeBytes); + expect(entitlement.artifact).toEqual(manifestModule?.artifact); + expect(entitlement.entitlementBase64).not.toContain('intranetPenetration'); + }); + + it('未配置付费模块镜像站时应拒绝生成清单', async () => { + delete process.env.PREMIUM_MODULE_REGISTRY_BASE; + const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); + + expect(() => + buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'), 'instance-1') + ).toThrow('PREMIUM_MODULE_REGISTRY_BASE'); }); }); diff --git a/license-server/lib/__tests__/premium-module-route-coverage.test.ts b/license-server/lib/__tests__/premium-module-route-coverage.test.ts index 1c2750f..7fa2ef3 100644 --- a/license-server/lib/__tests__/premium-module-route-coverage.test.ts +++ b/license-server/lib/__tests__/premium-module-route-coverage.test.ts @@ -14,13 +14,13 @@ const downloadRoutePath = path.resolve( 'route.ts' ); -describe('付费模块下载入口覆盖', () => { - it('下载接口必须保留请求签名、硬件指纹和模块包生成校验', () => { +describe('付费模块授权凭证入口覆盖', () => { + it('授权凭证接口必须保留请求签名、硬件指纹和凭证生成校验', () => { const source = fs.readFileSync(downloadRoutePath, 'utf8'); expect(source).toContain("verifyLicenseRequestSignature('premium-module-download'"); expect(source).toContain('calculateFingerprintMatchScore'); expect(source).toContain("license.licenseType === 'FREE'"); - expect(source).toContain('buildPremiumModulePackage'); + expect(source).toContain('buildPremiumModuleEntitlement'); }); }); diff --git a/license-server/lib/premium-module-manifest.ts b/license-server/lib/premium-module-manifest.ts index 82c42e9..0494227 100644 --- a/license-server/lib/premium-module-manifest.ts +++ b/license-server/lib/premium-module-manifest.ts @@ -3,17 +3,26 @@ import { signData } from './crypto'; import type { LicenseType } from './types'; export const PREMIUM_MODULE_MANIFEST_VERSION = 1; -export const PREMIUM_MODULE_PACKAGE_VERSION = 1; +export const PREMIUM_MODULE_ENTITLEMENT_VERSION = 1; + +export interface PremiumModuleArtifact { + type: 'registry-image'; + image: string; + digest?: string | null; + version: string; +} export interface PremiumModuleManifest { version: number; licenseType: Exclude; + instanceId: string; modules: Array<{ id: string; minLicenseType: Exclude; + artifact: PremiumModuleArtifact; sizeBytes: number; sha256: string; - packageVersion: number; + entitlementVersion: number; }>; issuedAt: string; expiresAt: string; @@ -24,12 +33,13 @@ export interface SignedPremiumModuleManifest { signature: string; } -export interface PremiumModulePackage { +export interface PremiumModuleEntitlement { moduleId: string; - contentBase64: string; + entitlementBase64: string; sha256: string; sizeBytes: number; expiresAt: string; + artifact: PremiumModuleArtifact; } interface PremiumModuleDefinition { @@ -56,7 +66,7 @@ const MODULE_IDS_BY_LICENSE: Record, string[]> = { PRO: ['custom-templates-core', 'intranet-penetration-core'], }; -function getPackageSecret(): string { +function getEntitlementSecret(): string { const secret = process.env.PREMIUM_MODULE_PACKAGE_SECRET || process.env.AES_SECRET_KEY || ''; if (!secret || secret.length < 32) { throw new Error('PREMIUM_MODULE_PACKAGE_SECRET 或 AES_SECRET_KEY 未配置或长度不足'); @@ -73,9 +83,45 @@ function canLicenseUseModule( return licenseType === 'PRO'; } -function encryptModulePayload(payload: Record): string { +function toModuleEnvKey(moduleId: string): string { + return moduleId.replace(/[^a-zA-Z0-9]/g, '_').toUpperCase(); +} + +function getDefaultModuleVersion(): string { + return process.env.PREMIUM_MODULE_VERSION?.trim() || '1.0.0'; +} + +function getPremiumModuleArtifact(moduleId: string): PremiumModuleArtifact { + const envKey = toModuleEnvKey(moduleId); + const explicitImage = process.env[`PREMIUM_MODULE_${envKey}_IMAGE`]?.trim(); + const version = process.env[`PREMIUM_MODULE_${envKey}_TAG`]?.trim() || getDefaultModuleVersion(); + const digest = process.env[`PREMIUM_MODULE_${envKey}_DIGEST`]?.trim() || null; + + if (explicitImage) { + return { + type: 'registry-image', + image: explicitImage, + digest, + version, + }; + } + + const registryBase = process.env.PREMIUM_MODULE_REGISTRY_BASE?.trim().replace(/\/+$/, ''); + if (!registryBase) { + throw new Error('PREMIUM_MODULE_REGISTRY_BASE 未配置,无法生成付费模块镜像地址'); + } + + return { + type: 'registry-image', + image: `${registryBase}/${moduleId}:${version}`, + digest, + version, + }; +} + +function encryptModuleEntitlement(payload: Record): string { const plaintext = JSON.stringify(payload); - const packageSecret = getPackageSecret(); + const packageSecret = getEntitlementSecret(); const key = crypto.createHash('sha256').update(packageSecret).digest(); const iv = crypto .createHash('sha256') @@ -88,7 +134,7 @@ function encryptModulePayload(payload: Record): string { encrypted += cipher.final('base64'); const envelope = { - version: PREMIUM_MODULE_PACKAGE_VERSION, + version: PREMIUM_MODULE_ENTITLEMENT_VERSION, algorithm: 'aes-256-gcm', iv: iv.toString('base64'), authTag: cipher.getAuthTag().toString('base64'), @@ -98,35 +144,38 @@ function encryptModulePayload(payload: Record): string { return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64'); } -export function buildPremiumModulePackage(params: { +export function buildPremiumModuleEntitlement(params: { moduleId: string; licenseType: Exclude; instanceId: string; leaseExpiresAt: Date; -}): PremiumModulePackage { +}): PremiumModuleEntitlement { const module = MODULE_DEFINITIONS[params.moduleId]; if (!module || !canLicenseUseModule(params.licenseType, module)) { throw new Error('付费模块不存在或许可证类型不允许使用'); } const expiresAt = params.leaseExpiresAt.toISOString(); - const contentBase64 = encryptModulePayload({ - packageType: 'flnxi-premium-module', - packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, + const artifact = getPremiumModuleArtifact(module.id); + const entitlementBase64 = encryptModuleEntitlement({ + payloadType: 'flnxi-premium-module-entitlement', + entitlementVersion: PREMIUM_MODULE_ENTITLEMENT_VERSION, moduleId: module.id, licenseType: params.licenseType, minLicenseType: module.minLicenseType, capabilities: module.capabilities, instanceId: params.instanceId, + artifact, expiresAt, }); return { moduleId: module.id, - contentBase64, - sha256: crypto.createHash('sha256').update(contentBase64).digest('hex'), - sizeBytes: Buffer.byteLength(contentBase64, 'utf8'), + entitlementBase64, + sha256: crypto.createHash('sha256').update(entitlementBase64).digest('hex'), + sizeBytes: Buffer.byteLength(entitlementBase64, 'utf8'), expiresAt, + artifact, }; } @@ -137,7 +186,7 @@ function buildModuleManifestItems(params: { }): PremiumModuleManifest['modules'] { return MODULE_IDS_BY_LICENSE[params.licenseType].map((moduleId) => { const module = MODULE_DEFINITIONS[moduleId]; - const modulePackage = buildPremiumModulePackage({ + const entitlement = buildPremiumModuleEntitlement({ moduleId, licenseType: params.licenseType, instanceId: params.instanceId, @@ -147,52 +196,24 @@ function buildModuleManifestItems(params: { return { id: module.id, minLicenseType: module.minLicenseType, - sizeBytes: modulePackage.sizeBytes, - sha256: modulePackage.sha256, - packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, + artifact: entitlement.artifact, + sizeBytes: entitlement.sizeBytes, + sha256: entitlement.sha256, + entitlementVersion: PREMIUM_MODULE_ENTITLEMENT_VERSION, }; }); } -const MODULES_BY_LICENSE: Record, PremiumModuleManifest['modules']> = { - STANDARD: [ - { - id: 'custom-templates-core', - minLicenseType: 'STANDARD', - sizeBytes: 0, - sha256: '', - packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, - }, - ], - PRO: [ - { - id: 'custom-templates-core', - minLicenseType: 'STANDARD', - sizeBytes: 0, - sha256: '', - packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, - }, - { - id: 'intranet-penetration-core', - minLicenseType: 'PRO', - sizeBytes: 0, - sha256: '', - packageVersion: PREMIUM_MODULE_PACKAGE_VERSION, - }, - ], -}; - export function buildSignedPremiumModuleManifest( licenseType: Exclude, leaseExpiresAt: Date, - instanceId?: string + instanceId: string ): SignedPremiumModuleManifest { const manifest: PremiumModuleManifest = { version: PREMIUM_MODULE_MANIFEST_VERSION, licenseType, - modules: instanceId - ? buildModuleManifestItems({ licenseType, instanceId, leaseExpiresAt }) - : MODULES_BY_LICENSE[licenseType], + instanceId, + modules: buildModuleManifestItems({ licenseType, instanceId, leaseExpiresAt }), issuedAt: new Date().toISOString(), expiresAt: leaseExpiresAt.toISOString(), }; @@ -202,3 +223,7 @@ export function buildSignedPremiumModuleManifest( signature: signData(JSON.stringify(manifest)), }; } + +// Backward-compatible export name for older internal tests/imports. This now +// returns an entitlement token, not the paid module core artifact itself. +export const buildPremiumModulePackage = buildPremiumModuleEntitlement; diff --git a/license-server/scripts/generate-keys.ts b/license-server/scripts/generate-keys.ts index c179363..94459c9 100644 --- a/license-server/scripts/generate-keys.ts +++ b/license-server/scripts/generate-keys.ts @@ -35,6 +35,9 @@ 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'); +const premiumModuleRegistryBase = + process.env.PREMIUM_MODULE_REGISTRY_BASE?.trim() || 'registry.example.com/flnxi/premium'; +const premiumModuleVersion = process.env.PREMIUM_MODULE_VERSION?.trim() || '1.0.0'; // 创建 keys 目录 const keysDir = path.join(__dirname, '..', 'keys'); @@ -73,6 +76,10 @@ JWT_SECRET="${jwtSecret}" # 许可证请求签名密钥(素材系统需使用同一值) LICENSE_REQUEST_SIGNING_SECRET="${licenseRequestSigningSecret}" +# 付费模块分发:模块核心本体在镜像站,验证服务器只发授权清单和凭证 +PREMIUM_MODULE_REGISTRY_BASE="${premiumModuleRegistryBase}" +PREMIUM_MODULE_VERSION="${premiumModuleVersion}" + # 管理员初始密码 ADMIN_INITIAL_PASSWORD="${adminInitialPassword}" `; @@ -94,3 +101,4 @@ console.log(publicKey); console.log(''); console.log('📋 许可证服务初始管理员密码已写入 license-server/.env 的 ADMIN_INITIAL_PASSWORD,不在终端日志中输出明文。'); console.log('📋 许可证请求签名密钥已写入 license-server/.env 的 LICENSE_REQUEST_SIGNING_SECRET,请在素材系统中配置同一值。'); +console.log('📋 付费模块镜像站已写入 license-server/.env 的 PREMIUM_MODULE_REGISTRY_BASE,正式部署前请确认不是示例地址。');