调整付费模块镜像站分发链路

This commit is contained in:
WuYongJie 2026-06-01 14:21:32 +08:00
parent 12f248dc69
commit 80ad51e3dd
17 changed files with 307 additions and 149 deletions

View File

@ -129,6 +129,8 @@ LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-key>
内置 PostgreSQL 部署时 `DATABASE_URL` 留空,由 Compose 自动指向内置数据库;只有外部 PostgreSQL 才填写真实连接串。`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 中的项目配置一致。
付费模块采用“镜像站放模块核心本体,验证服务器发授权清单和实例凭证”的分工;部署 `license-server` 时还要确认 `PREMIUM_MODULE_REGISTRY_BASE` 和模块版本指向当前可用镜像站。
### 初始化 Prisma
```bash

View File

@ -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。

View File

@ -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 免费版激活保存空签名,后续读取疑似失败(已修复)

View File

@ -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 全量测试通过。

View File

@ -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. 后台管理入口

View File

@ -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<typeof verify
const mockGetLocalLicense = getLocalLicense as jest.MockedFunction<typeof getLocalLicense>;
const mockIsLicenseConfigExpired = isLicenseConfigExpired as jest.MockedFunction<typeof isLicenseConfigExpired>;
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<string, unknown> = {}) {
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(),
},

View File

@ -9,12 +9,19 @@ import type { LicenseType } from './types';
export interface PremiumModuleManifest {
version: number;
licenseType: Exclude<LicenseType, 'FREE'>;
instanceId: string;
modules: Array<{
id: string;
minLicenseType: Exclude<LicenseType, 'FREE'>;
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<CachedPremiumModulePackages> {
async function getCachedPremiumModuleEntitlements(): Promise<CachedPremiumModuleEntitlements> {
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<void> {
async function savePremiumModuleEntitlements(cached: CachedPremiumModuleEntitlements): Promise<void> {
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<CachedPremiumMod
if (!localLicense || isLicenseConfigExpired(localLicense.cachedConfig)) return null;
if (!isPaidLicense(localLicense.cachedConfig.type)) return null;
if (cached.manifest.licenseType !== localLicense.cachedConfig.type) return null;
if (!cached.manifest.instanceId || cached.manifest.instanceId !== localLicense.instanceId) return null;
return cached;
} catch (error) {
@ -154,24 +164,38 @@ function buildPremiumModuleDownloadUrl(moduleId: string): string {
return `${baseUrl}/api/license/${projectCode}/premium-modules/${encodeURIComponent(moduleId)}`;
}
function isCachedPackageValid(
cached: CachedPremiumModulePackages,
module: PremiumModuleManifest['modules'][number]
function isSameArtifact(
left: PremiumModuleManifest['modules'][number]['artifact'] | null | undefined,
right: PremiumModuleManifest['modules'][number]['artifact'] | null | undefined
): boolean {
const modulePackage = cached.packages[module.id];
if (!modulePackage) return false;
if (modulePackage.sha256 !== module.sha256) return false;
if (modulePackage.sizeBytes !== module.sizeBytes) return false;
if (hashData(modulePackage.contentBase64) !== module.sha256) return false;
const packageExpiresAt = new Date(modulePackage.expiresAt).getTime();
return !Number.isNaN(packageExpiresAt) && packageExpiresAt > 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<ReturnType<typeof generateFingerprint>>
): Promise<PremiumModulePackageResponse['package']> {
): Promise<PremiumModuleEntitlementResponse['entitlement']> {
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<boolean> {
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);
}

View File

@ -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"

View File

@ -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 }

View File

@ -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 }

View File

@ -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'

View File

@ -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:

View File

@ -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');

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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<LicenseType, 'FREE'>;
instanceId: string;
modules: Array<{
id: string;
minLicenseType: Exclude<LicenseType, 'FREE'>;
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<Exclude<LicenseType, 'FREE'>, 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, unknown>): 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, unknown>): 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, unknown>): 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, unknown>): string {
return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64');
}
export function buildPremiumModulePackage(params: {
export function buildPremiumModuleEntitlement(params: {
moduleId: string;
licenseType: Exclude<LicenseType, 'FREE'>;
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<Exclude<LicenseType, 'FREE'>, 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<LicenseType, 'FREE'>,
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;

View File

@ -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,正式部署前请确认不是示例地址。');