diff --git a/.dockerignore b/.dockerignore index fc0263c..d0e436a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -91,6 +91,8 @@ gongxiangp2-source-reference-old-style-20260513-132942 license-server flnxi-agent flnxi-agent-go +premium-modules +deploy/registry-proxy # 数据库文件 *.sql diff --git a/.env.example b/.env.example index 21f0b50..a553b75 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ LICENSE_PROJECT_CODE=flnxi-sucai # Shared HMAC secret between material app and license server (minimum 32 characters) LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret +# Optional paid module service URL. Keep empty unless the module container is running. +INTRANET_PENETRATION_MODULE_URL= + # Versioned deployment images # Customer delivery must pin the full application image tag. Do not rely on latest. APP_IMAGE=registry.example.com/flnxi/material/flnxi-material:1.0.0 diff --git a/.gitignore b/.gitignore index a0fe8b5..690b4ed 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,13 @@ next-env.d.ts *.bak-* *.ps1.bak-* +# Local deployment/security test artifacts +deploy-artifacts/ +hack_*.js +hack_*.sql +reset_pw.sql +update_pw.sql + # Docker images (large files, use Git LFS if needed) /docker-images/**/*.tar /docker-images/**/*.tar.gz diff --git a/README.md b/README.md index 5f8aa8d..dad636a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ ├── scripts/ # 构建、迁移、修复、部署辅助脚本 ├── docs/ # 当前项目文档入口 ├── license-server/ # 独立许可证服务 +├── premium-modules/ # 付费模块镜像源码 ├── flnxi-agent/ # Node 版安装与运维 Agent └── flnxi-agent-go/ # Go 版安装与运维 Agent ``` @@ -129,7 +130,9 @@ LOCAL_ENCRYPTION_KEY= 内置 PostgreSQL 部署时 `DATABASE_URL` 留空,由 Compose 自动指向内置数据库;只有外部 PostgreSQL 才填写真实连接串。`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 中的项目配置一致。 -付费模块采用“镜像站放模块核心本体,验证服务器发授权清单和实例凭证”的分工;部署 `license-server` 时还要确认 `PREMIUM_MODULE_REGISTRY_BASE` 和模块版本指向当前可用镜像站。 +授权模块采用“镜像站放模块核心本体,验证服务器发授权清单、实例凭证和短期拉包 token”的分工;免费版的人数/素材数上限也通过 `license-limits-core` 模块下发,付费版再额外下发自定义模板、内网穿透等能力模块。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和模块 digest 指向当前可用镜像站。 + +当前已提供第一批模块源码骨架:`premium-modules/license-limits-core`、`premium-modules/custom-templates-core`、`premium-modules/intranet-penetration-core`。模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule` 发布,发布后把脚本输出的模块镜像和 digest 写入 `license-server`。其中 `custom-templates-core` 第一版先承接授权和拉包合同,模板业务仍由主应用执行,后续再逐步拆细。 ### 初始化 Prisma diff --git a/VERSION.txt b/VERSION.txt index b0f3d96..437d26b 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.0.8 +1.0.10 \ No newline at end of file diff --git a/app/api/license/activate-free/__tests__/route.test.ts b/app/api/license/activate-free/__tests__/route.test.ts index 2ac7c50..eb62b61 100644 --- a/app/api/license/activate-free/__tests__/route.test.ts +++ b/app/api/license/activate-free/__tests__/route.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; import { POST } from '../route'; import { verifyLicenseSignature } from '@/lib/license/crypto-service'; import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'; +import { syncPremiumModules } from '@/lib/license/premium-module-service'; import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'; jest.mock('@/lib/license/fingerprint-service', () => ({ @@ -28,6 +29,10 @@ jest.mock('@/lib/license/license-service', () => ({ startHeartbeat: jest.fn(), })); +jest.mock('@/lib/license/premium-module-service', () => ({ + syncPremiumModules: jest.fn(), +})); + jest.mock('@/lib/license/license-route-guards', () => ({ requireSuperAdminAfterInitialSetup: jest.fn(), })); @@ -36,6 +41,7 @@ const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction; const mockInitializeSystem = initializeSystem as jest.MockedFunction; const mockStartHeartbeat = startHeartbeat as jest.MockedFunction; +const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction; const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction; const signedFreeLicense = { @@ -102,6 +108,7 @@ describe('POST /api/license/activate-free', () => { process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32); mockVerifyLicenseSignature.mockReturnValue(true); mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true }); + mockSyncPremiumModules.mockResolvedValue({ synced: true }); mockInitializeSystem.mockResolvedValue({ admin: { created: false, @@ -126,6 +133,7 @@ describe('POST /api/license/activate-free', () => { signedFreeLicense.data, signedFreeLicense.signature ); + expect(mockSyncPremiumModules).toHaveBeenCalled(); expect(mockStartHeartbeat).toHaveBeenCalled(); const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); @@ -136,6 +144,24 @@ describe('POST /api/license/activate-free', () => { expect(fetchBody.contactInfo).toBe('13800000000'); }); + it('免费版授权模块同步失败不应阻断首次激活', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + mockRegisterFreeSuccess(); + mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' }); + + const response = await POST(createMockRequest()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockSaveLicenseLocally).toHaveBeenCalled(); + expect(mockInitializeSystem).toHaveBeenCalled(); + expect(mockStartHeartbeat).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down'); + + consoleWarnSpy.mockRestore(); + }); + it('已有最高权限账号后,未授权请求不能改写免费许可证', async () => { mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({ allowed: false, @@ -151,6 +177,7 @@ describe('POST /api/license/activate-free', () => { expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录'); expect(global.fetch).not.toHaveBeenCalled(); expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + expect(mockSyncPremiumModules).not.toHaveBeenCalled(); expect(mockInitializeSystem).not.toHaveBeenCalled(); expect(mockStartHeartbeat).not.toHaveBeenCalled(); }); @@ -165,6 +192,7 @@ describe('POST /api/license/activate-free', () => { expect(data.success).toBe(false); expect(data.error).toContain('许可证服务返回数据异常'); expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + expect(mockSyncPremiumModules).not.toHaveBeenCalled(); expect(mockInitializeSystem).not.toHaveBeenCalled(); expect(mockStartHeartbeat).not.toHaveBeenCalled(); }); @@ -180,6 +208,7 @@ describe('POST /api/license/activate-free', () => { expect(data.success).toBe(false); expect(data.error).toBe('许可证签名验证失败'); expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + expect(mockSyncPremiumModules).not.toHaveBeenCalled(); expect(mockInitializeSystem).not.toHaveBeenCalled(); expect(mockStartHeartbeat).not.toHaveBeenCalled(); }); @@ -201,6 +230,7 @@ describe('POST /api/license/activate-free', () => { expect(response.status).toBe(400); expect(data.error).toContain('激活码不匹配'); expect(mockSaveLicenseLocally).not.toHaveBeenCalled(); + expect(mockSyncPremiumModules).not.toHaveBeenCalled(); expect(mockInitializeSystem).not.toHaveBeenCalled(); expect(mockStartHeartbeat).not.toHaveBeenCalled(); }); diff --git a/app/api/license/activate-free/route.ts b/app/api/license/activate-free/route.ts index 7138bbe..622077d 100644 --- a/app/api/license/activate-free/route.ts +++ b/app/api/license/activate-free/route.ts @@ -4,6 +4,7 @@ import { verifyLicenseSignature } from '@/lib/license/crypto-service'; import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'; import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config'; import { buildSignedLicenseRequest } from '@/lib/license/request-signature'; +import { syncPremiumModules } from '@/lib/license/premium-module-service'; import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'; import { validateSignedLicenseBinding } from '@/lib/license/license-binding'; @@ -154,6 +155,10 @@ export async function POST(request: NextRequest) { // ✅ 在后端保存许可证 await saveLicenseLocally(activationCode, signedLicense); + const moduleSyncResult = await syncPremiumModules(); + if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { + console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error); + } // 初始化系统(创建默认管理员、部门、店铺等) const initResult = await initializeSystem(); diff --git a/app/api/license/activate/__tests__/route.test.ts b/app/api/license/activate/__tests__/route.test.ts index f8b7864..b3de767 100644 --- a/app/api/license/activate/__tests__/route.test.ts +++ b/app/api/license/activate/__tests__/route.test.ts @@ -258,7 +258,7 @@ describe('POST /api/license/activate', () => { expect(mockSyncPremiumModules).not.toHaveBeenCalled() }) - test('付费模块清单同步失败不应阻断许可证激活', async () => { + test('授权模块清单同步失败不应阻断许可证激活', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) mockActivateSuccess() mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' }) @@ -269,7 +269,7 @@ describe('POST /api/license/activate', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense) - expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 付费模块清单同步失败:', 'network down') + expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down') consoleWarnSpy.mockRestore() }) diff --git a/app/api/license/activate/route.ts b/app/api/license/activate/route.ts index 980cb50..f56b5e5 100644 --- a/app/api/license/activate/route.ts +++ b/app/api/license/activate/route.ts @@ -77,7 +77,7 @@ export async function POST(request: NextRequest) { await saveLicenseLocally(licenseKey, data.license); const moduleSyncResult = await syncPremiumModules(); if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { - console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error); + console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error); } // 初始化系统(创建默认管理员、部门、店铺等) diff --git a/app/api/license/check/__tests__/route.test.ts b/app/api/license/check/__tests__/route.test.ts index 8b9f6c0..d6413dd 100644 --- a/app/api/license/check/__tests__/route.test.ts +++ b/app/api/license/check/__tests__/route.test.ts @@ -1,11 +1,11 @@ import { NextRequest } from 'next/server' jest.mock('@/lib/license/license-service', () => ({ - getLocalLicenseReadOnly: jest.fn(), + getLocalLicense: jest.fn(), isLicenseConfigExpired: jest.fn(), })) -const { getLocalLicenseReadOnly, isLicenseConfigExpired } = require('@/lib/license/license-service') +const { getLocalLicense, isLicenseConfigExpired } = require('@/lib/license/license-service') import { GET } from '../route' @@ -16,7 +16,7 @@ describe('GET /api/license/check', () => { }) test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => { - getLocalLicenseReadOnly.mockResolvedValue({ + getLocalLicense.mockResolvedValue({ instanceId: 'test-instance-id', licenseKey: 'license-key', licenseData: 'encrypted-license', @@ -46,7 +46,7 @@ describe('GET /api/license/check', () => { }) test('本地许可证为空或验签失败时应返回未激活', async () => { - getLocalLicenseReadOnly.mockResolvedValue(null) + getLocalLicense.mockResolvedValue(null) const response = await GET(new NextRequest('http://localhost/api/license/check')) const data = await response.json() @@ -58,7 +58,7 @@ describe('GET /api/license/check', () => { test('本地许可证在线租约过期时不应恢复激活 cookie', async () => { isLicenseConfigExpired.mockReturnValue(true) - getLocalLicenseReadOnly.mockResolvedValue({ + getLocalLicense.mockResolvedValue({ instanceId: 'test-instance-id', licenseKey: 'license-key', licenseData: 'encrypted-license', diff --git a/app/api/license/check/route.ts b/app/api/license/check/route.ts index f56523f..17de53c 100644 --- a/app/api/license/check/route.ts +++ b/app/api/license/check/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getLocalLicenseReadOnly, isLicenseConfigExpired } from '@/lib/license/license-service'; +import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-service'; + +export const dynamic = 'force-dynamic'; /** * 检查数据库中是否有已激活的许可证 @@ -7,7 +9,7 @@ import { getLocalLicenseReadOnly, isLicenseConfigExpired } from '@/lib/license/l */ export async function GET(request: NextRequest) { try { - const license = await getLocalLicenseReadOnly(); + const license = await getLocalLicense(); if (!license || isLicenseConfigExpired(license.cachedConfig)) { return NextResponse.json({ activated: false }); } diff --git a/app/api/tunnel/start/route.ts b/app/api/tunnel/start/route.ts index 183ae36..d909123 100644 --- a/app/api/tunnel/start/route.ts +++ b/app/api/tunnel/start/route.ts @@ -5,6 +5,7 @@ import { featureFlags } from '@/lib/config' import { canUseIntranetPenetration } from '@/lib/license' import { prisma } from '@/lib/prisma' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { proxyIntranetModule } from '@/lib/tunnel/intranet-module' async function ensureTunnelEnabled() { if (featureFlags.tunnelEnabled) return true @@ -32,6 +33,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 }) } + const moduleResponse = await proxyIntranetModule('start') + if (moduleResponse) return moduleResponse + return NextResponse.json( { error: '内网穿透服务尚未接入真实提供商,不能启动占位隧道' }, { status: 501 } diff --git a/app/api/tunnel/status/route.ts b/app/api/tunnel/status/route.ts index cb358a3..aebc768 100644 --- a/app/api/tunnel/status/route.ts +++ b/app/api/tunnel/status/route.ts @@ -5,6 +5,7 @@ import { featureFlags } from '@/lib/config' import { canUseIntranetPenetration } from '@/lib/license' import { prisma } from '@/lib/prisma' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { proxyIntranetModule } from '@/lib/tunnel/intranet-module' export const dynamic = 'force-dynamic' @@ -38,6 +39,9 @@ export async function GET(req: NextRequest) { }, { status: 403 }) } + const moduleResponse = await proxyIntranetModule('status') + if (moduleResponse) return moduleResponse + return NextResponse.json( { enabled: true, diff --git a/app/api/tunnel/stop/route.ts b/app/api/tunnel/stop/route.ts index e005eab..2ef84a5 100644 --- a/app/api/tunnel/stop/route.ts +++ b/app/api/tunnel/stop/route.ts @@ -5,6 +5,7 @@ import { featureFlags } from '@/lib/config' import { canUseIntranetPenetration } from '@/lib/license' import { prisma } from '@/lib/prisma' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { proxyIntranetModule } from '@/lib/tunnel/intranet-module' async function ensureTunnelEnabled() { if (featureFlags.tunnelEnabled) return true @@ -32,6 +33,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 }) } + const moduleResponse = await proxyIntranetModule('stop') + if (moduleResponse) return moduleResponse + return NextResponse.json( { error: '内网穿透服务尚未接入真实提供商,不能停止占位隧道' }, { status: 501 } diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 index a77bb55..2798fa8 100644 --- a/deploy-registry.ps1 +++ b/deploy-registry.ps1 @@ -5,7 +5,7 @@ # ================================================================ param( - [ValidateSet("DeployApp", "PublishRelease", "PublishAgent", "SyncRuntime", "Status", "Menu")] + [ValidateSet("DeployApp", "PublishRelease", "PublishAgent", "PublishPremiumModule", "SyncRuntime", "Status", "Menu")] [string]$Action = "DeployApp", [string]$Server = "", @@ -43,6 +43,10 @@ param( [string]$Version = "", [string]$AgentRepository = "agent", [string]$AgentContext = "flnxi-agent-go", + [string]$PremiumModuleId = "intranet-penetration-core", + [string]$PremiumModuleContextRoot = "premium-modules", + [string]$PremiumModuleRepositoryPrefix = "premium", + [switch]$AllowMissingPremiumModuleDigest, [switch]$NoLatest, [switch]$PushLatest, [switch]$NoVersionFileUpdate, @@ -105,6 +109,7 @@ function Show-Help { Write-Host " .\deploy-registry.ps1 -Action DeployApp -DeployChannel test -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword" -ForegroundColor Yellow Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -PushLatest -RegistryPassword `$registryPassword" -ForegroundColor Yellow + Write-Host " .\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId intranet-penetration-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword" -ForegroundColor Yellow Write-Host " .\deploy-registry.ps1 -Action Menu -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow Write-Host "" Write-Host "只构建和推送,不远程部署:" -ForegroundColor White @@ -114,6 +119,7 @@ function Show-Help { Write-Host " -Action DeployApp 构建、推送并部署素材库应用(默认)" Write-Host " -Action PublishRelease 发布素材库正式版镜像,只推送不远程部署" Write-Host " -Action PublishAgent 构建并推送 Go Agent 镜像" + Write-Host " -Action PublishPremiumModule 构建并推送付费模块镜像,输出 license-server 所需 digest" Write-Host " -Action SyncRuntime 同步 Postgres / MinIO 等运行依赖镜像" Write-Host " -Action Status 查看 Registry、本机 Docker、远程容器状态" Write-Host " -Action Menu 打开交互菜单" @@ -132,6 +138,10 @@ function Show-Help { Write-Host " -Version 正式版或 Agent 版本号" Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)" Write-Host " -AgentRepository Agent 镜像路径,默认 agent" + Write-Host " -PremiumModuleId 付费模块 ID,默认 intranet-penetration-core" + Write-Host " -PremiumModuleContextRoot 付费模块源码根目录,默认 premium-modules" + Write-Host " -PremiumModuleRepositoryPrefix 付费模块镜像仓库前缀,默认 premium" + Write-Host " -AllowMissingPremiumModuleDigest 允许付费模块发布后暂时不输出 digest;正式商用不建议启用" Write-Host " -RemotePath 远程部署目录,默认 /opt/flnxi;已有客户环境必须传入真实部署目录" Write-Host " -FullComposeUp 远程执行完整 docker compose up -d;默认只更新 app 服务" Write-Host " -UpdateRemoteEnv 兼容旧参数;DeployApp 现在默认会把 APP_IMAGE 等写入远程 .env" @@ -241,6 +251,48 @@ function Get-RegistryImageFor { return "$RegistryHost/$RegistryNamespace/$($Repository.Trim('/'))`:$ImageTag" } +function Get-PremiumModuleEnvKey { + param([string]$ModuleId) + return (($ModuleId -replace '[^a-zA-Z0-9]', '_').ToUpperInvariant()) +} + +function Get-PushedImageDigest { + param([string]$Image) + + if ($DryRun) { + return "sha256:dryrun" + } + + $repoDigestsRaw = "" + try { + $repoDigestsRaw = docker image inspect $Image --format "{{json .RepoDigests}}" + } catch { + $repoDigestsRaw = "" + } + + if (-not [string]::IsNullOrWhiteSpace($repoDigestsRaw)) { + try { + $repoDigests = $repoDigestsRaw | ConvertFrom-Json + foreach ($repoDigest in $repoDigests) { + if ($repoDigest -match '@(sha256:[a-f0-9]+)$') { + return $Matches[1] + } + } + } catch { + Write-Warn "解析本地 RepoDigests 失败:$($_.Exception.Message)" + } + } + + if (Get-Command docker -ErrorAction SilentlyContinue) { + $digest = docker buildx imagetools inspect $Image --format "{{.Manifest.Digest}}" 2>$null + if ($LASTEXITCODE -eq 0 -and $digest -match '^sha256:[a-f0-9]+$') { + return $digest.Trim() + } + } + + return "" +} + function Get-DefaultTestTag { return "test-$(Get-Date -Format 'yyyyMMdd-HHmm')" } @@ -349,6 +401,9 @@ function Get-ComposeRuntimeEnv { } function Ensure-RegistryPassword { + if ($DryRun) { + return + } if ($SkipPush -and $SkipRemote) { return } @@ -413,10 +468,24 @@ function Test-RegistryEndpoint { $response = Invoke-WebRequest -Uri $uri -Headers $headers -TimeoutSec 8 -ErrorAction Stop if ($response.StatusCode -eq 200) { Write-Success "Registry 接口可访问" + } elseif ($response.StatusCode -eq 401) { + Write-Success "Registry 接口可访问(需要认证)" } else { throw "Registry 返回状态码 $($response.StatusCode)" } } catch { + $statusCode = $null + try { + $statusCode = [int]$_.Exception.Response.StatusCode + } catch { + $statusCode = $null + } + + if ($statusCode -eq 401) { + Write-Success "Registry 接口可访问(需要认证)" + return + } + throw "Registry 接口检查失败。若报 HTTPS/HTTP 相关错误,请先在 Docker Desktop 和目标机配置 insecure-registries:$RegistryHost。原始错误:$($_.Exception.Message)" } } @@ -429,15 +498,16 @@ function Invoke-DockerLogin { } $plain = ConvertTo-PlainText -SecureValue $RegistryPassword + if ($DryRun) { + Write-Info "登录 Registry:$RegistryHost" + Write-Host "[DRYRUN] docker login $RegistryHost -u $RegistryUsername --password-stdin" -ForegroundColor DarkGray + return + } if ([string]::IsNullOrWhiteSpace($plain)) { throw "Registry 密码为空,无法登录" } Write-Info "登录 Registry:$RegistryHost" - if ($DryRun) { - Write-Host "[DRYRUN] docker login $RegistryHost -u $RegistryUsername --password-stdin" -ForegroundColor DarkGray - return - } $plain | docker login $RegistryHost -u $RegistryUsername --password-stdin if ($LASTEXITCODE -ne 0) { @@ -1248,6 +1318,93 @@ function Invoke-PublishAgent { Write-Info "镜像:$versionImage" } +function Invoke-PublishPremiumModule { + Assert-RegistryHostConfigured + + Write-Host "========================================" -ForegroundColor Magenta + Write-Host " FLNXI素材库 - 付费模块镜像发布" -ForegroundColor Magenta + Write-Host "========================================" -ForegroundColor Magenta + + $moduleId = $PremiumModuleId.Trim() + if ([string]::IsNullOrWhiteSpace($moduleId)) { + throw "PremiumModuleId 不能为空" + } + + $moduleContext = Join-Path $PremiumModuleContextRoot $moduleId + if (-not (Test-Path $moduleContext)) { + throw "找不到付费模块目录:$moduleContext" + } + + $dockerfile = Join-Path $moduleContext "Dockerfile" + if (-not (Test-Path $dockerfile)) { + throw "付费模块缺少 Dockerfile:$dockerfile" + } + + $versionFile = Join-Path $moduleContext "VERSION.txt" + $currentVersion = Read-VersionFile -Path $versionFile + $moduleVersion = if (-not [string]::IsNullOrWhiteSpace($Tag)) { + $Tag.Trim() + } else { + Get-VersionOrPrompt -CurrentVersion $currentVersion -Prompt "请输入付费模块 $moduleId 版本号" + } + Write-VersionFileIfNeeded -Path $versionFile -Value $moduleVersion + + $moduleRepository = "$($PremiumModuleRepositoryPrefix.Trim('/'))/$moduleId" + $registryImage = Get-RegistryImageFor -Repository $moduleRepository -ImageTag $moduleVersion + $localImage = "flnxi-premium-$moduleId`:$moduleVersion" + + Assert-Tool "docker" + if (-not $SkipPush) { + Ensure-RegistryPassword + Test-RegistryEndpoint + Invoke-DockerLogin + } + + $env:DOCKER_BUILDKIT = "1" + $buildArgs = @( + "build", + "--progress=plain", + "-t", $localImage, + "-t", $registryImage, + "-f", $dockerfile + ) + if ($NoCache) { + $buildArgs += "--no-cache" + } + $buildArgs += $moduleContext + + Invoke-NativeCommand -FilePath "docker" -Arguments $buildArgs -Label "构建付费模块镜像:$registryImage" + if ($SkipPush) { + Write-Warn "已跳过推送付费模块镜像;未推送时无法作为客户拉取源使用" + } else { + Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $registryImage) -Label "推送付费模块镜像" + } + + $digest = if ($SkipPush) { "" } else { Get-PushedImageDigest -Image $registryImage } + if ([string]::IsNullOrWhiteSpace($digest) -and (-not $SkipPush) -and (-not $AllowMissingPremiumModuleDigest)) { + throw "未能自动读取付费模块镜像 digest。请确认 Registry 可通过 docker image inspect 或 docker buildx imagetools inspect 读取 digest;如只是临时内部调试,可显式传入 -AllowMissingPremiumModuleDigest。" + } + + if ([string]::IsNullOrWhiteSpace($digest)) { + Write-Warn "未能自动读取镜像 digest;请在 Registry 管理界面或 docker buildx imagetools inspect 中确认" + } + + $envKey = Get-PremiumModuleEnvKey -ModuleId $moduleId + Write-Success "付费模块发布完成" + Write-Info "模块:$moduleId" + Write-Info "镜像:$registryImage" + if (-not [string]::IsNullOrWhiteSpace($digest)) { + Write-Info "Digest:$digest" + } + Write-Host "" + Write-Host "请写入 license-server 环境变量:" -ForegroundColor Yellow + Write-Host "PREMIUM_MODULE_${envKey}_IMAGE=$registryImage" -ForegroundColor Yellow + Write-Host "PREMIUM_MODULE_${envKey}_TAG=$moduleVersion" -ForegroundColor Yellow + if (-not [string]::IsNullOrWhiteSpace($digest)) { + Write-Host "PREMIUM_MODULE_${envKey}_DIGEST=$digest" -ForegroundColor Yellow + } +} + function Invoke-SyncRuntimeImages { Assert-RegistryHostConfigured @@ -1420,8 +1577,9 @@ function Show-ActionMenu { Write-Host "1. 应用发布部署(默认正式版通道,测试需显式 -DeployChannel test)" Write-Host "2. 发布素材系统正式版镜像" Write-Host "3. 发布 Agent 镜像" - Write-Host "4. 同步 Runtime 镜像" - Write-Host "5. 查看状态" + Write-Host "4. 发布付费模块镜像" + Write-Host "5. 同步 Runtime 镜像" + Write-Host "6. 查看状态" Write-Host "0. 退出" } @@ -1433,8 +1591,9 @@ function Invoke-Menu { "1" { Invoke-DeployApp } "2" { Invoke-PublishRelease } "3" { Invoke-PublishAgent } - "4" { Invoke-SyncRuntimeImages } - "5" { Invoke-ShowStatus } + "4" { Invoke-PublishPremiumModule } + "5" { Invoke-SyncRuntimeImages } + "6" { Invoke-ShowStatus } "0" { return } default { Write-Warn "无效选择,请重新输入" } } @@ -1453,6 +1612,7 @@ function Main { "DeployApp" { Invoke-DeployApp } "PublishRelease" { Invoke-PublishRelease } "PublishAgent" { Invoke-PublishAgent } + "PublishPremiumModule" { Invoke-PublishPremiumModule } "SyncRuntime" { Invoke-SyncRuntimeImages } "Status" { Invoke-ShowStatus } "Menu" { Invoke-Menu } diff --git a/deploy/registry-proxy/README.md b/deploy/registry-proxy/README.md new file mode 100644 index 0000000..1530f6e --- /dev/null +++ b/deploy/registry-proxy/README.md @@ -0,0 +1,71 @@ +# FLNXI 付费模块 Registry 代理 + +这个目录只用于“客户拉取付费模块镜像”的 Registry 前置代理示例。 + +核心规则: + +- 发布方推送镜像仍走发布账号,不把发布账号给客户。 +- 客户拉取 `flnxi/premium/*` 付费模块时,用户名固定为 `flnxi-module`,密码是验证系统签发的 15 分钟短期 `pullToken`。 +- Nginx 代理会把 `Authorization`、Registry 主机、模块仓库路径、请求类型、tag/reference 和 digest 转发给 `license-server` 的 `GET /api/registry/auth`。 +- 验证系统确认 token、token 审计记录、许可证、激活实例和模块授权都有效后,代理才把请求放行到 Registry。 + +## 启动示例 + +先确认许可证服务和本代理在同一个 Docker 网络里,或者把 `LICENSE_SERVER_AUTH_URL` 改成代理能访问到的地址。 + +```bash +cd deploy/registry-proxy + +cat > .env <<'EOF' +REGISTRY_PROXY_PORT=5000 +LICENSE_NETWORK_NAME=license-network +LICENSE_SERVER_AUTH_URL=http://license-server:3000/api/registry/auth +EOF + +docker compose up -d +``` + +如果许可证服务是通过 `license-server/nginx/nginx.conf` 里的外层 Nginx 暴露,可以把 `LICENSE_SERVER_AUTH_URL` 写成对应内网地址,例如: + +```env +LICENSE_SERVER_AUTH_URL=http://license-nginx/api/registry/auth +``` + +如果镜像站已经存在,不想再启动一个新的 `registry:2`,使用接入现有 Registry 的编排: + +```bash +cat > .env <<'EOF' +REGISTRY_PROXY_PORT=5002 +REGISTRY_NETWORK_NAME=flnxi-registry-net +LICENSE_NETWORK_NAME=license-network +REGISTRY_BACKEND=http://flnxi-registry:5000 +REGISTRY_BACKEND_AUTH=Basic +LICENSE_SERVER_AUTH_URL=http://license-server:3000/api/registry/auth +EOF + +docker compose -f docker-compose.existing-registry.yml up -d +``` + +这种方式只新增 Nginx 拉取代理,不会创建新的 Registry 数据卷,也不会覆盖已有镜像站。如果现有 Registry 后端本身启用了 Basic Auth,必须配置一个只给代理使用的后端账号,并把 `REGISTRY_BACKEND_AUTH` 写成 `Basic base64(username:password)`;客户仍然只拿验证系统签发的短期 `pullToken`,不会拿到这个后端账号。 + +## 客户拉取方式 + +客户侧 Go Agent 拿到验证系统返回的短期凭证后,等价于: + +```bash +docker login registry.example.com:5000 -u flnxi-module -p '' +docker pull registry.example.com:5000/flnxi/premium/intranet-penetration-core:1.0.0 +``` + +没有短期 token、token 过期、仓库路径不匹配、tag 不匹配、镜像清单 digest 不匹配、许可证过期、激活失效或模块授权被吊销,都会被代理拒绝。Docker 拉取层文件时的 blob digest 不是整镜像 digest,最终整镜像 digest 由客户侧 Agent 拉取完成后再校验。 + +## 发布方注意 + +这个代理示例默认只放行客户拉取,不开放推送。发布付费模块镜像时应使用内部发布入口或临时直连 Registry 内网入口,再把固定 digest 写入 `license-server` 的模块配置: + +```env +PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE=registry.example.com:5000/flnxi/premium/intranet-penetration-core:1.0.0 +PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST=sha256:... +``` + +正式客户环境不要把发布账号、发布密码或 Registry 管理界面口令写进主应用 `.env`。Registry 原始后端也不要直接暴露给客户访问;客户入口应只到这个前置代理。 diff --git a/deploy/registry-proxy/docker-compose.existing-registry.yml b/deploy/registry-proxy/docker-compose.existing-registry.yml new file mode 100644 index 0000000..cdf774c --- /dev/null +++ b/deploy/registry-proxy/docker-compose.existing-registry.yml @@ -0,0 +1,24 @@ +services: + module-registry-proxy: + image: ${REGISTRY_PROXY_NGINX_IMAGE:-nginx:1.27-alpine} + container_name: ${REGISTRY_PROXY_CONTAINER_NAME:-flnxi-module-registry-proxy} + restart: unless-stopped + ports: + - "${REGISTRY_PROXY_BIND_HOST:-0.0.0.0}:${REGISTRY_PROXY_PORT:-5002}:5000" + environment: + REGISTRY_BACKEND: ${REGISTRY_BACKEND:-http://flnxi-registry:5000} + REGISTRY_BACKEND_AUTH: ${REGISTRY_BACKEND_AUTH:-} + LICENSE_SERVER_AUTH_URL: ${LICENSE_SERVER_AUTH_URL:?LICENSE_SERVER_AUTH_URL is required, for example http://license-server:3000/api/registry/auth} + volumes: + - ./nginx.module-auth.conf.template:/etc/nginx/templates/default.conf.template:ro + networks: + - registry-network + - license-network + +networks: + registry-network: + name: ${REGISTRY_NETWORK_NAME:-flnxi-registry-net} + external: true + license-network: + name: ${LICENSE_NETWORK_NAME:-license-network} + external: true diff --git a/deploy/registry-proxy/docker-compose.yml b/deploy/registry-proxy/docker-compose.yml new file mode 100644 index 0000000..a560457 --- /dev/null +++ b/deploy/registry-proxy/docker-compose.yml @@ -0,0 +1,41 @@ +services: + registry: + image: ${REGISTRY_IMAGE:-registry:2} + container_name: ${REGISTRY_CONTAINER_NAME:-flnxi-registry} + restart: unless-stopped + environment: + REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry + REGISTRY_HTTP_ADDR: 0.0.0.0:5000 + volumes: + - registry_data:/var/lib/registry + networks: + - registry-network + + module-registry-proxy: + image: ${REGISTRY_PROXY_NGINX_IMAGE:-nginx:1.27-alpine} + container_name: ${REGISTRY_PROXY_CONTAINER_NAME:-flnxi-module-registry-proxy} + restart: unless-stopped + ports: + - "${REGISTRY_PROXY_BIND_HOST:-0.0.0.0}:${REGISTRY_PROXY_PORT:-5000}:5000" + environment: + REGISTRY_BACKEND: ${REGISTRY_BACKEND:-http://registry:5000} + REGISTRY_BACKEND_AUTH: ${REGISTRY_BACKEND_AUTH:-} + LICENSE_SERVER_AUTH_URL: ${LICENSE_SERVER_AUTH_URL:?LICENSE_SERVER_AUTH_URL is required, for example http://license-server:3000/api/registry/auth} + volumes: + - ./nginx.module-auth.conf.template:/etc/nginx/templates/default.conf.template:ro + depends_on: + - registry + networks: + - registry-network + - license-network + +volumes: + registry_data: + name: ${REGISTRY_VOLUME_NAME:-flnxi-registry-data} + +networks: + registry-network: + name: ${REGISTRY_NETWORK_NAME:-flnxi-registry-network} + license-network: + name: ${LICENSE_NETWORK_NAME:-license-network} + external: true diff --git a/deploy/registry-proxy/nginx.module-auth.conf.template b/deploy/registry-proxy/nginx.module-auth.conf.template new file mode 100644 index 0000000..e1c5f67 --- /dev/null +++ b/deploy/registry-proxy/nginx.module-auth.conf.template @@ -0,0 +1,81 @@ +# FLNXI premium module Registry pull proxy. +# The Docker image push path should stay on the publisher-only Registry entry. +# Customer pulls for /v2/flnxi/premium/* are checked by license-server /api/registry/auth. + +map $request_method $flnxi_is_pull_method { + default 0; + GET 1; + HEAD 1; +} + +map $module_reference $module_digest_header { + default ""; + ~^sha256: $module_reference; +} + +server { + listen 5000; + server_name _; + client_max_body_size 0; + + proxy_read_timeout 900; + proxy_send_timeout 900; + proxy_request_buffering off; + + error_page 401 = @flnxi_module_auth_required; + + location = /v2/ { + if ($http_authorization = "") { + add_header Docker-Distribution-Api-Version registry/2.0 always; + add_header WWW-Authenticate 'Basic realm="flnxi premium modules"' always; + return 401; + } + + add_header Docker-Distribution-Api-Version registry/2.0 always; + add_header Content-Type application/json; + return 200 '{}'; + } + + location ~ ^/v2/(?flnxi/premium/[^/]+)/(?manifests|blobs)/(?.+)$ { + if ($flnxi_is_pull_method = 0) { + return 403; + } + + auth_request /_flnxi_module_auth; + auth_request_set $flnxi_auth_status $upstream_status; + + proxy_pass ${REGISTRY_BACKEND}; + proxy_set_header Authorization "${REGISTRY_BACKEND_AUTH}"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /_flnxi_module_auth { + internal; + proxy_pass ${LICENSE_SERVER_AUTH_URL}; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Authorization $http_authorization; + proxy_set_header X-Registry-Host $http_host; + proxy_set_header X-Registry-Repository $module_repository; + proxy_set_header X-Registry-Reference-Type $module_reference_type; + proxy_set_header X-Registry-Reference $module_reference; + proxy_set_header X-Registry-Digest $module_digest_header; + proxy_set_header X-Registry-Scope "repository:$module_repository:pull"; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /v2/ { + return 403; + } + + location @flnxi_module_auth_required { + add_header Docker-Distribution-Api-Version registry/2.0 always; + add_header WWW-Authenticate 'Basic realm="flnxi premium modules"' always; + return 401; + } +} diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 4cf2e64..7041be1 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -74,6 +74,7 @@ LICENSE_PROJECT_CODE=flnxi-sucai LICENSE_REQUEST_SIGNING_SECRET= LICENSE_PUBLIC_KEY= LOCAL_ENCRYPTION_KEY= +# INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787 MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 @@ -90,7 +91,8 @@ 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`,用于给客户实例下发签名模块清单。 +- `INTRANET_PENETRATION_MODULE_URL` 是可选项;只有客户购买并运行 `intranet-penetration-core` 模块后才填写。未填写时,内网穿透接口仍会明确返回“真实提供商未接入”。 +- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限也通过 `license-limits-core` 模块下发。 - `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。 @@ -138,6 +140,37 @@ docker info | grep -A 5 "Insecure Registries" Docker Desktop 需要在设置里的 Docker Engine 配置同样的 `insecure-registries`,保存并重启 Docker。 +### 2.4 付费模块私有拉取 + +主应用基础镜像和付费模块镜像要分开理解: + +- 基础镜像:客户通过 `APP_IMAGE` 拉取和升级,不能因为购买付费版就自动包含所有付费模块核心。 +- 付费模块镜像:发布方推送到镜像站后,由验证系统确认客户授权,再签发 15 分钟短期拉包 token。 + +推荐链路: + +```text +客户 Go Agent + -> license-server /api/license/{projectCode}/premium-modules/{moduleId}/pull-token + -> docker login registry.example.com -u flnxi-module -p <短期 pullToken> + -> Registry 前置代理 + -> license-server /api/registry/auth + -> Registry 按模块仓库和短期 token 放行拉取 +``` + +发布账号只用于内部推送镜像,不给客户安装现场长期保存。客户即使知道付费模块镜像地址,没有验证系统签发的短期 token,也不能拉取模块镜像。Registry 原始后端不要直接暴露给客户;客户入口应只到前置代理,否则会绕过这层鉴权。 + +模块发布时应尽量固定 digest,并在 `license-server` 配置中写入对应模块的 `_DIGEST` 变量。Docker 拉取时会先取镜像清单、再取多个层文件;代理会校验短期 token、模块仓库、tag、清单 digest、许可证、激活记录和模块授权状态,客户侧 Agent 拉取后还会比对整镜像 digest,不匹配就拒绝启动模块容器。 + +仓库内已提供可审查的代理示例: + +```text +deploy/registry-proxy/docker-compose.yml +deploy/registry-proxy/nginx.module-auth.conf.template +``` + +这个代理默认只放行 `flnxi/premium/*` 模块拉取;非拉取动作会被拒绝。发布方推送镜像应走内部发布入口或 Registry 内网入口,不通过客户拉取代理。 + ## 3. 标准部署步骤 ### 3.1 区分首装和升级 @@ -238,6 +271,9 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString .\deploy-registry.ps1 -Action DeployApp -DeployChannel release -Version 1.0.1 -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword $registryPassword -Password $sshPassword .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword .\deploy-registry.ps1 -Action PublishAgent -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword +.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId license-limits-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword +.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId custom-templates-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword +.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId intranet-penetration-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword .\deploy-registry.ps1 -Action SyncRuntime -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword ``` @@ -245,6 +281,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString - 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。 - 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。 +- 授权模块发布使用 `-Action PublishPremiumModule`,默认发布到 `flnxi/premium/{moduleId}:{version}`,脚本会输出 `PREMIUM_MODULE_{MODULE}_IMAGE`、`_TAG` 和 `_DIGEST`,这些值要写入 `license-server`。第一版至少发布 `license-limits-core`、`custom-templates-core`、`intranet-penetration-core` 三个模块,避免验证系统下发了模块清单但镜像站缺包。 - 客户 SSH 端口、登录用户和部署目录必须按现场显式确认;脚本默认端口是常见的 `22`,默认目录是 `/opt/flnxi`,内部测试机或自定义环境要显式传 `-SshPort`、`-Username`、`-RemotePath`。 - `DeployApp` 远程部署时默认会更新远端 `.env` 的 `APP_IMAGE`、访问地址和运行时镜像;只有临时验证时才使用 `-NoUpdateRemoteEnv`。 - `DeployApp` 默认会做远端维护窗口停写入、`.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。 diff --git a/docs/其他项目接入许可证系统说明.md b/docs/其他项目接入许可证系统说明.md index 9f722d4..5831e3b 100644 --- a/docs/其他项目接入许可证系统说明.md +++ b/docs/其他项目接入许可证系统说明.md @@ -120,7 +120,11 @@ 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}/premium-modules/{moduleId} +POST /api/license/{projectCode}/premium-modules/{moduleId}/pull-token POST /api/license/{projectCode}/generate +GET /api/registry/auth GET /api/version/{projectCode}/check GET /api/version/{projectCode}/agent @@ -128,7 +132,7 @@ GET /api/version/{projectCode}/agent ### 5.1.1 请求签名要求 -`activate`、`verify`、`heartbeat`、`register-free`、`premium-modules` 和 `premium-modules/{moduleId}` 当前不是“只带项目编码就能访问”。项目侧可信执行层必须使用 `LICENSE_REQUEST_SIGNING_SECRET` 对请求体做 HMAC 签名,并把下面三个字段放进请求体: +`activate`、`verify`、`heartbeat`、`register-free`、`premium-modules`、`premium-modules/{moduleId}` 和 `premium-modules/{moduleId}/pull-token` 当前不是“只带项目编码就能访问”。项目侧可信执行层必须使用 `LICENSE_REQUEST_SIGNING_SECRET` 对请求体做 HMAC 签名,并把下面三个字段放进请求体: ```json { @@ -142,6 +146,8 @@ GET /api/version/{projectCode}/agent 这和“验证许可证返回值的 RSA 签名”是两件事:请求签名用于防止外部伪造请求;许可证返回签名用于确认许可证内容没有被篡改。 +如果项目需要拆分付费模块,还要额外接短期拉包凭证:验证系统先按 `LicenseModuleGrant` 判断该许可证是否允许使用某模块,再给 15 分钟有效的 `pullToken`。Registry 前置代理通过 `GET /api/registry/auth` 校验 token、仓库路径和 digest 后才放行拉取。发布账号只给发布方推镜像使用,不应发给客户现场。 + ### 5.2 禁止再使用的旧接口 以下旧接口已经废弃,当前统一返回 `410`: diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index a915a54..b70bc0b 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`:付费模块链路已调整为“镜像站放模块核心本体,验证服务器发签名清单和实例绑定授权凭证”。免费版拒绝,付费版按许可证类型返回带 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`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单 + 本地有效授权凭证”三重门槛;没有验证服务器下发并校验通过的授权凭证时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。 +- `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`:授权模块链路已调整为“镜像站放模块核心本体,验证服务器发签名清单和实例绑定授权凭证”。免费版只返回 `license-limits-core`,标准版/专业版按许可证类型额外返回付费能力模块;硬件指纹明显不匹配时拒绝并告警。 +- `app/api/license/activate/route.ts`、`app/api/license/activate-free/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。 @@ -465,7 +465,7 @@ - `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 生成,以及免费注册入口的防误判和禁用/过期护栏。 +- `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 生成,以及免费注册入口的防误判和禁用/过期护栏。 验证: @@ -487,7 +487,7 @@ - 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。 - 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。 - 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。 -- 已完成:付费模块 manifest 和实例绑定授权凭证链路已落地,模块核心本体位置由 Registry 镜像地址承载;自定义模板和内网穿透已经接入本地有效授权凭证门槛,验证服务器不承担大文件下载站职责。 +- 已完成:授权模块 manifest 和实例绑定授权凭证链路已落地,模块核心本体位置由 Registry 镜像地址承载;免费版人数/素材数上限、自定义模板和内网穿透已经接入本地有效授权凭证门槛,验证服务器不承担大文件下载站职责。 - 后续增强:如果要做到更强的“核心业务实现完全不在主应用镜像里”,还需要继续把自定义模板/内网穿透内部服务端实现逐步移动到独立模块镜像或模块加载层;当前本轮完成的是清单、镜像地址、授权凭证、hash 校验、加密缓存和服务端入口强制依赖凭证的闭环。 ### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复) diff --git a/docs/客户首装升级坑位排查_20260530.md b/docs/客户首装升级坑位排查_20260530.md index 285cea6..ef9b608 100644 --- a/docs/客户首装升级坑位排查_20260530.md +++ b/docs/客户首装升级坑位排查_20260530.md @@ -62,7 +62,7 @@ 证据: -- `license-server/docker-compose.yml` 强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,并要求配置 `PREMIUM_MODULE_REGISTRY_BASE`,用于把付费模块核心本体指向镜像站。 +- `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`。 diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index 5404b0d..4f0b947 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -36,14 +36,15 @@ LICENSE_PROJECT_CODE=flnxi-sucai Go Agent 目前走的是另一条现状链路,文档和脚本还没有和 Node Agent 完全统一;接入时不要默认它也已经接好了同一套 `LICENSE_SERVER_URL` / `LICENSE_PROJECT_CODE` 版本检查流程。 -`license-server` 自身还需要配置付费模块的镜像站位置: +`license-server` 自身还需要配置授权模块的镜像站位置: ```env +MODULE_PULL_TOKEN_SECRET=replace-with-32-byte-or-longer-module-pull-token-secret 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` 这类变量覆盖。原则是:模块核心本体在镜像站,验证服务器只发授权清单和小型凭证。 +如某个模块要单独指定镜像,可使用 `PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE`、`PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE`、`PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE` 这类变量覆盖;固定 digest 可使用对应的 `_DIGEST` 变量。原则是:模块核心本体在镜像站,验证服务器只发授权清单、实例绑定凭证和短期拉包 token。 ## 2. 联调入口规则 @@ -69,12 +70,24 @@ POST /api/license/{projectCode}/heartbeat POST /api/license/{projectCode}/register-free POST /api/license/{projectCode}/premium-modules POST /api/license/{projectCode}/premium-modules/{moduleId} +POST /api/license/{projectCode}/premium-modules/{moduleId}/pull-token POST /api/license/{projectCode}/generate +GET /api/registry/auth GET /api/version/{projectCode}/check GET /api/version/{projectCode}/agent ``` +首次部署验证服务时,`docker compose up -d` 只会启动服务,不会自动创建数据库表、超管和项目登记。最短初始化顺序是: + +```bash +docker compose exec -T license-server npm run db:push +docker compose exec -T license-server npm run init-admin -- admin +docker compose exec -T license-server npm run init-project -- flnxi-sucai +``` + +`init-project` 会把 `flnxi-sucai` 写入验证服务主库的项目表,默认指向同一个 Compose PostgreSQL 的 `public` schema。没有这一步,素材系统请求 `/api/license/flnxi-sucai/...` 时会找不到项目。 + 旧的无项目接口: ```text @@ -107,7 +120,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`、`activate-free`、`verify`、`heartbeat` 返回的签名许可证必须包含 `projectCode`、`licenseKey`、`instanceId` 和 `fingerprintHash`;主应用保存和读取本地许可证时会核对这些绑定字段,缺少绑定字段的旧许可证需要重新激活。 -- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块授权清单和对应授权凭证;免费版会跳过,不访问远端模块接口。 +- `activate`、`activate-free`、`verify`、`heartbeat` 在许可证保存或续证成功后会尝试同步 `premium-modules` 授权模块清单、对应授权凭证和短期镜像拉取凭证;免费版也会同步 `license-limits-core`,用于保护人数/素材数上限。 - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 @@ -122,14 +135,37 @@ ${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(模块清单),每个模块会带镜像站上的模块本体地址、实例绑定信息、授权凭证大小和 SHA256;主应用随后只获取实例绑定的小型授权凭证,校验大小和 SHA256 后再本地加密缓存。 -- 付费模块核心本体应放在镜像站或等价的私有制品仓库,验证服务器只负责授权判断、签名清单和授权凭证,不承担大文件下载站职责。 -- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应授权凭证;许可证功能开关开启但没有有效授权凭证时,服务端仍会拒绝。 -- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId}` 当前会校验请求 HMAC 签名;返回的许可证、付费模块清单和授权凭证必须通过签名、hash、大小、租约、实例和许可证类型检查后才能缓存或放行。 +- 远端 `POST /api/license/{projectCode}/premium-modules` 是授权模块清单接口,要求同一套 HMAC 请求签名;清单来源是 `LicenseModuleGrant` 授权台账。默认规则是:免费版只有 `license-limits-core`,标准版增加 `custom-templates-core`,专业版再增加 `intranet-penetration-core`;禁用、过期、吊销或硬件指纹明显不匹配会被拒绝。 +- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个授权模块凭证接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块授权台账。 +- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}/pull-token` 是短期镜像拉取凭证接口,要求 `premium-module-pull-token` HMAC 请求签名;返回 `registryHost`、`repository`、`tag`、`digest`、用户名 `flnxi-module`、15 分钟有效的 `pullToken` 和 `expiresAt`。 +- 远端 `GET /api/registry/auth` 是给 Registry 前置代理使用的鉴权接口,用来校验客户拉取请求中的短期 token、token 审计台账、仓库路径、清单 digest、许可证状态、激活实例和模块授权状态。它不是普通前端接口。 +- `premium-modules` 返回签名 manifest(模块清单),每个模块会带镜像站上的模块本体地址、实例绑定信息、授权凭证大小和 SHA256;主应用随后获取实例绑定的小型授权凭证和短期拉包凭证,校验大小、SHA256、镜像地址和过期时间后再本地加密缓存。 +- 授权模块核心本体应放在镜像站或等价的私有制品仓库,验证服务器只负责授权判断、签名清单和授权凭证,不承担大文件下载站职责。 +- 主应用的人数/素材数上限、自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应授权凭证;许可证功能开关或本地数量字段被篡改但没有有效授权凭证时,服务端仍会拒绝。 +- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId} / premium-modules/{moduleId}/pull-token` 当前会校验请求 HMAC 签名;返回的许可证、授权模块清单、授权凭证和短期拉包凭证必须通过签名、hash、大小、租约、实例、许可证类型、镜像地址和过期时间检查后才能缓存或放行。 -## 5. 后台管理入口 +## 5. 付费模块拉取规则 + +当前采用“两层凭证”: + +- 模块授权凭证:证明这个客户、这台机器、这张许可证允许使用某个模块。 +- 镜像拉取凭证:15 分钟短期 token,只允许拉取 token 内声明的模块仓库和 tag;镜像清单 digest 和客户侧 Agent 的整镜像 digest 校验用于防止模块版本被替换。 + +镜像站推荐由 Nginx / Registry 代理做私有拉取鉴权:发布账号仍只用于内部推送镜像;客户侧拉付费模块时使用用户名 `flnxi-module`,密码使用验证系统签发的短期 `pullToken`。代理把 Registry 主机、仓库、请求类型、digest 和 token 转发给 `GET /api/registry/auth` 校验,通过后才放行拉取。Registry 原始后端不要直接对客户开放。 + +主应用不会直接挂 Docker Socket,也不会自己启动模块容器。当前 Go Agent 已提供最小 `POST /api/modules/sync` 和 `GET /api/modules/status` 入口,用来接收主应用准备好的短期拉取凭证,负责拉取模块镜像、校验 digest、启动模块容器、健康检查和失败回滚。 + +第一批模块骨架已经放在: + +```text +premium-modules/license-limits-core +premium-modules/custom-templates-core +premium-modules/intranet-penetration-core +``` + +`license-limits-core` 承接免费版和付费版的人数/素材数上限合同;`custom-templates-core` 第一版先承接授权和拉包合同,模板业务仍由主应用执行;主应用内网穿透入口通过许可证检查后,会优先调用 `INTRANET_PENETRATION_MODULE_URL` 指向的模块服务。未配置该变量时,仍保持“真实提供商未接入”的拒绝返回,不会伪装启动成功。 + +## 6. 后台管理入口 `license-server` 后台现在按项目维度工作: @@ -151,17 +187,19 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... 旧的无项目后台页现在只做跳转或废弃提示,不再承载真实管理逻辑。 -## 6. 接入前检查 +## 7. 接入前检查 上线前至少确认这 5 项: 1. `license-server` 主库里已存在目标项目,且 `projects.code = LICENSE_PROJECT_CODE` 2. 该项目对应的项目库连接配置正确 3. 主应用 `.env` 已配置 `LICENSE_SERVER_URL`、`LICENSE_PROJECT_CODE`、`LICENSE_REQUEST_SIGNING_SECRET`、`LICENSE_PUBLIC_KEY` 和 `LOCAL_ENCRYPTION_KEY` -4. Node Agent 如启用版本检查,也已配置相同的远端通信两项 -5. 不再调用任何旧的无项目公开接口 +4. `license-server` 已配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和需要固定的模块 digest +5. Registry 前置代理已能把客户拉取请求转发到 `GET /api/registry/auth` +6. Node Agent 如启用版本检查,也已配置相同的远端通信两项 +7. 不再调用任何旧的无项目公开接口 -## 7. 推荐联调方式 +## 8. 推荐联调方式 可以按这个顺序联调: diff --git a/docs/许可证链路安全加固记录_20260602.md b/docs/许可证链路安全加固记录_20260602.md index 2bac065..2a22360 100644 --- a/docs/许可证链路安全加固记录_20260602.md +++ b/docs/许可证链路安全加固记录_20260602.md @@ -58,7 +58,7 @@ - RSA 私钥只能存在于验证服务器运行环境 - 客户侧只保留 RSA 公钥和本地缓存加密密钥 - 本地缓存必须绑定项目、激活码和机器实例 -- 付费模块核心能力继续依赖验证服务器签发的实例绑定清单和授权凭证 +- 付费能力模块和免费版限额模块继续依赖验证服务器签发的实例绑定清单和授权凭证 ## 6. 运维必须处理的事项 diff --git a/docs/许可证链路安全评估_20260312.md b/docs/许可证链路安全评估_20260312.md index 6fb1c80..5400766 100644 --- a/docs/许可证链路安全评估_20260312.md +++ b/docs/许可证链路安全评估_20260312.md @@ -80,6 +80,8 @@ 2026-06-01 补充:自定义模板不只在管理维护入口做许可证拦截,普通读取和使用入口也已按许可证收口。`/api/templates`、`/api/upload-templates`、`/api/material-types` 在当前许可证不包含 `customTemplates` 时只返回内置模板,不继续暴露历史自定义模板,避免客户降级后仍使用付费模板能力。 +2026-06-02 补充:付费模块链路已进一步拆成“模块授权台账 + 短期镜像拉取凭证”。`license-server` 新增 `LicenseModuleGrant` 和 `ModulePullTokenAudit`,`premium-modules` 清单来源改为授权台账;客户侧获取模块核心镜像前必须先拿 15 分钟有效的 `pullToken`,Registry 前置代理再通过 `/api/registry/auth` 校验 token、token 审计记录、仓库路径、清单 digest、许可证状态、激活实例和模块授权状态。主应用只缓存授权凭证和短期拉包凭证,不直接挂 Docker Socket;Go Agent 已提供最小模块同步入口,负责拉取模块镜像、校验 digest、启动模块容器并在健康检查失败时尝试恢复旧模块容器。 + ## 3. 链路概览 当前大致链路如下: diff --git a/flnxi-agent-go/README.md b/flnxi-agent-go/README.md index 9b98700..382c472 100644 --- a/flnxi-agent-go/README.md +++ b/flnxi-agent-go/README.md @@ -10,6 +10,7 @@ - Go 版 Agent 内部安装和更新素材系统已改为读取完整版本镜像,不再默认拉取旧 Harbor / `latest`。 - 安装素材系统时必须提供完整主应用版本镜像,不能使用无版本 tag 或 `latest`。 - 安装素材系统时必须提供许可证服务参数;缺少关键参数时会在拉镜像前失败,避免客户装出一个启动不了的半成品。 +- 已提供付费模块最小运行入口:`POST /api/modules/sync` 会使用验证系统签发的短期拉包凭证拉取模块镜像、校验 digest、启动模块容器并在健康检查失败时尝试恢复旧模块容器。 - 版本列表需要通过 `FLNXI_VERSION_LIST_URL` 显式配置来源;未配置时不会伪造 `latest` 或默认版本。 - 当前仍建议先作为内部验证入口,等管理界面和客户安装流程联调完成后,再提升为客户正式安装主入口。 @@ -17,6 +18,7 @@ - 安装素材系统运行环境 - 执行更新 +- 拉取和运行已授权的付费模块容器 - 管理相关 Docker 容器 - 提供内置 Web 界面和 API @@ -63,6 +65,7 @@ docker run -d \ | `FLNXI_POSTGRES_IMAGE` | 安装时使用的 PostgreSQL 镜像;也兼容读取 `POSTGRES_IMAGE` | `postgres:15-alpine` | | `FLNXI_MINIO_IMAGE` | 安装时使用的 MinIO 镜像;也兼容读取 `MINIO_IMAGE` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` | | `FLNXI_VERSION_LIST_URL` | Agent 管理界面可选版本列表来源,返回 Registry tags 结构 | 未设置 | +| `FLNXI_MODULE_NETWORK` | 付费模块容器默认加入的 Docker 网络 | `flnxi-app-network` | | `LICENSE_SERVER_URL` | 主应用许可证服务地址,安装素材系统时会写入主应用容器 | 未设置 | | `LICENSE_PROJECT_CODE` | 主应用许可证项目编码,安装素材系统时会写入主应用容器 | 未设置 | | `LICENSE_REQUEST_SIGNING_SECRET` | 主应用请求许可证服务的签名密钥,至少 32 字符 | 未设置 | @@ -74,6 +77,36 @@ docker run -d \ - 当前 Go 版 Agent 的版本列表不再写死 Harbor;后续正式交付前,需要决定版本列表来自 Registry tags、许可证服务,还是发布管理接口。 - Go 版 Agent 目前会把主应用启动必需的许可证配置写入容器,但还没有把“许可证服务可安装版本、灰度版本、客户授权版本范围”做成最终客户安装向导。 +## 付费模块运行接口 + +主应用负责从许可证服务同步模块授权清单、模块授权凭证和短期拉包 token。Go Agent 不判断客户是否付费,只接收已经由主应用/验证系统准备好的模块运行请求: + +```text +POST /api/modules/sync +GET /api/modules/status +``` + +`POST /api/modules/sync` 至少需要: + +```json +{ + "moduleId": "intranet-penetration-core", + "image": "registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0", + "digest": "sha256:...", + "pullCredential": { + "registryHost": "registry.example.com", + "repository": "flnxi/premium/intranet-penetration-core", + "tag": "1.0.0", + "digest": "sha256:...", + "username": "flnxi-module", + "pullToken": "<15分钟短期token>", + "expiresAt": "2026-06-02T00:15:00Z" + } +} +``` + +Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少固定版本 tag 的请求。模块容器默认加入 `flnxi-app-network`,也可以用 `FLNXI_MODULE_NETWORK` 或请求里的 `network` 覆盖;默认容器名为 `flnxi-module-{moduleId}`。如健康检查失败,会尝试恢复旧模块容器。 + 安装素材系统前,至少要给 Agent 容器配置: ```bash diff --git a/flnxi-agent-go/internal/api/handlers.go b/flnxi-agent-go/internal/api/handlers.go index eb3335c..aba95b4 100644 --- a/flnxi-agent-go/internal/api/handlers.go +++ b/flnxi-agent-go/internal/api/handlers.go @@ -262,6 +262,11 @@ var ( installStatus = &InstallStatus{Status: "idle"} updateStatus = &UpdateStatus{Status: "idle"} statusMutex sync.RWMutex + + moduleStatuses = map[string]ModuleRuntimeStatus{} + moduleStatusesMutex sync.RWMutex + moduleSyncLocks = map[string]bool{} + moduleSyncLocksMutex sync.Mutex ) type InstallStatus struct { @@ -280,6 +285,36 @@ type UpdateStatus struct { Error string `json:"error,omitempty"` } +type ModulePullCredential struct { + RegistryHost string `json:"registryHost"` + Repository string `json:"repository"` + Tag string `json:"tag"` + Digest string `json:"digest,omitempty"` + Username string `json:"username"` + PullToken string `json:"pullToken"` + ExpiresAt string `json:"expiresAt"` +} + +type ModuleSyncRequest struct { + ModuleID string `json:"moduleId"` + Image string `json:"image"` + Digest string `json:"digest,omitempty"` + ContainerName string `json:"containerName,omitempty"` + Network string `json:"network,omitempty"` + Env map[string]string `json:"env,omitempty"` + HealthTimeoutSeconds int `json:"healthTimeoutSeconds,omitempty"` + PullCredential ModulePullCredential `json:"pullCredential"` +} + +type ModuleRuntimeStatus struct { + ModuleID string `json:"moduleId"` + Image string `json:"image"` + Digest string `json:"digest,omitempty"` + Status string `json:"status"` + LastSyncAt string `json:"lastSyncAt"` + LastError string `json:"lastError,omitempty"` +} + type InstallRequest struct { LicenseKey string `json:"licenseKey"` DataPath string `json:"dataPath"` @@ -562,6 +597,351 @@ func GetUpdateStatus(dc *docker.Client) gin.HandlerFunc { } } +func GetModuleStatuses() gin.HandlerFunc { + return func(c *gin.Context) { + moduleStatusesMutex.RLock() + defer moduleStatusesMutex.RUnlock() + + result := make([]ModuleRuntimeStatus, 0, len(moduleStatuses)) + for _, status := range moduleStatuses { + result = append(result, status) + } + c.JSON(http.StatusOK, result) + } +} + +func SyncModule(dc *docker.Client) gin.HandlerFunc { + return func(c *gin.Context) { + var req ModuleSyncRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := validateModuleSyncRequest(req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + syncKey := moduleContainerName(req) + if !tryBeginModuleSync(syncKey) { + c.JSON(http.StatusConflict, gin.H{"error": "该模块正在同步中,请稍后再试", "moduleId": req.ModuleID}) + return + } + + go func() { + defer finishModuleSync(syncKey) + runModuleSync(dc, req) + }() + c.JSON(http.StatusOK, gin.H{"message": "模块同步已开始", "moduleId": req.ModuleID}) + } +} + +type moduleDockerClient interface { + GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) + PullImage(ctx context.Context, image string, authStr string) error + GetImageRepoDigests(ctx context.Context, image string) ([]string, error) + StopContainer(ctx context.Context, containerID string) error + RemoveContainer(ctx context.Context, containerID string) error + StartContainer(ctx context.Context, containerID string) error + CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) + WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error +} + +func setModuleStatus(status ModuleRuntimeStatus) { + moduleStatusesMutex.Lock() + moduleStatuses[status.ModuleID] = status + moduleStatusesMutex.Unlock() +} + +func tryBeginModuleSync(syncKey string) bool { + moduleSyncLocksMutex.Lock() + defer moduleSyncLocksMutex.Unlock() + + if moduleSyncLocks[syncKey] { + return false + } + moduleSyncLocks[syncKey] = true + return true +} + +func finishModuleSync(syncKey string) { + moduleSyncLocksMutex.Lock() + delete(moduleSyncLocks, syncKey) + moduleSyncLocksMutex.Unlock() +} + +func moduleStatus(req ModuleSyncRequest, status string, err error) ModuleRuntimeStatus { + lastError := "" + if err != nil { + lastError = err.Error() + } + return ModuleRuntimeStatus{ + ModuleID: req.ModuleID, + Image: req.Image, + Digest: firstNonEmpty(req.Digest, req.PullCredential.Digest), + Status: status, + LastSyncAt: time.Now().UTC().Format(time.RFC3339), + LastError: lastError, + } +} + +func moduleContainerName(req ModuleSyncRequest) string { + if name := strings.TrimSpace(req.ContainerName); name != "" { + return name + } + + builder := strings.Builder{} + for _, r := range strings.ToLower(req.ModuleID) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + builder.WriteRune(r) + } + } + suffix := builder.String() + if suffix == "" { + suffix = "unknown" + } + return "flnxi-module-" + suffix +} + +func moduleEnv(req ModuleSyncRequest) []string { + env := []string{"FLNXI_MODULE_ID=" + req.ModuleID} + for key, value := range req.Env { + key = strings.TrimSpace(key) + if key == "" || strings.Contains(key, "=") { + continue + } + env = append(env, key+"="+value) + } + return env +} + +func moduleRegistryAuth(credential ModulePullCredential) string { + payload := map[string]string{ + "username": credential.Username, + "password": credential.PullToken, + "serveraddress": credential.RegistryHost, + } + b, _ := json.Marshal(payload) + return base64.URLEncoding.EncodeToString(b) +} + +func parseRegistryImage(image string) (registryHost string, repository string, tag string, err error) { + image = strings.TrimSpace(image) + imageName := image + if at := strings.Index(imageName, "@"); at >= 0 { + imageName = imageName[:at] + } + parts := strings.Split(imageName, "/") + if len(parts) < 2 || strings.TrimSpace(parts[0]) == "" { + err = fmt.Errorf("模块镜像地址无效") + return + } + registryHost = parts[0] + imagePath := strings.Join(parts[1:], "/") + lastSlash := strings.LastIndex(imagePath, "/") + lastColon := strings.LastIndex(imagePath, ":") + if lastColon <= lastSlash || lastColon == len(imagePath)-1 { + err = fmt.Errorf("模块镜像缺少固定版本 tag") + return + } + repository = imagePath[:lastColon] + tag = imagePath[lastColon+1:] + return +} + +func validateModuleSyncRequest(req ModuleSyncRequest) error { + if strings.TrimSpace(req.ModuleID) == "" { + return fmt.Errorf("moduleId is required") + } + if err := validateFixedImageTag(req.Image); err != nil { + return fmt.Errorf("module image: %w", err) + } + + registryHost, repository, tag, err := parseRegistryImage(req.Image) + if err != nil { + return err + } + credential := req.PullCredential + if credential.Username != "flnxi-module" { + return fmt.Errorf("模块拉取用户名无效") + } + if strings.TrimSpace(credential.PullToken) == "" { + return fmt.Errorf("模块拉取 token 不能为空") + } + if credential.RegistryHost != registryHost || credential.Repository != repository || credential.Tag != tag { + return fmt.Errorf("模块拉取凭证和镜像地址不匹配") + } + expectedDigest := firstNonEmpty(req.Digest, credential.Digest) + if expectedDigest == "" { + return fmt.Errorf("模块 digest 不能为空") + } + if !isValidSha256Digest(expectedDigest) { + return fmt.Errorf("模块 digest 必须是 sha256 格式") + } + if req.Digest != "" && !isValidSha256Digest(req.Digest) { + return fmt.Errorf("模块 digest 必须是 sha256 格式") + } + if credential.Digest != "" && !isValidSha256Digest(credential.Digest) { + return fmt.Errorf("模块拉取凭证 digest 必须是 sha256 格式") + } + if req.Digest != "" && credential.Digest != "" && req.Digest != credential.Digest { + return fmt.Errorf("模块 digest 和拉取凭证不匹配") + } + if credential.ExpiresAt == "" { + return fmt.Errorf("模块拉取凭证缺少过期时间") + } + expiresAt, err := time.Parse(time.RFC3339, credential.ExpiresAt) + if err != nil { + return fmt.Errorf("模块拉取凭证过期时间无效") + } + if !expiresAt.After(time.Now()) { + return fmt.Errorf("模块拉取凭证已过期") + } + return nil +} + +func isValidSha256Digest(digest string) bool { + if len(digest) != len("sha256:")+64 || !strings.HasPrefix(digest, "sha256:") { + return false + } + for _, ch := range digest[len("sha256:"):] { + if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { + continue + } + return false + } + return true +} + +func repoDigestsContain(repoDigests []string, digest string) bool { + if digest == "" { + return false + } + for _, value := range repoDigests { + if value == digest || strings.HasSuffix(value, "@"+digest) { + return true + } + } + return false +} + +func moduleContainerConfig(req ModuleSyncRequest) (*container.Config, *container.HostConfig) { + network := firstNonEmpty(req.Network, os.Getenv("FLNXI_MODULE_NETWORK"), "flnxi-app-network") + return &container.Config{ + Image: req.Image, + Env: moduleEnv(req), + }, &container.HostConfig{ + NetworkMode: container.NetworkMode(network), + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + ReadonlyRootfs: true, + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"no-new-privileges:true"}, + Tmpfs: map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"}, + Resources: container.Resources{ + Memory: 256 * 1024 * 1024, + NanoCPUs: 500000000, + }, + } +} + +func restoreModuleContainer(ctx context.Context, dc moduleDockerClient, old *docker.ContainerInfo, name string) error { + if old == nil { + return fmt.Errorf("旧模块容器不存在") + } + current, err := dc.GetContainer(ctx, name) + if err != nil { + return err + } + if current != nil && current.ID == old.ID { + if current.State != "running" { + return dc.StartContainer(ctx, current.ID) + } + return nil + } + if current != nil { + if current.State == "running" { + _ = dc.StopContainer(ctx, current.ID) + } + if err := dc.RemoveContainer(ctx, current.ID); err != nil { + return err + } + } + image := oldMaterialImage(old) + if image == "" { + return fmt.Errorf("旧模块镜像信息不存在") + } + cfg, hcfg, err := materialContainerConfig(old, image) + if err != nil { + return err + } + _, err = dc.CreateAndStartContainer(ctx, cfg, hcfg, name) + return err +} + +func runModuleSync(dc moduleDockerClient, req ModuleSyncRequest) { + ctx := context.Background() + name := moduleContainerName(req) + setModuleStatus(moduleStatus(req, "pulling", nil)) + + old, err := dc.GetContainer(ctx, name) + if err != nil { + setModuleStatus(moduleStatus(req, "unhealthy", err)) + return + } + + authStr := moduleRegistryAuth(req.PullCredential) + if err := dc.PullImage(ctx, req.Image, authStr); err != nil { + setModuleStatus(moduleStatus(req, "missing", err)) + return + } + + expectedDigest := firstNonEmpty(req.Digest, req.PullCredential.Digest) + repoDigests, err := dc.GetImageRepoDigests(ctx, req.Image) + if err != nil { + setModuleStatus(moduleStatus(req, "unhealthy", err)) + return + } + if !repoDigestsContain(repoDigests, expectedDigest) { + err := fmt.Errorf("模块镜像 digest 不匹配") + setModuleStatus(moduleStatus(req, "unhealthy", err)) + return + } + + if old != nil { + if old.State == "running" { + _ = dc.StopContainer(ctx, old.ID) + } + if err := dc.RemoveContainer(ctx, old.ID); err != nil { + setModuleStatus(moduleStatus(req, "unhealthy", err)) + return + } + } + + cfg, hcfg := moduleContainerConfig(req) + newID, err := dc.CreateAndStartContainer(ctx, cfg, hcfg, name) + if err != nil { + if old != nil { + _ = restoreModuleContainer(ctx, dc, old, name) + } + setModuleStatus(moduleStatus(req, "unhealthy", err)) + return + } + + timeout := time.Duration(req.HealthTimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + if err := dc.WaitForHealthy(ctx, newID, timeout); err != nil { + if old != nil { + _ = restoreModuleContainer(ctx, dc, old, name) + } + setModuleStatus(moduleStatus(req, "unhealthy", err)) + return + } + + setModuleStatus(moduleStatus(req, "running", nil)) +} + type updateDockerClient interface { GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) PullImage(ctx context.Context, image string, authStr string) error diff --git a/flnxi-agent-go/internal/api/handlers_test.go b/flnxi-agent-go/internal/api/handlers_test.go index 81fa5a6..55a2413 100644 --- a/flnxi-agent-go/internal/api/handlers_test.go +++ b/flnxi-agent-go/internal/api/handlers_test.go @@ -225,6 +225,163 @@ func TestRunUpdateRestoresOldContainerAfterHealthCheckFailure(t *testing.T) { } } +func TestValidateModuleSyncRequestRejectsExpiredPullToken(t *testing.T) { + req := ModuleSyncRequest{ + ModuleID: "intranet-penetration-core", + Image: "registry.local:5000/flnxi/premium/intranet-penetration-core:1.0.0", + Digest: testModuleDigest, + PullCredential: ModulePullCredential{ + RegistryHost: "registry.local:5000", + Repository: "flnxi/premium/intranet-penetration-core", + Tag: "1.0.0", + Digest: testModuleDigest, + Username: "flnxi-module", + PullToken: "token", + ExpiresAt: time.Now().Add(-time.Minute).Format(time.RFC3339), + }, + } + + err := validateModuleSyncRequest(req) + if err == nil || !strings.Contains(err.Error(), "已过期") { + t.Fatalf("validateModuleSyncRequest() error = %v, want expired token rejection", err) + } +} + +func TestValidateModuleSyncRequestRequiresDigest(t *testing.T) { + req := validModuleSyncRequest() + req.Digest = "" + req.PullCredential.Digest = "" + + err := validateModuleSyncRequest(req) + if err == nil || !strings.Contains(err.Error(), "digest 不能为空") { + t.Fatalf("validateModuleSyncRequest() error = %v, want missing digest rejection", err) + } +} + +func TestValidateModuleSyncRequestRejectsInvalidDigest(t *testing.T) { + req := validModuleSyncRequest() + req.Digest = "sha256:abc" + req.PullCredential.Digest = "sha256:abc" + + err := validateModuleSyncRequest(req) + if err == nil || !strings.Contains(err.Error(), "sha256 格式") { + t.Fatalf("validateModuleSyncRequest() error = %v, want invalid digest rejection", err) + } +} + +func TestModuleSyncLockRejectsConcurrentSameModule(t *testing.T) { + resetModuleStatusesForTest() + + key := moduleContainerName(validModuleSyncRequest()) + if !tryBeginModuleSync(key) { + t.Fatal("first module sync lock acquisition should succeed") + } + if tryBeginModuleSync(key) { + t.Fatal("second module sync lock acquisition should fail") + } + finishModuleSync(key) + if !tryBeginModuleSync(key) { + t.Fatal("module sync lock should be released") + } + finishModuleSync(key) +} + +func TestRunModuleSyncStartsContainerAfterDigestMatch(t *testing.T) { + resetModuleStatusesForTest() + + req := validModuleSyncRequest() + fake := &fakeModuleDocker{ + repoDigests: []string{"registry.local:5000/flnxi/premium/intranet-penetration-core@" + testModuleDigest}, + } + + runModuleSync(fake, req) + + moduleStatusesMutex.RLock() + status := moduleStatuses[req.ModuleID] + moduleStatusesMutex.RUnlock() + + if status.Status != "running" { + t.Fatalf("module status = %q, want running: %+v", status.Status, status) + } + if len(fake.pulledImages) != 1 || fake.pulledImages[0] != req.Image { + t.Fatalf("pulled images = %v, want %q", fake.pulledImages, req.Image) + } + if fake.lastAuth == "" { + t.Fatal("expected registry auth for module pull") + } + if len(fake.createdImages) != 1 || fake.createdImages[0] != req.Image { + t.Fatalf("created images = %v, want module image", fake.createdImages) + } + if fake.lastHostConfig == nil || !fake.lastHostConfig.ReadonlyRootfs { + t.Fatal("module container should use a read-only root filesystem by default") + } + if !contains(fake.lastHostConfig.CapDrop, "ALL") { + t.Fatalf("module container CapDrop = %v, want ALL", fake.lastHostConfig.CapDrop) + } + if !contains(fake.lastHostConfig.SecurityOpt, "no-new-privileges:true") { + t.Fatalf("module container SecurityOpt = %v, want no-new-privileges", fake.lastHostConfig.SecurityOpt) + } +} + +func TestRunModuleSyncRestoresOldContainerAfterHealthFailure(t *testing.T) { + resetModuleStatusesForTest() + + req := validModuleSyncRequest() + old := &docker.ContainerInfo{ + ID: "old-module", + ImageID: "sha256:old-module", + State: "running", + Config: &container.Config{ + Image: "registry.local:5000/flnxi/premium/intranet-penetration-core:0.9.0", + Env: []string{"OLD=1"}, + }, + HostConfig: &container.HostConfig{ + NetworkMode: "flnxi-network", + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + }, + } + fake := &fakeModuleDocker{ + container: old, + repoDigests: []string{"registry.local:5000/flnxi/premium/intranet-penetration-core@" + testModuleDigest}, + healthErr: errors.New("not healthy"), + } + + runModuleSync(fake, req) + + moduleStatusesMutex.RLock() + status := moduleStatuses[req.ModuleID] + moduleStatusesMutex.RUnlock() + + if status.Status != "unhealthy" { + t.Fatalf("module status = %q, want unhealthy", status.Status) + } + if fake.container == nil || fake.container.Config.Image != old.Config.Image { + t.Fatalf("restored container = %+v, want old image %q", fake.container, old.Config.Image) + } + if !contains(fake.removedIDs, "old-module") || !contains(fake.removedIDs, "new-module") { + t.Fatalf("removed IDs = %v, want old and failed new module", fake.removedIDs) + } +} + +const testModuleDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func validModuleSyncRequest() ModuleSyncRequest { + return ModuleSyncRequest{ + ModuleID: "intranet-penetration-core", + Image: "registry.local:5000/flnxi/premium/intranet-penetration-core:1.0.0", + Digest: testModuleDigest, + PullCredential: ModulePullCredential{ + RegistryHost: "registry.local:5000", + Repository: "flnxi/premium/intranet-penetration-core", + Tag: "1.0.0", + Digest: testModuleDigest, + Username: "flnxi-module", + PullToken: "token", + ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339), + }, + } +} + func resetUpdateStatusForTest() { statusMutex.Lock() updateStatus = &UpdateStatus{Status: "running", Step: "初始化", Progress: 0} @@ -237,6 +394,16 @@ func resetInstallStatusForTest() { statusMutex.Unlock() } +func resetModuleStatusesForTest() { + moduleStatusesMutex.Lock() + moduleStatuses = map[string]ModuleRuntimeStatus{} + moduleStatusesMutex.Unlock() + + moduleSyncLocksMutex.Lock() + moduleSyncLocks = map[string]bool{} + moduleSyncLocksMutex.Unlock() +} + func envSliceToMap(values []string) map[string]string { result := make(map[string]string, len(values)) for _, value := range values { @@ -285,6 +452,78 @@ type fakeUpdateDocker struct { startedIDs []string } +type fakeModuleDocker struct { + container *docker.ContainerInfo + healthErr error + repoDigests []string + pulledImages []string + createdImages []string + removedIDs []string + stoppedIDs []string + startedIDs []string + lastAuth string + lastHostConfig *container.HostConfig +} + +func (f *fakeModuleDocker) GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) { + return f.container, nil +} + +func (f *fakeModuleDocker) PullImage(ctx context.Context, image string, authStr string) error { + f.pulledImages = append(f.pulledImages, image) + f.lastAuth = authStr + return nil +} + +func (f *fakeModuleDocker) GetImageRepoDigests(ctx context.Context, image string) ([]string, error) { + return f.repoDigests, nil +} + +func (f *fakeModuleDocker) StopContainer(ctx context.Context, containerID string) error { + f.stoppedIDs = append(f.stoppedIDs, containerID) + if f.container != nil && f.container.ID == containerID { + f.container.State = "exited" + } + return nil +} + +func (f *fakeModuleDocker) RemoveContainer(ctx context.Context, containerID string) error { + f.removedIDs = append(f.removedIDs, containerID) + if f.container != nil && f.container.ID == containerID { + f.container = nil + } + return nil +} + +func (f *fakeModuleDocker) StartContainer(ctx context.Context, containerID string) error { + f.startedIDs = append(f.startedIDs, containerID) + if f.container != nil && f.container.ID == containerID { + f.container.State = "running" + } + return nil +} + +func (f *fakeModuleDocker) CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) { + f.createdImages = append(f.createdImages, config.Image) + f.lastHostConfig = hostConfig + id := "new-module" + if strings.Contains(config.Image, ":0.9.0") { + id = "restored-module" + } + f.container = &docker.ContainerInfo{ + ID: id, + ImageID: "sha256:" + id, + State: "running", + Config: config, + HostConfig: hostConfig, + } + return id, nil +} + +func (f *fakeModuleDocker) WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error { + return f.healthErr +} + func (f *fakeUpdateDocker) GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) { return f.container, nil } @@ -357,3 +596,4 @@ func contains(values []string, want string) bool { var _ updateDockerClient = (*fakeUpdateDocker)(nil) var _ installDockerClient = (*fakeInstallDocker)(nil) +var _ moduleDockerClient = (*fakeModuleDocker)(nil) diff --git a/flnxi-agent-go/internal/docker/client.go b/flnxi-agent-go/internal/docker/client.go index d3b3833..cc5d075 100644 --- a/flnxi-agent-go/internal/docker/client.go +++ b/flnxi-agent-go/internal/docker/client.go @@ -35,6 +35,15 @@ func (c *Client) PullImage(ctx context.Context, image string, authStr string) er return c.PullImageWithProgress(ctx, image, authStr, nil) } +// GetImageRepoDigests 读取本地镜像的 RepoDigest,用于校验拉取结果是否匹配许可证服务器声明的 digest。 +func (c *Client) GetImageRepoDigests(ctx context.Context, image string) ([]string, error) { + inspect, _, err := c.cli.ImageInspectWithRaw(ctx, image) + if err != nil { + return nil, err + } + return inspect.RepoDigests, nil +} + // PullImageWithProgress 拉取镜像并报告进度 func (c *Client) PullImageWithProgress(ctx context.Context, image string, authStr string, onProgress func(current, total int64, status string)) error { opts := types.ImagePullOptions{} diff --git a/flnxi-agent-go/main.go b/flnxi-agent-go/main.go index 3da498f..1d38ee2 100644 --- a/flnxi-agent-go/main.go +++ b/flnxi-agent-go/main.go @@ -56,6 +56,10 @@ func main() { apiGroup.GET("/update/status", api.GetUpdateStatus(dockerClient)) apiGroup.GET("/versions", api.GetVersions(dockerClient)) + // 付费模块运行 + apiGroup.POST("/modules/sync", api.SyncModule(dockerClient)) + apiGroup.GET("/modules/status", api.GetModuleStatuses()) + // 容器管理 apiGroup.GET("/containers", api.ListContainers(dockerClient)) apiGroup.POST("/containers/:name/restart", api.RestartContainer(dockerClient)) diff --git a/lib/__tests__/check-limits.test.ts b/lib/__tests__/check-limits.test.ts index 57d1e38..48e93b7 100644 --- a/lib/__tests__/check-limits.test.ts +++ b/lib/__tests__/check-limits.test.ts @@ -11,31 +11,41 @@ import { canUseIntranetPenetration, canUseSystemSettings, canUseApiAccess, - LimitCheckResult, } from '../license/check-limits'; // Mock license-service 模块 jest.mock('../license/license-service', () => ({ - checkLimit: jest.fn(), isFeatureEnabled: jest.fn(), getLicenseType: jest.fn(), })); jest.mock('../license/premium-module-service', () => ({ + getAuthorizedUsageLimits: jest.fn(), hasPremiumModule: jest.fn(), PREMIUM_MODULE_IDS: { + LICENSE_LIMITS_CORE: 'license-limits-core', CUSTOM_TEMPLATES_CORE: 'custom-templates-core', INTRANET_PENETRATION_CORE: 'intranet-penetration-core', }, })); -import { checkLimit, isFeatureEnabled, getLicenseType } from '../license/license-service'; -import { hasPremiumModule } from '../license/premium-module-service'; +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { count: jest.fn() }, + material: { count: jest.fn() }, + }, +})); + +import { prisma } from '@/lib/prisma'; +import { isFeatureEnabled, getLicenseType } from '../license/license-service'; +import { getAuthorizedUsageLimits, hasPremiumModule } from '../license/premium-module-service'; -const mockCheckLimit = checkLimit as jest.MockedFunction; const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction; const mockGetLicenseType = getLicenseType as jest.MockedFunction; +const mockGetAuthorizedUsageLimits = getAuthorizedUsageLimits as jest.MockedFunction; const mockHasPremiumModule = hasPremiumModule as jest.MockedFunction; +const mockUserCount = prisma.user.count as jest.MockedFunction; +const mockMaterialCount = prisma.material.count as jest.MockedFunction; describe('canCreateUser', () => { beforeEach(() => { @@ -43,7 +53,8 @@ describe('canCreateUser', () => { }); it('当未达到限制时应该返回 allowed: true', async () => { - mockCheckLimit.mockResolvedValue({ allowed: true, current: 5, max: 10 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); + mockUserCount.mockResolvedValue(5); const result = await canCreateUser(); @@ -51,11 +62,13 @@ describe('canCreateUser', () => { expect(result.current).toBe(5); expect(result.max).toBe(10); expect(result.message).toBeUndefined(); - expect(mockCheckLimit).toHaveBeenCalledWith('users'); + expect(mockGetAuthorizedUsageLimits).toHaveBeenCalled(); + expect(mockUserCount).toHaveBeenCalled(); }); it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => { - mockCheckLimit.mockResolvedValue({ allowed: false, current: 10, max: 10 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); + mockUserCount.mockResolvedValue(10); mockGetLicenseType.mockResolvedValue('FREE'); const result = await canCreateUser(); @@ -67,7 +80,8 @@ describe('canCreateUser', () => { }); it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => { - mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 100, maxMaterials: 1000 }); + mockUserCount.mockResolvedValue(100); mockGetLicenseType.mockResolvedValue('STANDARD'); const result = await canCreateUser(); @@ -76,6 +90,20 @@ describe('canCreateUser', () => { expect(result.message).toContain('联系管理员'); expect(result.message).toContain('100'); }); + + it('缺少限额授权模块时应该拒绝创建用户', async () => { + mockGetAuthorizedUsageLimits.mockResolvedValue(null); + + const result = await canCreateUser(); + + expect(result).toEqual({ + allowed: false, + current: 0, + max: 0, + message: '授权限额模块不可用,请重新验证许可证或联系管理员。', + }); + expect(mockUserCount).not.toHaveBeenCalled(); + }); }); describe('canUploadMaterial', () => { @@ -84,18 +112,21 @@ describe('canUploadMaterial', () => { }); it('当未达到限制时应该返回 allowed: true', async () => { - mockCheckLimit.mockResolvedValue({ allowed: true, current: 50, max: 100 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); + mockMaterialCount.mockResolvedValue(50); const result = await canUploadMaterial(); expect(result.allowed).toBe(true); expect(result.current).toBe(50); expect(result.max).toBe(100); - expect(mockCheckLimit).toHaveBeenCalledWith('materials'); + expect(mockGetAuthorizedUsageLimits).toHaveBeenCalled(); + expect(mockMaterialCount).toHaveBeenCalled(); }); it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => { - mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); + mockMaterialCount.mockResolvedValue(100); mockGetLicenseType.mockResolvedValue('FREE'); const result = await canUploadMaterial(); @@ -107,7 +138,8 @@ describe('canUploadMaterial', () => { }); it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => { - mockCheckLimit.mockResolvedValue({ allowed: false, current: 1000, max: 1000 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: -1, maxMaterials: 1000 }); + mockMaterialCount.mockResolvedValue(1000); mockGetLicenseType.mockResolvedValue('PRO'); const result = await canUploadMaterial(); @@ -115,6 +147,20 @@ describe('canUploadMaterial', () => { expect(result.allowed).toBe(false); expect(result.message).toContain('联系管理员'); }); + + it('缺少限额授权模块时应该拒绝上传素材', async () => { + mockGetAuthorizedUsageLimits.mockResolvedValue(null); + + const result = await canUploadMaterial(); + + expect(result).toEqual({ + allowed: false, + current: 0, + max: 0, + message: '授权限额模块不可用,请重新验证许可证或联系管理员。', + }); + expect(mockMaterialCount).not.toHaveBeenCalled(); + }); }); describe('功能开关检查', () => { @@ -137,7 +183,7 @@ describe('功能开关检查', () => { expect(mockHasPremiumModule).not.toHaveBeenCalled(); }); - it('当缺少付费模块凭证时应该返回 false', async () => { + it('当缺少付费能力模块凭证时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(true); mockHasPremiumModule.mockResolvedValue(false); expect(await canUseCustomTemplates()).toBe(false); @@ -172,7 +218,7 @@ describe('功能开关检查', () => { expect(mockHasPremiumModule).not.toHaveBeenCalled(); }); - it('当缺少付费模块凭证时应该返回 false', async () => { + it('当缺少付费能力模块凭证时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(true); mockHasPremiumModule.mockResolvedValue(false); expect(await canUseIntranetPenetration()).toBe(false); @@ -208,11 +254,12 @@ describe('功能开关检查', () => { describe('LimitCheckResult 接口', () => { it('应该正确处理无限制情况 (max = -1)', async () => { - mockCheckLimit.mockResolvedValue({ allowed: true, current: 0, max: -1 }); + mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: -1, maxMaterials: -1 }); const result = await canCreateUser(); expect(result.allowed).toBe(true); expect(result.max).toBe(-1); + expect(mockUserCount).not.toHaveBeenCalled(); }); }); diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index f5d32cf..3e95238 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -117,6 +117,24 @@ describe('许可证业务入口覆盖', () => { expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE'); }); + it('内网穿透入口通过许可证后必须代理到模块服务', () => { + for (const file of [ + 'app/api/tunnel/start/route.ts', + 'app/api/tunnel/status/route.ts', + 'app/api/tunnel/stop/route.ts', + ]) { + const source = readProjectFile(file); + expect(source).toContain('proxyIntranetModule'); + } + }); + + it('用户数和素材数限制必须来自签名授权模块', () => { + const source = readProjectFile('lib/license/check-limits.ts'); + + expect(source).toContain('getAuthorizedUsageLimits'); + expect(source).toContain('授权限额模块不可用'); + }); + it('高风险后台维护入口必须要求确认短语', () => { const destructiveRoutes = [ { diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts index f660d4f..78c4c55 100644 --- a/lib/__tests__/premium-module-service.test.ts +++ b/lib/__tests__/premium-module-service.test.ts @@ -61,6 +61,25 @@ const moduleArtifact = { version: '1.0.0', }; +const limitsModuleArtifact = { + type: 'registry-image' as const, + image: 'registry.test/flnxi/premium/license-limits-core:1.0.0', + digest: null, + version: '1.0.0', +}; + +function mockPullCredential(moduleId = 'intranet-penetration-core') { + return { + registryHost: 'registry.test', + repository: `flnxi/premium/${moduleId}`, + tag: '1.0.0', + digest: null, + username: 'flnxi-module', + pullToken: `pull-token:${moduleId}`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; +} + function mockManifestModule(overrides: Record = {}) { return { id: 'intranet-penetration-core', @@ -73,6 +92,42 @@ function mockManifestModule(overrides: Record = {}) { }; } +function mockLimitsManifestModule(overrides: Record = {}) { + return { + id: 'license-limits-core', + minLicenseType: 'FREE', + artifact: limitsModuleArtifact, + sizeBytes: 18, + sha256: 'hash', + entitlementVersion: 1, + limits: { maxUsers: 10, maxMaterials: 100 }, + ...overrides, + }; +} + +function mockFreeLicense() { + mockGetLocalLicense.mockResolvedValue({ + instanceId: 'instance-1', + licenseKey: 'FLNXI-FREE-TEST', + licenseData: 'encrypted-license', + lastVerified: new Date().toISOString(), + 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); +} + function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') { mockGetLocalLicense.mockResolvedValue({ instanceId: 'instance-1', @@ -103,33 +158,73 @@ describe('premium-module-service', () => { 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); + it('免费版也应同步限额授权模块', async () => { + mockFreeLicense(); + const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString(); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + manifest: { + version: 1, + licenseType: 'FREE', + instanceId: 'instance-1', + modules: [mockLimitsManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: manifestExpiresAt, + }, + signature: 'valid-signature', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + entitlement: { + moduleId: 'license-limits-core', + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: limitsModuleArtifact, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + pullCredential: mockPullCredential('license-limits-core'), + }), + }); const { syncPremiumModules } = await import('../license/premium-module-service'); const result = await syncPremiumModules(); - expect(result).toMatchObject({ synced: false, skipped: true }); - expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toEqual({ synced: true }); + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://license.test/api/license/flnxi/premium-modules', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'https://license.test/api/license/flnxi/premium-modules/license-limits-core', + expect.objectContaining({ method: 'POST' }) + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'https://license.test/api/license/flnxi/premium-modules/license-limits-core/pull-token', + expect.objectContaining({ method: 'POST' }) + ); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).manifestExpiresAt).toBe(manifestExpiresAt); + expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3); }); - it('付费版应拉取、验签并加密缓存付费模块清单', async () => { + it('付费版应拉取、验签并加密缓存授权模块清单', async () => { mockPaidLicense('PRO'); + const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString(); mockFetch .mockResolvedValueOnce({ ok: true, @@ -141,7 +236,7 @@ describe('premium-module-service', () => { instanceId: 'instance-1', modules: [mockManifestModule()], issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 60_000).toISOString(), + expiresAt: manifestExpiresAt, }, signature: 'valid-signature', }), @@ -159,6 +254,13 @@ describe('premium-module-service', () => { artifact: moduleArtifact, }, }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + pullCredential: mockPullCredential('intranet-penetration-core'), + }), }); const { syncPremiumModules } = await import('../license/premium-module-service'); @@ -175,10 +277,16 @@ describe('premium-module-service', () => { 'https://license.test/api/license/flnxi/premium-modules/intranet-penetration-core', expect.objectContaining({ method: 'POST' }) ); - expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith( + 3, + 'https://license.test/api/license/flnxi/premium-modules/intranet-penetration-core/pull-token', + expect.objectContaining({ method: 'POST' }) + ); + expect(JSON.parse(mockFetch.mock.calls[1][1].body).manifestExpiresAt).toBe(manifestExpiresAt); + expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3); }); - it('付费模块清单同步网络异常时应返回失败结果而不是抛出', async () => { + it('授权模块清单同步网络异常时应返回失败结果而不是抛出', async () => { mockPaidLicense('PRO'); mockFetch.mockRejectedValue(new Error('network down')); @@ -306,4 +414,180 @@ describe('premium-module-service', () => { await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); }); + + it('限额模块清单和授权凭证都有效时应返回签名限额', async () => { + mockFreeLicense(); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'FREE', + instanceId: 'instance-1', + modules: [mockLimitsManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + if (where.key === 'premium_module_entitlements') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + entitlements: { + 'license-limits-core': { + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: limitsModuleArtifact, + syncedAt: new Date().toISOString(), + }, + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); + + await expect(getAuthorizedUsageLimits()).resolves.toEqual({ + maxUsers: 10, + maxMaterials: 100, + }); + }); + + it('限额模块缺少有效授权凭证时不应返回限额', async () => { + mockFreeLicense(); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'FREE', + instanceId: 'instance-1', + modules: [mockLimitsManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); + + await expect(getAuthorizedUsageLimits()).resolves.toBeNull(); + }); + + it('缓存中的短期拉包凭证有效时才返回给模块运行器', async () => { + mockPaidLicense('PRO'); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'PRO', + instanceId: 'instance-1', + modules: [mockManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + if (where.key === 'premium_module_entitlements') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + entitlements: { + 'intranet-penetration-core': { + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: moduleArtifact, + pullCredential: mockPullCredential('intranet-penetration-core'), + syncedAt: new Date().toISOString(), + }, + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { getCachedModulePullCredential } = await import('../license/premium-module-service'); + + await expect(getCachedModulePullCredential('intranet-penetration-core')).resolves.toEqual( + expect.objectContaining({ + username: 'flnxi-module', + repository: 'flnxi/premium/intranet-penetration-core', + }) + ); + }); + + it('过期的短期拉包凭证不应返回给模块运行器', async () => { + mockPaidLicense('PRO'); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'PRO', + instanceId: 'instance-1', + modules: [mockManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + if (where.key === 'premium_module_entitlements') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + entitlements: { + 'intranet-penetration-core': { + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: moduleArtifact, + pullCredential: { + ...mockPullCredential('intranet-penetration-core'), + expiresAt: new Date(Date.now() - 1_000).toISOString(), + }, + syncedAt: new Date().toISOString(), + }, + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { getCachedModulePullCredential } = await import('../license/premium-module-service'); + + await expect(getCachedModulePullCredential('intranet-penetration-core')).resolves.toBeNull(); + }); }); diff --git a/lib/license/check-limits.ts b/lib/license/check-limits.ts index f9497e0..d6104f9 100644 --- a/lib/license/check-limits.ts +++ b/lib/license/check-limits.ts @@ -3,8 +3,9 @@ * 用于在各个 API 中检查是否达到许可证限制 */ -import { checkLimit, isFeatureEnabled, getLicenseType } from './license-service'; -import { hasPremiumModule, PREMIUM_MODULE_IDS } from './premium-module-service'; +import { isFeatureEnabled, getLicenseType } from './license-service'; +import { prisma } from '@/lib/prisma'; +import { getAuthorizedUsageLimits, hasPremiumModule, PREMIUM_MODULE_IDS } from './premium-module-service'; export interface LimitCheckResult { allowed: boolean; @@ -13,13 +14,53 @@ export interface LimitCheckResult { message?: string; } +const LIMIT_MODULE_UNAVAILABLE_MESSAGE = '授权限额模块不可用,请重新验证许可证或联系管理员。'; + +async function checkAuthorizedLimit(type: 'users' | 'materials'): Promise { + const limits = await getAuthorizedUsageLimits(); + if (!limits) { + return { + allowed: false, + current: 0, + max: 0, + message: LIMIT_MODULE_UNAVAILABLE_MESSAGE, + }; + } + + const max = type === 'users' ? limits.maxUsers : limits.maxMaterials; + if (max === -1) { + return { allowed: true, current: 0, max }; + } + + if (!Number.isInteger(max) || max < 0) { + return { + allowed: false, + current: 0, + max: 0, + message: LIMIT_MODULE_UNAVAILABLE_MESSAGE, + }; + } + + const current = type === 'users' + ? await prisma.user.count() + : await prisma.material.count(); + + return { + allowed: current < max, + current, + max, + }; +} + /** * 检查是否可以创建新用户 */ export async function canCreateUser(): Promise { - const result = await checkLimit('users'); + const result = await checkAuthorizedLimit('users'); if (!result.allowed) { + if (result.message) return result; + const licenseType = await getLicenseType(); return { ...result, @@ -37,9 +78,11 @@ export async function canCreateUser(): Promise { * 检查是否可以上传新素材 */ export async function canUploadMaterial(): Promise { - const result = await checkLimit('materials'); + const result = await checkAuthorizedLimit('materials'); if (!result.allowed) { + if (result.message) return result; + const licenseType = await getLicenseType(); return { ...result, diff --git a/lib/license/index.ts b/lib/license/index.ts index 901d20c..0cde073 100644 --- a/lib/license/index.ts +++ b/lib/license/index.ts @@ -32,6 +32,9 @@ export { export { getCachedPremiumModuleManifest, + getCachedPremiumModuleRuntimeStatus, + getCachedModulePullCredential, + getAuthorizedUsageLimits, syncPremiumModules, hasPremiumModule, } from './premium-module-service'; diff --git a/lib/license/license-service.ts b/lib/license/license-service.ts index 503cd9d..23abca0 100644 --- a/lib/license/license-service.ts +++ b/lib/license/license-service.ts @@ -473,7 +473,7 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error? await updateLocalLicense(data.license); const moduleSyncResult = await syncPremiumModules(); if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { - console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error); + console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error); } return { valid: true }; @@ -589,7 +589,7 @@ export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; er await updateLocalLicense(data.license); const moduleSyncResult = await syncPremiumModules(); if (!moduleSyncResult.synced && !moduleSyncResult.skipped) { - console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error); + console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error); } } diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts index cb30b65..0520086 100644 --- a/lib/license/premium-module-service.ts +++ b/lib/license/premium-module-service.ts @@ -8,11 +8,11 @@ import type { LicenseType } from './types'; export interface PremiumModuleManifest { version: number; - licenseType: Exclude; + licenseType: LicenseType; instanceId: string; modules: Array<{ id: string; - minLicenseType: Exclude; + minLicenseType: LicenseType; artifact: { type: 'registry-image'; image: string; @@ -22,6 +22,10 @@ export interface PremiumModuleManifest { sizeBytes: number; sha256: string; entitlementVersion?: number; + limits?: { + maxUsers: number; + maxMaterials: number; + }; }>; issuedAt: string; expiresAt: string; @@ -53,6 +57,39 @@ interface PremiumModuleEntitlementResponse { error?: string; } +export interface PremiumModulePullCredential { + registryHost: string; + repository: string; + tag: string; + digest: string | null; + username: string; + pullToken: string; + expiresAt: string; +} + +interface PremiumModulePullCredentialResponse { + success: boolean; + pullCredential?: PremiumModulePullCredential; + error?: string; +} + +export type PremiumModuleRuntimeStatusValue = + | 'missing' + | 'pulling' + | 'running' + | 'unhealthy' + | 'expired' + | 'revoked'; + +export interface PremiumModuleRuntimeStatus { + moduleId: string; + image: string; + digest: string | null; + status: PremiumModuleRuntimeStatusValue; + lastSyncAt: string; + lastError?: string; +} + interface CachedPremiumModuleEntitlements { entitlements: Record< string, @@ -62,6 +99,7 @@ interface CachedPremiumModuleEntitlements { sizeBytes: number; expiresAt: string; artifact: PremiumModuleManifest['modules'][number]['artifact']; + pullCredential?: PremiumModulePullCredential; syncedAt: string; } >; @@ -69,14 +107,18 @@ interface CachedPremiumModuleEntitlements { const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest'; const PREMIUM_MODULE_ENTITLEMENTS_CONFIG_KEY = 'premium_module_entitlements'; +const PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY = 'premium_module_runtime_status'; +const PREMIUM_MODULE_PULL_USERNAME = 'flnxi-module'; export const PREMIUM_MODULE_IDS = { + LICENSE_LIMITS_CORE: 'license-limits-core', CUSTOM_TEMPLATES_CORE: 'custom-templates-core', INTRANET_PENETRATION_CORE: 'intranet-penetration-core', } as const; -function isPaidLicense(type: LicenseType): type is Exclude { - return type === 'STANDARD' || type === 'PRO'; +export interface AuthorizedUsageLimits { + maxUsers: number; + maxMaterials: number; } function isManifestExpired(manifest: PremiumModuleManifest): boolean { @@ -88,6 +130,16 @@ function verifyPremiumModuleManifest(manifest: PremiumModuleManifest, signature: return verifySignature(JSON.stringify(manifest), signature); } +function isValidLimitValue(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= -1; +} + +function isValidUsageLimits(limits: unknown): limits is AuthorizedUsageLimits { + if (!limits || typeof limits !== 'object') return false; + const value = limits as Partial; + return isValidLimitValue(value.maxUsers) && isValidLimitValue(value.maxMaterials); +} + async function savePremiumModuleManifest( manifest: PremiumModuleManifest, signature: string @@ -118,7 +170,7 @@ async function getCachedPremiumModuleEntitlements(): Promise Date.now(); } +function isPullCredentialValid( + credential: PremiumModulePullCredential | null | undefined, + module: PremiumModuleManifest['modules'][number] +): credential is PremiumModulePullCredential { + if (!credential) return false; + if (credential.username !== PREMIUM_MODULE_PULL_USERNAME) return false; + + const imageParts = parseModuleImage(module.artifact.image); + if (!imageParts) return false; + + if (credential.registryHost !== imageParts.registryHost) return false; + if (credential.repository !== imageParts.repository) return false; + if (credential.tag !== imageParts.tag) return false; + if ((credential.digest || null) !== (module.artifact.digest || null)) return false; + if (!credential.pullToken || typeof credential.pullToken !== 'string') return false; + + const expiresAt = new Date(credential.expiresAt).getTime(); + return !Number.isNaN(expiresAt) && expiresAt > Date.now(); +} + async function downloadPremiumModuleEntitlement( module: PremiumModuleManifest['modules'][number], - fingerprint: Awaited> + fingerprint: Awaited>, + manifestExpiresAt: string ): Promise { const signedRequest = buildSignedLicenseRequest('premium-module-download', { moduleId: module.id, instanceId: fingerprint.instanceId, fingerprint, + manifestExpiresAt, }); const response = await fetch(buildPremiumModuleDownloadUrl(module.id), { @@ -210,28 +303,82 @@ async function downloadPremiumModuleEntitlement( const data = (await response.json()) as PremiumModuleEntitlementResponse; if (!response.ok || !data.success || !data.entitlement) { - throw new Error(data.error || `付费模块 ${module.id} 授权凭证获取失败`); + throw new Error(data.error || `授权模块 ${module.id} 授权凭证获取失败`); } if (data.entitlement.moduleId !== module.id) { - throw new Error(`付费模块 ${module.id} 返回内容不匹配`); + throw new Error(`授权模块 ${module.id} 返回内容不匹配`); } if (!isSameArtifact(data.entitlement.artifact, module.artifact)) { - throw new Error(`付费模块 ${module.id} 镜像地址校验失败`); + throw new Error(`授权模块 ${module.id} 镜像地址校验失败`); } if (data.entitlement.sha256 !== module.sha256 || hashData(data.entitlement.entitlementBase64) !== module.sha256) { - throw new Error(`付费模块 ${module.id} 授权凭证哈希校验失败`); + throw new Error(`授权模块 ${module.id} 授权凭证哈希校验失败`); } if (data.entitlement.sizeBytes !== module.sizeBytes) { - throw new Error(`付费模块 ${module.id} 大小校验失败`); + throw new Error(`授权模块 ${module.id} 大小校验失败`); } return data.entitlement; } +async function downloadPremiumModulePullCredential( + module: PremiumModuleManifest['modules'][number], + fingerprint: Awaited> +): Promise { + const signedRequest = buildSignedLicenseRequest('premium-module-pull-token', { + moduleId: module.id, + instanceId: fingerprint.instanceId, + fingerprint, + }); + + const response = await fetch(buildPremiumModulePullTokenUrl(module.id), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(signedRequest), + }); + const data = (await response.json()) as PremiumModulePullCredentialResponse; + + if (!response.ok || !data.success || !data.pullCredential) { + throw new Error(data.error || `授权模块 ${module.id} 镜像拉取凭证获取失败`); + } + + if (!isPullCredentialValid(data.pullCredential, module)) { + throw new Error(`授权模块 ${module.id} 镜像拉取凭证校验失败`); + } + + return data.pullCredential; +} + +export async function getCachedPremiumModuleRuntimeStatus(): Promise> { + try { + const config = await prisma.systemConfig.findUnique({ + where: { key: PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY }, + }); + + if (!config) return {}; + + const cached = JSON.parse(decryptLocal(config.value)) as Record; + if (!cached || typeof cached !== 'object') return {}; + + return cached; + } catch (error) { + console.warn('[License] 读取授权模块运行状态失败:', error); + return {}; + } +} + +async function savePremiumModuleRuntimeStatus(statuses: Record): Promise { + await prisma.systemConfig.upsert({ + where: { key: PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY }, + update: { value: encryptLocal(JSON.stringify(statuses)) }, + create: { key: PREMIUM_MODULE_RUNTIME_STATUS_CONFIG_KEY, value: encryptLocal(JSON.stringify(statuses)) }, + }); +} + export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: boolean; error?: string }> { try { const localLicense = await getLocalLicense(); @@ -239,10 +386,6 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: 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, @@ -259,48 +402,125 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: if (!response.ok || !data.success || !data.manifest || !data.signature) { return { synced: false, - error: data.error || '付费模块清单同步失败', + error: data.error || '授权模块清单同步失败', }; } if (data.manifest.licenseType !== localLicense.cachedConfig.type) { - return { synced: false, error: '付费模块清单许可证类型不匹配' }; + return { synced: false, error: '授权模块清单许可证类型不匹配' }; } if (!verifyPremiumModuleManifest(data.manifest, data.signature)) { - return { synced: false, error: '付费模块清单签名验证失败' }; + return { synced: false, error: '授权模块清单签名验证失败' }; } const cachedEntitlements = await getCachedPremiumModuleEntitlements(); + const runtimeStatuses = await getCachedPremiumModuleRuntimeStatus(); + const activeModuleIds = new Set(data.manifest.modules.map((moduleInfo) => moduleInfo.id)); + for (const moduleInfo of data.manifest.modules) { - if (isCachedEntitlementValid(cachedEntitlements, moduleInfo)) { - continue; + const existing = cachedEntitlements.entitlements[moduleInfo.id]; + const hasValidEntitlement = isCachedEntitlementValid(cachedEntitlements, moduleInfo); + let nextEntitlement = existing; + + if (!hasValidEntitlement) { + const entitlement = await downloadPremiumModuleEntitlement( + moduleInfo, + fingerprint, + data.manifest.expiresAt + ); + if (!entitlement) { + throw new Error(`授权模块 ${moduleInfo.id} 授权凭证为空`); + } + + nextEntitlement = { + entitlementBase64: entitlement.entitlementBase64, + sha256: entitlement.sha256, + sizeBytes: entitlement.sizeBytes, + expiresAt: entitlement.expiresAt, + artifact: entitlement.artifact, + pullCredential: existing?.pullCredential, + syncedAt: new Date().toISOString(), + }; } - const entitlement = await downloadPremiumModuleEntitlement(moduleInfo, fingerprint); - if (!entitlement) { - throw new Error(`付费模块 ${moduleInfo.id} 授权凭证为空`); + if (!nextEntitlement) { + throw new Error(`授权模块 ${moduleInfo.id} 本地凭证为空`); } - cachedEntitlements.entitlements[moduleInfo.id] = { - entitlementBase64: entitlement.entitlementBase64, - sha256: entitlement.sha256, - sizeBytes: entitlement.sizeBytes, - expiresAt: entitlement.expiresAt, - artifact: entitlement.artifact, - syncedAt: new Date().toISOString(), + if (!isPullCredentialValid(nextEntitlement.pullCredential, moduleInfo)) { + nextEntitlement.pullCredential = await downloadPremiumModulePullCredential(moduleInfo, fingerprint); + nextEntitlement.syncedAt = new Date().toISOString(); + } + + cachedEntitlements.entitlements[moduleInfo.id] = nextEntitlement; + + const existingStatus = runtimeStatuses[moduleInfo.id]; + runtimeStatuses[moduleInfo.id] = { + moduleId: moduleInfo.id, + image: moduleInfo.artifact.image, + digest: moduleInfo.artifact.digest || null, + status: + existingStatus?.status && existingStatus.status !== 'expired' && existingStatus.status !== 'revoked' + ? existingStatus.status + : 'missing', + lastSyncAt: new Date().toISOString(), }; } + for (const [moduleId, status] of Object.entries(runtimeStatuses)) { + if (!activeModuleIds.has(moduleId)) { + runtimeStatuses[moduleId] = { + ...status, + status: 'revoked', + lastSyncAt: new Date().toISOString(), + lastError: '许可证未包含该授权模块', + }; + } + } + await savePremiumModuleManifest(data.manifest, data.signature); await savePremiumModuleEntitlements(cachedEntitlements); + await savePremiumModuleRuntimeStatus(runtimeStatuses); return { synced: true }; } catch (error) { - const message = error instanceof Error ? error.message : '付费模块清单同步异常'; + const message = error instanceof Error ? error.message : '授权模块清单同步异常'; return { synced: false, error: message }; } } +export async function getAuthorizedUsageLimits(): Promise { + const cached = await getCachedPremiumModuleManifest(); + if (!cached) return null; + + const moduleInfo = cached.manifest.modules.find( + (item) => item.id === PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE + ); + if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null; + + const cachedEntitlements = await getCachedPremiumModuleEntitlements(); + if (!isCachedEntitlementValid(cachedEntitlements, moduleInfo)) return null; + + return { + maxUsers: moduleInfo.limits.maxUsers, + maxMaterials: moduleInfo.limits.maxMaterials, + }; +} + +export async function getCachedModulePullCredential(moduleId: string): Promise { + const cached = await getCachedPremiumModuleManifest(); + if (!cached) return null; + + const moduleInfo = cached.manifest.modules.find((item) => item.id === moduleId); + if (!moduleInfo) return null; + + const cachedEntitlements = await getCachedPremiumModuleEntitlements(); + if (!isCachedEntitlementValid(cachedEntitlements, moduleInfo)) return null; + + const credential = cachedEntitlements.entitlements[moduleId]?.pullCredential; + return isPullCredentialValid(credential, moduleInfo) ? credential : null; +} + export async function hasPremiumModule(moduleId: string): Promise { const cached = await getCachedPremiumModuleManifest(); if (!cached) return false; diff --git a/lib/tunnel/intranet-module.ts b/lib/tunnel/intranet-module.ts new file mode 100644 index 0000000..78d6ff1 --- /dev/null +++ b/lib/tunnel/intranet-module.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +type TunnelAction = 'status' | 'start' | 'stop'; + +function getModuleBaseUrl(): string { + return (process.env.INTRANET_PENETRATION_MODULE_URL || '').trim().replace(/\/+$/, ''); +} + +export async function proxyIntranetModule(action: TunnelAction): Promise { + const baseUrl = getModuleBaseUrl(); + if (!baseUrl) return null; + + const method = action === 'status' ? 'GET' : 'POST'; + const response = await fetch(`${baseUrl}/${action}`, { method }); + const data = await response.json().catch(() => ({ + error: '内网穿透模块返回数据异常', + })); + + return NextResponse.json(data, { status: response.status }); +} diff --git a/license-server/.env.example b/license-server/.env.example index fd565e7..7c80946 100644 --- a/license-server/.env.example +++ b/license-server/.env.example @@ -28,13 +28,21 @@ 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" -# 付费模块分发 +# 付费模块短期拉包 token 签名密钥(生产环境建议独立配置,最少 32 字节) +MODULE_PULL_TOKEN_SECRET="replace-with-32-byte-or-longer-module-pull-token-secret" + +# 授权模块分发 # 模块核心本体放在镜像站,验证服务器只下发授权清单和实例绑定凭证。 +# 免费版限额模块 license-limits-core 也走这条链路。 PREMIUM_MODULE_REGISTRY_BASE="registry.example.com/flnxi/premium" PREMIUM_MODULE_VERSION="1.0.0" # 如需单独覆盖某个模块镜像,可使用: +# PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE="registry.example.com/flnxi/premium/license-limits-core: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" +# PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST="sha256:..." +# PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST="sha256:..." +# PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST="sha256:..." # 管理员初始密码(只作为初始化入口,不要保留弱默认值) ADMIN_INITIAL_PASSWORD="replace-with-strong-initial-admin-password" diff --git a/license-server/Dockerfile b/license-server/Dockerfile index bcf4eca..507dbb9 100644 --- a/license-server/Dockerfile +++ b/license-server/Dockerfile @@ -40,6 +40,11 @@ COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/tsconfig.json ./ +COPY --from=builder /app/tsconfig.scripts.json ./ +COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma EXPOSE 3000 diff --git a/license-server/app/api/license/[projectCode]/generate/route.ts b/license-server/app/api/license/[projectCode]/generate/route.ts index d510e3b..9e66458 100644 --- a/license-server/app/api/license/[projectCode]/generate/route.ts +++ b/license-server/app/api/license/[projectCode]/generate/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPublicProjectDb } from '@/lib/project-auth'; import { verifyApiKey } from '@/lib/api-key'; import { LICENSE_CONFIGS } from '@/lib/types'; +import { ensureDefaultModuleGrants } from '@/lib/module-grants'; import { z } from 'zod'; const generateSchema = z.object({ @@ -134,6 +135,12 @@ export async function POST( }, }); + await ensureDefaultModuleGrants(tx, { + id: license.id, + licenseType: license.licenseType, + expiresAt: license.expiresAt, + }); + await tx.order.create({ data: { orderNo, diff --git a/license-server/app/api/license/[projectCode]/heartbeat/route.ts b/license-server/app/api/license/[projectCode]/heartbeat/route.ts index 9844767..02edde1 100644 --- a/license-server/app/api/license/[projectCode]/heartbeat/route.ts +++ b/license-server/app/api/license/[projectCode]/heartbeat/route.ts @@ -8,6 +8,7 @@ import { calculateFingerprintMatchScore, createPendingAlertOnce, enforceDeviceAbusePolicy, + isSamePersistentInstance, } from '@/lib/abuse-detection'; import { z } from 'zod'; @@ -125,7 +126,7 @@ export async function POST( const heartbeatThreshold = license.licenseType === 'FREE' ? 85 : 60; const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); - if (matchScore < heartbeatThreshold) { + if (!isSamePersistentInstance(storedFingerprint, fingerprint) && matchScore < heartbeatThreshold) { await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', { instanceId, matchScore, @@ -167,6 +168,7 @@ export async function POST( await projectDbResult.prisma.activation.update({ where: { id: activation.id }, data: { + fingerprint: fingerprint as object, lastHeartbeat: new Date(), usageStats: usage, }, diff --git a/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/pull-token/route.ts b/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/pull-token/route.ts new file mode 100644 index 0000000..dd223c6 --- /dev/null +++ b/license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/pull-token/route.ts @@ -0,0 +1,174 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPublicProjectDb } from '@/lib/project-auth'; +import { verifyLicenseRequestSignature } from '@/lib/request-signature'; +import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; +import { + calculateFingerprintMatchScore, + createPendingAlertOnce, + isSamePersistentInstance, +} from '@/lib/abuse-detection'; +import { getAuthorizedModuleGrant } from '@/lib/module-grants'; +import { parseRegistryImage } from '@/lib/premium-module-catalog'; +import { + createModulePullCredential, + hashModulePullToken, +} from '@/lib/module-pull-token'; +import { z } from 'zod'; + +const premiumModulePullTokenSchema = 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; moduleId: string }> } +) { + try { + const { projectCode, moduleId } = await params; + const projectDbResult = await getPublicProjectDb(projectCode); + + if (!projectDbResult.success) { + return projectDbResult.response; + } + + const body = await request.json().catch(() => ({})); + const signatureResult = verifyLicenseRequestSignature('premium-module-pull-token', projectCode, { + ...body, + moduleId, + }); + if (!signatureResult.valid) { + return NextResponse.json( + { success: false, error: '请求签名无效' }, + { status: signatureResult.response.status } + ); + } + + const parsed = premiumModulePullTokenSchema.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.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 (!isSamePersistentInstance(storedFingerprint, fingerprint) && matchScore < 70) { + await createPendingAlertOnce(projectDbResult.prisma, license.id, 'premium_module_pull_token_fingerprint_mismatch', { + instanceId, + moduleId, + matchScore, + }); + + return NextResponse.json( + { success: false, error: '硬件指纹不匹配,无法获取模块拉取凭证' }, + { status: 403 } + ); + } + + const grant = await getAuthorizedModuleGrant(projectDbResult.prisma, { + id: license.id, + licenseType: license.licenseType, + expiresAt: license.expiresAt, + }, moduleId); + if (!grant) { + return NextResponse.json( + { success: false, error: '当前许可证不允许拉取该授权模块' }, + { status: 403 } + ); + } + + const config = generateLicenseConfig(license.licenseType, license.expiresAt); + const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now()); + if (leaseExpiresAt <= new Date()) { + return NextResponse.json( + { success: false, error: '许可证租约已过期' }, + { status: 403 } + ); + } + + const parsedImage = parseRegistryImage(grant.artifact.image); + const { credential } = createModulePullCredential({ + projectCode, + licenseId: license.id, + activationId: activation.id, + instanceId, + moduleId, + registryHost: parsedImage.registryHost, + repository: parsedImage.repository, + tag: parsedImage.tag, + digest: grant.artifact.digest || null, + }); + + await projectDbResult.prisma.modulePullTokenAudit.create({ + data: { + licenseId: license.id, + activationId: activation.id, + moduleId, + repository: parsedImage.repository, + digest: grant.artifact.digest || null, + tokenHash: hashModulePullToken(credential.pullToken), + expiresAt: new Date(credential.expiresAt), + }, + }); + + return NextResponse.json({ + success: true, + pullCredential: credential, + }); + } catch (error) { + console.error('模块拉取凭证生成失败:', error); + return NextResponse.json( + { success: false, error: '服务器内部错误' }, + { status: 500 } + ); + } +} 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 1d071cf..0e167a7 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 @@ -2,8 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPublicProjectDb } from '@/lib/project-auth'; import { verifyLicenseRequestSignature } from '@/lib/request-signature'; import { buildPremiumModuleEntitlement } from '@/lib/premium-module-manifest'; +import { getAuthorizedModuleGrant } from '@/lib/module-grants'; import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; -import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection'; +import { + calculateFingerprintMatchScore, + createPendingAlertOnce, + isSamePersistentInstance, +} from '@/lib/abuse-detection'; import { z } from 'zod'; const premiumModuleDownloadSchema = z.object({ @@ -15,9 +20,24 @@ const premiumModuleDownloadSchema = z.object({ cpuId: z.string().optional(), instanceId: z.string(), }), + manifestExpiresAt: z.string().optional(), timestamp: z.number(), }); +function resolveEntitlementLeaseExpiresAt( + requestedExpiresAt: string | undefined, + maxLeaseExpiresAt: Date +): Date | null { + if (!requestedExpiresAt) return maxLeaseExpiresAt; + + const requested = new Date(requestedExpiresAt); + if (Number.isNaN(requested.getTime())) return null; + if (requested <= new Date()) return null; + if (requested > maxLeaseExpiresAt) return null; + + return requested; +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ projectCode: string; moduleId: string }> } @@ -50,7 +70,7 @@ export async function POST( ); } - const { instanceId, fingerprint, timestamp } = parsed.data; + const { instanceId, fingerprint, manifestExpiresAt, timestamp } = parsed.data; if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) { return NextResponse.json( { success: false, error: '请求已过期' }, @@ -76,12 +96,6 @@ export async function POST( } const { license } = activation; - if (license.licenseType === 'FREE') { - return NextResponse.json( - { success: false, error: '免费版不提供付费模块授权凭证' }, - { status: 403 } - ); - } if (license.status === 'disabled') { return NextResponse.json( @@ -99,7 +113,7 @@ export async function POST( const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint; const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); - if (matchScore < 70) { + if (!isSamePersistentInstance(storedFingerprint, fingerprint) && matchScore < 70) { await createPendingAlertOnce(projectDbResult.prisma, license.id, 'premium_module_download_fingerprint_mismatch', { instanceId, moduleId, @@ -107,26 +121,60 @@ 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 entitlement = buildPremiumModuleEntitlement({ - moduleId, + const maxLeaseExpiresAt = new Date(config.leaseExpiresAt || Date.now()); + const leaseExpiresAt = resolveEntitlementLeaseExpiresAt(manifestExpiresAt, maxLeaseExpiresAt); + if (!leaseExpiresAt) { + return NextResponse.json( + { success: false, error: '授权模块清单租约无效或已过期' }, + { status: 400 } + ); + } + const grant = await getAuthorizedModuleGrant(projectDbResult.prisma, { + id: license.id, licenseType: license.licenseType, - instanceId, - leaseExpiresAt, - }); + expiresAt: license.expiresAt, + }, moduleId); + if (!grant) { + return NextResponse.json( + { success: false, error: '当前许可证不允许使用该授权模块' }, + { status: 403 } + ); + } + + let entitlement; + try { + entitlement = buildPremiumModuleEntitlement({ + moduleId, + licenseType: license.licenseType, + instanceId, + leaseExpiresAt, + artifact: grant.artifact, + limits: grant.limits, + authorizedByGrant: true, + }); + } catch (moduleError) { + const message = moduleError instanceof Error ? moduleError.message : ''; + if (message.includes('不存在') || message.includes('不允许使用')) { + return NextResponse.json( + { success: false, error: '当前许可证不允许使用该授权模块' }, + { status: 403 } + ); + } + throw moduleError; + } return NextResponse.json({ success: true, 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 2d8526e..99dc2f6 100644 --- a/license-server/app/api/license/[projectCode]/premium-modules/route.ts +++ b/license-server/app/api/license/[projectCode]/premium-modules/route.ts @@ -1,9 +1,14 @@ 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 { buildSignedPremiumModuleManifestForGrants } from '@/lib/premium-module-manifest'; +import { listAuthorizedModuleGrants } from '@/lib/module-grants'; import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types'; -import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection'; +import { + calculateFingerprintMatchScore, + createPendingAlertOnce, + isSamePersistentInstance, +} from '@/lib/abuse-detection'; import { z } from 'zod'; const premiumModulesSchema = z.object({ @@ -73,12 +78,6 @@ export async function POST( } const { license } = activation; - if (license.licenseType === 'FREE') { - return NextResponse.json( - { success: false, error: '免费版不提供付费模块授权清单' }, - { status: 403 } - ); - } if (license.status === 'disabled') { return NextResponse.json( @@ -96,24 +95,30 @@ export async function POST( const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint; const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint); - if (matchScore < 70) { + if (!isSamePersistentInstance(storedFingerprint, fingerprint) && matchScore < 70) { await createPendingAlertOnce(projectDbResult.prisma, license.id, 'premium_module_fingerprint_mismatch', { instanceId, matchScore, }); 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 signedManifest = buildSignedPremiumModuleManifest( + const grants = await listAuthorizedModuleGrants(projectDbResult.prisma, { + id: license.id, + licenseType: license.licenseType, + expiresAt: license.expiresAt, + }); + const signedManifest = buildSignedPremiumModuleManifestForGrants( license.licenseType, leaseExpiresAt, - instanceId + instanceId, + grants ); return NextResponse.json({ @@ -121,7 +126,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/app/api/license/[projectCode]/register-free/route.ts b/license-server/app/api/license/[projectCode]/register-free/route.ts index f16d17f..f672c4c 100644 --- a/license-server/app/api/license/[projectCode]/register-free/route.ts +++ b/license-server/app/api/license/[projectCode]/register-free/route.ts @@ -5,6 +5,7 @@ import { verifyLicenseRequestSignature } from '@/lib/request-signature'; import { LICENSE_CONFIGS } from '@/lib/types'; import { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection'; import { buildLicenseSigningPayload } from '@/lib/license-payload'; +import { ensureDefaultModuleGrants } from '@/lib/module-grants'; import { z } from 'zod'; const registerFreeSchema = z.object({ @@ -104,6 +105,20 @@ export async function POST( ); } + await ensureDefaultModuleGrants(projectDbResult.prisma, { + id: existingActivation.license.id, + licenseType: existingActivation.license.licenseType, + expiresAt: existingActivation.license.expiresAt, + }); + + await projectDbResult.prisma.activation.update({ + where: { id: existingActivation.id }, + data: { + fingerprint: fingerprint as object, + lastHeartbeat: new Date(), + }, + }); + const signedLicense = signLicense(buildLicenseSigningPayload({ projectCode, licenseKey: existingActivation.license.licenseKey, @@ -176,6 +191,12 @@ export async function POST( }, }); + await ensureDefaultModuleGrants(tx, { + id: license.id, + licenseType: license.licenseType, + expiresAt: license.expiresAt, + }); + const activation = await tx.activation.create({ data: { licenseId: license.id, diff --git a/license-server/app/api/license/[projectCode]/verify/route.ts b/license-server/app/api/license/[projectCode]/verify/route.ts index 4c4f6d9..ac82eab 100644 --- a/license-server/app/api/license/[projectCode]/verify/route.ts +++ b/license-server/app/api/license/[projectCode]/verify/route.ts @@ -151,7 +151,10 @@ export async function POST( await projectDbResult.prisma.activation.update({ where: { id: activation.id }, - data: { lastHeartbeat: new Date() }, + data: { + fingerprint: fingerprint as object, + lastHeartbeat: new Date(), + }, }); const signedLicense = signLicense(buildLicenseSigningPayload({ diff --git a/license-server/app/api/registry/auth/route.ts b/license-server/app/api/registry/auth/route.ts new file mode 100644 index 0000000..20790af --- /dev/null +++ b/license-server/app/api/registry/auth/route.ts @@ -0,0 +1,189 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPublicProjectDb } from '@/lib/project-auth'; +import { + MODULE_PULL_TOKEN_USERNAME, + hashModulePullToken, + verifyModulePullToken, +} from '@/lib/module-pull-token'; +import { getAuthorizedModuleGrant } from '@/lib/module-grants'; + +export const dynamic = 'force-dynamic'; + +function extractToken(request: NextRequest): string | null { + const queryToken = request.nextUrl.searchParams.get('token'); + if (queryToken) return queryToken; + + const authorization = request.headers.get('authorization') || ''; + if (authorization.startsWith('Bearer ')) { + return authorization.slice('Bearer '.length).trim(); + } + + if (authorization.startsWith('Basic ')) { + try { + const decoded = Buffer.from(authorization.slice('Basic '.length), 'base64').toString('utf8'); + const separatorIndex = decoded.indexOf(':'); + if (separatorIndex < 0) return null; + + const username = decoded.slice(0, separatorIndex); + const password = decoded.slice(separatorIndex + 1); + if (username !== MODULE_PULL_TOKEN_USERNAME) return null; + return password || null; + } catch { + return null; + } + } + + return null; +} + +function extractRepository(request: NextRequest): string | null { + const direct = + request.nextUrl.searchParams.get('repository') || + request.nextUrl.searchParams.get('name') || + request.headers.get('x-registry-repository'); + if (direct) return direct; + + const scope = request.nextUrl.searchParams.get('scope') || request.headers.get('x-registry-scope'); + const match = scope?.match(/^repository:([^:]+):pull/); + return match?.[1] || null; +} + +function extractRegistryHost(request: NextRequest): string | null { + return request.nextUrl.searchParams.get('registryHost') || request.headers.get('x-registry-host'); +} + +function isManifestDigestRequest(request: NextRequest, requestedDigest: string | null): boolean { + const referenceType = + request.nextUrl.searchParams.get('referenceType') || + request.headers.get('x-registry-reference-type') || + ''; + + return referenceType === 'manifests' && !!requestedDigest; +} + +function extractReferenceType(request: NextRequest): string { + return ( + request.nextUrl.searchParams.get('referenceType') || + request.headers.get('x-registry-reference-type') || + '' + ); +} + +function extractReference(request: NextRequest): string | null { + return request.nextUrl.searchParams.get('reference') || request.headers.get('x-registry-reference'); +} + +function wasTokenRecentlyValidated(tokenAudit: { lastValidatedAt?: Date | string | null }, now: Date): boolean { + if (!tokenAudit.lastValidatedAt) return false; + + const validatedAt = new Date(tokenAudit.lastValidatedAt).getTime(); + return !Number.isNaN(validatedAt) && now.getTime() - validatedAt <= 5 * 60 * 1000; +} + +export async function GET(request: NextRequest) { + try { + const token = extractToken(request); + if (!token) { + return NextResponse.json({ success: false, error: '缺少模块拉取凭证' }, { status: 401 }); + } + + const payload = verifyModulePullToken(token); + if (!payload) { + return NextResponse.json({ success: false, error: '模块拉取凭证无效或已过期' }, { status: 401 }); + } + + const requestedRepository = extractRepository(request); + if (!requestedRepository || requestedRepository !== payload.repository) { + return NextResponse.json({ success: false, error: '模块仓库不匹配' }, { status: 403 }); + } + + const requestedRegistryHost = extractRegistryHost(request); + if (requestedRegistryHost && requestedRegistryHost !== payload.registryHost) { + return NextResponse.json({ success: false, error: 'Registry 主机不匹配' }, { status: 403 }); + } + + const requestedReferenceType = extractReferenceType(request); + const requestedReference = extractReference(request); + if ( + requestedReferenceType === 'manifests' && + requestedReference && + !requestedReference.startsWith('sha256:') && + requestedReference !== payload.tag + ) { + return NextResponse.json({ success: false, error: '模块 tag 不匹配' }, { status: 403 }); + } + + const projectDbResult = await getPublicProjectDb(payload.projectCode); + if (!projectDbResult.success) { + return projectDbResult.response; + } + + const now = new Date(); + const tokenHash = hashModulePullToken(token); + const tokenAudit = await projectDbResult.prisma.modulePullTokenAudit.findFirst({ + where: { + tokenHash, + licenseId: payload.licenseId, + activationId: payload.activationId, + moduleId: payload.moduleId, + repository: payload.repository, + digest: payload.digest, + expiresAt: { gt: now }, + }, + }); + if (!tokenAudit) { + return NextResponse.json({ success: false, error: '模块拉取凭证未登记或已过期' }, { status: 403 }); + } + + const requestedDigest = request.nextUrl.searchParams.get('digest') || request.headers.get('x-registry-digest'); + if ( + isManifestDigestRequest(request, requestedDigest) && + payload.digest && + requestedDigest !== payload.digest && + !wasTokenRecentlyValidated(tokenAudit, now) + ) { + return NextResponse.json({ success: false, error: '模块 digest 不匹配' }, { status: 403 }); + } + + const activation = await projectDbResult.prisma.activation.findFirst({ + where: { + id: payload.activationId, + licenseId: payload.licenseId, + instanceId: payload.instanceId, + isActive: true, + }, + include: { license: true }, + }); + const license = activation?.license; + if (!activation || !license || license.status !== 'active' || (license.expiresAt && license.expiresAt < now)) { + return NextResponse.json({ success: false, error: '许可证不可用' }, { status: 403 }); + } + + const grant = await getAuthorizedModuleGrant(projectDbResult.prisma, { + id: license.id, + licenseType: license.licenseType, + expiresAt: license.expiresAt, + }, payload.moduleId); + if (!grant) { + return NextResponse.json({ success: false, error: '模块授权已失效' }, { status: 403 }); + } + + await projectDbResult.prisma.modulePullTokenAudit.updateMany({ + where: { tokenHash }, + data: { lastValidatedAt: now }, + }); + + return NextResponse.json({ + success: true, + moduleId: payload.moduleId, + repository: payload.repository, + digest: payload.digest, + }); + } catch (error) { + console.error('Registry 模块拉取鉴权失败:', error); + return NextResponse.json( + { success: false, error: '服务器内部错误' }, + { status: 500 } + ); + } +} diff --git a/license-server/deploy.ps1 b/license-server/deploy.ps1 index 8864987..19fb09a 100644 --- a/license-server/deploy.ps1 +++ b/license-server/deploy.ps1 @@ -89,6 +89,7 @@ function Test-EnvConfig { "RSA_PUBLIC_KEY", "AES_SECRET_KEY", "LICENSE_REQUEST_SIGNING_SECRET", + "MODULE_PULL_TOKEN_SECRET", "PREMIUM_MODULE_REGISTRY_BASE", "PREMIUM_MODULE_VERSION", "JWT_SECRET", @@ -207,7 +208,7 @@ $envExists = ssh -i $SshKey "$User@$Server" "test -f $RemotePath/.env && echo 'e if ($envExists -ne "exists") { Write-Host " 生成安全 .env 配置..." -ForegroundColor Yellow if ([string]::IsNullOrWhiteSpace($PremiumModuleRegistryBase)) { - throw "请通过 -PremiumModuleRegistryBase 指定付费模块镜像站路径,例如 registry.example.com/flnxi/premium" + throw "请通过 -PremiumModuleRegistryBase 指定授权模块镜像站路径,例如 registry.example.com/flnxi/premium" } $postgresUser = "license" @@ -217,6 +218,7 @@ if ($envExists -ne "exists") { $mainDbEncryptionKey = New-RandomHex $aesSecretKey = New-RandomHex $licenseRequestSigningSecret = New-RandomHex + $modulePullTokenSecret = New-RandomHex $adminInitialPassword = New-RandomBase64Url $rsaKeys = New-RsaKeyPair $mainDatabaseUrl = "postgresql://${postgresUser}:${postgresPassword}@postgres:5432/${postgresDb}?schema=main" @@ -235,6 +237,7 @@ RSA_PUBLIC_KEY=$(Convert-ToEscapedEnvMultiline $rsaKeys.PublicKey) AES_SECRET_KEY=$aesSecretKey JWT_SECRET=$jwtSecret LICENSE_REQUEST_SIGNING_SECRET=$licenseRequestSigningSecret +MODULE_PULL_TOKEN_SECRET=$modulePullTokenSecret PREMIUM_MODULE_REGISTRY_BASE=$PremiumModuleRegistryBase PREMIUM_MODULE_VERSION=$PremiumModuleVersion ADMIN_INITIAL_PASSWORD=$adminInitialPassword @@ -281,4 +284,5 @@ Write-Host "" Write-Host "后续步骤:" -ForegroundColor White Write-Host "1. 查看安全配置: ssh -i $SshKey $User@$Server 'sed -n \"1,20p\" $RemotePath/.env'" -ForegroundColor Gray Write-Host "2. 重启服务: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose restart'" -ForegroundColor Gray -Write-Host "3. 初始化超管: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose exec license-server npx ts-node scripts/init-admin.ts admin'" -ForegroundColor Gray +Write-Host "3. 初始化超管: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose exec license-server npm run init-admin -- admin'" -ForegroundColor Gray +Write-Host "4. 初始化项目: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose exec license-server npm run init-project -- flnxi-sucai'" -ForegroundColor Gray diff --git a/license-server/docker-compose.yml b/license-server/docker-compose.yml index 56faf77..23caef8 100644 --- a/license-server/docker-compose.yml +++ b/license-server/docker-compose.yml @@ -22,13 +22,29 @@ services: - 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} - DATABASE_URL=${DATABASE_URL:?DATABASE_URL is required} + - POSTGRES_USER=${POSTGRES_USER:?POSTGRES_USER is required} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + - POSTGRES_DB=${POSTGRES_DB:?POSTGRES_DB is required} + - LICENSE_PROJECT_CODE=${LICENSE_PROJECT_CODE:-flnxi-sucai} + - LICENSE_PROJECT_NAME=${LICENSE_PROJECT_NAME:-FLNXI素材库} + - LICENSE_PROJECT_DESCRIPTION=${LICENSE_PROJECT_DESCRIPTION:-FLNXI素材库商业授权项目} + - PROJECT_DB_HOST=${PROJECT_DB_HOST:-postgres} + - PROJECT_DB_PORT=${PROJECT_DB_PORT:-5432} + - PROJECT_DB_NAME=${PROJECT_DB_NAME:-${POSTGRES_DB}} + - PROJECT_DB_USER=${PROJECT_DB_USER:-${POSTGRES_USER}} + - PROJECT_DB_PASSWORD=${PROJECT_DB_PASSWORD:-${POSTGRES_PASSWORD}} + - PROJECT_DB_SCHEMA=${PROJECT_DB_SCHEMA:-public} - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required} - RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY:?RSA_PRIVATE_KEY is required} - 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} + - MODULE_PULL_TOKEN_SECRET=${MODULE_PULL_TOKEN_SECRET:?MODULE_PULL_TOKEN_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_LICENSE_LIMITS_CORE_IMAGE=${PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE:-} + - PREMIUM_MODULE_LICENSE_LIMITS_CORE_TAG=${PREMIUM_MODULE_LICENSE_LIMITS_CORE_TAG:-} + - PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST=${PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST:-} - 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:-} @@ -66,4 +82,5 @@ volumes: networks: license-network: + name: ${LICENSE_NETWORK_NAME:-license-network} driver: bridge diff --git a/license-server/lib/__tests__/abuse-detection.test.ts b/license-server/lib/__tests__/abuse-detection.test.ts index a93bc8c..aec6127 100644 --- a/license-server/lib/__tests__/abuse-detection.test.ts +++ b/license-server/lib/__tests__/abuse-detection.test.ts @@ -27,7 +27,7 @@ function createPrismaMock(recentHeartbeats: Array<{ instanceId: string; fingerpr } describe('abuse-detection', () => { - it('免费版同许可证出现多个有效指纹时应阻断', async () => { + it('免费版同许可证出现多个实例时应阻断', async () => { const prisma = createPrismaMock([ { instanceId: 'instance-2', fingerprintHash: 'b'.repeat(64) }, ]); @@ -49,6 +49,22 @@ describe('abuse-detection', () => { ); }); + it('免费版同一持久实例的容器指纹变化不应阻断', async () => { + const prisma = createPrismaMock([ + { instanceId: 'instance-1', fingerprintHash: 'b'.repeat(64) }, + ]); + + const result = await enforceDeviceAbusePolicy(prisma as any, { + licenseId: 'license-1', + licenseType: 'FREE', + instanceId: 'instance-1', + fingerprint: createFingerprint('instance-1'), + }); + + expect(result).toEqual({ allowed: true }); + expect(prisma.alert.create).not.toHaveBeenCalled(); + }); + it('付费版多个有效指纹应告警但不直接阻断', async () => { const prisma = createPrismaMock([ { instanceId: 'instance-2', fingerprintHash: 'b'.repeat(64) }, diff --git a/license-server/lib/__tests__/env-example.test.ts b/license-server/lib/__tests__/env-example.test.ts index 454a690..d0702fa 100644 --- a/license-server/lib/__tests__/env-example.test.ts +++ b/license-server/lib/__tests__/env-example.test.ts @@ -8,9 +8,11 @@ describe('license-server 首装环境示例', () => { expect(envExample).toContain('LICENSE_REQUEST_SIGNING_SECRET='); }); - it('应该包含付费模块镜像站配置,避免验证服务器承担模块本体下载', () => { + it('应该包含授权模块镜像站配置,避免验证服务器承担模块本体下载', () => { expect(envExample).toContain('PREMIUM_MODULE_REGISTRY_BASE='); expect(envExample).toContain('PREMIUM_MODULE_VERSION='); + expect(envExample).toContain('PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE'); + expect(envExample).toContain('MODULE_PULL_TOKEN_SECRET='); }); it('Compose 示例数据库地址应该使用 postgres 服务名', () => { diff --git a/license-server/lib/__tests__/init-project-script.test.ts b/license-server/lib/__tests__/init-project-script.test.ts new file mode 100644 index 0000000..8a10de5 --- /dev/null +++ b/license-server/lib/__tests__/init-project-script.test.ts @@ -0,0 +1,15 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +describe('init-project script', () => { + const source = fs.readFileSync(path.join(__dirname, '..', '..', 'scripts', 'init-project.ts'), 'utf8'); + + it('should create or update the project without printing database password', () => { + expect(source).toContain("getRequiredEnv('POSTGRES_PASSWORD')"); + expect(source).toContain('encryptPassword(config.dbPassword)'); + expect(source).toContain('prisma.project.create'); + expect(source).toContain('prisma.project.update'); + expect(source).not.toContain('console.log(config.dbPassword)'); + expect(source).not.toContain('console.error(config.dbPassword)'); + }); +}); diff --git a/license-server/lib/__tests__/module-grants.test.ts b/license-server/lib/__tests__/module-grants.test.ts new file mode 100644 index 0000000..4462adc --- /dev/null +++ b/license-server/lib/__tests__/module-grants.test.ts @@ -0,0 +1,134 @@ +type GrantRecord = { + licenseId: string; + moduleId: string; + status: 'active' | 'disabled' | 'revoked'; + image?: string | null; + version?: string | null; + digest?: string | null; + expiresAt?: Date | null; + revokedAt?: Date | null; +}; + +function createFakePrisma(initial: GrantRecord[] = []) { + const grants = new Map(); + for (const grant of initial) { + grants.set(`${grant.licenseId}:${grant.moduleId}`, grant); + } + + return { + grants, + licenseModuleGrant: { + findUnique: jest.fn(async ({ where }) => { + const key = `${where.licenseId_moduleId.licenseId}:${where.licenseId_moduleId.moduleId}`; + return grants.get(key) || null; + }), + findMany: jest.fn(async ({ where, orderBy }) => { + const now = where.OR?.[1]?.expiresAt?.gt; + const result = Array.from(grants.values()).filter((grant) => { + if (grant.licenseId !== where.licenseId) return false; + if (grant.status !== where.status) return false; + if (where.revokedAt === null && grant.revokedAt) return false; + if (!grant.expiresAt) return true; + return now ? grant.expiresAt > now : true; + }); + + if (orderBy?.moduleId === 'asc') { + result.sort((left, right) => left.moduleId.localeCompare(right.moduleId)); + } + + return result; + }), + create: jest.fn(async ({ data }) => { + const record = { ...data, revokedAt: null } as GrantRecord; + grants.set(`${record.licenseId}:${record.moduleId}`, record); + return record; + }), + }, + }; +} + +describe('module-grants', () => { + beforeEach(() => { + process.env.PREMIUM_MODULE_REGISTRY_BASE = 'registry.test/flnxi/premium'; + process.env.PREMIUM_MODULE_VERSION = '1.0.0'; + }); + + afterEach(() => { + delete process.env.PREMIUM_MODULE_REGISTRY_BASE; + delete process.env.PREMIUM_MODULE_VERSION; + }); + + it('应按许可证版本初始化默认模块授权台账', async () => { + const { ensureDefaultModuleGrants } = await import('../module-grants'); + const prisma = createFakePrisma(); + + await ensureDefaultModuleGrants(prisma, { + id: 'license-1', + licenseType: 'STANDARD', + expiresAt: null, + }); + + expect(prisma.licenseModuleGrant.create).toHaveBeenCalledTimes(2); + expect(Array.from(prisma.grants.keys()).sort()).toEqual([ + 'license-1:custom-templates-core', + 'license-1:license-limits-core', + ]); + }); + + it('已禁用的模块授权不应被默认初始化重新启用', async () => { + const { ensureDefaultModuleGrants, getAuthorizedModuleGrant } = await import('../module-grants'); + const prisma = createFakePrisma([ + { + licenseId: 'license-1', + moduleId: 'custom-templates-core', + status: 'disabled', + image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + version: '1.0.0', + digest: null, + expiresAt: null, + revokedAt: null, + }, + ]); + + const license = { id: 'license-1', licenseType: 'STANDARD' as const, expiresAt: null }; + await ensureDefaultModuleGrants(prisma, license); + + expect(prisma.grants.get('license-1:custom-templates-core')?.status).toBe('disabled'); + await expect(getAuthorizedModuleGrant(prisma, license, 'custom-templates-core')).resolves.toBeNull(); + }); + + it('被吊销或过期的模块不应出现在授权清单里', async () => { + const { listAuthorizedModuleGrants } = await import('../module-grants'); + const prisma = createFakePrisma([ + { + licenseId: 'license-1', + moduleId: 'license-limits-core', + status: 'active', + expiresAt: null, + revokedAt: null, + }, + { + licenseId: 'license-1', + moduleId: 'custom-templates-core', + status: 'active', + expiresAt: null, + revokedAt: new Date('2026-05-01T00:00:00.000Z'), + }, + { + licenseId: 'license-1', + moduleId: 'intranet-penetration-core', + status: 'active', + expiresAt: new Date('2026-05-30T00:00:00.000Z'), + revokedAt: null, + }, + ]); + + const grants = await listAuthorizedModuleGrants( + prisma, + { id: 'license-1', licenseType: 'PRO', expiresAt: null }, + new Date('2026-06-02T00:00:00.000Z') + ); + + expect(grants.map((grant) => grant.moduleId)).toEqual(['license-limits-core']); + }); +}); diff --git a/license-server/lib/__tests__/module-pull-token.test.ts b/license-server/lib/__tests__/module-pull-token.test.ts new file mode 100644 index 0000000..3d7c05e --- /dev/null +++ b/license-server/lib/__tests__/module-pull-token.test.ts @@ -0,0 +1,80 @@ +describe('module-pull-token', () => { + beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-02T00:00:00.000Z')); + process.env.MODULE_PULL_TOKEN_SECRET = 'm'.repeat(32); + }); + + afterEach(() => { + jest.useRealTimers(); + delete process.env.MODULE_PULL_TOKEN_SECRET; + }); + + it('应生成 15 分钟有效且绑定模块仓库的短期拉包凭证', async () => { + const { createModulePullCredential, verifyModulePullToken } = await import('../module-pull-token'); + + const { credential, payload } = createModulePullCredential({ + projectCode: 'flnxi', + licenseId: 'license-1', + activationId: 'activation-1', + instanceId: 'instance-1', + moduleId: 'intranet-penetration-core', + registryHost: 'registry.test', + repository: 'flnxi/premium/intranet-penetration-core', + tag: '1.0.0', + digest: 'sha256:abc', + }); + + expect(credential.username).toBe('flnxi-module'); + expect(credential.expiresAt).toBe('2026-06-02T00:15:00.000Z'); + expect(payload.repository).toBe('flnxi/premium/intranet-penetration-core'); + expect(verifyModulePullToken(credential.pullToken)).toEqual( + expect.objectContaining({ + projectCode: 'flnxi', + moduleId: 'intranet-penetration-core', + repository: 'flnxi/premium/intranet-penetration-core', + digest: 'sha256:abc', + }) + ); + }); + + it('篡改 token 内容后应拒绝验证', async () => { + const { createModulePullCredential, verifyModulePullToken } = await import('../module-pull-token'); + const { credential } = createModulePullCredential({ + projectCode: 'flnxi', + licenseId: 'license-1', + activationId: 'activation-1', + instanceId: 'instance-1', + moduleId: 'intranet-penetration-core', + registryHost: 'registry.test', + repository: 'flnxi/premium/intranet-penetration-core', + tag: '1.0.0', + digest: null, + }); + + const [payload, signature] = credential.pullToken.split('.'); + const decoded = JSON.parse(Buffer.from(payload + '==', 'base64url').toString('utf8')); + decoded.moduleId = 'custom-templates-core'; + const tamperedPayload = Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url'); + + expect(verifyModulePullToken(`${tamperedPayload}.${signature}`)).toBeNull(); + }); + + it('过期 token 应拒绝验证', async () => { + const { createModulePullCredential, verifyModulePullToken } = await import('../module-pull-token'); + const { credential } = createModulePullCredential({ + projectCode: 'flnxi', + licenseId: 'license-1', + activationId: 'activation-1', + instanceId: 'instance-1', + moduleId: 'intranet-penetration-core', + registryHost: 'registry.test', + repository: 'flnxi/premium/intranet-penetration-core', + tag: '1.0.0', + digest: null, + }); + + expect(verifyModulePullToken(credential.pullToken, new Date('2026-06-02T00:15:01.000Z'))).toBeNull(); + }); +}); diff --git a/license-server/lib/__tests__/premium-module-manifest.test.ts b/license-server/lib/__tests__/premium-module-manifest.test.ts index 7aa2ceb..8afc476 100644 --- a/license-server/lib/__tests__/premium-module-manifest.test.ts +++ b/license-server/lib/__tests__/premium-module-manifest.test.ts @@ -24,23 +24,38 @@ describe('premium-module-manifest', () => { 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.map((module) => module.id)).toEqual([ + 'license-limits-core', + 'custom-templates-core', + ]); expect(result.manifest.modules[0].artifact).toEqual({ type: 'registry-image', - image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + image: 'registry.test/flnxi/premium/license-limits-core:1.0.0', digest: null, version: '1.0.0', }); + expect(result.manifest.modules[0].limits).toEqual({ maxUsers: -1, maxMaterials: -1 }); expect(result.manifest.modules[0].sizeBytes).toBeGreaterThan(0); expect(result.manifest.modules[0].sha256).toMatch(/^[a-f0-9]{64}$/); expect(result.signature).toContain('signed:'); }); - it('应为专业版生成全部付费模块清单', async () => { + it('应为免费版生成限额授权模块清单', async () => { + const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); + const result = buildSignedPremiumModuleManifest('FREE', new Date('2026-06-05T00:00:00.000Z'), 'instance-1'); + + expect(result.manifest.licenseType).toBe('FREE'); + expect(result.manifest.modules.map((module) => module.id)).toEqual(['license-limits-core']); + expect(result.manifest.modules[0].minLicenseType).toBe('FREE'); + expect(result.manifest.modules[0].limits).toEqual({ maxUsers: 10, maxMaterials: 100 }); + }); + + it('应为专业版生成全部授权模块清单', async () => { const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z'), 'instance-1'); expect(result.manifest.modules.map((module) => module.id)).toEqual([ + 'license-limits-core', 'custom-templates-core', 'intranet-penetration-core', ]); @@ -64,7 +79,7 @@ describe('premium-module-manifest', () => { expect(entitlement.entitlementBase64).not.toContain('intranetPenetration'); }); - it('未配置付费模块镜像站时应拒绝生成清单', async () => { + it('未配置授权模块镜像站时应拒绝生成清单', async () => { delete process.env.PREMIUM_MODULE_REGISTRY_BASE; const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); 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 7fa2ef3..c71e741 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,62 @@ const downloadRoutePath = path.resolve( 'route.ts' ); -describe('付费模块授权凭证入口覆盖', () => { - it('授权凭证接口必须保留请求签名、硬件指纹和凭证生成校验', () => { +const pullTokenRoutePath = path.resolve( + __dirname, + '..', + '..', + 'app', + 'api', + 'license', + '[projectCode]', + 'premium-modules', + '[moduleId]', + 'pull-token', + 'route.ts' +); + +const registryAuthRoutePath = path.resolve( + __dirname, + '..', + '..', + 'app', + 'api', + 'registry', + 'auth', + 'route.ts' +); + +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('getAuthorizedModuleGrant'); expect(source).toContain('buildPremiumModuleEntitlement'); + expect(source).toContain('当前许可证不允许使用该授权模块'); + }); + + it('短期拉包凭证接口必须绑定模块授权、镜像仓库和审计记录', () => { + const source = fs.readFileSync(pullTokenRoutePath, 'utf8'); + + expect(source).toContain("verifyLicenseRequestSignature('premium-module-pull-token'"); + expect(source).toContain('getAuthorizedModuleGrant'); + expect(source).toContain('parseRegistryImage'); + expect(source).toContain('createModulePullCredential'); + expect(source).toContain('modulePullTokenAudit.create'); + }); + + it('Registry 鉴权入口必须校验短期 token、仓库路径和模块授权状态', () => { + const source = fs.readFileSync(registryAuthRoutePath, 'utf8'); + + expect(source).toContain('verifyModulePullToken'); + expect(source).toContain('requestedRepository !== payload.repository'); + expect(source).toContain('isManifestDigestRequest'); + expect(source).toContain('requestedReference !== payload.tag'); + expect(source).toContain('modulePullTokenAudit.findFirst'); + expect(source).toContain('activation.findFirst'); + expect(source).toContain('getAuthorizedModuleGrant'); + expect(source).toContain('lastValidatedAt'); }); }); diff --git a/license-server/lib/__tests__/registry-auth-route.test.ts b/license-server/lib/__tests__/registry-auth-route.test.ts new file mode 100644 index 0000000..c9ed0c2 --- /dev/null +++ b/license-server/lib/__tests__/registry-auth-route.test.ts @@ -0,0 +1,283 @@ +import { NextRequest } from 'next/server'; + +jest.mock('@/lib/project-auth', () => ({ + getPublicProjectDb: jest.fn(), +})); + +jest.mock('@/lib/module-pull-token', () => ({ + MODULE_PULL_TOKEN_USERNAME: 'flnxi-module', + hashModulePullToken: jest.fn(() => 'token-hash'), + verifyModulePullToken: jest.fn(), +})); + +jest.mock('@/lib/module-grants', () => ({ + getAuthorizedModuleGrant: jest.fn(), +})); + +import { getPublicProjectDb } from '@/lib/project-auth'; +import { verifyModulePullToken } from '@/lib/module-pull-token'; +import { getAuthorizedModuleGrant } from '@/lib/module-grants'; + +const mockGetPublicProjectDb = getPublicProjectDb as jest.MockedFunction; +const mockVerifyModulePullToken = verifyModulePullToken as jest.MockedFunction; +const mockGetAuthorizedModuleGrant = getAuthorizedModuleGrant as jest.MockedFunction; +let projectPrisma: any; + +function createBasicTokenHeader(token: string): string { + return `Basic ${Buffer.from(`flnxi-module:${token}`, 'utf8').toString('base64')}`; +} + +describe('registry auth route', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockVerifyModulePullToken.mockReturnValue({ + tokenType: 'flnxi-module-pull', + projectCode: 'flnxi', + licenseId: 'license-1', + activationId: 'activation-1', + instanceId: 'instance-1', + moduleId: 'intranet-penetration-core', + registryHost: 'registry.test', + repository: 'flnxi/premium/intranet-penetration-core', + tag: '1.0.0', + digest: 'sha256:abc', + iat: 1780272000, + exp: 1780272900, + jti: 'jti-1', + }); + mockGetAuthorizedModuleGrant.mockResolvedValue({ + moduleId: 'intranet-penetration-core', + artifact: { + type: 'registry-image', + image: 'registry.test/flnxi/premium/intranet-penetration-core:1.0.0', + version: '1.0.0', + digest: 'sha256:abc', + }, + }); + projectPrisma = { + license: { + findUnique: jest.fn().mockResolvedValue({ + id: 'license-1', + licenseType: 'PRO', + status: 'active', + expiresAt: null, + }), + }, + activation: { + findFirst: jest.fn().mockResolvedValue({ + id: 'activation-1', + licenseId: 'license-1', + instanceId: 'instance-1', + isActive: true, + license: { + id: 'license-1', + licenseType: 'PRO', + status: 'active', + expiresAt: null, + }, + }), + }, + licenseModuleGrant: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + }, + modulePullTokenAudit: { + findFirst: jest.fn().mockResolvedValue({ + id: 'audit-1', + licenseId: 'license-1', + activationId: 'activation-1', + moduleId: 'intranet-penetration-core', + repository: 'flnxi/premium/intranet-penetration-core', + digest: 'sha256:abc', + expiresAt: new Date('2026-06-02T00:15:00.000Z'), + lastValidatedAt: null, + }), + updateMany: jest.fn(), + }, + }; + mockGetPublicProjectDb.mockResolvedValue({ + success: true, + prisma: projectPrisma, + } as any); + }); + + it('应允许 token 声明仓库和 digest 完全匹配的拉取请求', async () => { + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:abc&referenceType=manifests&reference=sha256:abc', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual( + expect.objectContaining({ + success: true, + moduleId: 'intranet-penetration-core', + repository: 'flnxi/premium/intranet-penetration-core', + }) + ); + expect(mockGetPublicProjectDb).toHaveBeenCalledWith('flnxi'); + }); + + it('manifest tag 和 token 不匹配时应拒绝', async () => { + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&referenceType=manifests&reference=2.0.0', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(403); + expect(mockGetPublicProjectDb).not.toHaveBeenCalled(); + }); + + it('manifest tag 和 token 匹配时应允许继续校验项目状态', async () => { + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&referenceType=manifests&reference=1.0.0', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(200); + expect(mockGetPublicProjectDb).toHaveBeenCalledWith('flnxi'); + }); + + it('已登记 token 的 blob 层文件请求不应拿镜像 digest 误判', async () => { + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:layer&referenceType=blobs', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(200); + }); + + it('同一 token 已验证主清单后,应允许 Docker 继续请求平台子清单 digest', async () => { + projectPrisma.modulePullTokenAudit.findFirst.mockResolvedValue({ + id: 'audit-1', + licenseId: 'license-1', + activationId: 'activation-1', + moduleId: 'intranet-penetration-core', + repository: 'flnxi/premium/intranet-penetration-core', + digest: 'sha256:abc', + expiresAt: new Date('2026-06-02T00:15:00.000Z'), + lastValidatedAt: new Date(), + }); + + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:child-manifest&referenceType=manifests&reference=sha256:child-manifest', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(200); + }); + + it('未先验证主清单时,陌生 manifest digest 仍应拒绝', async () => { + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:other&referenceType=manifests&reference=sha256:other', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(403); + }); + + it('token 未登记或已过期时应拒绝拉取', async () => { + projectPrisma.modulePullTokenAudit.findFirst.mockResolvedValue(null); + + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(403); + }); + + it('激活记录失效时应拒绝拉取', async () => { + projectPrisma.activation.findFirst.mockResolvedValue(null); + + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + 'X-Registry-Host': 'registry.test', + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(403); + }); + + it('请求仓库和 token 不匹配时应拒绝,不进入项目数据库', async () => { + const { GET } = await import('../../app/api/registry/auth/route'); + const request = new NextRequest( + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/custom-templates-core:pull', + { + headers: { + Authorization: createBasicTokenHeader('pull-token'), + }, + } + ); + + const response = await GET(request); + + expect(response.status).toBe(403); + expect(mockGetPublicProjectDb).not.toHaveBeenCalled(); + }); +}); diff --git a/license-server/lib/abuse-detection.ts b/license-server/lib/abuse-detection.ts index 7096165..548d549 100644 --- a/license-server/lib/abuse-detection.ts +++ b/license-server/lib/abuse-detection.ts @@ -31,6 +31,20 @@ export function calculateFingerprintMatchScore( return (score / total) * 100; } +export function isSamePersistentInstance( + stored: HardwareFingerprint, + current: HardwareFingerprint +): boolean { + return Boolean( + stored.instanceId && + current.instanceId && + stored.instanceId === current.instanceId && + stored.machineId && + current.machineId && + stored.machineId === current.machineId + ); +} + function toHardwareFingerprint(value: unknown): HardwareFingerprint | null { if (!value || typeof value !== 'object') return null; @@ -183,7 +197,7 @@ export async function enforceDeviceAbusePolicy( const distinctInstances = new Set(normalizedHeartbeats.map((item) => item.instanceId)); distinctInstances.add(params.instanceId); - if (params.licenseType === 'FREE' && (distinctFingerprints.size > 1 || distinctInstances.size > 1)) { + if (params.licenseType === 'FREE' && distinctInstances.size > 1) { await createPendingAlertOnce(prisma, params.licenseId, 'free_multi_device_blocked', { instanceIds: Array.from(distinctInstances), fingerprintCount: distinctFingerprints.size, diff --git a/license-server/lib/module-grants.ts b/license-server/lib/module-grants.ts new file mode 100644 index 0000000..a0ef616 --- /dev/null +++ b/license-server/lib/module-grants.ts @@ -0,0 +1,120 @@ +import type { LicenseType } from './types'; +import { + getDefaultModuleGrantSpecs, + getModuleLimits, + getPremiumModuleArtifact, + type PremiumModuleArtifact, +} from './premium-module-catalog'; +import type { AuthorizedPremiumModuleGrant } from './premium-module-manifest'; + +type ProjectPrismaLike = { + licenseModuleGrant: { + findUnique: (args: any) => Promise; + findMany: (args: any) => Promise; + create: (args: any) => Promise; + }; +}; + +export interface LicenseForModuleGrant { + id: string; + licenseType: LicenseType; + expiresAt: Date | null; +} + +function buildArtifactFromGrant(grant: any): PremiumModuleArtifact { + const defaultArtifact = getPremiumModuleArtifact(grant.moduleId); + return { + type: 'registry-image', + image: grant.image || defaultArtifact.image, + version: grant.version || defaultArtifact.version, + digest: grant.digest ?? defaultArtifact.digest ?? null, + }; +} + +export async function ensureDefaultModuleGrants( + prisma: ProjectPrismaLike, + license: LicenseForModuleGrant +): Promise { + const defaultGrants = getDefaultModuleGrantSpecs(license.licenseType); + + for (const grant of defaultGrants) { + const existing = await prisma.licenseModuleGrant.findUnique({ + where: { + licenseId_moduleId: { + licenseId: license.id, + moduleId: grant.moduleId, + }, + }, + }); + + if (existing) continue; + + await prisma.licenseModuleGrant.create({ + data: { + licenseId: license.id, + moduleId: grant.moduleId, + status: 'active', + image: grant.artifact.image, + version: grant.artifact.version, + digest: grant.artifact.digest || null, + expiresAt: license.expiresAt, + }, + }); + } +} + +export async function listAuthorizedModuleGrants( + prisma: ProjectPrismaLike, + license: LicenseForModuleGrant, + now: Date = new Date() +): Promise { + await ensureDefaultModuleGrants(prisma, license); + + const grants = await prisma.licenseModuleGrant.findMany({ + where: { + licenseId: license.id, + status: 'active', + revokedAt: null, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: now } }, + ], + }, + orderBy: { moduleId: 'asc' }, + }); + + return grants.map((grant) => ({ + moduleId: grant.moduleId, + artifact: buildArtifactFromGrant(grant), + limits: getModuleLimits(grant.moduleId, license.licenseType), + })); +} + +export async function getAuthorizedModuleGrant( + prisma: ProjectPrismaLike, + license: LicenseForModuleGrant, + moduleId: string, + now: Date = new Date() +): Promise { + await ensureDefaultModuleGrants(prisma, license); + + const grant = await prisma.licenseModuleGrant.findUnique({ + where: { + licenseId_moduleId: { + licenseId: license.id, + moduleId, + }, + }, + }); + + if (!grant) return null; + if (grant.status !== 'active') return null; + if (grant.revokedAt) return null; + if (grant.expiresAt && grant.expiresAt <= now) return null; + + return { + moduleId: grant.moduleId, + artifact: buildArtifactFromGrant(grant), + limits: getModuleLimits(grant.moduleId, license.licenseType), + }; +} diff --git a/license-server/lib/module-pull-token.ts b/license-server/lib/module-pull-token.ts new file mode 100644 index 0000000..f2591c4 --- /dev/null +++ b/license-server/lib/module-pull-token.ts @@ -0,0 +1,155 @@ +import * as crypto from 'crypto'; + +export const MODULE_PULL_TOKEN_TTL_MS = 15 * 60 * 1000; +export const MODULE_PULL_TOKEN_USERNAME = 'flnxi-module'; + +export interface ModulePullTokenPayload { + tokenType: 'flnxi-module-pull'; + projectCode: string; + licenseId: string; + activationId: string; + instanceId: string; + moduleId: string; + registryHost: string; + repository: string; + tag: string; + digest: string | null; + iat: number; + exp: number; + jti: string; +} + +export interface ModulePullCredential { + registryHost: string; + repository: string; + tag: string; + digest: string | null; + username: string; + pullToken: string; + expiresAt: string; +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, 'utf8') + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +function base64UrlDecode(value: string): string { + const padded = value + '='.repeat((4 - (value.length % 4)) % 4); + return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); +} + +function getPullTokenSecret(): string { + const secret = + process.env.MODULE_PULL_TOKEN_SECRET || + process.env.LICENSE_REQUEST_SIGNING_SECRET || + process.env.AES_SECRET_KEY || + ''; + + if (!secret || secret.length < 32) { + throw new Error('MODULE_PULL_TOKEN_SECRET 或 LICENSE_REQUEST_SIGNING_SECRET 未配置或长度不足'); + } + + return secret; +} + +function signPayload(encodedPayload: string): string { + return crypto + .createHmac('sha256', getPullTokenSecret()) + .update(encodedPayload) + .digest('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +function isTimingSafeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +export function hashModulePullToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +export function createModulePullCredential(params: { + projectCode: string; + licenseId: string; + activationId: string; + instanceId: string; + moduleId: string; + registryHost: string; + repository: string; + tag: string; + digest: string | null; + now?: Date; +}): { credential: ModulePullCredential; payload: ModulePullTokenPayload } { + const nowMs = params.now?.getTime() ?? Date.now(); + const expiresMs = nowMs + MODULE_PULL_TOKEN_TTL_MS; + const payload: ModulePullTokenPayload = { + tokenType: 'flnxi-module-pull', + projectCode: params.projectCode, + licenseId: params.licenseId, + activationId: params.activationId, + instanceId: params.instanceId, + moduleId: params.moduleId, + registryHost: params.registryHost, + repository: params.repository, + tag: params.tag, + digest: params.digest, + iat: Math.floor(nowMs / 1000), + exp: Math.floor(expiresMs / 1000), + jti: crypto.randomBytes(16).toString('hex'), + }; + + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signature = signPayload(encodedPayload); + const pullToken = `${encodedPayload}.${signature}`; + + return { + payload, + credential: { + registryHost: params.registryHost, + repository: params.repository, + tag: params.tag, + digest: params.digest, + username: MODULE_PULL_TOKEN_USERNAME, + pullToken, + expiresAt: new Date(expiresMs).toISOString(), + }, + }; +} + +export function verifyModulePullToken(token: string, now: Date = new Date()): ModulePullTokenPayload | null { + const [encodedPayload, signature] = token.split('.'); + if (!encodedPayload || !signature) return null; + + const expectedSignature = signPayload(encodedPayload); + if (!isTimingSafeEqual(signature, expectedSignature)) return null; + + try { + const payload = JSON.parse(base64UrlDecode(encodedPayload)) as ModulePullTokenPayload; + if (payload.tokenType !== 'flnxi-module-pull') return null; + if ( + !payload.projectCode || + !payload.licenseId || + !payload.activationId || + !payload.instanceId || + !payload.moduleId || + !payload.registryHost || + !payload.repository || + !payload.tag + ) { + return null; + } + if (payload.exp * 1000 <= now.getTime()) return null; + + return payload; + } catch { + return null; + } +} diff --git a/license-server/lib/premium-module-catalog.ts b/license-server/lib/premium-module-catalog.ts new file mode 100644 index 0000000..6f821ec --- /dev/null +++ b/license-server/lib/premium-module-catalog.ts @@ -0,0 +1,143 @@ +import { LICENSE_CONFIGS, type LicenseType } from './types'; + +export interface PremiumModuleArtifact { + type: 'registry-image'; + image: string; + digest?: string | null; + version: string; +} + +export interface PremiumModuleDefinition { + id: string; + minLicenseType: LicenseType; + capabilities: string[]; +} + +export interface PremiumModuleGrantSpec { + moduleId: string; + artifact: PremiumModuleArtifact; + limits?: { + maxUsers: number; + maxMaterials: number; + }; +} + +export interface ParsedRegistryImage { + registryHost: string; + repository: string; + tag: string; +} + +export const MODULE_DEFINITIONS: Record = { + 'license-limits-core': { + id: 'license-limits-core', + minLicenseType: 'FREE', + capabilities: ['licenseLimits'], + }, + 'custom-templates-core': { + id: 'custom-templates-core', + minLicenseType: 'STANDARD', + capabilities: ['customTemplates'], + }, + 'intranet-penetration-core': { + id: 'intranet-penetration-core', + minLicenseType: 'PRO', + capabilities: ['intranetPenetration'], + }, +}; + +export const MODULE_IDS_BY_LICENSE: Record = { + FREE: ['license-limits-core'], + STANDARD: ['license-limits-core', 'custom-templates-core'], + PRO: ['license-limits-core', 'custom-templates-core', 'intranet-penetration-core'], +}; + +const LICENSE_TYPE_RANK: Record = { + FREE: 0, + STANDARD: 1, + PRO: 2, +}; + +export function getModuleDefinition(moduleId: string): PremiumModuleDefinition | null { + return MODULE_DEFINITIONS[moduleId] || null; +} + +export function canLicenseUseModule( + licenseType: LicenseType, + module: PremiumModuleDefinition +): boolean { + return LICENSE_TYPE_RANK[licenseType] >= LICENSE_TYPE_RANK[module.minLicenseType]; +} + +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'; +} + +export 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, + }; +} + +export function getModuleLimits( + moduleId: string, + licenseType: LicenseType +): PremiumModuleGrantSpec['limits'] | undefined { + return moduleId === 'license-limits-core' + ? LICENSE_CONFIGS[licenseType].limits + : undefined; +} + +export function getDefaultModuleGrantSpecs(licenseType: LicenseType): PremiumModuleGrantSpec[] { + return MODULE_IDS_BY_LICENSE[licenseType].map((moduleId) => ({ + moduleId, + artifact: getPremiumModuleArtifact(moduleId), + limits: getModuleLimits(moduleId, licenseType), + })); +} + +export function parseRegistryImage(image: string): ParsedRegistryImage { + const [registryHost, ...pathParts] = image.split('/'); + if (!registryHost || pathParts.length === 0) { + throw new Error(`模块镜像地址无效:${image}`); + } + + const imagePath = pathParts.join('/'); + const lastSlashIndex = imagePath.lastIndexOf('/'); + const tagSeparatorIndex = imagePath.lastIndexOf(':'); + if (tagSeparatorIndex <= lastSlashIndex) { + throw new Error(`模块镜像地址缺少 tag:${image}`); + } + + return { + registryHost, + repository: imagePath.slice(0, tagSeparatorIndex), + tag: imagePath.slice(tagSeparatorIndex + 1), + }; +} diff --git a/license-server/lib/premium-module-manifest.ts b/license-server/lib/premium-module-manifest.ts index 0494227..20c95ee 100644 --- a/license-server/lib/premium-module-manifest.ts +++ b/license-server/lib/premium-module-manifest.ts @@ -1,28 +1,33 @@ import * as crypto from 'crypto'; import { signData } from './crypto'; import type { LicenseType } from './types'; +import { + canLicenseUseModule, + getDefaultModuleGrantSpecs, + getModuleDefinition, + getModuleLimits, + getPremiumModuleArtifact, + type PremiumModuleArtifact, +} from './premium-module-catalog'; export const PREMIUM_MODULE_MANIFEST_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: LicenseType; instanceId: string; modules: Array<{ id: string; - minLicenseType: Exclude; + minLicenseType: LicenseType; artifact: PremiumModuleArtifact; sizeBytes: number; sha256: string; entitlementVersion: number; + limits?: { + maxUsers: number; + maxMaterials: number; + }; }>; issuedAt: string; expiresAt: string; @@ -42,30 +47,15 @@ export interface PremiumModuleEntitlement { artifact: PremiumModuleArtifact; } -interface PremiumModuleDefinition { - id: string; - minLicenseType: Exclude; - capabilities: string[]; +export interface AuthorizedPremiumModuleGrant { + moduleId: string; + artifact: PremiumModuleArtifact; + limits?: { + maxUsers: number; + maxMaterials: number; + }; } -const MODULE_DEFINITIONS: Record = { - 'custom-templates-core': { - id: 'custom-templates-core', - minLicenseType: 'STANDARD', - capabilities: ['customTemplates'], - }, - 'intranet-penetration-core': { - id: 'intranet-penetration-core', - minLicenseType: 'PRO', - capabilities: ['intranetPenetration'], - }, -}; - -const MODULE_IDS_BY_LICENSE: Record, string[]> = { - STANDARD: ['custom-templates-core'], - PRO: ['custom-templates-core', 'intranet-penetration-core'], -}; - function getEntitlementSecret(): string { const secret = process.env.PREMIUM_MODULE_PACKAGE_SECRET || process.env.AES_SECRET_KEY || ''; if (!secret || secret.length < 32) { @@ -75,50 +65,6 @@ function getEntitlementSecret(): string { return secret; } -function canLicenseUseModule( - licenseType: Exclude, - module: PremiumModuleDefinition -): boolean { - if (module.minLicenseType === 'STANDARD') return licenseType === 'STANDARD' || licenseType === 'PRO'; - return licenseType === 'PRO'; -} - -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 = getEntitlementSecret(); @@ -146,17 +92,23 @@ function encryptModuleEntitlement(payload: Record): string { export function buildPremiumModuleEntitlement(params: { moduleId: string; - licenseType: Exclude; + licenseType: LicenseType; instanceId: string; leaseExpiresAt: Date; + artifact?: PremiumModuleArtifact; + limits?: { + maxUsers: number; + maxMaterials: number; + }; + authorizedByGrant?: boolean; }): PremiumModuleEntitlement { - const module = MODULE_DEFINITIONS[params.moduleId]; - if (!module || !canLicenseUseModule(params.licenseType, module)) { - throw new Error('付费模块不存在或许可证类型不允许使用'); + const module = getModuleDefinition(params.moduleId); + if (!module || (!params.authorizedByGrant && !canLicenseUseModule(params.licenseType, module))) { + throw new Error('授权模块不存在或许可证类型不允许使用'); } const expiresAt = params.leaseExpiresAt.toISOString(); - const artifact = getPremiumModuleArtifact(module.id); + const artifact = params.artifact || getPremiumModuleArtifact(module.id); const entitlementBase64 = encryptModuleEntitlement({ payloadType: 'flnxi-premium-module-entitlement', entitlementVersion: PREMIUM_MODULE_ENTITLEMENT_VERSION, @@ -164,6 +116,7 @@ export function buildPremiumModuleEntitlement(params: { licenseType: params.licenseType, minLicenseType: module.minLicenseType, capabilities: module.capabilities, + limits: params.limits ?? getModuleLimits(module.id, params.licenseType), instanceId: params.instanceId, artifact, expiresAt, @@ -180,17 +133,26 @@ export function buildPremiumModuleEntitlement(params: { } function buildModuleManifestItems(params: { - licenseType: Exclude; + licenseType: LicenseType; instanceId: string; leaseExpiresAt: Date; + grants?: AuthorizedPremiumModuleGrant[]; }): PremiumModuleManifest['modules'] { - return MODULE_IDS_BY_LICENSE[params.licenseType].map((moduleId) => { - const module = MODULE_DEFINITIONS[moduleId]; + const grants = params.grants || getDefaultModuleGrantSpecs(params.licenseType); + return grants.map((grant) => { + const module = getModuleDefinition(grant.moduleId); + if (!module) { + throw new Error(`授权模块不存在:${grant.moduleId}`); + } + const entitlement = buildPremiumModuleEntitlement({ - moduleId, + moduleId: grant.moduleId, licenseType: params.licenseType, instanceId: params.instanceId, leaseExpiresAt: params.leaseExpiresAt, + artifact: grant.artifact, + limits: grant.limits, + authorizedByGrant: true, }); return { @@ -200,20 +162,22 @@ function buildModuleManifestItems(params: { sizeBytes: entitlement.sizeBytes, sha256: entitlement.sha256, entitlementVersion: PREMIUM_MODULE_ENTITLEMENT_VERSION, + limits: grant.limits, }; }); } -export function buildSignedPremiumModuleManifest( - licenseType: Exclude, +export function buildSignedPremiumModuleManifestForGrants( + licenseType: LicenseType, leaseExpiresAt: Date, - instanceId: string + instanceId: string, + grants?: AuthorizedPremiumModuleGrant[] ): SignedPremiumModuleManifest { const manifest: PremiumModuleManifest = { version: PREMIUM_MODULE_MANIFEST_VERSION, licenseType, instanceId, - modules: buildModuleManifestItems({ licenseType, instanceId, leaseExpiresAt }), + modules: buildModuleManifestItems({ licenseType, instanceId, leaseExpiresAt, grants }), issuedAt: new Date().toISOString(), expiresAt: leaseExpiresAt.toISOString(), }; @@ -224,6 +188,14 @@ export function buildSignedPremiumModuleManifest( }; } +export function buildSignedPremiumModuleManifest( + licenseType: LicenseType, + leaseExpiresAt: Date, + instanceId: string +): SignedPremiumModuleManifest { + return buildSignedPremiumModuleManifestForGrants(licenseType, leaseExpiresAt, instanceId); +} + // 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/package.json b/license-server/package.json index d62662a..5479faf 100644 --- a/license-server/package.json +++ b/license-server/package.json @@ -16,7 +16,9 @@ "db:push:project": "prisma db push --schema=prisma/schema.prisma", "db:migrate": "prisma migrate dev --schema=prisma/schema.prisma", "db:studio": "prisma studio", - "generate-keys": "ts-node scripts/generate-keys.ts" + "generate-keys": "ts-node --project tsconfig.scripts.json --transpile-only scripts/generate-keys.ts", + "init-admin": "ts-node --project tsconfig.scripts.json --transpile-only scripts/init-admin.ts", + "init-project": "ts-node --project tsconfig.scripts.json --transpile-only scripts/init-project.ts" }, "dependencies": { "next": "14.2.5", diff --git a/license-server/prisma/schema.prisma b/license-server/prisma/schema.prisma index 94f5b9a..1dba85e 100644 --- a/license-server/prisma/schema.prisma +++ b/license-server/prisma/schema.prisma @@ -37,6 +37,12 @@ enum LicenseStatus { expired } +enum ModuleGrantStatus { + active + disabled + revoked +} + // 许可证表 model License { id String @id @default(uuid()) @@ -58,10 +64,53 @@ model License { heartbeatLogs HeartbeatLog[] alerts Alert[] order Order? + moduleGrants LicenseModuleGrant[] + pullTokenAudits ModulePullTokenAudit[] @@map("licenses") } +model LicenseModuleGrant { + id String @id @default(uuid()) + licenseId String @map("license_id") + moduleId String @map("module_id") + status ModuleGrantStatus @default(active) + image String? + version String? + digest String? + expiresAt DateTime? @map("expires_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + license License @relation(fields: [licenseId], references: [id], onDelete: Cascade) + + @@unique([licenseId, moduleId]) + @@index([licenseId, status]) + @@index([moduleId, status]) + @@map("license_module_grants") +} + +model ModulePullTokenAudit { + id String @id @default(uuid()) + licenseId String @map("license_id") + activationId String? @map("activation_id") + moduleId String @map("module_id") + repository String + digest String? + tokenHash String @map("token_hash") + expiresAt DateTime @map("expires_at") + lastValidatedAt DateTime? @map("last_validated_at") + createdAt DateTime @default(now()) @map("created_at") + + license License @relation(fields: [licenseId], references: [id], onDelete: Cascade) + + @@index([licenseId, moduleId]) + @@index([tokenHash]) + @@index([expiresAt]) + @@map("module_pull_token_audits") +} + // 激活记录表 model Activation { id String @id @default(uuid()) diff --git a/license-server/scripts/generate-keys.ts b/license-server/scripts/generate-keys.ts index 94459c9..69c8467 100644 --- a/license-server/scripts/generate-keys.ts +++ b/license-server/scripts/generate-keys.ts @@ -24,6 +24,9 @@ const jwtSecret = crypto.randomBytes(32).toString('hex'); // 生成许可证请求签名密钥 const licenseRequestSigningSecret = crypto.randomBytes(32).toString('hex'); +// 生成付费模块短期拉包 token 签名密钥 +const modulePullTokenSecret = crypto.randomBytes(32).toString('hex'); + // 主库加密密钥 const mainDbEncryptionKey = crypto.randomBytes(32).toString('hex'); @@ -76,7 +79,10 @@ JWT_SECRET="${jwtSecret}" # 许可证请求签名密钥(素材系统需使用同一值) LICENSE_REQUEST_SIGNING_SECRET="${licenseRequestSigningSecret}" -# 付费模块分发:模块核心本体在镜像站,验证服务器只发授权清单和凭证 +# 付费模块短期拉包 token 签名密钥 +MODULE_PULL_TOKEN_SECRET="${modulePullTokenSecret}" + +# 授权模块分发:模块核心本体在镜像站,验证服务器只发授权清单和凭证 PREMIUM_MODULE_REGISTRY_BASE="${premiumModuleRegistryBase}" PREMIUM_MODULE_VERSION="${premiumModuleVersion}" @@ -101,4 +107,5 @@ 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,正式部署前请确认不是示例地址。'); +console.log('📋 付费模块短期拉包 token 密钥已写入 license-server/.env 的 MODULE_PULL_TOKEN_SECRET,仅许可证服务使用。'); +console.log('📋 授权模块镜像站已写入 license-server/.env 的 PREMIUM_MODULE_REGISTRY_BASE,正式部署前请确认不是示例地址。'); diff --git a/license-server/scripts/init-project.ts b/license-server/scripts/init-project.ts new file mode 100644 index 0000000..e1da597 --- /dev/null +++ b/license-server/scripts/init-project.ts @@ -0,0 +1,115 @@ +import { PrismaClient } from '.prisma/main-client'; +import * as crypto from 'crypto'; + +const prisma = new PrismaClient(); + +function getEnv(name: string, fallback = ''): string { + return (process.env[name] || fallback).trim(); +} + +function getRequiredEnv(name: string): string { + const value = getEnv(name); + if (!value) { + throw new Error(`缺少环境变量:${name}`); + } + return value; +} + +function getEncryptionKey(): string { + const key = getRequiredEnv('MAIN_DB_ENCRYPTION_KEY'); + if (key.length < 32) { + throw new Error('MAIN_DB_ENCRYPTION_KEY 长度不足 32 字节'); + } + return key.substring(0, 32); +} + +function encryptPassword(password: string): string { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + + let encrypted = cipher.update(password, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return `${iv.toString('hex')}:${encrypted}`; +} + +function getProjectCode(): string { + const fromArg = process.argv[2]?.trim(); + return fromArg || getEnv('LICENSE_PROJECT_CODE', 'flnxi-sucai'); +} + +function getProjectConfig() { + const postgresUser = getRequiredEnv('POSTGRES_USER'); + const postgresPassword = getRequiredEnv('POSTGRES_PASSWORD'); + const postgresDb = getRequiredEnv('POSTGRES_DB'); + + return { + code: getProjectCode(), + name: getEnv('LICENSE_PROJECT_NAME', 'FLNXI素材库'), + description: getEnv('LICENSE_PROJECT_DESCRIPTION', 'FLNXI素材库商业授权项目'), + dbHost: getEnv('PROJECT_DB_HOST', 'postgres'), + dbPort: Number.parseInt(getEnv('PROJECT_DB_PORT', '5432'), 10), + dbName: getEnv('PROJECT_DB_NAME', postgresDb), + dbUser: getEnv('PROJECT_DB_USER', postgresUser), + dbPassword: getEnv('PROJECT_DB_PASSWORD', postgresPassword), + dbSchema: getEnv('PROJECT_DB_SCHEMA', 'public'), + }; +} + +async function main() { + const config = getProjectConfig(); + if (!Number.isFinite(config.dbPort) || config.dbPort <= 0) { + throw new Error('PROJECT_DB_PORT 必须是有效端口号'); + } + + const encryptedPassword = encryptPassword(config.dbPassword); + const existing = await prisma.project.findUnique({ + where: { code: config.code }, + }); + + if (existing) { + await prisma.project.update({ + where: { code: config.code }, + data: { + name: config.name, + description: config.description, + dbHost: config.dbHost, + dbPort: config.dbPort, + dbName: config.dbName, + dbUser: config.dbUser, + dbPassword: encryptedPassword, + dbSchema: config.dbSchema, + isActive: true, + }, + }); + console.log(`项目 "${config.code}" 已存在,连接配置已更新。`); + return; + } + + await prisma.project.create({ + data: { + code: config.code, + name: config.name, + description: config.description, + dbHost: config.dbHost, + dbPort: config.dbPort, + dbName: config.dbName, + dbUser: config.dbUser, + dbPassword: encryptedPassword, + dbSchema: config.dbSchema, + isActive: true, + }, + }); + + console.log(`项目 "${config.code}" 创建成功。`); +} + +main() + .catch((error) => { + console.error('初始化项目失败:', error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/license-server/tsconfig.scripts.json b/license-server/tsconfig.scripts.json new file mode 100644 index 0000000..e69ad75 --- /dev/null +++ b/license-server/tsconfig.scripts.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "isolatedModules": false + } +} diff --git a/premium-modules/custom-templates-core/Dockerfile b/premium-modules/custom-templates-core/Dockerfile new file mode 100644 index 0000000..df1a0c5 --- /dev/null +++ b/premium-modules/custom-templates-core/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.m.daocloud.io/node:20-alpine + +WORKDIR /app + +COPY package.json server.mjs ./ + +ENV NODE_ENV=production +ENV PORT=8787 + +EXPOSE 8787 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "server.mjs"] diff --git a/premium-modules/custom-templates-core/README.md b/premium-modules/custom-templates-core/README.md new file mode 100644 index 0000000..16cf8d4 --- /dev/null +++ b/premium-modules/custom-templates-core/README.md @@ -0,0 +1,16 @@ +# custom-templates-core + +这是 FLNXI 标准版及以上的自定义模板授权模块骨架。 + +当前状态: + +- 提供 `/health` 健康检查。 +- 提供 `/status` 能力状态。 +- 第一版先承接“验证系统授权、短期拉包、Agent 运行状态”这条商业化链路;模板业务处理仍在主应用中执行,避免一次性拆散模板、上传、筛选和迁移链路。 + +发布后需要把固定镜像和 digest 写入 `license-server`: + +```env +PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE=registry.example.com:5000/flnxi/premium/custom-templates-core:1.0.0 +PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST=sha256:... +``` diff --git a/premium-modules/custom-templates-core/VERSION.txt b/premium-modules/custom-templates-core/VERSION.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/premium-modules/custom-templates-core/VERSION.txt @@ -0,0 +1 @@ +1.0.0 diff --git a/premium-modules/custom-templates-core/package.json b/premium-modules/custom-templates-core/package.json new file mode 100644 index 0000000..52aaaf7 --- /dev/null +++ b/premium-modules/custom-templates-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "flnxi-custom-templates-core", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/premium-modules/custom-templates-core/server.mjs b/premium-modules/custom-templates-core/server.mjs new file mode 100644 index 0000000..d945024 --- /dev/null +++ b/premium-modules/custom-templates-core/server.mjs @@ -0,0 +1,38 @@ +import http from 'node:http'; + +const moduleId = 'custom-templates-core'; +const port = Number.parseInt(process.env.PORT || '8787', 10); + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { moduleId, status: 'healthy' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/status') { + sendJson(res, 200, { + moduleId, + status: 'authorization_contract_ready', + capabilities: ['customTemplates'], + note: '模板业务仍由主应用执行;此模块先承接授权和拉包合同', + }); + return; + } + + sendJson(res, 404, { moduleId, error: '接口不存在' }); +}); + +server.listen(port, '0.0.0.0', () => { + console.log(`${moduleId} listening on ${port}`); +}); diff --git a/premium-modules/intranet-penetration-core/Dockerfile b/premium-modules/intranet-penetration-core/Dockerfile new file mode 100644 index 0000000..df1a0c5 --- /dev/null +++ b/premium-modules/intranet-penetration-core/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.m.daocloud.io/node:20-alpine + +WORKDIR /app + +COPY package.json server.mjs ./ + +ENV NODE_ENV=production +ENV PORT=8787 + +EXPOSE 8787 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "server.mjs"] diff --git a/premium-modules/intranet-penetration-core/README.md b/premium-modules/intranet-penetration-core/README.md new file mode 100644 index 0000000..0d0638a --- /dev/null +++ b/premium-modules/intranet-penetration-core/README.md @@ -0,0 +1,18 @@ +# intranet-penetration-core + +这是 FLNXI 第一批拆分的付费模块镜像骨架,用来承接专业版内网穿透模块的运行合同。 + +当前状态: + +- 提供 `/health` 健康检查。 +- 提供 `/status`、`/start`、`/stop` 三个接口合同。 +- 未配置真实穿透提供商时明确返回 `provider_not_configured`,不返回占位成功。 + +发布后需要把固定镜像和 digest 写入 `license-server`: + +```env +PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE=registry.example.com:5000/flnxi/premium/intranet-penetration-core:1.0.0 +PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST=sha256:... +``` + +后续接入真实 provider 时,只在这个模块里扩展,不把核心实现重新塞回主应用基础镜像。 diff --git a/premium-modules/intranet-penetration-core/VERSION.txt b/premium-modules/intranet-penetration-core/VERSION.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/premium-modules/intranet-penetration-core/VERSION.txt @@ -0,0 +1 @@ +1.0.0 diff --git a/premium-modules/intranet-penetration-core/package.json b/premium-modules/intranet-penetration-core/package.json new file mode 100644 index 0000000..26ffcc7 --- /dev/null +++ b/premium-modules/intranet-penetration-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "flnxi-intranet-penetration-core", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/premium-modules/intranet-penetration-core/server.mjs b/premium-modules/intranet-penetration-core/server.mjs new file mode 100644 index 0000000..17652f7 --- /dev/null +++ b/premium-modules/intranet-penetration-core/server.mjs @@ -0,0 +1,65 @@ +import http from 'node:http'; + +const moduleId = 'intranet-penetration-core'; +const port = Number.parseInt(process.env.PORT || '8787', 10); +const provider = (process.env.TUNNEL_PROVIDER || '').trim(); + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function providerNotConfigured(res, action) { + sendJson(res, 501, { + moduleId, + status: 'provider_not_configured', + action, + error: '内网穿透模块已加载,但尚未配置真实穿透提供商', + }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { moduleId, status: 'healthy' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/status') { + if (!provider) { + providerNotConfigured(res, 'status'); + return; + } + providerNotConfigured(res, 'status'); + return; + } + + if (req.method === 'POST' && url.pathname === '/start') { + if (!provider) { + providerNotConfigured(res, 'start'); + return; + } + providerNotConfigured(res, 'start'); + return; + } + + if (req.method === 'POST' && url.pathname === '/stop') { + if (!provider) { + providerNotConfigured(res, 'stop'); + return; + } + providerNotConfigured(res, 'stop'); + return; + } + + sendJson(res, 404, { moduleId, error: '接口不存在' }); +}); + +server.listen(port, '0.0.0.0', () => { + console.log(`${moduleId} listening on ${port}`); +}); diff --git a/premium-modules/license-limits-core/Dockerfile b/premium-modules/license-limits-core/Dockerfile new file mode 100644 index 0000000..df1a0c5 --- /dev/null +++ b/premium-modules/license-limits-core/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.m.daocloud.io/node:20-alpine + +WORKDIR /app + +COPY package.json server.mjs ./ + +ENV NODE_ENV=production +ENV PORT=8787 + +EXPOSE 8787 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "server.mjs"] diff --git a/premium-modules/license-limits-core/README.md b/premium-modules/license-limits-core/README.md new file mode 100644 index 0000000..cbb6944 --- /dev/null +++ b/premium-modules/license-limits-core/README.md @@ -0,0 +1,16 @@ +# license-limits-core + +这是 FLNXI 授权模块链路中的基础限额模块。所有版本都需要通过验证系统拿到这个模块授权,用来承载“用户数上限、素材数上限”这类商业规则。 + +当前状态: + +- 提供 `/health` 健康检查。 +- 提供 `/status` 能力状态。 +- 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。 + +发布后需要把固定镜像和 digest 写入 `license-server`: + +```env +PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE=registry.example.com:5000/flnxi/premium/license-limits-core:1.0.0 +PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST=sha256:... +``` diff --git a/premium-modules/license-limits-core/VERSION.txt b/premium-modules/license-limits-core/VERSION.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/premium-modules/license-limits-core/VERSION.txt @@ -0,0 +1 @@ +1.0.0 diff --git a/premium-modules/license-limits-core/package.json b/premium-modules/license-limits-core/package.json new file mode 100644 index 0000000..b605067 --- /dev/null +++ b/premium-modules/license-limits-core/package.json @@ -0,0 +1,9 @@ +{ + "name": "flnxi-license-limits-core", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/premium-modules/license-limits-core/server.mjs b/premium-modules/license-limits-core/server.mjs new file mode 100644 index 0000000..866ec05 --- /dev/null +++ b/premium-modules/license-limits-core/server.mjs @@ -0,0 +1,47 @@ +import http from 'node:http'; + +const moduleId = 'license-limits-core'; +const port = Number.parseInt(process.env.PORT || '8787', 10); + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { moduleId, status: 'healthy' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/status') { + sendJson(res, 200, { + moduleId, + status: 'ready', + capabilities: ['licenseLimits'], + note: '具体人数和素材数上限以验证系统下发的实例授权为准', + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/limits') { + sendJson(res, 200, { + moduleId, + status: 'entitlement_required', + error: '此模块只提供限额能力合同,具体限额必须来自已签名的实例授权', + }); + return; + } + + sendJson(res, 404, { moduleId, error: '接口不存在' }); +}); + +server.listen(port, '0.0.0.0', () => { + console.log(`${moduleId} listening on ${port}`); +}); diff --git a/scripts/__tests__/deploy-registry-script.test.ts b/scripts/__tests__/deploy-registry-script.test.ts index 6ff0e5c..f93c480 100644 --- a/scripts/__tests__/deploy-registry-script.test.ts +++ b/scripts/__tests__/deploy-registry-script.test.ts @@ -76,6 +76,32 @@ describe('deploy-registry.ps1', () => { expect(script).toContain('Tag = "1.36"') }) + it('支持发布付费模块镜像并输出验证服务器 digest 配置', () => { + expect(script).toContain('"PublishPremiumModule"') + expect(script).toContain('[string]$PremiumModuleId = "intranet-penetration-core"') + expect(script).toContain('[string]$PremiumModuleRepositoryPrefix = "premium"') + expect(script).toContain('[switch]$AllowMissingPremiumModuleDigest') + expect(script).toContain('function Invoke-PublishPremiumModule') + expect(script).toContain('$moduleRepository = "$($PremiumModuleRepositoryPrefix.Trim(\'/\'))/$moduleId"') + expect(script).toContain('if (-not $SkipPush)') + expect(script).toContain('未能自动读取付费模块镜像 digest') + expect(script).toContain('Get-PushedImageDigest -Image $registryImage') + expect(script).toContain('PREMIUM_MODULE_${envKey}_DIGEST=$digest') + }) + + it('基础镜像构建上下文必须排除付费模块源码', () => { + const dockerignore = fs.readFileSync(path.join(process.cwd(), '.dockerignore'), 'utf8') + + expect(dockerignore).toContain('premium-modules') + expect(dockerignore).toContain('deploy/registry-proxy') + }) + + it('试跑和只构建付费模块时不应强制连接 Registry', () => { + expect(script).toContain('if ($DryRun) {\n return\n }') + expect(script).toContain('已跳过推送付费模块镜像;未推送时无法作为客户拉取源使用') + expect(script).toContain('$digest = if ($SkipPush) { "" } else { Get-PushedImageDigest -Image $registryImage }') + }) + it('远端 shell 变量应以单引号字符串保留,避免被 PowerShell 提前展开', () => { expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" up -d --pull never\')') expect(script).toContain('$lines.Add(\'docker compose -f "$compose_file" ps\')') diff --git a/scripts/__tests__/premium-module-source.test.ts b/scripts/__tests__/premium-module-source.test.ts new file mode 100644 index 0000000..2f7da71 --- /dev/null +++ b/scripts/__tests__/premium-module-source.test.ts @@ -0,0 +1,37 @@ +import fs from 'fs' +import path from 'path' + +describe('premium module source packages', () => { + const moduleIds = [ + 'license-limits-core', + 'custom-templates-core', + 'intranet-penetration-core', + ] + + it('所有授权模块都应可作为独立模块镜像构建', () => { + for (const moduleId of moduleIds) { + const moduleDir = path.join(process.cwd(), 'premium-modules', moduleId) + const dockerfile = fs.readFileSync(path.join(moduleDir, 'Dockerfile'), 'utf8') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + + expect(fs.existsSync(path.join(moduleDir, 'VERSION.txt'))).toBe(true) + expect(fs.existsSync(path.join(moduleDir, 'package.json'))).toBe(true) + expect(dockerfile).toContain('HEALTHCHECK') + expect(dockerfile).toContain('EXPOSE 8787') + expect(server).toContain(`const moduleId = '${moduleId}'`) + expect(server).toContain("url.pathname === '/health'") + expect(server).not.toContain("status: 'success'") + } + }) + + it('内网穿透模块未接真实 provider 时必须明确返回未配置', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'intranet-penetration-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + + expect(server).toContain("status: 'provider_not_configured'") + expect(server).toContain("url.pathname === '/start'") + expect(server).toContain("url.pathname === '/status'") + expect(server).toContain("url.pathname === '/stop'") + expect(server).not.toContain("status: 'success'") + }) +}) diff --git a/scripts/__tests__/registry-proxy-config.test.ts b/scripts/__tests__/registry-proxy-config.test.ts new file mode 100644 index 0000000..f62a6fb --- /dev/null +++ b/scripts/__tests__/registry-proxy-config.test.ts @@ -0,0 +1,42 @@ +import fs from 'fs' +import path from 'path' + +describe('registry premium module proxy config', () => { + const config = fs.readFileSync( + path.join(process.cwd(), 'deploy', 'registry-proxy', 'nginx.module-auth.conf.template'), + 'utf8' + ) + const existingRegistryCompose = fs.readFileSync( + path.join(process.cwd(), 'deploy', 'registry-proxy', 'docker-compose.existing-registry.yml'), + 'utf8' + ) + + it('只对 flnxi/premium 模块拉取做验证服务器鉴权', () => { + expect(config).toContain('^/v2/(?flnxi/premium/[^/]+)') + expect(config).toContain('auth_request /_flnxi_module_auth') + expect(config).toContain('if ($flnxi_is_pull_method = 0)') + expect(config).toContain("return 200 '{}'") + }) + + it('会把短期 token、仓库路径、请求类型和 digest 转发给许可证服务', () => { + expect(config).toContain('proxy_set_header Authorization $http_authorization') + expect(config).toContain('proxy_set_header X-Registry-Host $http_host') + expect(config).toContain('proxy_set_header X-Registry-Repository $module_repository') + expect(config).toContain('proxy_set_header X-Registry-Reference-Type $module_reference_type') + expect(config).toContain('proxy_set_header X-Registry-Reference $module_reference') + expect(config).toContain('proxy_set_header X-Registry-Digest $module_digest_header') + expect(config).toContain('proxy_set_header X-Registry-Scope "repository:$module_repository:pull"') + expect(config).toContain('${LICENSE_SERVER_AUTH_URL}') + expect(config).toContain('proxy_set_header Authorization "${REGISTRY_BACKEND_AUTH}"') + }) + + it('已有镜像站编排只新增代理,不创建第二个 Registry 数据卷', () => { + expect(existingRegistryCompose).toContain('module-registry-proxy:') + expect(existingRegistryCompose).toContain('REGISTRY_BACKEND: ${REGISTRY_BACKEND:-http://flnxi-registry:5000}') + expect(existingRegistryCompose).toContain('REGISTRY_BACKEND_AUTH: ${REGISTRY_BACKEND_AUTH:-}') + expect(existingRegistryCompose).toContain('name: ${REGISTRY_NETWORK_NAME:-flnxi-registry-net}') + expect(existingRegistryCompose).toContain('external: true') + expect(existingRegistryCompose).not.toContain('image: ${REGISTRY_IMAGE:-registry:2}') + expect(existingRegistryCompose).not.toContain('registry_data:') + }) +}) diff --git a/发布管理.ps1 b/发布管理.ps1 index 9ce9df1..92744ac 100644 --- a/发布管理.ps1 +++ b/发布管理.ps1 @@ -831,6 +831,7 @@ function Deploy-LicenseServer-Fresh { # 生成其他密钥 $jwtSecret = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 32 | ForEach-Object {[char]$_}) $aesKey = -join ((48..57) + (65..70) + (97..102) | Get-Random -Count 64 | ForEach-Object {[char]$_}) + $adminPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 18 | ForEach-Object {[char]$_}) # Step 2: 创建 .env 文件 Write-Info "创建环境配置文件..." @@ -854,7 +855,7 @@ JWT_SECRET=$jwtSecret AES_SECRET_KEY=$aesKey # 管理员初始密码 -ADMIN_INITIAL_PASSWORD=admin123 +ADMIN_INITIAL_PASSWORD=$adminPassword "@ $envContent | Out-File -FilePath "license-server/.env" -Encoding UTF8 -NoNewline @@ -890,12 +891,13 @@ docker compose exec -T license-server node -e " const { PrismaClient } = require('@prisma/client'); const bcrypt = require('bcryptjs'); const prisma = new PrismaClient(); +const initialPassword = '$adminPassword'; async function init() { const existing = await prisma.admin.findFirst(); if (!existing) { - const hash = await bcrypt.hash('admin123', 10); + const hash = await bcrypt.hash(initialPassword, 10); await prisma.admin.create({ data: { username: 'admin', password: hash, role: 'SUPER_ADMIN' } }); - console.log('管理员账号已创建: admin / admin123'); + console.log('管理员账号已创建: admin / ' + initialPassword); } else { console.log('管理员账号已存在'); } @@ -914,7 +916,7 @@ init().catch(console.error).finally(() => prisma.\$disconnect()); Write-Color "═══════════════════════════════════════════════════════════" "Cyan" Write-Info "访问地址: http://$($Config.Server):3001" Write-Info "管理后台: http://$($Config.Server):3001/admin" - Write-Info "默认账号: admin / admin123" + Write-Info "初始账号: admin / $adminPassword" Write-Host "" Write-Color "⚠️ 重要:请将以下公钥更新到素材系统" "Yellow" Write-Color " 文件: lib/license/crypto-service.ts" "Yellow"