diff --git a/.env.example b/.env.example index a553b75..b83e490 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,12 @@ 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= +# Paid module service URLs. Keep the defaults only when module containers run on the same Docker network. +LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787 +CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787 +INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787 +# Agent -> app callback token for POST /api/license/premium-modules/runtime-status. +MODULE_RUNTIME_STATUS_TOKEN=replace-with-strong-module-runtime-status-token # Versioned deployment images # Customer delivery must pin the full application image tag. Do not rely on latest. diff --git a/README.md b/README.md index dad636a..785f965 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ LOCAL_ENCRYPTION_KEY= 授权模块采用“镜像站放模块核心本体,验证服务器发授权清单、实例凭证和短期拉包 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` 第一版先承接授权和拉包合同,模板业务仍由主应用执行,后续再逐步拆细。 +当前已提供第一批模块源码:`premium-modules/license-limits-core`、`premium-modules/custom-templates-core`、`premium-modules/intranet-penetration-core`。模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule` 发布,发布后把脚本输出的模块镜像和 digest 写入 `license-server`。主应用现在不仅检查许可证和模块授权凭证,还会要求对应模块处于 `running`、模块 `/health` 正常,并通过模块 `/authorize` 放行后才开放人数/素材限额、自定义模板和内网穿透能力。`custom-templates-core` 当前承接自定义模板授权执行层,模板 CRUD、迁移和数据处理代码后续还要继续迁到模块内部。 ### 初始化 Prisma diff --git a/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts b/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts new file mode 100644 index 0000000..60df109 --- /dev/null +++ b/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts @@ -0,0 +1,99 @@ +import { NextRequest } from 'next/server'; +import { POST } from '../route'; +import { updatePremiumModuleRuntimeStatus } from '@/lib/license/premium-module-service'; + +jest.mock('@/lib/license/premium-module-service', () => ({ + PREMIUM_MODULE_IDS: { + LICENSE_LIMITS_CORE: 'license-limits-core', + CUSTOM_TEMPLATES_CORE: 'custom-templates-core', + INTRANET_PENETRATION_CORE: 'intranet-penetration-core', + }, + updatePremiumModuleRuntimeStatus: jest.fn(), +})); + +const mockUpdatePremiumModuleRuntimeStatus = + updatePremiumModuleRuntimeStatus as jest.MockedFunction; + +function createRequest(body: Record, token = 'a'.repeat(32)) { + return new NextRequest('http://localhost/api/license/premium-modules/runtime-status', { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/license/premium-modules/runtime-status', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.MODULE_RUNTIME_STATUS_TOKEN = 'a'.repeat(32); + delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN; + }); + + afterEach(() => { + delete process.env.MODULE_RUNTIME_STATUS_TOKEN; + delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN; + }); + + it('需要回写 token,避免公开接口被任意写入模块运行态', async () => { + const response = await POST(createRequest({ + moduleId: 'custom-templates-core', + image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + status: 'running', + lastSyncAt: new Date().toISOString(), + }, 'wrong-token')); + + expect(response.status).toBe(401); + expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled(); + }); + + it('token 未配置时默认关闭回写入口', async () => { + delete process.env.MODULE_RUNTIME_STATUS_TOKEN; + + const response = await POST(createRequest({ + moduleId: 'custom-templates-core', + image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + status: 'running', + lastSyncAt: new Date().toISOString(), + })); + + expect(response.status).toBe(503); + expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled(); + }); + + it('接受 Agent 上报的合法模块运行状态并持久化', async () => { + const lastSyncAt = new Date().toISOString(); + const response = await POST(createRequest({ + moduleId: 'custom-templates-core', + image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + digest: 'sha256:' + 'a'.repeat(64), + status: 'running', + lastSyncAt, + })); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ success: true, updated: 1 }); + expect(mockUpdatePremiumModuleRuntimeStatus).toHaveBeenCalledWith({ + moduleId: 'custom-templates-core', + image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + digest: 'sha256:' + 'a'.repeat(64), + status: 'running', + lastSyncAt, + lastError: undefined, + }); + }); + + it('拒绝未知模块或非法状态', async () => { + const response = await POST(createRequest({ + moduleId: 'unknown-module', + image: 'registry.test/flnxi/premium/unknown:1.0.0', + status: 'running', + lastSyncAt: new Date().toISOString(), + })); + + expect(response.status).toBe(400); + expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/license/premium-modules/runtime-status/route.ts b/app/api/license/premium-modules/runtime-status/route.ts new file mode 100644 index 0000000..acd9204 --- /dev/null +++ b/app/api/license/premium-modules/runtime-status/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + PREMIUM_MODULE_IDS, + updatePremiumModuleRuntimeStatus, + type PremiumModuleRuntimeStatus, + type PremiumModuleRuntimeStatusValue, +} from '@/lib/license/premium-module-service'; + +const validModuleIds = new Set(Object.values(PREMIUM_MODULE_IDS)); +const validStatuses = new Set([ + 'missing', + 'pulling', + 'running', + 'unhealthy', + 'expired', + 'revoked', +]); + +function getRuntimeStatusToken(): string { + return ( + process.env.MODULE_RUNTIME_STATUS_TOKEN || + process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN || + '' + ).trim(); +} + +function readBearerToken(request: NextRequest): string { + const authorization = request.headers.get('authorization') || ''; + if (authorization.toLowerCase().startsWith('bearer ')) { + return authorization.slice('bearer '.length).trim(); + } + return (request.headers.get('x-module-status-token') || '').trim(); +} + +function normalizeRuntimeStatus(input: unknown): PremiumModuleRuntimeStatus | null { + if (!input || typeof input !== 'object') return null; + + const raw = input as Record; + const moduleId = typeof raw.moduleId === 'string' ? raw.moduleId.trim() : ''; + const image = typeof raw.image === 'string' ? raw.image.trim() : ''; + const digest = typeof raw.digest === 'string' && raw.digest.trim() ? raw.digest.trim() : null; + const status = typeof raw.status === 'string' ? raw.status.trim() : ''; + const lastSyncAt = typeof raw.lastSyncAt === 'string' ? raw.lastSyncAt.trim() : ''; + const lastError = typeof raw.lastError === 'string' && raw.lastError.trim() + ? raw.lastError.trim() + : undefined; + + if (!validModuleIds.has(moduleId)) return null; + if (!image) return null; + if (!validStatuses.has(status as PremiumModuleRuntimeStatusValue)) return null; + + const lastSyncAtTime = Date.parse(lastSyncAt); + if (!lastSyncAt || Number.isNaN(lastSyncAtTime)) return null; + + return { + moduleId, + image, + digest, + status: status as PremiumModuleRuntimeStatusValue, + lastSyncAt: new Date(lastSyncAtTime).toISOString(), + lastError, + }; +} + +export async function POST(request: NextRequest) { + const configuredToken = getRuntimeStatusToken(); + if (!configuredToken || configuredToken.length < 32) { + return NextResponse.json( + { success: false, error: '模块运行状态回写 token 未配置' }, + { status: 503 } + ); + } + + if (readBearerToken(request) !== configuredToken) { + return NextResponse.json( + { success: false, error: '模块运行状态回写未授权' }, + { status: 401 } + ); + } + + const body = await request.json().catch(() => null); + const rawStatuses: unknown[] = Array.isArray(body?.statuses) ? body.statuses : [body]; + const statuses = rawStatuses.map(normalizeRuntimeStatus); + + if (statuses.length === 0 || statuses.some((status: PremiumModuleRuntimeStatus | null) => !status)) { + return NextResponse.json( + { success: false, error: '模块运行状态参数无效' }, + { status: 400 } + ); + } + + for (const status of statuses as PremiumModuleRuntimeStatus[]) { + await updatePremiumModuleRuntimeStatus(status); + } + + return NextResponse.json({ + success: true, + updated: statuses.length, + }); +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bef686a..fa2d969 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -135,6 +135,10 @@ services: - LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required} - LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:?LICENSE_PUBLIC_KEY is required} - LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:?LOCAL_ENCRYPTION_KEY is required} + - LICENSE_LIMITS_MODULE_URL=${LICENSE_LIMITS_MODULE_URL:-http://flnxi-module-license-limits-core:8787} + - CUSTOM_TEMPLATES_MODULE_URL=${CUSTOM_TEMPLATES_MODULE_URL:-http://flnxi-module-custom-templates-core:8787} + - INTRANET_PENETRATION_MODULE_URL=${INTRANET_PENETRATION_MODULE_URL:-http://flnxi-module-intranet-penetration-core:8787} + - MODULE_RUNTIME_STATUS_TOKEN=${MODULE_RUNTIME_STATUS_TOKEN:-} # 初始管理员账号(供 /api/system/init 使用) - INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi} - INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-} diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 7041be1..785caef 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -74,7 +74,10 @@ 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 +LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787 +CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787 +INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787 +MODULE_RUNTIME_STATUS_TOKEN= MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 @@ -91,8 +94,9 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 - `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。 - `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。 -- `INTRANET_PENETRATION_MODULE_URL` 是可选项;只有客户购买并运行 `intranet-penetration-core` 模块后才填写。未填写时,内网穿透接口仍会明确返回“真实提供商未接入”。 -- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限也通过 `license-limits-core` 模块下发。 +- `LICENSE_LIMITS_MODULE_URL`、`CUSTOM_TEMPLATES_MODULE_URL`、`INTRANET_PENETRATION_MODULE_URL` 指向同一 Docker 网络里的模块容器。模块没运行或健康检查失败时,对应人数/素材限额、自定义模板、内网穿透能力都会被服务端拒绝。 +- `MODULE_RUNTIME_STATUS_TOKEN` 用于 Agent 回写模块运行状态;Agent 侧 `FLNXI_MODULE_STATUS_CALLBACK_TOKEN` 必须和它一致,回调地址通常是 `http://<主应用地址>/api/license/premium-modules/runtime-status`。 +- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`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。 diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index 4f0b947..e178194 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -153,9 +153,9 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... 镜像站推荐由 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、启动模块容器、健康检查和失败回滚。 +主应用不会直接挂 Docker Socket,也不会自己启动模块容器。当前 Go Agent 已提供 `POST /api/modules/sync` 和 `GET /api/modules/status` 入口,用来接收主应用准备好的短期拉取凭证,负责拉取模块镜像、校验 digest、启动模块容器、健康检查和失败回滚。模块运行状态变化后,Agent 可通过 `POST /api/license/premium-modules/runtime-status` 回写主应用;主应用会把回写状态持久化,并把运行态纳入付费能力判断。 -第一批模块骨架已经放在: +第一批模块源码已经放在: ```text premium-modules/license-limits-core @@ -163,7 +163,7 @@ premium-modules/custom-templates-core premium-modules/intranet-penetration-core ``` -`license-limits-core` 承接免费版和付费版的人数/素材数上限合同;`custom-templates-core` 第一版先承接授权和拉包合同,模板业务仍由主应用执行;主应用内网穿透入口通过许可证检查后,会优先调用 `INTRANET_PENETRATION_MODULE_URL` 指向的模块服务。未配置该变量时,仍保持“真实提供商未接入”的拒绝返回,不会伪装启动成功。 +`license-limits-core` 承接免费版和付费版的人数/素材数上限门禁;`custom-templates-core` 承接自定义模板授权执行层;`intranet-penetration-core` 承接内网穿透模块门禁。主应用现在会同时检查模块授权凭证、Agent 回写的 `running` 状态、模块 `/health` 和模块 `/authorize`,任一环节不可用都会拒绝对应付费能力。自定义模板的 CRUD、迁移和复杂数据处理后续还需要继续从主应用迁到 `custom-templates-core` 内部。 ## 6. 后台管理入口 diff --git a/flnxi-agent-go/README.md b/flnxi-agent-go/README.md index 382c472..6f4e06f 100644 --- a/flnxi-agent-go/README.md +++ b/flnxi-agent-go/README.md @@ -65,7 +65,9 @@ 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` | +| `FLNXI_MODULE_NETWORK` | 付费模块容器默认加入的 Docker 网络 | `flnxi-network` | +| `FLNXI_MODULE_STATUS_CALLBACK_URL` | 付费模块运行状态变化时回调主应用的接口地址;未配置时不回调 | 未设置 | +| `FLNXI_MODULE_STATUS_CALLBACK_TOKEN` | 付费模块状态回调使用的 Bearer token;需和回调地址同时配置,不会写入日志 | 未设置 | | `LICENSE_SERVER_URL` | 主应用许可证服务地址,安装素材系统时会写入主应用容器 | 未设置 | | `LICENSE_PROJECT_CODE` | 主应用许可证项目编码,安装素材系统时会写入主应用容器 | 未设置 | | `LICENSE_REQUEST_SIGNING_SECRET` | 主应用请求许可证服务的签名密钥,至少 32 字符 | 未设置 | @@ -105,7 +107,22 @@ GET /api/modules/status } ``` -Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少固定版本 tag 的请求。模块容器默认加入 `flnxi-app-network`,也可以用 `FLNXI_MODULE_NETWORK` 或请求里的 `network` 覆盖;默认容器名为 `flnxi-module-{moduleId}`。如健康检查失败,会尝试恢复旧模块容器。 +Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少固定版本 tag 的请求。模块容器默认加入 `flnxi-network`,也可以用 `FLNXI_MODULE_NETWORK` 或请求里的 `network` 覆盖;默认容器名为 `flnxi-module-{moduleId}`。如健康检查失败,会尝试恢复旧模块容器。 + +模块状态变化时,Agent 会先更新本地 `GET /api/modules/status` 可见的状态。如果同时配置了 `FLNXI_MODULE_STATUS_CALLBACK_URL` 和 `FLNXI_MODULE_STATUS_CALLBACK_TOKEN`,Agent 会向主应用回调地址发送 `POST` 请求,并在请求头里带上 `Authorization: Bearer `。通过 Agent 安装主应用时,如果没有显式配置回调地址,安装流程会为本次安装生成回写 token,并按主应用访问地址生成默认回调地址。请求体为: + +```json +{ + "moduleId": "intranet-penetration-core", + "image": "registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0", + "digest": "sha256:...", + "status": "running", + "lastSyncAt": "2026-06-02T00:20:00Z", + "lastError": "" +} +``` + +未同时配置回调地址和 token 时,Agent 保持现有行为,只在本地保存模块状态,不主动通知主应用。回调失败不会改变模块本地运行状态,主应用仍可通过 `GET /api/modules/status` 主动查询。 安装素材系统前,至少要给 Agent 容器配置: diff --git a/flnxi-agent-go/__tests__/install-script.test.ts b/flnxi-agent-go/__tests__/install-script.test.ts index 8e6c42a..bc0702a 100644 --- a/flnxi-agent-go/__tests__/install-script.test.ts +++ b/flnxi-agent-go/__tests__/install-script.test.ts @@ -21,6 +21,12 @@ describe('flnxi-agent-go/install.sh', () => { expect(script).toContain('未完整配置许可证服务参数') }) + it('会把付费模块运行网络和状态回写配置透传给 Agent 容器', () => { + expect(script).toContain('FLNXI_MODULE_NETWORK=${FLNXI_MODULE_NETWORK:-flnxi-network}') + expect(script).toContain('FLNXI_MODULE_STATUS_CALLBACK_URL=${FLNXI_MODULE_STATUS_CALLBACK_URL:-}') + expect(script).toContain('FLNXI_MODULE_STATUS_CALLBACK_TOKEN=${FLNXI_MODULE_STATUS_CALLBACK_TOKEN:-}') + }) + it('文案明确这是 Agent 安装脚本,不冒充客户最终首装入口', () => { expect(script).toContain('FLNXI Agent 安装脚本') expect(script).toContain('Agent 安装脚本') diff --git a/flnxi-agent-go/install.sh b/flnxi-agent-go/install.sh index acf0cd5..b0dff45 100644 --- a/flnxi-agent-go/install.sh +++ b/flnxi-agent-go/install.sh @@ -233,6 +233,9 @@ run_agent() { -e "LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:-}" -e "LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:-}" -e "LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:-}" + -e "FLNXI_MODULE_NETWORK=${FLNXI_MODULE_NETWORK:-flnxi-network}" + -e "FLNXI_MODULE_STATUS_CALLBACK_URL=${FLNXI_MODULE_STATUS_CALLBACK_URL:-}" + -e "FLNXI_MODULE_STATUS_CALLBACK_TOKEN=${FLNXI_MODULE_STATUS_CALLBACK_TOKEN:-}" ) if [ -z "${FLNXI_MATERIAL_IMAGE:-${APP_IMAGE:-}}" ]; then diff --git a/flnxi-agent-go/internal/api/handlers.go b/flnxi-agent-go/internal/api/handlers.go index aba95b4..8e59058 100644 --- a/flnxi-agent-go/internal/api/handlers.go +++ b/flnxi-agent-go/internal/api/handlers.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -161,7 +162,12 @@ func resolveInstallLicenseConfig(req InstallRequest, secrets InstallSecrets) (In return config, nil } +func resolveModuleStatusToken(secrets InstallSecrets) string { + return firstNonEmpty(os.Getenv(envModuleStatusCallbackToken), secrets.ModuleStatusToken) +} + func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig InstallLicenseConfig, localIP string, webPort int) []string { + moduleStatusToken := resolveModuleStatusToken(secrets) return []string{ "LICENSE_KEY=" + req.LicenseKey, "LICENSE_SERVER_URL=" + licenseConfig.ServerURL, @@ -185,6 +191,10 @@ func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig In "NEXTAUTH_SECRET=" + secrets.NextAuthSecret, "NEXTAUTH_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort), "APP_PUBLIC_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort), + "LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787", + "CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787", + "INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787", + "MODULE_RUNTIME_STATUS_TOKEN=" + moduleStatusToken, } } @@ -256,6 +266,8 @@ const ( envLicenseRequestSigningSecret = "LICENSE_REQUEST_SIGNING_SECRET" envLicensePublicKey = "LICENSE_PUBLIC_KEY" envLocalEncryptionKey = "LOCAL_ENCRYPTION_KEY" + envModuleStatusCallbackURL = "FLNXI_MODULE_STATUS_CALLBACK_URL" + envModuleStatusCallbackToken = "FLNXI_MODULE_STATUS_CALLBACK_TOKEN" ) var ( @@ -267,6 +279,9 @@ var ( moduleStatusesMutex sync.RWMutex moduleSyncLocks = map[string]bool{} moduleSyncLocksMutex sync.Mutex + moduleCallbackURL string + moduleCallbackToken string + moduleCallbackMutex sync.RWMutex ) type InstallStatus struct { @@ -545,6 +560,11 @@ func runInstall(dc installDockerClient, req InstallRequest) { // 获取宿主机 IP 用于配置公开访问地址(从用户请求中获取,确保是宿主机 IP 而非容器 IP) localIP := getHostIP() + moduleStatusCallbackURL := firstNonEmpty( + os.Getenv(envModuleStatusCallbackURL), + "http://"+localIP+":"+fmt.Sprintf("%d", webPort)+"/api/license/premium-modules/runtime-status", + ) + setModuleStatusCallbackConfig(moduleStatusCallbackURL, resolveModuleStatusToken(secrets)) // 启动素材系统 setStep("启动服务", 90, "启动素材系统...") @@ -651,6 +671,56 @@ func setModuleStatus(status ModuleRuntimeStatus) { moduleStatusesMutex.Lock() moduleStatuses[status.ModuleID] = status moduleStatusesMutex.Unlock() + + go reportModuleStatus(status) +} + +func setModuleStatusCallbackConfig(callbackURL string, callbackToken string) { + moduleCallbackMutex.Lock() + moduleCallbackURL = strings.TrimSpace(callbackURL) + moduleCallbackToken = strings.TrimSpace(callbackToken) + moduleCallbackMutex.Unlock() +} + +func getModuleStatusCallbackConfig() (string, string) { + callbackURL := strings.TrimSpace(os.Getenv(envModuleStatusCallbackURL)) + callbackToken := strings.TrimSpace(os.Getenv(envModuleStatusCallbackToken)) + if callbackURL != "" && callbackToken != "" { + return callbackURL, callbackToken + } + + moduleCallbackMutex.RLock() + defer moduleCallbackMutex.RUnlock() + return moduleCallbackURL, moduleCallbackToken +} + +func reportModuleStatus(status ModuleRuntimeStatus) { + callbackURL, callbackToken := getModuleStatusCallbackConfig() + if callbackURL == "" || callbackToken == "" { + return + } + + body, err := json.Marshal(status) + if err != nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+callbackToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) } func tryBeginModuleSync(syncKey string) bool { @@ -826,7 +896,7 @@ func repoDigestsContain(repoDigests []string, digest string) bool { } func moduleContainerConfig(req ModuleSyncRequest) (*container.Config, *container.HostConfig) { - network := firstNonEmpty(req.Network, os.Getenv("FLNXI_MODULE_NETWORK"), "flnxi-app-network") + network := firstNonEmpty(req.Network, os.Getenv("FLNXI_MODULE_NETWORK"), "flnxi-network") return &container.Config{ Image: req.Image, Env: moduleEnv(req), diff --git a/flnxi-agent-go/internal/api/handlers_test.go b/flnxi-agent-go/internal/api/handlers_test.go index 55a2413..b506b9c 100644 --- a/flnxi-agent-go/internal/api/handlers_test.go +++ b/flnxi-agent-go/internal/api/handlers_test.go @@ -2,7 +2,10 @@ package api import ( "context" + "encoding/json" "errors" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -115,6 +118,7 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) { S3Bucket: "materials", NextAuthSecret: "next-secret", LocalEncryptionKey: strings.Repeat("b", 32), + ModuleStatusToken: strings.Repeat("c", 32), } licenseConfig := InstallLicenseConfig{ ServerURL: "https://license.example.test", @@ -135,6 +139,10 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) { "S3_PUBLIC_DOMAIN", "NEXTAUTH_URL", "APP_PUBLIC_URL", + "LICENSE_LIMITS_MODULE_URL", + "CUSTOM_TEMPLATES_MODULE_URL", + "INTRANET_PENETRATION_MODULE_URL", + "MODULE_RUNTIME_STATUS_TOKEN", } { if env[want] == "" { t.Fatalf("materialAppEnv() missing %s in %v", want, env) @@ -143,6 +151,9 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) { if env["APP_PUBLIC_URL"] != "http://192.168.1.20:4320" { t.Fatalf("APP_PUBLIC_URL = %q, want host URL", env["APP_PUBLIC_URL"]) } + if env["MODULE_RUNTIME_STATUS_TOKEN"] != strings.Repeat("c", 32) { + t.Fatalf("MODULE_RUNTIME_STATUS_TOKEN not propagated to app") + } } func TestRunInstallStopsBeforePullWhenLicenseConfigMissing(t *testing.T) { @@ -363,6 +374,116 @@ func TestRunModuleSyncRestoresOldContainerAfterHealthFailure(t *testing.T) { } } +func TestSetModuleStatusSkipsCallbackWhenConfigMissing(t *testing.T) { + resetModuleStatusesForTest() + setModuleStatusCallbackConfig("", "") + t.Setenv(envModuleStatusCallbackURL, "") + t.Setenv(envModuleStatusCallbackToken, "callback-token") + + called := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called <- struct{}{} + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + setModuleStatus(moduleStatus(validModuleSyncRequest(), "running", nil)) + + select { + case <-called: + t.Fatal("module status callback should not be sent without callback URL") + case <-time.After(100 * time.Millisecond): + } +} + +func TestSetModuleStatusReportsCallbackWhenConfigured(t *testing.T) { + resetModuleStatusesForTest() + setModuleStatusCallbackConfig("", "") + + received := make(chan struct { + auth string + status ModuleRuntimeStatus + }, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var status ModuleRuntimeStatus + if err := json.NewDecoder(r.Body).Decode(&status); err != nil { + t.Errorf("decode callback body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + received <- struct { + auth string + status ModuleRuntimeStatus + }{ + auth: r.Header.Get("Authorization"), + status: status, + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv(envModuleStatusCallbackURL, server.URL) + t.Setenv(envModuleStatusCallbackToken, "callback-token") + + req := validModuleSyncRequest() + setModuleStatus(moduleStatus(req, "unhealthy", errors.New("not healthy"))) + + select { + case got := <-received: + if got.auth != "Bearer callback-token" { + t.Fatalf("Authorization = %q, want bearer callback token", got.auth) + } + if got.status.ModuleID != req.ModuleID { + t.Fatalf("moduleId = %q, want %q", got.status.ModuleID, req.ModuleID) + } + if got.status.Image != req.Image { + t.Fatalf("image = %q, want %q", got.status.Image, req.Image) + } + if got.status.Digest != req.Digest { + t.Fatalf("digest = %q, want %q", got.status.Digest, req.Digest) + } + if got.status.Status != "unhealthy" { + t.Fatalf("status = %q, want unhealthy", got.status.Status) + } + if got.status.LastSyncAt == "" { + t.Fatal("lastSyncAt should be set") + } + if !strings.Contains(got.status.LastError, "not healthy") { + t.Fatalf("lastError = %q, want health error", got.status.LastError) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for module status callback") + } +} + +func TestSetModuleStatusReportsInstallFallbackCallback(t *testing.T) { + resetModuleStatusesForTest() + + received := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + t.Setenv(envModuleStatusCallbackURL, "") + t.Setenv(envModuleStatusCallbackToken, "") + setModuleStatusCallbackConfig(server.URL, "fallback-token") + + setModuleStatus(moduleStatus(validModuleSyncRequest(), "running", nil)) + + select { + case got := <-received: + if got != "Bearer fallback-token" { + t.Fatalf("Authorization = %q, want fallback bearer token", got) + } + case <-time.After(time.Second): + t.Fatal("module status callback not received") + } +} + const testModuleDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" func validModuleSyncRequest() ModuleSyncRequest { @@ -402,6 +523,8 @@ func resetModuleStatusesForTest() { moduleSyncLocksMutex.Lock() moduleSyncLocks = map[string]bool{} moduleSyncLocksMutex.Unlock() + + setModuleStatusCallbackConfig("", "") } func envSliceToMap(values []string) map[string]string { diff --git a/flnxi-agent-go/internal/api/install_secrets.go b/flnxi-agent-go/internal/api/install_secrets.go index 0bb06a2..05b1cb2 100644 --- a/flnxi-agent-go/internal/api/install_secrets.go +++ b/flnxi-agent-go/internal/api/install_secrets.go @@ -15,6 +15,7 @@ type InstallSecrets struct { S3Bucket string NextAuthSecret string LocalEncryptionKey string + ModuleStatusToken string } func generateInstallSecrets() (InstallSecrets, error) { @@ -38,6 +39,11 @@ func generateInstallSecrets() (InstallSecrets, error) { return InstallSecrets{}, err } + moduleStatusToken, err := randomHex(32) + if err != nil { + return InstallSecrets{}, err + } + return InstallSecrets{ DbUser: "flnxi", DbPassword: dbPassword, @@ -47,6 +53,7 @@ func generateInstallSecrets() (InstallSecrets, error) { S3Bucket: "flnxi", NextAuthSecret: nextAuthSecret, LocalEncryptionKey: localEncryptionKey, + ModuleStatusToken: moduleStatusToken, }, nil } diff --git a/lib/__tests__/check-limits.test.ts b/lib/__tests__/check-limits.test.ts index 48e93b7..ac68fae 100644 --- a/lib/__tests__/check-limits.test.ts +++ b/lib/__tests__/check-limits.test.ts @@ -20,8 +20,8 @@ jest.mock('../license/license-service', () => ({ })); jest.mock('../license/premium-module-service', () => ({ + authorizePremiumModuleAction: jest.fn(), getAuthorizedUsageLimits: jest.fn(), - hasPremiumModule: jest.fn(), PREMIUM_MODULE_IDS: { LICENSE_LIMITS_CORE: 'license-limits-core', CUSTOM_TEMPLATES_CORE: 'custom-templates-core', @@ -38,18 +38,28 @@ jest.mock('@/lib/prisma', () => ({ import { prisma } from '@/lib/prisma'; import { isFeatureEnabled, getLicenseType } from '../license/license-service'; -import { getAuthorizedUsageLimits, hasPremiumModule } from '../license/premium-module-service'; +import { + authorizePremiumModuleAction, + getAuthorizedUsageLimits, +} from '../license/premium-module-service'; 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 mockAuthorizePremiumModuleAction = + authorizePremiumModuleAction as jest.MockedFunction; const mockUserCount = prisma.user.count as jest.MockedFunction; const mockMaterialCount = prisma.material.count as jest.MockedFunction; describe('canCreateUser', () => { beforeEach(() => { jest.clearAllMocks(); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'license-limits-core', + action: 'licenseLimits:enforce', + status: 'running', + }); }); it('当未达到限制时应该返回 allowed: true', async () => { @@ -109,6 +119,12 @@ describe('canCreateUser', () => { describe('canUploadMaterial', () => { beforeEach(() => { jest.clearAllMocks(); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'license-limits-core', + action: 'licenseLimits:enforce', + status: 'running', + }); }); it('当未达到限制时应该返回 allowed: true', async () => { @@ -171,21 +187,34 @@ describe('功能开关检查', () => { describe('canUseCustomTemplates', () => { it('当功能启用时应该返回 true', async () => { mockIsFeatureEnabled.mockResolvedValue(true); - mockHasPremiumModule.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'custom-templates-core', + action: 'customTemplates:use', + status: 'running', + }); expect(await canUseCustomTemplates()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates'); - expect(mockHasPremiumModule).toHaveBeenCalledWith('custom-templates-core'); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith( + 'custom-templates-core', + 'customTemplates:use' + ); }); it('当功能禁用时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(false); expect(await canUseCustomTemplates()).toBe(false); - expect(mockHasPremiumModule).not.toHaveBeenCalled(); + expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled(); }); - it('当缺少付费能力模块凭证时应该返回 false', async () => { + it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(true); - mockHasPremiumModule.mockResolvedValue(false); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: false, + moduleId: 'custom-templates-core', + action: 'customTemplates:use', + status: 'missing', + }); expect(await canUseCustomTemplates()).toBe(false); }); }); @@ -206,21 +235,34 @@ describe('功能开关检查', () => { describe('canUseIntranetPenetration', () => { it('当功能启用时应该返回 true', async () => { mockIsFeatureEnabled.mockResolvedValue(true); - mockHasPremiumModule.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'intranet-penetration-core', + action: 'intranetPenetration:use', + status: 'running', + }); expect(await canUseIntranetPenetration()).toBe(true); expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration'); - expect(mockHasPremiumModule).toHaveBeenCalledWith('intranet-penetration-core'); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith( + 'intranet-penetration-core', + 'intranetPenetration:use' + ); }); it('当功能禁用时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(false); expect(await canUseIntranetPenetration()).toBe(false); - expect(mockHasPremiumModule).not.toHaveBeenCalled(); + expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled(); }); - it('当缺少付费能力模块凭证时应该返回 false', async () => { + it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => { mockIsFeatureEnabled.mockResolvedValue(true); - mockHasPremiumModule.mockResolvedValue(false); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: false, + moduleId: 'intranet-penetration-core', + action: 'intranetPenetration:use', + status: 'unhealthy', + }); expect(await canUseIntranetPenetration()).toBe(false); }); }); diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index 3bf07be..c54b7ab 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -112,7 +112,7 @@ describe('许可证业务入口覆盖', () => { it('高价值付费能力必须同时检查签名模块凭证', () => { const source = readProjectFile('lib/license/check-limits.ts'); - expect(source).toContain('hasPremiumModule'); + expect(source).toContain('authorizePremiumModuleAction'); expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE'); expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE'); }); diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts index 78c4c55..4249d9b 100644 --- a/lib/__tests__/premium-module-service.test.ts +++ b/lib/__tests__/premium-module-service.test.ts @@ -156,6 +156,9 @@ describe('premium-module-service', () => { jest.clearAllMocks(); mockFetch.mockReset(); mockVerifySignature.mockReturnValue(true); + delete process.env.LICENSE_LIMITS_MODULE_URL; + delete process.env.CUSTOM_TEMPLATES_MODULE_URL; + delete process.env.INTRANET_PENETRATION_MODULE_URL; }); it('免费版也应同步限额授权模块', async () => { @@ -452,8 +455,32 @@ describe('premium-module-service', () => { }); } + if (where.key === 'premium_module_runtime_status') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + 'license-limits-core': { + moduleId: 'license-limits-core', + image: limitsModuleArtifact.image, + digest: null, + status: 'running', + lastSyncAt: new Date().toISOString(), + }, + })}`, + }); + } + return Promise.resolve(null); }); + process.env.LICENSE_LIMITS_MODULE_URL = 'http://license-limits-module.test'; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'license-limits-core', status: 'healthy' }), + }).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'license-limits-core', allowed: true }), + }); const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); @@ -491,6 +518,66 @@ describe('premium-module-service', () => { await expect(getAuthorizedUsageLimits()).resolves.toBeNull(); }); + 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(), + }, + }, + })}`, + }); + } + + if (where.key === 'premium_module_runtime_status') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + 'license-limits-core': { + moduleId: 'license-limits-core', + image: limitsModuleArtifact.image, + digest: null, + status: 'missing', + lastSyncAt: new Date().toISOString(), + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + + const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); + + await expect(getAuthorizedUsageLimits()).resolves.toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('缓存中的短期拉包凭证有效时才返回给模块运行器', async () => { mockPaidLicense('PRO'); (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { diff --git a/lib/license/check-limits.ts b/lib/license/check-limits.ts index d6104f9..984a00b 100644 --- a/lib/license/check-limits.ts +++ b/lib/license/check-limits.ts @@ -5,7 +5,11 @@ import { isFeatureEnabled, getLicenseType } from './license-service'; import { prisma } from '@/lib/prisma'; -import { getAuthorizedUsageLimits, hasPremiumModule, PREMIUM_MODULE_IDS } from './premium-module-service'; +import { + authorizePremiumModuleAction, + getAuthorizedUsageLimits, + PREMIUM_MODULE_IDS, +} from './premium-module-service'; export interface LimitCheckResult { allowed: boolean; @@ -101,7 +105,12 @@ export async function canUploadMaterial(): Promise { */ export async function canUseCustomTemplates(): Promise { if (!(await isFeatureEnabled('customTemplates'))) return false; - return await hasPremiumModule(PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE); + return ( + await authorizePremiumModuleAction( + PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE, + 'customTemplates:use' + ) + ).available; } /** @@ -116,7 +125,12 @@ export async function canUseDepartmentIsolation(): Promise { */ export async function canUseIntranetPenetration(): Promise { if (!(await isFeatureEnabled('intranetPenetration'))) return false; - return await hasPremiumModule(PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE); + return ( + await authorizePremiumModuleAction( + PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE, + 'intranetPenetration:use' + ) + ).available; } /** diff --git a/lib/license/index.ts b/lib/license/index.ts index 0cde073..1613c18 100644 --- a/lib/license/index.ts +++ b/lib/license/index.ts @@ -37,6 +37,10 @@ export { getAuthorizedUsageLimits, syncPremiumModules, hasPremiumModule, + hasUsablePremiumModule, + isPremiumModuleRunning, + authorizePremiumModuleAction, + updatePremiumModuleRuntimeStatus, } from './premium-module-service'; // 导出指纹服务 diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts index 0520086..466e3f6 100644 --- a/lib/license/premium-module-service.ts +++ b/lib/license/premium-module-service.ts @@ -109,6 +109,7 @@ 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'; +const PREMIUM_MODULE_HEALTH_TIMEOUT_MS = 3000; export const PREMIUM_MODULE_IDS = { LICENSE_LIMITS_CORE: 'license-limits-core', @@ -121,6 +122,19 @@ export interface AuthorizedUsageLimits { maxMaterials: number; } +export interface PremiumModuleRuntimeGateResult { + available: boolean; + moduleId: string; + status?: PremiumModuleRuntimeStatusValue; + message?: string; +} + +export interface PremiumModuleAuthorizeResult extends PremiumModuleRuntimeGateResult { + action: string; +} + +type PremiumModuleActionContext = Record; + function isManifestExpired(manifest: PremiumModuleManifest): boolean { const expiresAt = new Date(manifest.expiresAt).getTime(); return Number.isNaN(expiresAt) || expiresAt <= Date.now(); @@ -283,6 +297,73 @@ function isPullCredentialValid( return !Number.isNaN(expiresAt) && expiresAt > Date.now(); } +function getPremiumModuleServiceBaseUrl(moduleId: string): string { + const envByModuleId: Record = { + [PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE]: process.env.LICENSE_LIMITS_MODULE_URL, + [PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE]: process.env.CUSTOM_TEMPLATES_MODULE_URL, + [PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE]: process.env.INTRANET_PENETRATION_MODULE_URL, + }; + + return (envByModuleId[moduleId] || '').trim().replace(/\/+$/, ''); +} + +function isRuntimeStatusForModule( + status: PremiumModuleRuntimeStatus | null | undefined, + module: PremiumModuleManifest['modules'][number] +): boolean { + if (!status) return false; + if (status.moduleId !== module.id) return false; + if (status.status !== 'running') return false; + if (status.image !== module.artifact.image) return false; + if ((status.digest || null) !== (module.artifact.digest || null)) return false; + return true; +} + +async function fetchModuleJson( + baseUrl: string, + path: string, + init: RequestInit = {} +): Promise<{ ok: boolean; status: number; data: any }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PREMIUM_MODULE_HEALTH_TIMEOUT_MS); + + try { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + signal: controller.signal, + }); + const data = await response.json().catch(() => null); + return { ok: response.ok, status: response.status, data }; + } finally { + clearTimeout(timer); + } +} + +async function checkPremiumModuleHealth(moduleId: string, baseUrl: string): Promise { + try { + const result = await fetchModuleJson(baseUrl, '/health', { method: 'GET' }); + return result.ok && result.data?.moduleId === moduleId && result.data?.status === 'healthy'; + } catch (error) { + console.warn(`[License] 授权模块 ${moduleId} 健康检查失败:`, error); + return false; + } +} + +async function getPremiumModuleInfo( + moduleId: string +): Promise { + const cached = await getCachedPremiumModuleManifest(); + if (!cached) return null; + return cached.manifest.modules.find((item) => item.id === moduleId) || null; +} + +async function hasValidPremiumModuleEntitlement( + module: PremiumModuleManifest['modules'][number] +): Promise { + const cachedEntitlements = await getCachedPremiumModuleEntitlements(); + return isCachedEntitlementValid(cachedEntitlements, module); +} + async function downloadPremiumModuleEntitlement( module: PremiumModuleManifest['modules'][number], fingerprint: Awaited>, @@ -456,15 +537,20 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: cachedEntitlements.entitlements[moduleInfo.id] = nextEntitlement; const existingStatus = runtimeStatuses[moduleInfo.id]; + const canCarryRuntimeStatus = Boolean( + existingStatus && + existingStatus.image === moduleInfo.artifact.image && + (existingStatus.digest || null) === (moduleInfo.artifact.digest || null) && + existingStatus.status !== 'expired' && + existingStatus.status !== 'revoked' + ); 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', + status: canCarryRuntimeStatus && existingStatus ? existingStatus.status : 'missing', lastSyncAt: new Date().toISOString(), + lastError: canCarryRuntimeStatus && existingStatus ? existingStatus.lastError : undefined, }; } @@ -490,16 +576,15 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: } 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 - ); + const moduleInfo = await getPremiumModuleInfo(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE); if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null; - const cachedEntitlements = await getCachedPremiumModuleEntitlements(); - if (!isCachedEntitlementValid(cachedEntitlements, moduleInfo)) return null; + if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) return null; + const authorization = await authorizePremiumModuleAction( + PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE, + 'licenseLimits:enforce' + ); + if (!authorization.available) return null; return { maxUsers: moduleInfo.limits.maxUsers, @@ -522,12 +607,138 @@ export async function getCachedModulePullCredential(moduleId: string): Promise

