diff --git a/app/api/license/heartbeat/route.ts b/app/api/license/heartbeat/route.ts index 5aa5ce8..8b308af 100644 --- a/app/api/license/heartbeat/route.ts +++ b/app/api/license/heartbeat/route.ts @@ -5,6 +5,7 @@ import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config'; import { prisma } from '@/lib/prisma'; import { verifyLicenseSignature } from '@/lib/license/crypto-service'; import { buildSignedLicenseRequest } from '@/lib/license/request-signature'; +import { syncPremiumModules } from '@/lib/license/premium-module-service'; import type { HeartbeatResponse } from '@/lib/license/types'; export async function POST(request: NextRequest) { @@ -59,6 +60,10 @@ export async function POST(request: NextRequest) { // ✅ 在后端更新本地缓存 await updateLocalLicense(data.license); + const moduleSyncResult = await syncPremiumModules(); + if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { + console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error); + } } return NextResponse.json({ diff --git a/app/api/license/verify/route.ts b/app/api/license/verify/route.ts index 623d6bd..55dc734 100644 --- a/app/api/license/verify/route.ts +++ b/app/api/license/verify/route.ts @@ -9,6 +9,7 @@ import { import { verifyLicenseSignature } from '@/lib/license/crypto-service'; import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config'; import { buildSignedLicenseRequest } from '@/lib/license/request-signature'; +import { syncPremiumModules } from '@/lib/license/premium-module-service'; import type { VerifyResponse } from '@/lib/license/types'; // 宽限期(7天) @@ -77,6 +78,10 @@ export async function POST(request: NextRequest) { // ✅ 在后端更新本地缓存 await updateLocalLicense(data.license); + const moduleSyncResult = await syncPremiumModules(); + if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { + console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error); + } return NextResponse.json({ valid: true }); } catch (error) { diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 83b3e97..1c98018 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -37,6 +37,8 @@ docker-compose.prod.yml 这些组件根据交付场景单独部署或由安装 Agent 编排。 +独立许可证服务的默认编排在 `license-server/docker-compose.yml`。该编排会由 Nginx 对外暴露 `9527`,再反代到内部 `license-server:3000`;应用容器不再直接暴露宿主机端口。这样登录和 API 限流配置会进入默认链路,避免验证服务器在低配置机器上被暴力请求直接打到 Next 服务。 + ## 2. 部署前准备 ### 2.1 机器条件 @@ -297,6 +299,8 @@ docker exec -i flnxi-postgres pg_restore \ - `LICENSE_REQUEST_SIGNING_SECRET` 是否与许可证服务一致 - 许可证服务是否存在对应项目编码 - 主应用 `/api/license/check`、`/api/license/verify` 响应 +- 如果独立部署 `license-server/docker-compose.yml`,确认对外访问的是 Nginx 暴露的 `9527`,不是容器内部 `3000`。 +- 如果请求返回 `429`,优先检查 Nginx 限流是否被触发,再看是否存在脚本重试、暴力激活或心跳频率异常。 ### 数据迁移失败 diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index 17956ab..c970ac4 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -402,11 +402,18 @@ - `app/api/license/info/route.ts`:管理端许可证信息改为读取 `getLocalLicense()` 的已校验本地许可证,不再只看进程内缓存,避免回拨阻断后管理页仍显示正常激活。 - `lib/license/request-signature.ts`、`license-server/lib/request-signature.ts`:主应用转发到验证服务器的 `activate / register-free / verify / heartbeat` 请求新增 HMAC 签名、`nonce` 和 5 分钟时间窗口;验证服务器会拒绝无签名、签名错误、过期和重复 `nonce` 请求,降低抓包重放和脚本伪造成本。 - `.env.example`、`docker-compose.prod.yml`、`license-server/docker-compose.yml`:新增并强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,主应用和验证服务器必须使用同一个共享签名密钥,且不能写入 Git。 +- `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/lib/premium-module-manifest.ts`:新增付费模块清单接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并在硬件指纹明显不匹配时拒绝并告警。 +- `lib/license/premium-module-service.ts`:主应用在验证和心跳续证成功后同步付费模块 manifest,本地加密缓存并校验 RSA 签名、租约和当前许可证类型,避免降级后继续使用旧 PRO 模块清单。 +- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉。 - `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。 - `lib/__tests__/license-request-signature.test.ts`、`license-server/lib/__tests__/request-signature.test.ts`:新增测试覆盖签名生成、篡改拒绝、重复 `nonce` 拒绝、过期拒绝和缺少服务端共享密钥时拒绝静默降级。 - `lib/__tests__/license-service.test.ts`:新增测试覆盖旧本地许可证自动补时间锚点、小幅回拨不误伤、明显回拨拒绝功能和额度、本地时间状态被篡改后拒绝,以及网络失败宽限期前也先检查回拨状态。 - `lib/__tests__/check-limits.test.ts`:功能检查 helper 已全部改为异步调用底层许可证检查。 +- `lib/__tests__/premium-module-service.test.ts`、`license-server/lib/__tests__/premium-module-manifest.test.ts`、`license-server/lib/__tests__/free-registration-route-coverage.test.ts`:新增测试覆盖付费模块 manifest 同步/验签/降级失效、验证服务器 manifest 生成,以及免费注册入口的防误判和禁用/过期护栏。 验证: @@ -415,6 +422,8 @@ - `npm test -- lib/__tests__/license-request-signature.test.ts app/api/license/activate/__tests__/route.test.ts app/api/license/activate-free/__tests__/route.test.ts --runInBand` - `npm test -- lib/__tests__/license-service.test.ts app/api/license/check/__tests__/route.test.ts app/api/license/info/__tests__/route.test.ts --runInBand` - `npm test -- license-server/lib/__tests__/request-signature.test.ts --runInBand` +- `npm test -- lib/__tests__/premium-module-service.test.ts lib/__tests__/license-route-coverage.test.ts --runInBand` +- `npm test -- license-server/lib/__tests__/abuse-detection.test.ts license-server/lib/__tests__/premium-module-manifest.test.ts license-server/lib/__tests__/free-registration-route-coverage.test.ts --runInBand` - `npx tsc --noEmit --project license-server/tsconfig.json` - `npm run type-check` @@ -424,7 +433,10 @@ - 已完成:免费版采用更短在线租约,适合主要防破解和防白嫖;付费版采用更长租约,优先避免误伤正常客户,同时仍能限制付费凭证长期滥用。 - 已完成:主应用到验证服务器的公开许可证请求已有轻量签名和防重放,不再只依赖项目编码、时间戳和硬件指纹。 - 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。 -- 后续增强:异常设备聚合、验证服务器限流、模块包下载和付费核心文件拆分仍需单独推进;当前本轮先保证过期、降级、租约过期、抓包重放和明显本地时间回拨不会因为本地旧缓存继续放行。 +- 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。 +- 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。 +- 已完成:付费模块 manifest 基础链路已落地,适合低配置验证服务器和约 5M 带宽;当前只传输小清单,不在验证服务器承担重计算或大文件实时分发。 +- 后续增强:真正把付费功能核心文件从镜像中拆出、加密分发、下载缓存和模块加载仍需单独推进;当前本轮完成的是签名清单、权限校验、缓存校验和后续拆包的接口地基。 ### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复) diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index 81e018f..099f48c 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -52,6 +52,7 @@ POST /api/license/{projectCode}/activate POST /api/license/{projectCode}/verify POST /api/license/{projectCode}/heartbeat POST /api/license/{projectCode}/register-free +POST /api/license/{projectCode}/premium-modules POST /api/license/{projectCode}/generate GET /api/version/{projectCode}/check @@ -89,6 +90,7 @@ GET /api/version/{projectCode}/agent - `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。 - `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。 +- `verify`、`heartbeat` 在续证成功后会尝试同步 `premium-modules` 付费模块清单;免费版会跳过,不访问远端模块清单接口。 - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 @@ -103,7 +105,9 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... 补充: - 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。 -- `activate / verify / heartbeat / register-free` 当前会校验请求 HMAC 签名和服务端返回许可证签名;前者防抓包重放和伪造请求,后者防伪造许可证。 +- 远端 `POST /api/license/{projectCode}/premium-modules` 是付费模块清单接口,要求同一套 HMAC 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。 +- `premium-modules` 当前返回的是签名 manifest(模块清单),用于后续真正拆分付费核心文件时复用权限、签名、缓存和过期链路;当前不直接承载大文件下载,适合低配置、低带宽验证服务器。 +- `activate / verify / heartbeat / register-free / premium-modules` 当前会校验请求 HMAC 签名;返回的许可证和付费模块清单还会分别做服务端签名,主应用本地必须验签后再缓存。 ## 5. 后台管理入口 diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts new file mode 100644 index 0000000..2f47ba6 --- /dev/null +++ b/lib/__tests__/license-route-coverage.test.ts @@ -0,0 +1,76 @@ +import fs from 'fs'; +import path from 'path'; + +const rootDir = path.resolve(__dirname, '..', '..'); + +function readProjectFile(relativePath: string): string { + return fs.readFileSync(path.join(rootDir, relativePath), 'utf8'); +} + +describe('许可证业务入口覆盖', () => { + const requiredChecks: Array<{ + file: string; + helper: string; + reason: string; + }> = [ + { + file: 'app/api/auth/register/route.ts', + helper: 'canCreateUser', + reason: '免费版用户数限制必须在注册入口服务端执行', + }, + { + file: 'app/api/materials/upload/route.ts', + helper: 'canUploadMaterial', + reason: '免费版素材数限制必须在上传入口服务端执行', + }, + { + file: 'app/api/admin/upload-templates/route.ts', + helper: 'canUseCustomTemplates', + reason: '自定义模板新增必须走许可证功能开关', + }, + { + file: 'app/api/admin/upload-templates/[id]/route.ts', + helper: 'canUseCustomTemplates', + reason: '自定义模板编辑和删除必须走许可证功能开关', + }, + { + file: 'app/api/admin/upload-templates/[id]/migrate/route.ts', + helper: 'canUseCustomTemplates', + reason: '模板迁移必须走许可证功能开关', + }, + { + file: 'app/api/departments/route.ts', + helper: 'canUseDepartmentIsolation', + reason: '部门隔离相关写操作必须走许可证功能开关', + }, + { + file: 'app/api/system-settings/route.ts', + helper: 'canUseSystemSettings', + reason: '系统设置写操作必须走许可证功能开关', + }, + { + file: 'app/api/tunnel/start/route.ts', + helper: 'canUseIntranetPenetration', + reason: '内网穿透启动必须走专业版功能开关', + }, + { + file: 'app/api/tunnel/status/route.ts', + helper: 'canUseIntranetPenetration', + reason: '内网穿透状态查询不能绕过专业版功能开关', + }, + { + file: 'app/api/tunnel/stop/route.ts', + helper: 'canUseIntranetPenetration', + reason: '内网穿透停止入口也必须维持同一许可证边界', + }, + ]; + + it.each(requiredChecks)('$file 应调用 $helper', ({ file, helper, reason }) => { + const source = readProjectFile(file); + + expect(source.includes(helper)).toBe(true); + if (!source.includes(helper)) { + throw new Error(reason); + } + }); +}); diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts new file mode 100644 index 0000000..def2f9d --- /dev/null +++ b/lib/__tests__/premium-module-service.test.ts @@ -0,0 +1,190 @@ +jest.mock('@/lib/prisma', () => ({ + prisma: { + systemConfig: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + }, +})); + +jest.mock('../license/fingerprint-service', () => ({ + generateFingerprint: jest.fn().mockResolvedValue({ + machineId: 'machine-1', + hostname: 'nas-1', + macAddress: 'aa:bb:cc:dd:ee:ff', + instanceId: 'instance-1', + }), +})); + +jest.mock('../license/license-service', () => ({ + getLocalLicense: jest.fn(), + isLicenseConfigExpired: jest.fn(), +})); + +jest.mock('../license/crypto-service', () => ({ + encryptLocal: jest.fn((data) => `encrypted:${data}`), + decryptLocal: jest.fn((data) => data.replace('encrypted:', '')), + verifySignature: jest.fn(), +})); + +jest.mock('../license/request-signature', () => ({ + buildSignedLicenseRequest: jest.fn((action, payload) => ({ + ...payload, + timestamp: 1779753600000, + nonce: 'nonce', + signature: `signature:${action}`, + })), +})); + +jest.mock('../license/server-config', () => ({ + buildLicenseServerLicenseApiUrl: jest.fn((action) => `https://license.test/api/license/flnxi/${action}`), +})); + +import { prisma } from '@/lib/prisma'; +import { verifySignature } from '../license/crypto-service'; +import { getLocalLicense, isLicenseConfigExpired } from '../license/license-service'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const mockVerifySignature = verifySignature as jest.MockedFunction; +const mockGetLocalLicense = getLocalLicense as jest.MockedFunction; +const mockIsLicenseConfigExpired = isLicenseConfigExpired as jest.MockedFunction; + +function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') { + mockGetLocalLicense.mockResolvedValue({ + instanceId: 'instance-1', + licenseKey: `FLNXI-${type}-TEST`, + licenseData: 'encrypted-license', + lastVerified: new Date().toISOString(), + cachedConfig: { + type, + limits: { maxUsers: -1, maxMaterials: -1 }, + features: { + customTemplates: true, + systemSettings: true, + departmentIsolation: true, + apiAccess: true, + intranetPenetration: type === 'PRO', + }, + expiresAt: null, + leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + } as any); + mockIsLicenseConfigExpired.mockReturnValue(false); +} + +describe('premium-module-service', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockReset(); + mockVerifySignature.mockReturnValue(true); + }); + + it('免费版同步付费模块时应跳过,不访问验证服务器', async () => { + mockGetLocalLicense.mockResolvedValue({ + cachedConfig: { + type: 'FREE', + limits: { maxUsers: 10, maxMaterials: 100 }, + features: { + customTemplates: false, + systemSettings: false, + departmentIsolation: false, + apiAccess: false, + intranetPenetration: false, + }, + expiresAt: null, + leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + } as any); + mockIsLicenseConfigExpired.mockReturnValue(false); + + const { syncPremiumModules } = await import('../license/premium-module-service'); + const result = await syncPremiumModules(); + + expect(result).toMatchObject({ synced: false, skipped: true }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('付费版应拉取、验签并加密缓存付费模块清单', async () => { + mockPaidLicense('PRO'); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + manifest: { + version: 1, + licenseType: 'PRO', + modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + }), + }); + + const { syncPremiumModules } = await import('../license/premium-module-service'); + const result = await syncPremiumModules(); + + expect(result).toEqual({ synced: true }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://license.test/api/license/flnxi/premium-modules', + expect.objectContaining({ method: 'POST' }) + ); + expect(prisma.systemConfig.upsert).toHaveBeenCalled(); + }); + + it('付费模块清单同步网络异常时应返回失败结果而不是抛出', async () => { + mockPaidLicense('PRO'); + mockFetch.mockRejectedValue(new Error('network down')); + + const { syncPremiumModules } = await import('../license/premium-module-service'); + const result = await syncPremiumModules(); + + expect(result).toEqual({ synced: false, error: 'network down' }); + expect(prisma.systemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('缓存清单签名无效时不应视为可用模块', async () => { + mockPaidLicense('PRO'); + mockVerifySignature.mockReturnValue(false); + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'PRO', + modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'bad-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + + const { hasPremiumModule } = await import('../license/premium-module-service'); + + await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); + }); + + it('缓存清单许可证类型和当前许可证不一致时不应视为可用模块', async () => { + mockPaidLicense('STANDARD'); + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'PRO', + modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + + const { hasPremiumModule } = await import('../license/premium-module-service'); + + await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); + }); +}); diff --git a/lib/license/index.ts b/lib/license/index.ts index d9d029c..5bcc0fa 100644 --- a/lib/license/index.ts +++ b/lib/license/index.ts @@ -28,6 +28,12 @@ export { canUseApiAccess, } from './check-limits'; +export { + getCachedPremiumModuleManifest, + syncPremiumModules, + hasPremiumModule, +} from './premium-module-service'; + // 导出指纹服务 export { generateFingerprint, diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts new file mode 100644 index 0000000..64d97c7 --- /dev/null +++ b/lib/license/premium-module-service.ts @@ -0,0 +1,145 @@ +import { prisma } from '@/lib/prisma'; +import { decryptLocal, encryptLocal, verifySignature } from './crypto-service'; +import { generateFingerprint } from './fingerprint-service'; +import { buildSignedLicenseRequest } from './request-signature'; +import { buildLicenseServerLicenseApiUrl } from './server-config'; +import { getLocalLicense, isLicenseConfigExpired } from './license-service'; +import type { LicenseType } from './types'; + +export interface PremiumModuleManifest { + version: number; + licenseType: Exclude; + modules: Array<{ + id: string; + minLicenseType: Exclude; + sizeBytes: number; + sha256: string; + }>; + issuedAt: string; + expiresAt: string; +} + +interface SignedPremiumModuleManifest { + success: boolean; + manifest?: PremiumModuleManifest; + signature?: string; + error?: string; +} + +interface CachedPremiumModuleManifest { + manifest: PremiumModuleManifest; + signature: string; + syncedAt: string; +} + +const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest'; + +function isPaidLicense(type: LicenseType): type is Exclude { + return type === 'STANDARD' || type === 'PRO'; +} + +function isManifestExpired(manifest: PremiumModuleManifest): boolean { + const expiresAt = new Date(manifest.expiresAt).getTime(); + return Number.isNaN(expiresAt) || expiresAt <= Date.now(); +} + +function verifyPremiumModuleManifest(manifest: PremiumModuleManifest, signature: string): boolean { + return verifySignature(JSON.stringify(manifest), signature); +} + +async function savePremiumModuleManifest( + manifest: PremiumModuleManifest, + signature: string +): Promise { + const cached: CachedPremiumModuleManifest = { + manifest, + signature, + syncedAt: new Date().toISOString(), + }; + + await prisma.systemConfig.upsert({ + where: { key: PREMIUM_MODULE_CONFIG_KEY }, + update: { value: encryptLocal(JSON.stringify(cached)) }, + create: { key: PREMIUM_MODULE_CONFIG_KEY, value: encryptLocal(JSON.stringify(cached)) }, + }); +} + +export async function getCachedPremiumModuleManifest(): Promise { + try { + const config = await prisma.systemConfig.findUnique({ + where: { key: PREMIUM_MODULE_CONFIG_KEY }, + }); + + if (!config) return null; + + const cached = JSON.parse(decryptLocal(config.value)) as CachedPremiumModuleManifest; + if (!cached.manifest || !cached.signature) return null; + + if (isManifestExpired(cached.manifest)) return null; + if (!verifyPremiumModuleManifest(cached.manifest, cached.signature)) return null; + + const localLicense = await getLocalLicense(); + if (!localLicense || isLicenseConfigExpired(localLicense.cachedConfig)) return null; + if (!isPaidLicense(localLicense.cachedConfig.type)) return null; + if (cached.manifest.licenseType !== localLicense.cachedConfig.type) return null; + + return cached; + } catch (error) { + console.warn('[License] 读取付费模块缓存失败:', error); + return null; + } +} + +export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: boolean; error?: string }> { + try { + const localLicense = await getLocalLicense(); + if (!localLicense || isLicenseConfigExpired(localLicense.cachedConfig)) { + return { synced: false, skipped: true, error: '本地许可证不可用' }; + } + + if (!isPaidLicense(localLicense.cachedConfig.type)) { + return { synced: false, skipped: true, error: '免费版不需要付费模块' }; + } + + const fingerprint = await generateFingerprint(); + const signedRequest = buildSignedLicenseRequest('premium-modules', { + instanceId: fingerprint.instanceId, + fingerprint, + }); + + const response = await fetch(buildLicenseServerLicenseApiUrl('premium-modules'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedRequest), + }); + const data = (await response.json()) as SignedPremiumModuleManifest; + + if (!response.ok || !data.success || !data.manifest || !data.signature) { + return { + synced: false, + error: data.error || '付费模块清单同步失败', + }; + } + + if (data.manifest.licenseType !== localLicense.cachedConfig.type) { + return { synced: false, error: '付费模块清单许可证类型不匹配' }; + } + + if (!verifyPremiumModuleManifest(data.manifest, data.signature)) { + return { synced: false, error: '付费模块清单签名验证失败' }; + } + + await savePremiumModuleManifest(data.manifest, data.signature); + return { synced: true }; + } catch (error) { + const message = error instanceof Error ? error.message : '付费模块清单同步异常'; + return { synced: false, error: message }; + } +} + +export async function hasPremiumModule(moduleId: string): Promise { + const cached = await getCachedPremiumModuleManifest(); + if (!cached) return false; + + return cached.manifest.modules.some((module) => module.id === moduleId); +} diff --git a/license-server/app/api/license/[projectCode]/activate/route.ts b/license-server/app/api/license/[projectCode]/activate/route.ts index 27e0cfa..6c6f698 100644 --- a/license-server/app/api/license/[projectCode]/activate/route.ts +++ b/license-server/app/api/license/[projectCode]/activate/route.ts @@ -3,6 +3,7 @@ import { signLicense, isValidLicenseKeyFormat } from '@/lib/crypto'; import { getPublicProjectDb } from '@/lib/project-auth'; import { verifyLicenseRequestSignature } from '@/lib/request-signature'; import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; +import { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection'; import { z } from 'zod'; const activateSchema = z.object({ @@ -99,6 +100,12 @@ export async function POST( if (existingActivation) { const existingFingerprint = existingActivation.fingerprint as unknown as HardwareFingerprint; if (existingFingerprint.instanceId !== fingerprint.instanceId) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'activation_on_other_device', { + existingInstanceId: existingFingerprint.instanceId, + requestedInstanceId: fingerprint.instanceId, + licenseType: license.licenseType, + }); + return NextResponse.json( { success: false, error: '许可证已在其他设备上激活,请联系管理员重置' }, { status: 409 } @@ -113,6 +120,26 @@ export async function POST( }, }); } else { + if (license.licenseType === 'FREE') { + const reuseResult = await findFreeFingerprintReuse(projectDbResult.prisma, fingerprint); + if (reuseResult.reused && reuseResult.existingLicenseKey !== license.licenseKey) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'free_reactivation_blocked', { + requestedInstanceId: fingerprint.instanceId, + existingInstanceId: reuseResult.existingInstanceId, + existingLicenseKey: reuseResult.existingLicenseKey, + matchScore: reuseResult.matchScore, + }); + + return NextResponse.json( + { + success: false, + error: '该设备已绑定过其他免费许可证,请继续使用原免费许可证或升级付费版', + }, + { status: 409 } + ); + } + } + await projectDbResult.prisma.activation.create({ data: { licenseId: license.id, diff --git a/license-server/app/api/license/[projectCode]/heartbeat/route.ts b/license-server/app/api/license/[projectCode]/heartbeat/route.ts index 51f8c0d..9f4e5f2 100644 --- a/license-server/app/api/license/[projectCode]/heartbeat/route.ts +++ b/license-server/app/api/license/[projectCode]/heartbeat/route.ts @@ -3,30 +3,13 @@ import { hashFingerprint, signLicense } from '@/lib/crypto'; import { getPublicProjectDb } from '@/lib/project-auth'; import { verifyLicenseRequestSignature } from '@/lib/request-signature'; import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; +import { + calculateFingerprintMatchScore, + createPendingAlertOnce, + enforceDeviceAbusePolicy, +} from '@/lib/abuse-detection'; import { z } from 'zod'; -function calculateFingerprintMatchScore( - stored: HardwareFingerprint, - current: HardwareFingerprint -): number { - let score = 0; - let total = 0; - - if (stored.machineId === current.machineId) score += 40; - total += 40; - - if (stored.hostname === current.hostname) score += 20; - total += 20; - - if (stored.macAddress === current.macAddress) score += 30; - total += 30; - - if (stored.instanceId === current.instanceId) score += 10; - total += 10; - - return (score / total) * 100; -} - const heartbeatSchema = z.object({ instanceId: z.string(), fingerprint: z.object({ @@ -64,26 +47,9 @@ async function detectAnomaly( uniqueFingerprints.add(fingerprintHash); if (uniqueFingerprints.size > 1) { - const existingAlert = await prisma.alert.findFirst({ - where: { - licenseId, - alertType: 'multi_device', - status: 'pending', - }, + await createPendingAlertOnce(prisma, licenseId, 'multi_device', { + fingerprints: Array.from(uniqueFingerprints), }); - - if (!existingAlert) { - await prisma.alert.create({ - data: { - licenseId, - alertType: 'multi_device', - details: { - fingerprints: Array.from(uniqueFingerprints), - detectedAt: new Date().toISOString(), - }, - }, - }); - } } } @@ -159,6 +125,13 @@ export async function POST( const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); if (matchScore < heartbeatThreshold) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', { + instanceId, + matchScore, + threshold: heartbeatThreshold, + licenseType: license.licenseType, + }); + return NextResponse.json( { acknowledged: false, error: '硬件指纹不匹配' }, { status: 403 } @@ -167,6 +140,20 @@ export async function POST( const fingerprintHash = hashFingerprint(fingerprint); + const abusePolicy = await enforceDeviceAbusePolicy(projectDbResult.prisma, { + licenseId: license.id, + licenseType: license.licenseType, + instanceId, + fingerprint, + }); + + if (!abusePolicy.allowed) { + return NextResponse.json( + { acknowledged: false, error: abusePolicy.error }, + { status: abusePolicy.status } + ); + } + await projectDbResult.prisma.heartbeatLog.create({ data: { licenseId: license.id, diff --git a/license-server/app/api/license/[projectCode]/premium-modules/route.ts b/license-server/app/api/license/[projectCode]/premium-modules/route.ts new file mode 100644 index 0000000..f361232 --- /dev/null +++ b/license-server/app/api/license/[projectCode]/premium-modules/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPublicProjectDb } from '@/lib/project-auth'; +import { verifyLicenseRequestSignature } from '@/lib/request-signature'; +import { buildSignedPremiumModuleManifest } from '@/lib/premium-module-manifest'; +import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; +import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection'; +import { z } from 'zod'; + +const premiumModulesSchema = z.object({ + instanceId: z.string(), + fingerprint: z.object({ + machineId: z.string(), + hostname: z.string(), + macAddress: z.string(), + cpuId: z.string().optional(), + instanceId: z.string(), + }), + timestamp: z.number(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectCode: string }> } +) { + try { + const { projectCode } = await params; + const projectDbResult = await getPublicProjectDb(projectCode); + + if (!projectDbResult.success) { + return projectDbResult.response; + } + + const body = await request.json().catch(() => ({})); + const signatureResult = verifyLicenseRequestSignature('premium-modules', projectCode, body); + if (!signatureResult.valid) { + return NextResponse.json( + { success: false, error: '请求签名无效' }, + { status: signatureResult.response.status } + ); + } + + const parsed = premiumModulesSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: '请求参数无效' }, + { status: 400 } + ); + } + + const { instanceId, fingerprint, timestamp } = parsed.data; + if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) { + return NextResponse.json( + { success: false, error: '请求已过期' }, + { status: 400 } + ); + } + + const activation = await projectDbResult.prisma.activation.findFirst({ + where: { + instanceId, + isActive: true, + }, + include: { + license: true, + }, + }); + + if (!activation) { + return NextResponse.json( + { success: false, error: '激活记录不存在' }, + { status: 404 } + ); + } + + const { license } = activation; + if (license.licenseType === 'FREE') { + return NextResponse.json( + { success: false, error: '免费版不提供付费模块包' }, + { status: 403 } + ); + } + + if (license.status === 'disabled') { + return NextResponse.json( + { success: false, error: '许可证已被禁用' }, + { status: 403 } + ); + } + + if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) { + return NextResponse.json( + { success: false, error: '许可证已过期' }, + { status: 403 } + ); + } + + const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint; + const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); + if (matchScore < 70) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'premium_module_fingerprint_mismatch', { + instanceId, + matchScore, + }); + + return NextResponse.json( + { success: false, error: '硬件指纹不匹配,无法下载付费模块包' }, + { status: 403 } + ); + } + + const config = generateLicenseConfig(license.licenseType, license.expiresAt); + const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now()); + const signedManifest = buildSignedPremiumModuleManifest(license.licenseType, leaseExpiresAt); + + return NextResponse.json({ + success: true, + ...signedManifest, + }); + } catch (error) { + console.error('付费模块包清单生成失败:', error); + return NextResponse.json( + { success: false, error: '服务器内部错误' }, + { status: 500 } + ); + } +} + diff --git a/license-server/app/api/license/[projectCode]/register-free/route.ts b/license-server/app/api/license/[projectCode]/register-free/route.ts index 86877b6..c19d23f 100644 --- a/license-server/app/api/license/[projectCode]/register-free/route.ts +++ b/license-server/app/api/license/[projectCode]/register-free/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPublicProjectDb } from '@/lib/project-auth'; -import { signLicense } from '@/lib/crypto'; +import { hashFingerprint, signLicense } from '@/lib/crypto'; import { verifyLicenseRequestSignature } from '@/lib/request-signature'; import { generateLicenseConfig, LICENSE_CONFIGS } from '@/lib/types'; +import { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection'; import { z } from 'zod'; const registerFreeSchema = z.object({ @@ -85,6 +86,23 @@ export async function POST( }); if (existingActivation) { + if (existingActivation.license.status === 'disabled') { + return NextResponse.json( + { success: false, error: '许可证已被禁用' }, + { status: 403 } + ); + } + + if ( + existingActivation.license.status === 'expired' || + (existingActivation.license.expiresAt && existingActivation.license.expiresAt < new Date()) + ) { + return NextResponse.json( + { success: false, error: '许可证已过期' }, + { status: 403 } + ); + } + const signedLicense = signLicense( generateLicenseConfig( existingActivation.license.licenseType, @@ -109,6 +127,31 @@ export async function POST( }); } + const reuseResult = await findFreeFingerprintReuse(projectDbResult.prisma, fingerprint); + if (reuseResult.reused) { + const relatedLicense = await projectDbResult.prisma.license.findUnique({ + where: { licenseKey: reuseResult.existingLicenseKey }, + select: { id: true }, + }); + + if (relatedLicense) { + await createPendingAlertOnce(projectDbResult.prisma, relatedLicense.id, 'free_reactivation_blocked', { + requestedInstanceId: fingerprint.instanceId, + existingInstanceId: reuseResult.existingInstanceId, + existingLicenseKey: reuseResult.existingLicenseKey, + matchScore: reuseResult.matchScore, + }); + } + + return NextResponse.json( + { + success: false, + error: '该设备已注册过免费版,请继续使用原免费许可证或升级付费版', + }, + { status: 409 } + ); + } + const activationCode = generateFreeActivationCode(); const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || @@ -142,7 +185,7 @@ export async function POST( data: { licenseId: license.id, instanceId: fingerprint.instanceId, - fingerprintHash: fingerprint.machineId, + fingerprintHash: hashFingerprint(fingerprint), ipAddress: clientIP, }, }); diff --git a/license-server/app/api/license/[projectCode]/verify/route.ts b/license-server/app/api/license/[projectCode]/verify/route.ts index cc62908..f4f2d26 100644 --- a/license-server/app/api/license/[projectCode]/verify/route.ts +++ b/license-server/app/api/license/[projectCode]/verify/route.ts @@ -3,6 +3,11 @@ import { signLicense } from '@/lib/crypto'; import { getPublicProjectDb } from '@/lib/project-auth'; import { verifyLicenseRequestSignature } from '@/lib/request-signature'; import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; +import { + calculateFingerprintMatchScore, + createPendingAlertOnce, + enforceDeviceAbusePolicy, +} from '@/lib/abuse-detection'; import { z } from 'zod'; const verifySchema = z.object({ @@ -17,28 +22,6 @@ const verifySchema = z.object({ timestamp: z.number(), }); -function calculateFingerprintMatchScore( - stored: HardwareFingerprint, - current: HardwareFingerprint -): number { - let score = 0; - let total = 0; - - if (stored.machineId === current.machineId) score += 40; - total += 40; - - if (stored.hostname === current.hostname) score += 20; - total += 20; - - if (stored.macAddress === current.macAddress) score += 30; - total += 30; - - if (stored.instanceId === current.instanceId) score += 10; - total += 10; - - return (score / total) * 100; -} - export async function POST( request: NextRequest, { params }: { params: Promise<{ projectCode: string }> } @@ -122,6 +105,13 @@ export async function POST( if (isInstanceIdMatch && !isMachineIdMatch) { const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); if (matchScore < strictThreshold) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', { + instanceId, + matchScore, + threshold: strictThreshold, + licenseType: license.licenseType, + }); + return NextResponse.json( { valid: false, error: '硬件指纹不匹配,请重新激活' }, { status: 403 } @@ -130,6 +120,13 @@ export async function POST( } else if (!isInstanceIdMatch) { const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); if (matchScore < normalThreshold) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', { + instanceId, + matchScore, + threshold: normalThreshold, + licenseType: license.licenseType, + }); + return NextResponse.json( { valid: false, error: '硬件指纹不匹配,请重新激活' }, { status: 403 } @@ -137,6 +134,20 @@ export async function POST( } } + const abusePolicy = await enforceDeviceAbusePolicy(projectDbResult.prisma, { + licenseId: license.id, + licenseType: license.licenseType, + instanceId, + fingerprint, + }); + + if (!abusePolicy.allowed) { + return NextResponse.json( + { valid: false, error: abusePolicy.error }, + { status: abusePolicy.status } + ); + } + await projectDbResult.prisma.activation.update({ where: { id: activation.id }, data: { lastHeartbeat: new Date() }, diff --git a/license-server/docker-compose.yml b/license-server/docker-compose.yml index d7a4519..45570ff 100644 --- a/license-server/docker-compose.yml +++ b/license-server/docker-compose.yml @@ -1,12 +1,23 @@ -version: '3.8' - services: + nginx: + image: ${NGINX_IMAGE:-nginx:1.27-alpine} + container_name: license-nginx + restart: unless-stopped + ports: + - "9527:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - license-server + networks: + - license-network + license-server: build: . container_name: license-server restart: unless-stopped - ports: - - "9527:3000" + expose: + - "3000" environment: - MAIN_DATABASE_URL=${MAIN_DATABASE_URL:?MAIN_DATABASE_URL is required} - MAIN_DB_ENCRYPTION_KEY=${MAIN_DB_ENCRYPTION_KEY:?MAIN_DB_ENCRYPTION_KEY is required} diff --git a/license-server/lib/__tests__/abuse-detection.test.ts b/license-server/lib/__tests__/abuse-detection.test.ts new file mode 100644 index 0000000..a93bc8c --- /dev/null +++ b/license-server/lib/__tests__/abuse-detection.test.ts @@ -0,0 +1,90 @@ +jest.mock('../crypto', () => ({ + hashFingerprint: jest.fn(() => 'a'.repeat(64)), +})); + +import { enforceDeviceAbusePolicy } from '../abuse-detection'; +import type { HardwareFingerprint } from '../types'; + +function createFingerprint(instanceId = 'instance-1'): HardwareFingerprint { + return { + machineId: 'machine-1', + hostname: 'nas-1', + macAddress: 'aa:bb:cc:dd:ee:ff', + instanceId, + }; +} + +function createPrismaMock(recentHeartbeats: Array<{ instanceId: string; fingerprintHash: string }>) { + return { + heartbeatLog: { + findMany: jest.fn().mockResolvedValue(recentHeartbeats), + }, + alert: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + }, + }; +} + +describe('abuse-detection', () => { + it('免费版同许可证出现多个有效指纹时应阻断', async () => { + const prisma = createPrismaMock([ + { instanceId: 'instance-2', fingerprintHash: 'b'.repeat(64) }, + ]); + + const result = await enforceDeviceAbusePolicy(prisma as any, { + licenseId: 'license-1', + licenseType: 'FREE', + instanceId: 'instance-1', + fingerprint: createFingerprint(), + }); + + expect(result).toMatchObject({ allowed: false, status: 403 }); + expect(prisma.alert.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + alertType: 'free_multi_device_blocked', + }), + }) + ); + }); + + it('付费版多个有效指纹应告警但不直接阻断', async () => { + const prisma = createPrismaMock([ + { instanceId: 'instance-2', fingerprintHash: 'b'.repeat(64) }, + { instanceId: 'instance-3', fingerprintHash: 'c'.repeat(64) }, + ]); + + const result = await enforceDeviceAbusePolicy(prisma as any, { + licenseId: 'license-1', + licenseType: 'PRO', + instanceId: 'instance-1', + fingerprint: createFingerprint(), + }); + + expect(result).toEqual({ allowed: true }); + expect(prisma.alert.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + alertType: 'paid_multi_device_suspected', + }), + }) + ); + }); + + it('旧版本非哈希心跳记录不应导致免费版误判多设备', async () => { + const prisma = createPrismaMock([ + { instanceId: 'instance-legacy', fingerprintHash: 'machine-legacy' }, + ]); + + const result = await enforceDeviceAbusePolicy(prisma as any, { + licenseId: 'license-1', + licenseType: 'FREE', + instanceId: 'instance-1', + fingerprint: createFingerprint(), + }); + + expect(result).toEqual({ allowed: true }); + expect(prisma.alert.create).not.toHaveBeenCalled(); + }); +}); diff --git a/license-server/lib/__tests__/free-registration-route-coverage.test.ts b/license-server/lib/__tests__/free-registration-route-coverage.test.ts new file mode 100644 index 0000000..60b7f01 --- /dev/null +++ b/license-server/lib/__tests__/free-registration-route-coverage.test.ts @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; + +const routePath = path.resolve( + __dirname, + '..', + '..', + 'app', + 'api', + 'license', + '[projectCode]', + 'register-free', + 'route.ts' +); + +describe('免费版注册入口覆盖', () => { + it('首条心跳记录应写入统一的硬件指纹哈希,避免后续误判多设备', () => { + const source = fs.readFileSync(routePath, 'utf8'); + + expect(source).toContain("import { hashFingerprint, signLicense } from '@/lib/crypto'"); + expect(source).toContain('fingerprintHash: hashFingerprint(fingerprint)'); + expect(source).not.toContain('fingerprintHash: fingerprint.machineId'); + }); + + it('复用已有免费激活前应检查许可证禁用和过期状态', () => { + const source = fs.readFileSync(routePath, 'utf8'); + + expect(source).toContain("existingActivation.license.status === 'disabled'"); + expect(source).toContain("existingActivation.license.status === 'expired'"); + expect(source).toContain('existingActivation.license.expiresAt && existingActivation.license.expiresAt < new Date()'); + }); +}); diff --git a/license-server/lib/__tests__/premium-module-manifest.test.ts b/license-server/lib/__tests__/premium-module-manifest.test.ts new file mode 100644 index 0000000..ea8724b --- /dev/null +++ b/license-server/lib/__tests__/premium-module-manifest.test.ts @@ -0,0 +1,34 @@ +jest.mock('../crypto', () => ({ + signData: jest.fn((data) => `signed:${data}`), +})); + +describe('premium-module-manifest', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('应为标准版生成自定义模板核心模块清单', async () => { + const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); + const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z')); + + expect(result.manifest.licenseType).toBe('STANDARD'); + expect(result.manifest.modules.map((module) => module.id)).toEqual(['custom-templates-core']); + expect(result.signature).toContain('signed:'); + }); + + it('应为专业版生成全部付费模块清单', async () => { + const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); + const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z')); + + expect(result.manifest.modules.map((module) => module.id)).toEqual([ + 'custom-templates-core', + 'intranet-penetration-core', + ]); + }); +}); + diff --git a/license-server/lib/abuse-detection.ts b/license-server/lib/abuse-detection.ts new file mode 100644 index 0000000..7096165 --- /dev/null +++ b/license-server/lib/abuse-detection.ts @@ -0,0 +1,209 @@ +import { hashFingerprint } from './crypto'; +import type { HardwareFingerprint, LicenseType } from './types'; + +const FREE_REACTIVATION_WINDOW_DAYS = 30; +const FREE_REACTIVATION_WINDOW_MS = FREE_REACTIVATION_WINDOW_DAYS * 24 * 60 * 60 * 1000; +const FREE_FINGERPRINT_REUSE_THRESHOLD = 85; +const PAID_MULTI_DEVICE_ALERT_THRESHOLD = 2; +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/i; + +type ProjectDb = Awaited>; + +export function calculateFingerprintMatchScore( + stored: HardwareFingerprint, + current: HardwareFingerprint +): number { + let score = 0; + let total = 0; + + if (stored.machineId && stored.machineId === current.machineId) score += 40; + total += 40; + + if (stored.hostname && stored.hostname === current.hostname) score += 20; + total += 20; + + if (stored.macAddress && stored.macAddress === current.macAddress) score += 30; + total += 30; + + if (stored.instanceId && stored.instanceId === current.instanceId) score += 10; + total += 10; + + return (score / total) * 100; +} + +function toHardwareFingerprint(value: unknown): HardwareFingerprint | null { + if (!value || typeof value !== 'object') return null; + + const fingerprint = value as Partial; + if ( + typeof fingerprint.machineId !== 'string' || + typeof fingerprint.hostname !== 'string' || + typeof fingerprint.macAddress !== 'string' || + typeof fingerprint.instanceId !== 'string' + ) { + return null; + } + + return { + machineId: fingerprint.machineId, + hostname: fingerprint.hostname, + macAddress: fingerprint.macAddress, + cpuId: typeof fingerprint.cpuId === 'string' ? fingerprint.cpuId : undefined, + instanceId: fingerprint.instanceId, + }; +} + +function isStoredFingerprintHash(value: string): boolean { + return SHA256_HEX_PATTERN.test(value); +} + +export async function createPendingAlertOnce( + prisma: ProjectDb, + licenseId: string, + alertType: string, + details: Record +): Promise { + const existingAlert = await prisma.alert.findFirst({ + where: { + licenseId, + alertType, + status: 'pending', + }, + }); + + if (existingAlert) { + return; + } + + await prisma.alert.create({ + data: { + licenseId, + alertType, + details: { + ...details, + detectedAt: new Date().toISOString(), + }, + }, + }); +} + +export async function findFreeFingerprintReuse( + prisma: ProjectDb, + fingerprint: HardwareFingerprint +): Promise<{ + reused: boolean; + existingLicenseKey?: string; + existingInstanceId?: string; + matchScore?: number; +}> { + const since = new Date(Date.now() - FREE_REACTIVATION_WINDOW_MS); + const recentFreeActivations = await prisma.activation.findMany({ + where: { + isActive: true, + activatedAt: { gte: since }, + license: { + licenseType: 'FREE', + status: 'active', + }, + }, + select: { + instanceId: true, + fingerprint: true, + license: { + select: { + licenseKey: true, + }, + }, + }, + take: 200, + orderBy: { activatedAt: 'desc' }, + }); + + for (const activation of recentFreeActivations) { + if (activation.instanceId === fingerprint.instanceId) { + continue; + } + + const storedFingerprint = toHardwareFingerprint(activation.fingerprint); + if (!storedFingerprint) { + continue; + } + + const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); + const sameMachine = storedFingerprint.machineId === fingerprint.machineId; + const sameMacAddress = + storedFingerprint.macAddress !== 'unknown' && + fingerprint.macAddress !== 'unknown' && + storedFingerprint.macAddress === fingerprint.macAddress; + + if (sameMachine || sameMacAddress || matchScore >= FREE_FINGERPRINT_REUSE_THRESHOLD) { + return { + reused: true, + existingLicenseKey: activation.license.licenseKey, + existingInstanceId: activation.instanceId, + matchScore, + }; + } + } + + return { reused: false }; +} + +export async function enforceDeviceAbusePolicy( + prisma: ProjectDb, + params: { + licenseId: string; + licenseType: LicenseType; + instanceId: string; + fingerprint: HardwareFingerprint; + } +): Promise<{ allowed: true } | { allowed: false; status: number; error: string }> { + const fingerprintHash = hashFingerprint(params.fingerprint); + + const recentHeartbeats = await prisma.heartbeatLog.findMany({ + where: { + licenseId: params.licenseId, + createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, + }, + select: { + instanceId: true, + fingerprintHash: true, + }, + distinct: ['fingerprintHash'], + }); + + const normalizedHeartbeats = recentHeartbeats.filter((item) => + isStoredFingerprintHash(item.fingerprintHash) + ); + + // 兼容旧版本免费注册首条心跳曾写入 machineId 的数据,避免升级后误判多设备。 + const distinctFingerprints = new Set(normalizedHeartbeats.map((item) => item.fingerprintHash)); + distinctFingerprints.add(fingerprintHash); + + const distinctInstances = new Set(normalizedHeartbeats.map((item) => item.instanceId)); + distinctInstances.add(params.instanceId); + + if (params.licenseType === 'FREE' && (distinctFingerprints.size > 1 || distinctInstances.size > 1)) { + await createPendingAlertOnce(prisma, params.licenseId, 'free_multi_device_blocked', { + instanceIds: Array.from(distinctInstances), + fingerprintCount: distinctFingerprints.size, + fingerprintHash, + }); + + return { + allowed: false, + status: 403, + error: '免费版许可证检测到多设备复用,请重新激活或升级付费版', + }; + } + + if (params.licenseType !== 'FREE' && distinctFingerprints.size > PAID_MULTI_DEVICE_ALERT_THRESHOLD) { + await createPendingAlertOnce(prisma, params.licenseId, 'paid_multi_device_suspected', { + instanceIds: Array.from(distinctInstances), + fingerprintCount: distinctFingerprints.size, + fingerprintHash, + }); + } + + return { allowed: true }; +} diff --git a/license-server/lib/premium-module-manifest.ts b/license-server/lib/premium-module-manifest.ts new file mode 100644 index 0000000..3dca2a4 --- /dev/null +++ b/license-server/lib/premium-module-manifest.ts @@ -0,0 +1,66 @@ +import { signData } from './crypto'; +import type { LicenseType } from './types'; + +export const PREMIUM_MODULE_MANIFEST_VERSION = 1; + +export interface PremiumModuleManifest { + version: number; + licenseType: Exclude; + modules: Array<{ + id: string; + minLicenseType: Exclude; + sizeBytes: number; + sha256: string; + }>; + issuedAt: string; + expiresAt: string; +} + +export interface SignedPremiumModuleManifest { + manifest: PremiumModuleManifest; + signature: string; +} + +const MODULES_BY_LICENSE: Record, PremiumModuleManifest['modules']> = { + STANDARD: [ + { + id: 'custom-templates-core', + minLicenseType: 'STANDARD', + sizeBytes: 4096, + sha256: 'pending-real-module-split-standard', + }, + ], + PRO: [ + { + id: 'custom-templates-core', + minLicenseType: 'STANDARD', + sizeBytes: 4096, + sha256: 'pending-real-module-split-standard', + }, + { + id: 'intranet-penetration-core', + minLicenseType: 'PRO', + sizeBytes: 4096, + sha256: 'pending-real-module-split-pro', + }, + ], +}; + +export function buildSignedPremiumModuleManifest( + licenseType: Exclude, + leaseExpiresAt: Date +): SignedPremiumModuleManifest { + const manifest: PremiumModuleManifest = { + version: PREMIUM_MODULE_MANIFEST_VERSION, + licenseType, + modules: MODULES_BY_LICENSE[licenseType], + issuedAt: new Date().toISOString(), + expiresAt: leaseExpiresAt.toISOString(), + }; + + return { + manifest, + signature: signData(JSON.stringify(manifest)), + }; +} + diff --git a/license-server/nginx/nginx.conf b/license-server/nginx/nginx.conf index 9a98e33..5282039 100644 --- a/license-server/nginx/nginx.conf +++ b/license-server/nginx/nginx.conf @@ -5,7 +5,10 @@ limit_conn_zone $binary_remote_addr zone=conn_limit:10m; server { listen 80; - server_name lic.flnxi.com; + server_name _; + + limit_req_status 429; + limit_conn_status 429; # 基础安全头 add_header X-Frame-Options "SAMEORIGIN" always; @@ -23,7 +26,7 @@ server { # 登录接口 - 严格限流(防暴力破解) location /api/admin/auth/login { limit_req zone=login_limit burst=3 nodelay; - proxy_pass http://127.0.0.1:9527; + proxy_pass http://license-server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -33,7 +36,7 @@ server { # API 接口 - 普通限流 location /api/ { limit_req zone=api_limit burst=20 nodelay; - proxy_pass http://127.0.0.1:9527; + proxy_pass http://license-server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -42,7 +45,7 @@ server { # 静态资源和页面 location / { - proxy_pass http://127.0.0.1:9527; + proxy_pass http://license-server:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;