{ - const cached = await getCachedPremiumModuleManifest(); - if (!cached) return false; - - const moduleInfo = cached.manifest.modules.find((item) => item.id === moduleId); + const moduleInfo = await getPremiumModuleInfo(moduleId); if (!moduleInfo) return false; - const cachedEntitlements = await getCachedPremiumModuleEntitlements(); - return isCachedEntitlementValid(cachedEntitlements, moduleInfo); + return hasValidPremiumModuleEntitlement(moduleInfo); +} + +export async function isPremiumModuleRunning(moduleId: string): Promise { + const moduleInfo = await getPremiumModuleInfo(moduleId); + if (!moduleInfo) { + return { + available: false, + moduleId, + message: '当前许可证未包含该授权模块', + }; + } + + if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) { + return { + available: false, + moduleId, + message: '授权模块凭证不可用,请重新验证许可证', + }; + } + + const runtimeStatuses = await getCachedPremiumModuleRuntimeStatus(); + const runtimeStatus = runtimeStatuses[moduleId]; + if (!isRuntimeStatusForModule(runtimeStatus, moduleInfo)) { + return { + available: false, + moduleId, + status: runtimeStatus?.status || 'missing', + message: + runtimeStatus?.lastError || + '授权模块尚未运行,请先通过 Agent 拉取并启动对应模块', + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + if (!baseUrl) { + return { + available: false, + moduleId, + status: runtimeStatus.status, + message: '授权模块服务地址未配置,无法确认模块健康状态', + }; + } + + if (!(await checkPremiumModuleHealth(moduleId, baseUrl))) { + return { + available: false, + moduleId, + status: 'unhealthy', + message: '授权模块健康检查未通过', + }; + } + + return { + available: true, + moduleId, + status: runtimeStatus.status, + }; +} + +export async function hasUsablePremiumModule(moduleId: string): Promise { + return (await isPremiumModuleRunning(moduleId)).available; +} + +export async function authorizePremiumModuleAction( + moduleId: string, + action: string, + context: PremiumModuleActionContext = {} +): Promise { + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + ...gate, + action, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, context }), + }); + const allowed = result.ok && result.data?.allowed === true && result.data?.moduleId === moduleId; + return { + available: allowed, + moduleId, + action, + status: allowed ? 'running' : 'unhealthy', + message: allowed ? undefined : result.data?.error || '授权模块拒绝该操作', + }; + } catch (error) { + const message = error instanceof Error ? error.message : '授权模块操作授权失败'; + return { + available: false, + moduleId, + action, + status: 'unhealthy', + message, + }; + } +} + +export async function updatePremiumModuleRuntimeStatus(status: PremiumModuleRuntimeStatus): Promise { + if (!status.moduleId || !status.image || !status.lastSyncAt) { + throw new Error('授权模块运行状态缺少必要字段'); + } + + const validStatuses: PremiumModuleRuntimeStatusValue[] = [ + 'missing', + 'pulling', + 'running', + 'unhealthy', + 'expired', + 'revoked', + ]; + if (!validStatuses.includes(status.status)) { + throw new Error('授权模块运行状态无效'); + } + + const runtimeStatuses = await getCachedPremiumModuleRuntimeStatus(); + runtimeStatuses[status.moduleId] = { + moduleId: status.moduleId, + image: status.image, + digest: status.digest || null, + status: status.status, + lastSyncAt: status.lastSyncAt, + lastError: status.lastError, + }; + await savePremiumModuleRuntimeStatus(runtimeStatuses); } diff --git a/premium-modules/custom-templates-core/README.md b/premium-modules/custom-templates-core/README.md index 16cf8d4..e9f3357 100644 --- a/premium-modules/custom-templates-core/README.md +++ b/premium-modules/custom-templates-core/README.md @@ -1,12 +1,14 @@ # custom-templates-core -这是 FLNXI 标准版及以上的自定义模板授权模块骨架。 +这是 FLNXI 标准版及以上的自定义模板授权执行模块。 当前状态: - 提供 `/health` 健康检查。 - 提供 `/status` 能力状态。 -- 第一版先承接“验证系统授权、短期拉包、Agent 运行状态”这条商业化链路;模板业务处理仍在主应用中执行,避免一次性拆散模板、上传、筛选和迁移链路。 +- 提供 `/capabilities` 能力清单。 +- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `customTemplates:use/create/update/delete/migrate/read/list/validate/filter`。 +- 第一版先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路,避免一次性拆散模板、上传、筛选和迁移链路。 发布后需要把固定镜像和 digest 写入 `license-server`: diff --git a/premium-modules/custom-templates-core/server.mjs b/premium-modules/custom-templates-core/server.mjs index d945024..442c030 100644 --- a/premium-modules/custom-templates-core/server.mjs +++ b/premium-modules/custom-templates-core/server.mjs @@ -2,6 +2,18 @@ import http from 'node:http'; const moduleId = 'custom-templates-core'; const port = Number.parseInt(process.env.PORT || '8787', 10); +const capabilities = ['customTemplates']; +const allowedActions = new Set([ + 'customTemplates:use', + 'customTemplates:create', + 'customTemplates:update', + 'customTemplates:delete', + 'customTemplates:migrate', + 'customTemplates:read', + 'customTemplates:list', + 'customTemplates:validate', + 'customTemplates:filter', +]); function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload); @@ -12,6 +24,45 @@ function sendJson(res, statusCode, payload) { res.end(body); } +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + + req.on('data', (chunk) => { + body += chunk; + if (body.length > 1024 * 1024) { + req.destroy(); + reject(new Error('request_body_too_large')); + } + }); + + req.on('end', () => { + if (!body.trim()) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('invalid_json')); + } + }); + + req.on('error', reject); + }); +} + +function sendAuthorization(res, action) { + const allowed = typeof action === 'string' && allowedActions.has(action); + sendJson(res, allowed ? 200 : 403, { + moduleId, + allowed, + action, + capabilities, + }); +} + const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); @@ -24,12 +75,32 @@ const server = http.createServer((req, res) => { sendJson(res, 200, { moduleId, status: 'authorization_contract_ready', - capabilities: ['customTemplates'], - note: '模板业务仍由主应用执行;此模块先承接授权和拉包合同', + capabilities, + note: '此模块先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路', }); return; } + if (req.method === 'GET' && url.pathname === '/capabilities') { + sendJson(res, 200, { + moduleId, + capabilities, + actions: Array.from(allowedActions), + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/authorize') { + readJsonBody(req) + .then((body) => { + sendAuthorization(res, body.action); + }) + .catch((error) => { + sendJson(res, 400, { moduleId, allowed: false, error: error.message }); + }); + return; + } + sendJson(res, 404, { moduleId, error: '接口不存在' }); }); diff --git a/premium-modules/intranet-penetration-core/README.md b/premium-modules/intranet-penetration-core/README.md index 0d0638a..60a71f0 100644 --- a/premium-modules/intranet-penetration-core/README.md +++ b/premium-modules/intranet-penetration-core/README.md @@ -1,11 +1,13 @@ # intranet-penetration-core -这是 FLNXI 第一批拆分的付费模块镜像骨架,用来承接专业版内网穿透模块的运行合同。 +这是 FLNXI 第一批拆分的付费模块镜像,用来承接专业版内网穿透模块的运行合同。 当前状态: - 提供 `/health` 健康检查。 - 提供 `/status`、`/start`、`/stop` 三个接口合同。 +- 提供 `/capabilities` 能力清单。 +- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `intranetPenetration:status/start/stop/read`。 - 未配置真实穿透提供商时明确返回 `provider_not_configured`,不返回占位成功。 发布后需要把固定镜像和 digest 写入 `license-server`: diff --git a/premium-modules/intranet-penetration-core/server.mjs b/premium-modules/intranet-penetration-core/server.mjs index 17652f7..f08a668 100644 --- a/premium-modules/intranet-penetration-core/server.mjs +++ b/premium-modules/intranet-penetration-core/server.mjs @@ -3,6 +3,13 @@ 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(); +const capabilities = ['intranetPenetration']; +const allowedActions = new Set([ + 'intranetPenetration:status', + 'intranetPenetration:start', + 'intranetPenetration:stop', + 'intranetPenetration:read', +]); function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload); @@ -13,6 +20,35 @@ function sendJson(res, statusCode, payload) { res.end(body); } +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + + req.on('data', (chunk) => { + body += chunk; + if (body.length > 1024 * 1024) { + req.destroy(); + reject(new Error('request_body_too_large')); + } + }); + + req.on('end', () => { + if (!body.trim()) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('invalid_json')); + } + }); + + req.on('error', reject); + }); +} + function providerNotConfigured(res, action) { sendJson(res, 501, { moduleId, @@ -22,6 +58,16 @@ function providerNotConfigured(res, action) { }); } +function sendAuthorization(res, action) { + const allowed = typeof action === 'string' && allowedActions.has(action); + sendJson(res, allowed ? 200 : 403, { + moduleId, + allowed, + action, + capabilities, + }); +} + const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); @@ -39,6 +85,15 @@ const server = http.createServer((req, res) => { return; } + if (req.method === 'GET' && url.pathname === '/capabilities') { + sendJson(res, 200, { + moduleId, + capabilities, + actions: Array.from(allowedActions), + }); + return; + } + if (req.method === 'POST' && url.pathname === '/start') { if (!provider) { providerNotConfigured(res, 'start'); @@ -57,6 +112,17 @@ const server = http.createServer((req, res) => { return; } + if (req.method === 'POST' && url.pathname === '/authorize') { + readJsonBody(req) + .then((body) => { + sendAuthorization(res, body.action); + }) + .catch((error) => { + sendJson(res, 400, { moduleId, allowed: false, error: error.message }); + }); + return; + } + sendJson(res, 404, { moduleId, error: '接口不存在' }); }); diff --git a/premium-modules/license-limits-core/README.md b/premium-modules/license-limits-core/README.md index cbb6944..236a76b 100644 --- a/premium-modules/license-limits-core/README.md +++ b/premium-modules/license-limits-core/README.md @@ -6,6 +6,8 @@ - 提供 `/health` 健康检查。 - 提供 `/status` 能力状态。 +- 提供 `/capabilities` 能力清单。 +- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `licenseLimits:check/read/validate/enforce`。 - 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。 发布后需要把固定镜像和 digest 写入 `license-server`: diff --git a/premium-modules/license-limits-core/server.mjs b/premium-modules/license-limits-core/server.mjs index 866ec05..4104a9b 100644 --- a/premium-modules/license-limits-core/server.mjs +++ b/premium-modules/license-limits-core/server.mjs @@ -2,6 +2,13 @@ import http from 'node:http'; const moduleId = 'license-limits-core'; const port = Number.parseInt(process.env.PORT || '8787', 10); +const capabilities = ['licenseLimits']; +const allowedActions = new Set([ + 'licenseLimits:check', + 'licenseLimits:read', + 'licenseLimits:validate', + 'licenseLimits:enforce', +]); function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload); @@ -12,6 +19,45 @@ function sendJson(res, statusCode, payload) { res.end(body); } +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + + req.on('data', (chunk) => { + body += chunk; + if (body.length > 1024 * 1024) { + req.destroy(); + reject(new Error('request_body_too_large')); + } + }); + + req.on('end', () => { + if (!body.trim()) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('invalid_json')); + } + }); + + req.on('error', reject); + }); +} + +function sendAuthorization(res, action) { + const allowed = typeof action === 'string' && allowedActions.has(action); + sendJson(res, allowed ? 200 : 403, { + moduleId, + allowed, + action, + capabilities, + }); +} + const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); @@ -24,12 +70,21 @@ const server = http.createServer((req, res) => { sendJson(res, 200, { moduleId, status: 'ready', - capabilities: ['licenseLimits'], + capabilities, note: '具体人数和素材数上限以验证系统下发的实例授权为准', }); return; } + if (req.method === 'GET' && url.pathname === '/capabilities') { + sendJson(res, 200, { + moduleId, + capabilities, + actions: Array.from(allowedActions), + }); + return; + } + if (req.method === 'GET' && url.pathname === '/limits') { sendJson(res, 200, { moduleId, @@ -39,6 +94,17 @@ const server = http.createServer((req, res) => { return; } + if (req.method === 'POST' && url.pathname === '/authorize') { + readJsonBody(req) + .then((body) => { + sendAuthorization(res, body.action); + }) + .catch((error) => { + sendJson(res, 400, { moduleId, allowed: false, error: error.message }); + }); + return; + } + sendJson(res, 404, { moduleId, error: '接口不存在' }); });