MCP 网关 · 技术文档
面向企业的 Model Context Protocol 工具治理平台。对外暴露唯一一个 MCP Server, 对下聚合任意多个上游 MCP Server,在同一处集中完成鉴权、RBAC、限流、熔断、并发控制、 审计留痕、PII 脱敏与凭据加密。本文档面向具备一定工程基础的读者, 目标是在一次通读内建立起对整个代码库的心智模型。
项目概览
先建立整体印象:它是什么、替谁解决什么问题、由哪些能力构成。
它到底是什么
MCP 网关是一个单进程、单文件数据库、零外部依赖的 Node.js 服务。 它扮演双重角色:
- 对上游(AI 客户端 / 模型):它是一个标准的 MCP Server,端点为
POST /mcp, 用Authorization: Bearer mgw_live_…鉴权,支持initialize/tools/list/tools/call/ping,并通过 SSE 推送notifications/tools/list_changed。 - 对下游(真实工具服务):它是一个 MCP Client 池,可同时连接多个上游
MCP Server,支持
stdio(拉起子进程)与http(Streamable HTTP)两种 transport。
所有上游工具被聚合进一张命名空间化的注册表,全名规则为
upstream__tool(双下划线分隔)。模型看到的是扁平的一张工具清单,
网关负责把 wiki__search 还原成「wiki 上游的 search 工具」并转发过去。
解决什么问题
把 MCP Server 直接开放给模型,在企业环境里会立刻撞上四类问题:
| 问题 | 裸接 MCP 的现状 | 网关的做法 |
|---|---|---|
| 权限失控 | 任何拿到 Server 的模型都能调用全部工具,实习生和 CTO 的权限完全一样 | API Key 绑定角色 → 角色绑定通配符规则(deny / allow 有序列表)→
tools/list 与 tools/call 共用同一份 allowedSet,做到「可见即可调」 |
| 无法追责 | 模型调了什么工具、传了什么参数、成功还是失败,全都没有记录 | 每次调用写 audit_logs:请求 ID、用户、团队、角色、工具、参数摘要、
耗时、排队时长、错误码、调用方 IP、客户端信息 |
| 故障扩散 | 某个上游挂了,所有会话一起卡死;某个模型疯狂重试,把上游彻底打死 | 三态熔断(closed / half-open / open)+ 令牌桶限流 + 每上游并发信号量, 单点故障被隔离在网关内部 |
| 凭据裸奔 | 上游的 token 写在配置文件里明文提交;PII 直接进日志 | 上游凭据用 AES-256-GCM 加密后入库;审计入库前完成手机 / 邮箱 / 身份证 / 银行卡正则脱敏 |
核心能力一览
observe 影子模式做灰度预演redacted 标记iv(12) | tag(16) | ciphertext 后 base64169.254.0.0/16 与 127.0.0.0/8 永久拒绝change_logs,含 before / after 快照与人类可读 diff/healthz + /metrics(JSON) + /metrics.prom(Prometheus) + 事件循环延迟监控关键数字速览
mgw_ 前缀快速开始
从零到跑通第一次工具调用,最快 3 分钟。
环境要求
| 依赖 | 版本 | 说明 |
|---|---|---|
| Node.js | ≥ 20.18.1 | start.sh 会检查主版本号,低于 20 直接退出。MCP SDK 与 better-sqlite3 均要求 20+ |
| npm | 随 Node | 两个子包各自持有 package-lock.json,不是 workspace monorepo |
| 构建工具链 | python3 / make / g++ | 仅首次安装需要:better-sqlite3 是原生模块,会本地编译 |
| lsof | 可选 | 用于端口预检;缺失时脚本仅告警并跳过 |
一键启动(推荐)
仓库根目录的 start.sh 封装了「加载 env → 端口预检 → 装依赖 → seed → 双进程并跑」全流程:
cd mcp-agent
# 首次:准备环境变量(可选,不准备也能跑,会走内置默认值)
cp backend/.env.example backend/.env
./start.sh # 启动:后端 :8080 + 前端 :5173
./start.sh --stop # 停止已在运行的实例(含残留端口占用)
./start.sh --force # 端口被占用时自动停旧实例再启动
./start.sh --no-seed # 跳过 seed(确认库结构已就绪时用)
./start.sh -h # 帮助启动成功后终端会打印:
════════════════════════ MCP 网关已启动 ════════════════════════
▶ 管理后台 http://127.0.0.1:5173
登录 Token: mgw_admin_dev_token (生产请通过 ADMIN_TOKEN 环境变量修改)
▶ MCP 端点 http://127.0.0.1:8080/mcp
鉴权头: Authorization: Bearer <mgw_live_…>
▶ 健康检查 http://127.0.0.1:8080/healthz
按 Ctrl+C 一键停止 · 或另开终端执行 ./start.sh --stopstart.sh 的几个值得注意的实现细节:
- env 加载方式:后端不内置 dotenv,直接读进程环境变量。
脚本用
set -a; source backend/.env; set +a在当前 shell 里加载后自动 export 给子进程, 加载顺序为backend/.env优先、仓库根.env兜底。 - 递归杀进程树:
kill_tree()先用pgrep -P找出子进程, 先杀子再杀父,避免留下孤儿node占着端口。 - 优雅停止:
TERM→ 最多等 5s → 仍存活则KILL。Ctrl+C与--stop走同一条路径。 - 彩色前缀分流:两路输出分别经
awk打上[backend](青)与[frontend](品红)前缀后合并到同一终端, 并且fflush()保证实时性。 - seed 幂等:不会覆盖已有 settings / 角色 / Key,重复执行安全。
手动启动(分别跑两个进程)
# ── 终端 A:后端 ─────────────────────────────
cd backend
npm install # 首次,会编译 better-sqlite3
cp .env.example .env # 按需填写 MGW_SECRET / ADMIN_TOKEN
set -a && source .env && set +a # 手动喂入环境变量
npm run seed # 建库 + 内置角色 / settings / 首个 Key
npm run start:dev # → http://127.0.0.1:8080
# ── 终端 B:前端 ─────────────────────────────
cd frontend
npm install
npm run dev # → http://127.0.0.1:5173| 包 | 脚本 | 作用 |
|---|---|---|
backend | npm run start:dev | nest start --watch,开发热重载 |
npm run build | nest build → dist/ | |
npm start | node dist/main,生产启动 | |
npm run seed | ts-node -r tsconfig-paths/register src/database/seed.ts | |
npm run typecheck | tsc --noEmit | |
npm run lint | ESLint 检查 src/**/*.ts | |
frontend | npm run dev | Vite 开发服务器(:5173,代理 /admin/api 与 /mcp 到 :8080) |
npm run build | vue-tsc --noEmit && vite build → dist/ | |
npm run preview | 预览构建产物 | |
npm run typecheck | vue-tsc --noEmit |
登录管理后台
- 打开 http://127.0.0.1:5173
未登录会被路由守卫重定向到
#/login?redirect=/dashboard。 - 输入管理员标识与 Token
Token 默认
mgw_admin_dev_token(开发默认值,生产必须改); 用户名任意填写,仅用于change_logs.actor留痕。 - 凭据落在 localStorage
键名
mgw_admin_token与mgw_admin_user, 由frontend/src/api/client.ts的tokenStore统一读写。 - 之后所有请求自动带头
Authorization: Bearer <token>与x-admin-user: <name>; 收到 401 时setUnauthorizedHandler会清空凭据并跳回登录页。
发起第一次 MCP 调用
先在「API Key」页签发一把 Key(明文只显示一次),然后:
KEY="mgw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
BASE="http://127.0.0.1:8080/mcp"
# 1) initialize —— 协商协议版本,拿到 Mcp-Session-Id
curl -i -X POST "$BASE" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"curl","version":"0"}
}
}'
# → 响应头含 mcp-session-id: <uuid>
SID="<上一步返回的 mcp-session-id>"
# 2) notifications/initialized —— 握手完成通知(无响应体)
curl -X POST "$BASE" \
-H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3) tools/list —— 只返回该 Key 角色「有权看到」的工具
curl -X POST "$BASE" \
-H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 4) tools/call —— 走完整治理链
curl -X POST "$BASE" \
-H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"wiki__search","arguments":{"q":"MCP"}}
}'
# 5) 订阅工具变更通知(SSE 长连接,25s 心跳)
curl -N "$BASE" -H "Authorization: Bearer $KEY" -H "mcp-session-id: $SID"在 Claude Desktop / Cursor 等客户端里,配置形如:
{
"mcpServers": {
"corp-gateway": {
"type": "streamable-http",
"url": "http://127.0.0.1:8080/mcp",
"headers": { "Authorization": "Bearer mgw_live_xxxxxxxxxxxx" }
}
}
}冒烟自检
scripts/ 下按能力档位准备了 5 个冒烟脚本,可直接对着运行中的网关打:
| 脚本 | 覆盖范围 |
|---|---|
smoke-test.sh | 基础连通:healthz → initialize → tools/list → tools/call 全链路 |
smoke-p0.sh | P0 六项:影子模式、权限回滚、影响面预览、Key 即时吊销、事件循环监控、stdio crash loop |
smoke-p1.sh | P1 首批:熔断事件面板、数据库自动备份、Prometheus 指标 |
smoke-p1b.sh | P1 收尾:备份失败告警、HTTP 入口埋点、熔断 SSE 推流、Grafana 模板 |
smoke-domestic.sh | 国产化 / 内网环境适配验证 |
mock-mcp-server.mjs | 本地假上游,用于无真实 MCP Server 时联调 |
seed-calls.py / seed-dashboard.sh | 灌入模拟调用数据,让仪表盘与报表页有内容可看 |
想快速看到仪表盘效果,跑一遍 python3 scripts/seed-calls.py 即可灌入历史调用样本;
它会直接写 backend/data/gateway.db,执行前请先停掉网关。
架构总览
一张图看懂请求怎么走、模块怎么分、依赖朝哪个方向。
全景拓扑
一张图看清六件事:谁在调、从哪进、过哪些闸、 由谁支撑、打到哪里去、账记在哪里。 实线是请求主流程,虚线是「闸 → 支撑服务」的依赖关系;虚线边框的盒子在 NestJS 进程之外。
transport/mcp.controller.ts)就完成了,
② ~ ⑥ 五道闸在 routeCall() 内顺序执行,
脱敏与审计则作为横切能力挂在回程上——三者不在同一层,
详见 06 治理链。
双通道物理隔离
这是整个系统最重要的一条架构约束:面向模型的数据面与面向管理员的控制面,从入口就完全分开。
| 维度 | MCP 通道 /mcp | 管理通道 /admin/api/* |
|---|---|---|
| 使用者 | AI 客户端 / 模型 | 运维与管理员(人类) |
| 凭据 | Authorization: Bearer mgw_live_…(每 Key 独立、可吊销、绑角色) |
Authorization: Bearer <ADMIN_TOKEN> + x-admin-user(全局单一令牌) |
| 校验方式 | SHA-256 哈希查库 → 命中后取角色 / profile | timingSafeEqual 常量时间比较,防时序侧信道 |
| 失败语义 | MCP 规范:工具业务失败用 isError: true,让模型有机会改策略 | 标准 HTTP 状态码 + JSON 错误体 |
| 审计事件 | channel = 'mcp' | channel = 'admin',写操作另进 change_logs |
| 例外 | — | /healthz、/metrics、/metrics.prom 三个探针端点不鉴权(供 K8s / Prometheus 抓取) |
管理 Token 绝不能作为 MCP Key 使用,反之亦然。两套凭据在代码里没有交集:
AdminTokenGuard 只挂在 admin/ 下的控制器上,
ApiKeyService.verify() 只在 transport/mcp.controller.ts 里被调用。
一次 tools/call 的完整时序
- HTTP 入口 · transport/mcp.controller.ts
MetricsMiddleware先埋点(QPS / 延时直方图)→ 校验Origin(防 DNS rebinding) → 校验Accept头必须同时包含application/json与text/event-stream→ 解析Authorization调ApiKeyService.verify()。 失败返回 401 且带WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"。 - 会话校验 · transport/session.manager.ts
除
initialize外的所有方法都要求合法的Mcp-Session-Id; 会话是进程内Map,含keyId/role/userName/SSE Subject/lastActiveAt。 - 协议版本协商
客户端声明
2025-06-18则原样回应;否则回落到2025-03-26。initialize响应带capabilities.tools.listChanged = true与serverInfo = { name: 'mcp-gateway', version: '2.0.0' }。 - 方法分派
dispatch()是一个显式 switch,支持initialize/notifications/initialized(兼容裸initialized)/ping/tools/list/tools/call;未知方法回 JSON-RPC-32601。 - 进入治理链 · gateway/router.service.ts
routeCall(fullName, args, caller, ctx)依次过六道闸,详见 06 治理链。任何一道失败都走统一的fail(): 写审计 + 打指标 + 返回结构化GatewayToolError。 - 转发上游 · gateway/upstream-manager.service.ts
从 Client 池取出对应连接,用
AbortController施加超时后调client.callTool(), 把上游返回的原名工具结果映射回全名。 - 回程落账 · governance/audit.service.ts
结果先经
RedactService递归脱敏,再进内存队列攒批; 队列满flushSize或定时器到flushMs时单事务批量 insert。 - 响应客户端
成功 →
result.content;工具业务失败 →result.isError = true+[错误码] 中文简述;协议级错误 → JSON-RPCerror。
模块划分与依赖方向
backend/src/app.module.ts 装配 8 个业务模块,其中 3 个标注 @Global():
| 模块 | 全局 | 导出内容 / 职责 |
|---|---|---|
ConfigModule | @Global | 三级配置合并(env > gateway.json > 默认),导出 ConfigService |
DatabaseModule | @Global | TypeORM 数据源(SQLite / WAL),注册 11 个实体 |
GatewayModule | @Global | ToolRegistryService · UpstreamManagerService · GatewayRouterService。
transport 与 admin 都依赖这三者,故设为全局 |
GovernanceModule | @Global | Redact · Audit · RateLimit · Breaker · BreakerEvents · Concurrency · Backup · Metrics 八个治理服务 |
AuthModule | — | ApiKeyService · RbacService · CryptoService |
TransportModule | — | McpController · SessionManager(对客户端的 MCP 端点) |
AdminModule | — | 9 个控制器 + AdminTokenGuard + ChangeLogService |
NotifyModule | — | ImService(IM Webhook 告警,带同类抑制) |
依赖是单向向外的,不存在环:
router→ 依赖registry+upstream-manager+ 全部治理服务upstream-manager→ 依赖registry(刷新后写回工具列表)registry→ 只依赖 TypeORM Repository,不反向依赖任何网关服务ssrf.ts→ 纯函数,被 upstream-manager 在注册 URL 时调用,零依赖admin/*→ 依赖 Repository +gateway/*+auth/crypto+transport/session.manager, 不反向依赖网关内部细节,保持管理面与运行面解耦
热更新:改完就生效,不重启
管理后台的写操作完成后会触发一条广播链,让在线的 AI 客户端立刻感知:
rulesVersion++
两个版本戳是这套机制的关键:registryVersion(工具集变化)与
rulesVersion(权限规则变化)。RBAC 的 allowedSet 缓存键同时包含这两者与 60s TTL,
任一变化即令缓存自然失效,无需显式清理。
关键设计取舍
| 决策 | 选了什么 | 为什么,代价是什么 |
|---|---|---|
| 存储 | 单文件 SQLite(WAL)而非 PostgreSQL | 换来「一个二进制 + 一个 db 文件」的部署极简性;代价是单写者, 因此审计必须攒批(见 11 审计) |
| 治理链编排 | 显式顺序调用,而非中间件洋葱模型 | 顺序有强语义(权限在限流前、限流在熔断前),显式代码让顺序不可被误改;
代价是新增一道闸需要改 routeCall |
| 会话 | 进程内 Map,不做 Redis 共享 |
与单进程定位一致,零外部依赖;代价是不能水平扩容, 多实例部署需要 sticky session 或改造为共享存储 |
| MCP SDK 加载 | 动态 await import() |
SDK 是 ESM-only,而 NestJS 项目编译为 CommonJS,只能在运行时动态导入;
代价是类型需要 typeof import(…) 间接声明 |
| 前端路由 | hash 模式(createWebHashHistory) |
静态托管(Nginx / 任意静态服务器)无需 rewrite 规则即可直达子路由 |
| UI 组件 | 全部自研 + UnoCSS shortcuts,不引组件库 | 黑白毛玻璃主题需要极致的令牌控制;代价是 Modal / Toast / Spinner 都要自己写 |
| 建表 | MGW_DB_SYNCHRONIZE=1 默认开启 |
MVP 零迁移上手;生产建议置 0 并改用 migrations |
技术栈
每一个依赖都有明确职责,没有「先加上再说」的库。版本号与 package.json 一致。
后端运行时依赖
| 包 | 版本 | 职责与选型理由 |
|---|---|---|
@nestjs/common / core / platform-express | 10.4.4 | 应用骨架。模块化 + 装饰器让「治理链每一步是一个可注入服务」这件事表达得很自然 |
@nestjs/typeorm + typeorm | 10.0.2 / 0.3.20 | 实体映射与 Repository。11 个实体集中在 database/entities/ |
better-sqlite3 | 11.3.0 | 同步 API 的 SQLite 驱动,性能显著优于 sqlite3;
提供 db.backup() 在线热备份能力(P1-A 的基础) |
@modelcontextprotocol/sdk | 1.0.4 | 官方 MCP SDK。ESM-only,通过动态 import() 在 CJS 环境加载;
用到 Client / StdioClientTransport / StreamableHTTPClientTransport |
zod | 3.23.8 | Admin API 请求体校验。配合 ZodExceptionFilter 把 ZodError 归一化为 400 + 友好消息 |
prom-client | 15.1.3 | Prometheus 指标。使用独立 Registry(非全局默认),避免与 Nest 内部指标串味;
统一 mgw_ 前缀 |
node-cron | 4.6.0 | 备份调度。settings 里的 backup.cron / backup.tz 改动后热重建任务 |
rxjs | 7.8.1 | SSE 推流。Subject 承载 tools/list_changed 与熔断事件;
merge(stream$, interval(25s)) 实现心跳 |
reflect-metadata | 0.2.2 | NestJS / TypeORM 装饰器元数据必需 |
开发依赖:@nestjs/cli 10.4.5 · typescript 5.6.3 ·
ts-node 10.9.2 · tsconfig-paths 4.2.0 · @types/node 20.16.11 ·
@types/better-sqlite3 7.6.11 · eslint + @typescript-eslint/*。
前端依赖
| 包 | 版本 | 职责 |
|---|---|---|
vue | 3.5.13 | Composition API + <script setup lang="ts"> |
vue-router | 4.4.5 | hash 模式;视图全部懒加载,首屏只下登录 + 布局 + 仪表盘 |
pinia | 2.2.6 | 唯一 store 是 auth(token / user / authenticated) |
vite | 5.4.11 | 开发服务器 + 构建;/admin/api 与 /mcp 代理到 :8080 |
unocss | 0.64.1 | 原子化 CSS。presetUno({ dark: 'class' }) + presetAttributify
+ transformerDirectives + transformerVariantGroup |
vue-tsc | 2.1.6 | 模板级类型检查,build 前强制执行 |
没有 UI 组件库。项目未引入 Element Plus / Ant Design Vue / Naive UI, 所有 Modal、Toast、Spinner、Icon、Empty、PageHeader 均为自研组件 + UnoCSS shortcuts 组合。 这是黑白毛玻璃主题能做到「改一处 shortcuts 即换肤」的前提。详见 16 设计体系。
SQLite 运行参数
backend/src/database/data-source.ts 在建立连接后执行四条 PRAGMA:
PRAGMA journal_mode = WAL; -- 写前日志:读写并发,读不阻塞写
PRAGMA busy_timeout = 5000; -- 写锁竞争时最多等 5s,而非立刻 SQLITE_BUSY
PRAGMA foreign_keys = ON; -- SQLite 默认关闭外键约束,必须显式打开
PRAGMA synchronous = NORMAL; -- WAL 模式下的推荐档位:性能与崩溃安全性的平衡点由此产生的文件布局:data/gateway.db(主库)+ gateway.db-wal(预写日志)+
gateway.db-shm(共享内存索引)。备份时必须三个文件一起考虑,
这也是为什么项目用 db.backup() API 而不是简单 cp(见 12 数据模型)。
协议与传输
- MCP 协议版本
2025-06-18(首选)·2025-03-26(回落)- 传输形态
- Streamable HTTP:单一
POST /mcp承载 JSON-RPC,GET /mcp升级为 SSE 通知流,DELETE /mcp终止会话 - RPC 语义
- JSON-RPC 2.0;工具业务失败用
result.isError = true而非error字段(MCP 规范要求) - 会话标识
Mcp-Session-Id响应头下发,后续请求需回传- 心跳
- SSE 每 25s 一条
: ping注释帧,防中间代理断连 - Admin API
- REST + JSON,路径前缀
/admin/api,Bearer Token 鉴权 - 指标暴露
/metrics(JSON,供前端仪表盘)·/metrics.prom(Prometheus text format 0.0.4)
目录结构
知道东西放在哪,比知道东西是什么更重要。这份树标注了每个目录的职责边界。
仓库顶层
注意:backend 与 frontend 各自持有
package.json 与 package-lock.json,不是 npm workspace monorepo,
没有根级 package.json。要装依赖必须分别进入两个目录。
后端源码 backend/src/
前端源码 frontend/src/
构建配置在 frontend/ 根:vite.config.ts(含 dev 代理)、
uno.config.ts(主题令牌与 shortcuts)、tsconfig.json、index.html。
运行时产物
- backend/data/gateway.db
- 主数据库(+
-wal/-shm伴随文件) - backend/data/backup/
- 备份产物,命名
gateway-YYYYMMDD-HHMMSS-<trigger>.db,trigger 为manual或cron - backend/data/audit-fallback.jsonl
- 审计落库失败时的兜底队列,启动时回放
- backend/config/gateway.json
- 中间层配置文件(优先级低于环境变量)
- backend/.env
- 本地环境变量,不入库;模板为
.env.example - backend/dist/ · frontend/dist/
- 构建产物
- .run/gateway.pid
start.sh记录的 pipeline PID(空格分隔两个)
治理链:系统的十字路口
backend/src/gateway/router.service.ts 是整个网关最值得逐行阅读的文件。 它把六道治理闸按固定顺序串起来,顺序本身就是设计。
顺序为什么不可调换
源码里的类注释把理由写得很直白:
「GatewayRouter:整个系统的十字路口。治理链顺序不可调换:权限在限流前(无权限不该消耗令牌), 限流在熔断前(被限流不该污染熔断统计),熔断在获取并发槽前(熔断态不该排队)。」
把这三条反过来会立刻出问题:
- 限流放在权限前 → 一个无权调用
finance__*的实习生,光是被拒绝就在消耗财务工具的令牌桶,形成廉价 DoS。 - 熔断放在限流前 → 被限流打回的请求计入失败统计,上游明明健康却被误判熔断。
- 并发槽放在熔断前 → 上游已经 open,请求还在队列里排着占内存,等 60s 后统一超时。
六道闸全景
TOOL_NOT_FOUND
FORBIDDEN / WOULD_FORBID
RATE_LIMITED
UPSTREAM_DOWN
UPSTREAM_BUSY
UPSTREAM_TIMEOUT / ERROR
常被描述为「8 步治理链」的另外两步,实际发生在 routeCall 的外层与内层,
理解这个分层能避免读代码时找不到位置:
鉴权(第 0 步)在 transport/mcp.controller.ts 完成,
请求还没进 router 就已经拿到 caller = { keyId, role, userName, team, profile };
脱敏(第 7 步)在 governance/audit.service.ts 的
toEntity() 内部完成,属于审计落库的前置处理,不在主链路上阻塞响应。
入口签名
async routeCall(
fullName: string, // 'wiki__search'
args: Record<string, unknown>, // 模型给的参数
caller: { // 由 mcp.controller 鉴权后注入
keyId: number;
role: string;
userName: string | null;
team: string | null;
profile: string | null;
},
ctx: { // 请求上下文
requestId: string;
sessionId: string | null;
callerIp: string | null;
clientInfo: string | null;
},
): Promise<ToolCallResult>返回值有两种形态:成功时透传上游 content;失败时由统一的 fail() 构造
{ isError: true, content: [{ type: 'text', text: '[ERROR_CODE] 中文简述' }] }。
GATE 1 · 注册表查找
const entry = this.registry.get(fullName);
if (!entry || !entry.enabled) {
return this.fail(caller, ctx, fullName, null,
ErrorCode.TOOL_NOT_FOUND, '工具不存在或已下线', 0);
}
const u = await this.upstreamRepo.findOne({ where: { name: entry.upstream } });
if (!u || !u.enabled) {
return this.fail(caller, ctx, fullName, entry.upstream,
ErrorCode.UPSTREAM_DOWN, '上游服务暂不可用', 0);
}两个检查点:工具本身是否在注册表且 enabled(可被后台单独下线而不删上游), 以及所属上游是否在库且 enabled。二者对应不同错误码,便于运维定位。
GATE 2 · RBAC 权限(含影子模式)
// 与 tools/list 共用同一份 allowedSet —— 「可见即可调」契约
const allNames = this.registry.allToolNames();
const allowed = this.rbac.buildAllowedSet(caller.role, allNames, this.registry.version);
if (!allowed.has(fullName)) {
if (this.rbac.getMode() === 'observe') {
// F1 影子模式:放行,但打一条 rbac_would_forbid 审计
this.audit.log({
event: 'rbac_would_forbid',
tool: fullName,
upstream: entry.upstream,
errorCode: ErrorCode.WOULD_FORBID,
resultOk: 1, // 注意:实际是成功的
...caller, ...ctx,
});
// 不 return,继续往下走限流 / 熔断 / 转发
} else {
return this.fail(caller, ctx, fullName, entry.upstream,
ErrorCode.FORBIDDEN, '无权调用该工具', 0, 'client-error');
}
}影子模式(rbac.mode = observe)的价值在于:在真正收紧权限之前,先用生产流量验证规则写对了没有。
被影子拦下的调用会写 rbac_would_forbid 审计事件,仪表盘的
wouldForbidCount / wouldForbidTop 直接给出「如果切 enforce 会影响多少调用、影响谁」。
这是权限变更从「拍脑袋」变成「看数据」的关键一步。
FORBIDDEN 用 kind = 'client-error' 构造,
因此不计入熔断统计——权限问题不是上游的错,不该让上游背锅被熔断。
同理 BAD_ARGS 也是 client-error。
GATE 3 · 令牌桶限流
// 工具级覆写 QPM 优先于全局默认
const ov = await this.overrideRepo.findOne({ where: { fullName } });
const rate = ov?.rateQpm ?? undefined; // undefined → 走 settings 默认 30
const rl = this.rateLimit.take(caller.keyId, fullName, rate, rate);
if (!rl.ok) {
return this.fail(caller, ctx, fullName, entry.upstream,
ErrorCode.RATE_LIMITED, '调用过于频繁,请稍后重试', 0, 'client-error',
{ retryAfterS: rl.retryAfterS });
}桶键是 ${keyId}|${tool} 的组合——按「谁的哪把 Key 调哪个工具」双维度限流,
因此 A 团队打爆自己的桶不会影响 B 团队。注意限流也标记为 client-error,同样不污染熔断。
算法细节见 10 限流·熔断·并发。
GATE 4 · 三态熔断检查
const st = this.breaker.allow(entry.upstream); // 'closed' | 'half-open' | 'open'
if (st === 'open') {
return this.fail(caller, ctx, fullName, entry.upstream,
ErrorCode.UPSTREAM_DOWN, '上游服务暂不可用', 0, 'upstream-error');
}
// half-open:breaker 内部用 probing 布尔位保证只有一个请求作为探针放行熔断在上游维度而非工具维度——一个上游挂了,它下面所有工具一起快速失败, 这才是熔断的意义(保护上游,不是保护单个工具)。
GATE 5 · 并发信号量
const limit = u.concurrency ?? defaultConcurrency; // settings: 20
// stdio 上游是本地子进程,并发能力有限,强制收敛到 ≤2
const effLimit = u.transport === 'stdio' ? Math.min(limit, 2) : limit;
const acq = await this.concurrency.acquire(entry.upstream, effLimit, 50);
if (!acq.ok) {
return this.fail(caller, ctx, fullName, entry.upstream,
ErrorCode.UPSTREAM_BUSY, '上游繁忙,请稍后重试', 0, 'upstream-error',
{ queueMs: acq.waitedMs });
}
try {
// … GATE 6 转发 …
} finally {
acq.release(); // 必须在 finally 里释放,否则 slot 泄漏
}acquire() 的第三个参数 50 是等待队列上限:超过就直接拒绝,
而不是让请求无限排队。queueMs 会写进审计,用于识别「上游不是慢,是挤」。
历史坑(已修):释放 slot 时如果只做 s.active-- 而不唤醒等待者,
队列里的请求会永久悬挂。当前实现是
s.active = Math.max(0, s.active - 1); const next = s.waiters.shift(); if (next) next();,
Math.max(0, …) 则防止重复 release 把计数打成负数、凭空放大并发额度。
GATE 6 · 超时转发
const timeout = u.timeoutMs ?? defaultTimeout; // settings: 20000
const t0 = Date.now();
try {
const raw = await this.upstreamManager.callTool(entry.upstream, entry.originalName, args, timeout);
const durationMs = Date.now() - t0;
this.breaker.onSuccess(entry.upstream);
this.metrics.toolCall(entry.upstream, 'ok', durationMs);
this.audit.log({ event: 'tool_call', resultOk: 1, durationMs, queueMs: acq.waitedMs, … });
return { content: raw.content, isError: false };
} catch (e) {
const ge = toGatewayError(e); // 归一化
this.breaker.onFailure(entry.upstream, ge.kind); // client-error 直接 return null
this.metrics.toolCall(entry.upstream, ge.code, Date.now() - t0);
return this.fail(caller, ctx, fullName, entry.upstream, ge.code, ge.safeMessage,
Date.now() - t0, ge.kind, ge.meta);
}超时通过 AbortController 施加在 SDK 调用上,超时后映射为
UPSTREAM_TIMEOUT;连接失败映射 UPSTREAM_DOWN;
上游返回业务错误映射 UPSTREAM_ERROR。三者的 kind 都是
upstream-error,都计入熔断。
统一失败路径 fail()
所有闸的失败都汇聚到同一个私有方法,保证「失败也有完整审计」:
- 构造错误对象:
GatewayToolError(code, safeMessage, raw, kind, meta),message格式固定为[CODE] 中文简述。 - 消息净化:
safeMessage()剥离堆栈、IP:port、SQL 片段、 绝对路径、Bearer xxx,并截断到 200 字符——这段文本会返回给模型, 绝不能泄漏内网拓扑。原始错误另存error_msg字段,仅后台可见。 - 打指标:
metrics.toolCall(upstream, code, durationMs)。 - 写审计:
resultOk = 0+errorCode+errorMsg。 - 返回 MCP 语义:
{ isError: true, content: [{ type:'text', text }] }。 不是 JSON-RPC error——这是 MCP 规范的明确要求:工具业务失败要让模型看到并调整策略, 协议级失败(方法不存在、参数结构错)才用 JSON-RPC error。
「可见即可调」契约
这是 RBAC 设计上最容易被忽视、也最容易出安全漏洞的一点:
// tools/list 走这里
listTools(caller) {
const allowed = this.rbac.buildAllowedSet(caller.role, this.registry.allToolNames(), this.registry.version);
return this.registry.listFor(allowed); // 只返回 allowed 命中的
}
// tools/call 走 GATE 2,用的是同一个 buildAllowedSet()
// 缓存键 = role + registryVersion + rulesVersion + 60s TTL
// → 同一次会话内,两者结果必然一致如果两者用了不同的判定路径,就会出现「列表里看得到但调不动」(体验灾难)或 「列表里没有但直接猜名字能调通」(严重越权)。
工具注册表
把 N 个上游的 M 个工具压平成一张带命名空间的表,并用版本戳驱动全局缓存一致性。
命名空间聚合
全名规则:<upstream>__<originalName>(两个下划线)。例如上游 wiki 的
search 工具 → wiki__search。
- 为什么用双下划线:单下划线在工具名里太常见(
delete_record), 双下划线几乎不会与真实工具名冲突,且split('__')反解无歧义。 - 上游名有硬约束:
NAME_RE = /^[a-z][a-z0-9_-]{1,31}$/—— 小写字母开头,2~32 字符,只允许小写字母 / 数字 / 下划线 / 连字符。 这条正则在upstream.controller.ts创建时就校验,从源头杜绝注入到全名里的非法字符。 - RBAC 通配符天然适配:
wiki__*匹配整个上游,*__delete_*跨上游匹配所有删除类工具。
内存条目结构
interface RegistryEntry {
fullName: string; // 'wiki__search'
upstream: string; // 'wiki'
originalName: string; // 'search' —— 转发给上游时用这个名字
def: { // 暴露给模型的 MCP 工具定义
name: string;
title?: string;
description?: string;
inputSchema?: object;
};
rawDescription: string; // 上游原始描述(覆写前的原值,供后台对比/还原)
enabled: boolean; // 可被 tool_overrides 单独下线
sensitivity: 'normal' | 'sensitive'; // 继承自上游,决定是否存全量参数
}注册表本体是进程内 Map<string, RegistryEntry>,
不做懒加载——冷启动时从 tools_snapshot 表一次性灌满,
因此即使所有上游都连不上,网关也能立刻对外提供 tools/list(降级可用)。
版本戳:缓存一致性的支点
private _version = 0;
get version(): number { return this._version; }
/** 工具集发生任何变化都调它:单调递增,永不回退 */
private bump(): void { this._version++; }触发 bump() 的时机:
- 上游刷新工具列表(手动 refresh / 定时刷新 / 重连成功)
- 新增或删除上游
- 工具覆写变化(改描述 / 上下线 / 改限流)
消费方是 RBAC 的 allowedSet 缓存:缓存键为
${role}|${registryVersion}|${rulesVersion},另有 60s TTL 兜底。
版本号变化 → 键变化 → 旧缓存自然不再命中,无需任何显式失效逻辑,
也就没有「忘了清缓存」这类 bug 的生存空间。
该值同时作为 Prometheus Gauge mgw_registry_version 暴露,
运维可以用 changes(mgw_registry_version[10m]) 观察注册表抖动频率。
快照落库与冷启动
refreshUpstream(name, tools) 的语义是整体替换该上游的条目集合:
先删掉旧的全名,再写入新的,然后 upsert 到 tools_snapshot
(更新 last_seen_at,新工具写 first_seen_at),最后 bump()。
这样做的好处是上游下线了某个工具,网关侧会自动消失,不会留下「僵尸工具」被模型调用后报错。
工具覆写
tool_overrides 表提供四项不改动上游的治理能力:
| 字段 | 作用 | 典型场景 |
|---|---|---|
description | 覆盖暴露给模型的描述文本 | 上游描述写得含糊或有误导,改写得更精确以提升模型选对工具的概率;
rawDescription 保留原值,随时可还原 |
enabled | 单独下线某个工具(不影响上游其它工具) | 某工具有 bug 或不该开放,先摘掉,比禁用整个上游粒度更细 |
rate_qpm | 工具级限流覆写,优先于全局默认 | 给昂贵工具(如大模型二次调用)单独收紧到 5 QPM |
cache_ttl_s | 结果缓存时长(预留字段) | 幂等只读工具可缓存降载 |
note / updated_by | 备注与操作人 | 全量进 change_logs |
applyOverride() 在注册表条目生成时套用覆写,因此治理链看到的
entry.enabled 与 entry.def.description 已经是覆写后的最终值——
覆写逻辑不会散落到调用路径各处。
定时刷新
后台每 registry.refresh_interval_s(默认 300s)遍历所有 enabled 上游,
调 client.listTools() 并走 refreshUpstream()。这是为了捕捉
上游自己变了但没发通知的漂移情况(很多 MCP Server 并不实现 listChanged 推送)。
刷新失败不会清空注册表——保留上一次成功的快照,只更新上游的
last_status / last_error / last_checked_at。
「拿不到新的」永远优于「把旧的弄丢」。
描述里的 Prompt 注入检测
上游工具描述会直接进入模型的上下文,因此是 prompt 注入的天然入口。
upstream.controller.ts 内置一条 SUSPICIOUS_DESC 正则,
在注册/刷新时对描述做扫描,命中即返回告警提示(如「忽略以上指令」「你现在是…」之类的模式),
由管理员决定是否放行。这是把供应链安全纳入网关职责的一个具体落点。
上游管理与 MCP Client 池
gateway/upstream-manager.service.ts(346 行)是后端最重的一个文件, 承担连接生命周期、两种 transport、健康检查、退避重连与进程隔离。
两种 transport
| transport | 连接方式 | 必填字段 | 约束 |
|---|---|---|---|
stdio |
拉起本地子进程,通过 stdin/stdout 交换 JSON-RPC | command · argsJson · cwd(可选)· envEnc(加密) |
受 MGW_STDIO_MAX(默认 8)总量限制;并发被强制收敛到 ≤2;
env 走白名单隔离(见下) |
http |
Streamable HTTP,连接远端 MCP Server | url · headersEnc(加密,通常放 Authorization) |
URL 必须通过 SSRF CIDR 白名单校验;请求带 redirect: 'manual' 防重定向绕过 |
动态导入 ESM SDK
MCP SDK 是 ESM-only 包,而 NestJS 项目 tsconfig 输出 CommonJS,
顶层 import 会直接编译成 require() 而崩溃。解法是运行时动态导入:
// 类型层面:用 typeof import 拿到类型,不产生运行时 require
type ClientCtor = typeof import('@modelcontextprotocol/sdk/client/index.js');
// 运行时:三个入口分别动态 import
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
const { StreamableHTTPClientTransport } =
await import('@modelcontextprotocol/sdk/client/streamableHttp.js');模块加载器会缓存结果,因此动态 import 只在实际建连时付一次代价,之后是纯内存查找。
stdio 子进程的 env 白名单隔离
这是安全设计里最容易被忽略、但后果最严重的一环。网关进程持有
MGW_SECRET、ADMIN_TOKEN 等致命凭据,
如果原样 process.env 传给子进程,任何一个上游 MCP Server(可能是第三方代码)
都能读走它们。
const STDIO_ENV_ALLOW_KEYS = new Set<string>([
'PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'TMPDIR', 'PWD', 'TZ', 'NODE_ENV',
]);
const STDIO_ENV_ALLOW_PREFIXES = ['LC_'];
function buildChildEnv(custom: Record<string, string>): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(process.env)) {
if (STDIO_ENV_ALLOW_KEYS.has(k) || STDIO_ENV_ALLOW_PREFIXES.some((p) => k.startsWith(p))) {
out[k] = v;
}
}
// 只叠加管理员显式配置的、解密后的自定义变量
return { ...out, ...custom };
}结果是子进程看不到 MGW_*、ADMIN_TOKEN、
*_FILE、NODE_OPTIONS 等任何敏感或可被滥用的变量
(NODE_OPTIONS 尤其危险——它能注入 --require 执行任意代码)。
子进程需要的凭据,必须由管理员在上游配置里显式录入,经 AES-256-GCM 加密存 env_enc 字段。
SSRF 防护
gateway/ssrf.ts 是一组零依赖纯函数:
/** 永久拒绝,无论白名单怎么写 */
const ALWAYS_DENY = ['169.254.0.0/16', '127.0.0.0/8'];
export function ipToLong(ip: string): number; // '10.0.0.1' → 167772161
export function inCidr(ip: string, cidr: string): boolean;
export function checkUpstreamUrl(
rawUrl: string,
allowedCidrs: string[],
resolvedIp?: string, // 已做 DNS 解析时传入,避免 TOCTOU
): { ok: true } | { ok: false; reason: string };三层防线:
- 协议与主机名合法性:只接受
http:/https:,主机名必须能解析。 - CIDR 白名单:解析出的 IP 必须落在
MGW_OUTBOUND_ALLOWED_CIDRS(默认10.0.0.0/8,127.0.0.1/32,192.168.0.0/16)之内。 - 永久拒绝段:
169.254.0.0/16覆盖云厂商元数据服务 (169.254.169.254,拿到它往往等于拿到实例角色凭据),127.0.0.0/8阻止打本机其它服务。 这两段即使被显式写进白名单也依然拒绝。
默认白名单里出现的 127.0.0.1/32 与永久拒绝的 127.0.0.0/8 冲突时,
ALWAYS_DENY 优先。本地联调请把 mock 上游挂到局域网地址,或临时调整
ALWAYS_DENY(不建议提交)。
此外,出站请求统一使用 redirect: 'manual':
否则白名单内的服务返回一个 302 → http://169.254.169.254/ 就能绕过全部校验。
健康检查
// 每 30s 遍历所有 enabled 上游
setInterval(() => this.healthCheckAll(), 30_000);
// 单次检查:尝试 client.listTools(),据此更新状态
private async probe(name: string): Promise<void> {
try {
await this.clientFor(name).listTools();
await this.repo.update({ name }, {
lastStatus: 'up', lastError: null, lastCheckedAt: Date.now(),
});
if (wasDown) { // 从 down 恢复 → 通知 + 熔断恢复
this.im.upstreamRecovered(name);
this.breaker.onUpstreamUp(name);
await this.registry.refreshUpstream(name, tools);
}
} catch (e) {
await this.repo.update({ name }, {
lastStatus: 'down', lastError: safeMessage(e), lastCheckedAt: Date.now(),
});
}
}后台的「上游服务」页与顶栏健康徽章读的就是 last_status;
GET /admin/api/upstreams/:name/probe 提供手动即时探活。
指数退避重连
// 每次失败翻倍,上限 5 分钟
state.lastBackoff = Math.min((state.lastBackoff ?? 1000) * 2, 300_000);
setTimeout(() => this.connect(name), state.lastBackoff);上限 5 分钟的意义:一个彻底挂掉的上游不会让网关每秒都在尝试 spawn 子进程 / 建 TCP 连接,
把资源耗在注定失败的事情上。连接成功后 lastBackoff 复位。
F9 · stdio crash loop 自动熔断
stdio 子进程如果启动即崩(命令不存在、依赖缺失、参数错误),退避重连会陷入 「spawn → 立刻退出 → 再 spawn」的循环,日志被刷爆、CPU 被吃光。 P0-F9 加了一道硬闸:
const CRASH_WINDOW_MS = 5 * 60_000; // 5 分钟观察窗
const CRASH_THRESHOLD = 5; // 窗口内崩溃 5 次
private recordCrash(name: string): void {
const s = this.crashState(name);
s.hits = s.hits.filter((t) => Date.now() - t < CRASH_WINDOW_MS);
s.hits.push(Date.now());
if (s.hits.length < CRASH_THRESHOLD) return;
// 触发:彻底禁用,停止一切重试
s.hits = [];
this.repo.update({ name }, { enabled: 0, lastError: 'crash_loop' });
this.changeLog.record({
actor: 'system', targetType: 'upstream', targetId: name,
action: 'auto-disable',
diffSummary: `${name} 5 分钟内崩溃 ${CRASH_THRESHOLD} 次,已自动禁用`,
});
this.im.stdioCrashLoop(name);
logger.warn({ event: 'stdio_crash_loop', upstream: name });
}三个动作缺一不可:禁用(止血)+ 留痕(action='auto-disable'
让变更记录页能看到是系统而非人做的)+ 告警(人得知道)。
恢复方式是在后台修正配置后手动启用。
callTool 与超时
async callTool(upstream: string, originalName: string,
args: object, timeoutMs: number) {
const client = this.clientFor(upstream); // 未连接 → 抛 UPSTREAM_DOWN
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
return await client.callTool(
{ name: originalName, arguments: args }, // 注意:用原名,不是全名
undefined,
{ signal: ac.signal, timeout: timeoutMs },
);
} catch (e) {
if (ac.signal.aborted) throw upstreamError(ErrorCode.UPSTREAM_TIMEOUT, '上游响应超时', String(e));
throw e;
} finally {
clearTimeout(timer);
}
}AbortController 与 timeout 双保险:前者确保 Promise 一定被拒绝、
不留悬挂句柄,后者是 SDK 自身的超时机制。finally 里的
clearTimeout 防止定时器泄漏——高频调用下漏掉的定时器会累积成可观的内存占用。
创建上游时的「先试连、失败不落库」
POST /admin/api/upstreams 的行为值得单独说明:它会先真实建连并拉一次工具列表,
成功才写库。连接失败直接返回 502 并附上净化后的原因,数据库里不留任何脏记录。
这个取舍的理由:一条连不上的上游记录会持续触发重连与告警, 让「配置写错了」这种低级问题以「系统故障」的形式表现出来,排查成本极高。 宁可让管理员在提交那一刻就吃到错误。
鉴权与 RBAC
两套互不相干的凭据体系,加上一套可灰度预演的通配符权限引擎。
API Key 的形态
generateApiKey(): { key: string; hash: string; prefix: string } {
const key = 'mgw_live_' + randomBytes(24).toString('base64url'); // 32 字符随机体
return {
key,
hash: createHash('sha256').update(key).digest('hex'), // 库里只存这个
prefix: key.slice(0, 16), // 'mgw_live_AbCdEf'
};
}- mgw_live_
- 固定前缀,让密钥扫描器(GitHub Secret Scanning 等)能识别; 同时肉眼可辨来源,避免与其它系统的 token 混淆
- base64url(24B)
- 192 bit 熵,
crypto.randomBytes是 CSPRNG; base64url 无需转义,可安全出现在 URL / Header / JSON 中 - 只存 SHA-256
- 数据库泄漏不等于凭据泄漏。不可逆—— 后台永远无法「查看」明文 Key,只能重新签发
- prefix(前 16 字符)
- 明文存库,用于后台列表识别与人工比对
("这把
mgw_live_AbCdEf…是谁的"),泄漏 16 字符前缀不影响安全性
明文 Key 只在签发响应里出现一次(IssuedKey.key),
前端弹窗提示「请立即复制保存,关闭后无法再次查看」。
任何声称能「找回」明文 Key 的实现都是安全缺陷。
F4 · Key 校验与 ≤2s 即时吊销
async verify(rawKey: string): Promise<Caller | null> {
if (!rawKey?.startsWith('mgw_live_')) return null; // 快速失败,省一次哈希
// 吊销黑名单缓存:命中即拒,TTL 2s
if (this.revokedCache.has(rawKey)) return null;
const hash = sha256(rawKey);
const row = await this.repo.findOne({ where: { keyHash: hash } });
if (!row || row.revoked) { this.revokedCache.set(rawKey, true); return null; }
if (row.expiresAt && row.expiresAt < Date.now()) return null;
row.lastUsedAt = Date.now(); // 异步更新,不阻塞主链路
return { keyId: row.id, role: row.role, userName: row.userName,
team: row.team, profile: row.profile };
}P0-F4 要解决的问题:Key 一旦泄漏(提交进 Git、打进日志、被截图), 吊销必须立刻生效,不能等缓存自然过期。做法是吊销时主动写入一个短 TTL 黑名单, 而不是依赖「查库结果缓存」的失效:
- 正常校验路径:哈希 → 查库(SQLite 本地查询,微秒级)→ 命中即放行。
- 吊销路径:
POST /admin/api/keys/:id/revoke→ 库里置revoked=1, revoked_at, revoke_reason→ 同时把该 Key 明文前缀推入黑名单缓存。 - 结果:吊销后最多 2s 内全链路失效(含正在进行的会话——会话每次调用都会重新 verify)。
revoke_reason 是枚举 leak / offboard / rotate / other,
写进 change_logs 供事后审计「为什么这把 Key 被吊销了」。
Key 轮换与宽限期
POST /admin/api/keys/:id/rotate 接受 graceMinutes:
签发一把新 Key 并让旧 Key 在宽限期内继续有效,宽限期到后再自动吊销。
这让「换 Key」从一次需要停机协调的操作,变成可以灰度推进的常规运维——
业务方有时间把配置从旧 Key 切到新 Key,不会在切换瞬间全线 401。
角色与规则模型
一个角色(roles)对应一组有序规则(role_rules),
每条规则三要素:effect(deny / allow)、
pattern(通配符)、sort(顺序)。
按 sort 升序遍历,首条命中即决定结果,后续规则不再评估。
/** 通配符 → 正则。只有 '*' 是元字符,其余全部转义 */
private toRegex(pattern: string): RegExp {
const body = pattern.split('*').map(escapeRegExp).join('.*');
return new RegExp('^' + body + '$', 's'); // 's' 让 '.' 也匹配换行
}
compute(role: string, fullName: string): boolean {
const rules = this.rulesOf(role); // 已按 sort 升序
for (const r of rules) {
if (this.regexOf(r.pattern).test(fullName)) {
return r.effect === 'allow'; // ★ 首条命中即返回
}
}
return false; // 默认拒绝(deny by default)
}顺序决定语义。deny finance__* 排在
allow * 之前,财务工具就是禁的;反过来写,前者永远不生效。
这也是为什么后台的规则编辑器必须支持拖拽排序,且 normalizeSort()
在批量写入时会把 deny 组统一排到 allow 组之前(sort 10, 20, 30…),降低误配概率。
三个内置角色
由 database/seed.ts 幂等写入(is_builtin = 1,后台不可删):
| 角色 | 规则(按 sort 升序) | 语义 |
|---|---|---|
intern |
deny *__delete_*
deny *__write_*
deny finance__*allow wiki__*
allow *__search
|
只读为主:任何上游的删除 / 写入类工具全禁,财务上游整体禁;
知识库全部可用,其它上游只开放 *__search 结尾的检索类工具。
未命中的默认拒绝 |
support |
deny crm__delete_*
deny finance__refund_*allow crm__*
allow wiki__*
allow order__*
|
客服场景:CRM 可读写但不能删客户,可查订单与知识库,
但不能发起退款(finance__refund_* 被拦,
且 finance__* 其余部分因未命中 allow 而默认拒绝) |
admin |
deny *__drop_*allow *
|
几乎全通,但仍保留一条底线:任何删库级操作都禁。 这条规则的存在是为了防止模型幻觉出一次不可逆的破坏 |
ReDoS 防护
规则 pattern 由管理员输入,最终会被编译成正则并在每次工具调用时执行。 一个写得恶劣的 pattern 可以造成灾难性回溯,让整个事件循环卡死:
/** 检测嵌套量词,如 (a+)+ 、 (a*){2,} —— 指数级回溯的典型形态 */
const NESTED_QUANTIFIER = /\([^)]*[+*]\)[+*{]/;
const MAX_PATTERN_LEN = 200;
validatePattern(p: string): void {
if (p.length > MAX_PATTERN_LEN) throw new BadRequestException('pattern 过长(上限 200)');
if (NESTED_QUANTIFIER.test(p)) throw new BadRequestException('pattern 含嵌套量词,存在 ReDoS 风险');
}由于 toRegex() 只把 * 翻译为 .*、其余字符全部转义,
实际上管理员无法直接注入正则量词;这道校验主要防的是
*(*a*)* 这类经翻译后产生 .*\(.*a.*\).* 的嵌套结构,
以及超长 pattern 带来的编译开销。防御纵深,不依赖单一保证。
F1 · 影子模式(observe)
权限收紧最危险的时刻是「切换生效的那一瞬间」。影子模式把这一瞬间拉长成一段可观察的时期:
| 模式 | settings 值 | 行为 |
|---|---|---|
| 强制(默认) | rbac.mode = enforce |
未命中 allow → 立即返回 FORBIDDEN,写 tool_call 审计(resultOk=0) |
| 影子 | rbac.mode = observe |
未命中 allow → 照常放行并完成调用,额外写一条
event='rbac_would_forbid' + errorCode='WOULD_FORBID' +
resultOk=1 的审计记录 |
切换方式:后台「系统设置」里改 rbac.mode,或
PUT /admin/api/settings。热生效,无需重启(RbacService.getMode() 每次读取)。
配套的观测能力(GET /admin/api/stats/overview):
- rbacMode
- 当前模式,仪表盘顶部用徽章直接显示,避免「忘了自己开着影子模式」
- wouldForbidCount
- 统计周期内被影子标记的调用总数 = 切 enforce 后会失败多少次
- wouldForbidTop
- 按「工具 × 角色」聚合的 Top 榜 = 具体谁会被影响、影响在哪个工具
推荐的权限收紧流程:
① 切 observe → ② 配好新规则 → ③ 观察 3~7 天
wouldForbidTop → ④ 用「影响面预览」量化受影响 Key 与近 7 日调用量
→ ⑤ 与业务方确认 → ⑥ 切 enforce。全程零故障窗口。
F3 · 影响面预览
POST /admin/api/permissions/preview 接受「角色 + 一组候选规则」,
在不写库的前提下算出规则变更的完整影响面:
interface PreviewResult {
role: string;
currentCount: number; // 当前可见工具数
nextCount: number; // 新规则下可见工具数
gained: string[]; // 新增可见的工具全名
lost: string[]; // 失去可见的工具全名
affectedKeys: KeyItem[]; // 受影响的 Key 明细
affectedKeyCount: number; // 受影响的 Key 数量
recent7dCalls: number; // 这些 Key 近 7 日总调用量
lostCalls7d: number; // 其中打在 lost 工具上的调用量 ← 最关键的数字
gainedCalls7d: number; // 其中打在 gained 工具上的调用量
totalImpact7d: number; // lostCalls7d + gainedCalls7d
}实现上走的是 RbacService.computeWithRules()——一条不经过缓存、不污染线上状态
的独立计算路径,用传入的候选规则临时构造判定器。lostCalls7d 通过联查
audit_logs 得到,把「权限变更」从抽象的规则 diff 翻译成
「未来 7 天会有多少次调用失败」这种业务能听懂的语言。
F2 · 权限矩阵一键回滚
每次 PUT /admin/api/roles/:name/rules 都会把变更前的完整规则集写进
change_logs.before_json。POST /admin/api/roles/:name/rules/rollback
读取最近一条 target_type='role_rules' 的记录,把 before_json 原样写回,
并再记一条 action='rollback' 的新变更(回滚本身也是变更,同样留痕)。
配合 F3 的预览,权限运营形成了闭环:预览 → 应用 → 观察 → 一键回滚。
allowedSet 缓存
buildAllowedSet(role: string, allNames: string[], registryVersion: number): Set<string> {
const key = `${role}|${registryVersion}|${this.rulesVersion}`;
const hit = this.cache.get(key);
if (hit && Date.now() - hit.at < 60_000) return hit.set; // 60s TTL 兜底
const set = new Set<string>();
for (const n of allNames) if (this.compute(role, n)) set.add(n);
this.cache.set(key, { set, at: Date.now() });
return set;
}复杂度从 O(调用次数 × 工具数 × 规则数) 降到 O(工具数 × 规则数) 每 60 秒一次。
注册表有 200 个工具、角色有 10 条规则时,单次构建 2000 次正则匹配,
缓存命中后 tools/call 的权限判定退化为一次 Set.has()。
缓存键包含两个版本戳,所以不需要任何主动失效代码:
工具集变了 registryVersion 递增,规则变了 rulesVersion 递增,
旧键再也不会被查询到,靠 TTL 与 Map 的自然增长上限回收。
Admin Token 与 AdminTokenGuard
@Injectable()
export class AdminTokenGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
// 例外:SSE 端点(EventSource 无法自定义 Header),允许 ?token= 查询参数
if (req.path.endsWith('/stream')) {
const q = req.query?.token;
if (q && this.crypto.safeEqual(q, this.config.adminToken)) return true;
throw new UnauthorizedException('无效的管理令牌');
}
const h = req.headers['authorization'] || '';
const token = h.startsWith('Bearer ') ? h.slice(7) : '';
if (!token || !this.crypto.safeEqual(token, this.config.adminToken)) {
throw new UnauthorizedException('无效的管理令牌');
}
return true;
}
}- 常量时间比较:
safeEqual()内部用crypto.timingSafeEqual,先比对长度再逐字节异或累积。 普通===会在首个不同字节处提前返回,攻击者可据此逐字符爆破。 - 操作人标识:
x-admin-user请求头由前端携带, 写进change_logs.actor与audit_logs.user_name。 它是自报的、不可信的,仅用于留痕,不参与任何权限判定—— 真正的鉴权边界只有 Token 本身。 - SSE 例外:浏览器原生
EventSource不支持自定义 Header, 因此以/stream结尾的路径开放?token=。 代价是 token 会出现在 URL 里(可能被访问日志记录),所以这类端点只有只读的熔断事件流。 - 开发默认值:未设置
ADMIN_TOKEN时回落到mgw_admin_dev_token。生产环境必须通过环境变量或ADMIN_TOKEN_FILE覆盖。
加密原语
/** 主密钥派生:64 位 hex 直接用,否则 SHA-256 展开成 32 字节 */
private masterKey(secret: string): Buffer {
return /^[0-9a-f]{64}$/i.test(secret)
? Buffer.from(secret, 'hex')
: createHash('sha256').update(secret).digest();
}
encrypt(plain: string): string {
const iv = randomBytes(12); // GCM 推荐 96 bit
const c = createCipheriv('aes-256-gcm', this.key, iv);
const enc = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
const tag = c.getAuthTag(); // 16 字节认证标签
return Buffer.concat([iv, tag, enc]).toString('base64'); // iv|tag|ct 自描述
}
decrypt(payload: string): string {
const buf = Buffer.from(payload, 'base64');
const iv = buf.subarray(0, 12), tag = buf.subarray(12, 28), ct = buf.subarray(28);
const d = createDecipheriv('aes-256-gcm', this.key, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
}- 为什么选 GCM
- 它是认证加密(AEAD):密文被篡改时
d.final()会抛错, 而不是解出乱码后继续执行。CBC 之类的模式需要额外 HMAC 才能达到同等保证 - 为什么 iv|tag|ct 拼在一起
- 密文自描述,不需要额外的列存 IV;
每次加密都用新的
randomBytes(12),同一明文两次加密结果不同, 无法通过比对密文推断哪些上游用了相同凭据 - 加密了什么
upstreams.headers_enc(HTTP 上游的请求头,通常是 Authorization) 与upstreams.env_enc(stdio 上游的自定义环境变量)- MGW_SECRET 变更后果
- 历史密文全部无法解密,必须重新录入所有上游凭据。 这是一次不可逆操作,轮换密钥前务必先导出配置
- *_FILE 支持
MGW_SECRET_FILE/ADMIN_TOKEN_FILE优先于同名的值变量, 用于 Docker / K8s 把 secret 挂成文件的场景,避免密钥出现在env输出与进程列表里
限流 · 熔断 · 并发
三个纯内存状态机,全部无外部依赖,各自解决一类不同的过载问题。
令牌桶限流
governance/ratelimit.service.ts(76 行)实现的是经典令牌桶, 而非固定窗口计数器——区别在于桶允许突发:
take(keyId: number, tool: string, rate = this.defaultQpm, burst = this.defaultBurst) {
const id = `${keyId}|${tool}`; // ★ 双维度桶键
const now = Date.now();
let b = this.buckets.get(id);
if (!b) { b = { tokens: burst, last: now }; this.buckets.set(id, b); }
const capacity = burst;
// 按经过时间线性补充令牌,上限为 capacity
b.tokens = Math.min(capacity, b.tokens + ((now - b.last) / 60_000) * rate);
b.last = now;
if (b.tokens < 1) {
const need = 1 - b.tokens;
return { ok: false, retryAfterS: Math.ceil((need / rate) * 60) };
}
b.tokens -= 1;
return { ok: true, retryAfterS: 0 };
}- 默认速率
ratelimit.default_qpm = 30(每分钟 30 次),ratelimit.burst = 30- 桶键
${keyId}|${tool}——同一把 Key 调不同工具用不同的桶, 不同 Key 调同一工具也用不同的桶。限流的是"某人对某工具"的行为,不是工具本身的总吞吐- 覆写优先级
tool_overrides.rate_qpm>settings.ratelimit.default_qpm- 惰性补充
- 没有后台定时器补令牌,而是在每次
take()时按(now - last) / 60000 * rate一次性算出应补的量。零定时器开销,且天然精确 - 内存回收
sweep(idleMs = 600_000)每 60s 最多执行一次, 清掉 10 分钟未使用的桶,防止 Key × 工具的组合基数把 Map 撑爆。 当前桶数暴露为 Gaugemgw_ratelimit_buckets- retryAfterS
- 随
RATE_LIMITED错误一起返回,模型/客户端可据此退避而非盲目重试
三态熔断状态机
governance/breaker.service.ts(137 行)按上游维度维护状态:
滑动窗口计失败
UPSTREAM_DOWN
其余仍拒
通知恢复
private readonly failThreshold = 5; // 窗口内失败阈值
private readonly openMs = 60_000; // open 持续时间
private readonly windowMs = 60_000; // 失败计数滑动窗口
allow(upstream: string): 'closed' | 'half-open' | 'open' {
const s = this.state(upstream);
const now = Date.now();
if (s.state === 'open') {
if (now - s.openedAt < this.openMs) return 'open';
s.state = 'half-open'; // 时间到,进入探测态
s.probing = false;
}
if (s.state === 'half-open') {
if (s.probing) return 'open'; // ★ 已有探针在飞,其余请求仍拒
s.probing = true; // 独占探针位
return 'half-open';
}
return 'closed';
}
onFailure(upstream: string, kind: 'client-error' | 'upstream-error'): BreakerTransition | null {
if (kind === 'client-error') return null; // ★ BAD_ARGS / FORBIDDEN 不计入熔断
const s = this.state(upstream);
const now = Date.now();
s.fails = s.fails.filter((t) => now - t < this.windowMs);
s.fails.push(now);
if (s.state === 'half-open') { // 探针失败 → 重新 open,重新计时
s.state = 'open'; s.openedAt = now; s.probing = false;
return { kind: 'opened', from: 'half-open', to: 'open', fails: s.fails.length };
}
if (s.fails.length >= this.failThreshold) {
const from = s.state;
s.state = 'open'; s.openedAt = now;
return { kind: 'opened', from, to: 'open', fails: s.fails.length };
}
return null;
}为什么 half-open 只放一个探针:如果 60s 后一次性放开全部流量,
而上游其实还没恢复,就会造成第二波冲击(惊群),把刚有起色的上游再次打死。
独占探针位(probing 布尔)保证「恢复」这件事被谨慎验证。
为什么 client-error 不计入:模型传错参数、角色无权限, 这些是调用方的问题。把它们计入熔断会导致「一个不会用工具的模型把整个上游搞熔断」, 这是必须避免的错误归因。
P1-C/F · 熔断事件落库与实时推流
状态跃迁是有价值的运维信号,不能只留在内存。BreakerEventsService 负责两件事:
record(t: BreakerTransition, upstream: string, reason?: string): void {
// 1) 落库 circuit_breaker_events
this.repo.insert({
ts: Date.now(), upstream,
event: t.kind, // 'opened' | 'recovered'
fromState: t.from, toState: t.to,
failsInWindow: t.fails,
openMs: t.kind === 'opened' ? this.openMs : null,
reason: reason || '',
});
// 2) 推进 SSE Subject,前端熔断事件页实时刷新
this.stream$.next({ type: 'breaker', data: { … } });
// 3) IM 告警(带 5min 同类抑制)
t.kind === 'opened' ? this.im.circuitOpened(upstream) : this.im.circuitClosed(upstream);
}前端订阅:GET /admin/api/breaker/events/stream?token=… 是一条 SSE 长连接,
服务端用 merge(breaker$, interval(25_000).pipe(map(() => ({ type: 'ping' }))))
把业务事件与心跳合流,防止代理层因空闲断开。
另有 GET /admin/api/breaker/summary?days= 给出聚合视图:
各上游的熔断次数、累计 open 时长、最近一次跃迁时间——用于回答「哪个上游最不稳定」。
并发信号量
async acquire(upstream: string, limit: number, maxQueue = 50) {
const s = this.state(upstream);
const t0 = Date.now();
if (s.active < limit) { // 有空位,直接拿
s.active++;
return { ok: true, waitedMs: 0, release: () => this.release(upstream) };
}
if (s.waiters.length >= maxQueue) { // 队列满,立即拒绝
return { ok: false, waitedMs: Date.now() - t0, release: () => {} };
}
// 排队等待,直到被前一个 release 唤醒
await new Promise<void>((resolve) => s.waiters.push(resolve));
s.active++;
return { ok: true, waitedMs: Date.now() - t0, release: () => this.release(upstream) };
}
private release(upstream: string): void {
const s = this.state(upstream);
s.active = Math.max(0, s.active - 1); // 防重复 release 打成负数
const next = s.waiters.shift();
if (next) next(); // ★ 必须唤醒等待者,否则永久悬挂
}- 默认并发上限
upstream.concurrency = 20(settings 全局默认),可被upstreams.concurrency逐上游覆盖- stdio 特例
effLimit = transport === 'stdio' ? Math.min(limit, 2) : limit—— 本地子进程的 stdout 是单条管道,高并发下响应会交错错乱,硬性收敛到 2- 队列上限
- 50。超过立即返回
UPSTREAM_BUSY, 快速失败优于无限排队:排队 30s 后超时对调用方毫无价值 - queueMs
- 等待时长写进
audit_logs.queue_ms。 诊断口诀:duration 高但 queue 低 = 上游慢;duration 高且 queue 高 = 并发不够
三者的分工
| 机制 | 防的是什么 | 维度 | 失败时的错误码 |
|---|---|---|---|
| 限流 | 调用方过度使用(含恶意重试) | Key × 工具 | RATE_LIMITED + retryAfterS(client-error,不计熔断) |
| 熔断 | 上游已故障,继续打只会雪上加霜 | 上游 | UPSTREAM_DOWN(upstream-error) |
| 并发 | 上游瞬时容量不足(还活着,只是挤) | 上游 | UPSTREAM_BUSY + queueMs(upstream-error) |
三者语义清晰不重叠:限流管"你调太多",熔断管"它坏了",并发管"它现在忙"。 错误码不同,模型和运维都能据此采取正确动作。
审计与脱敏
治理系统的可信度底线:谁在什么时候调了什么、改了什么,都必须能查到。
攒批落库
SQLite 是单写者,逐条 insert 在高 QPS 下会成为瓶颈。AuditService 的做法是
内存攒批 + 单事务批量写:
log(entry: AuditEntry): void { // 同步、非阻塞,调用方永不 await
this.queue.push(this.toEntity(entry));
if (this.queue.length >= this.flushSize) void this.flush(); // 默认 100 条
}
private async flush(): Promise<void> {
if (!this.queue.length || this.flushing) return;
this.flushing = true;
const batch = this.queue.splice(0, this.queue.length);
try {
await this.dataSource.transaction(async (m) => { // 单事务
for (const e of batch) await m.getRepository(AuditLog).insert(e);
});
} catch (err) {
this.writeFallback(batch); // 落 JSONL 兜底
this.im.auditFallbackGrowing(this.fallbackSize());
} finally {
this.flushing = false;
}
}
// 定时器:默认每 1000ms 强制 flush 一次
setInterval(() => void this.flush(), this.flushMs);- 触发条件
- 队列长度 ≥
MGW_AUDIT_FLUSH_SIZE(默认 100) 或 定时器到MGW_AUDIT_FLUSH_MS(默认 1000ms),二者先到先触发 - flushing 标志位
- 防止定时器与阈值触发并发执行 flush,导致同一批被写两次
- splice 取批
- 先原子地摘走当前队列再写库,写库期间新来的日志进新队列, 不会因为写库慢而丢日志
- 队列深度指标
- Gauge
mgw_audit_queue_depth。持续 > 0 说明写入跟不上产生速度 - 优雅停机
onModuleDestroy()里await flush(), 确保进程退出前队列排空
失败兜底与启动回放
写库失败(磁盘满、库被锁、WAL 异常)时,审计不能丢:
- 落 JSONL:整批以每行一个 JSON 的形式追加到 data/audit-fallback.jsonl。
- 告警:
ImService.auditFallbackGrowing(size),带 5 分钟同类抑制, 避免磁盘满时告警风暴。 - 启动回放:
BootstrapService在启动阶段检查该文件, 逐行解析后批量 insert 回audit_logs。 - 重命名而非删除:回放成功后执行
renameSync(path, path + '.replayed.' + Date.now())。 不删文件——万一回放有 bug 造成数据错误,原始记录还在,可以人工重来。 清理是运维的显式动作,不是程序的自动行为。
参数存什么:摘要 vs 全量
private toEntity(e: AuditEntry): AuditLog {
const argsText = JSON.stringify(e.args ?? {});
const sensitive = e.sensitivity === 'sensitive';
return {
…e,
// 普通工具:只存 SHA-256 前 16 位 + 脱敏后的摘要
argsDigest: sha256(argsText).slice(0, 16),
// 敏感工具:存脱敏后的全量 JSON(便于事后取证)
argsFull: sensitive ? this.redact.redactDeep(e.args) : null,
redacted: sensitive ? 1 : 0,
resultBytes: e.resultBytes ?? null,
};
}这个分级是「可追溯性」与「数据最小化」的平衡:
- 普通工具:16 位十六进制摘要(64 bit)足以完成 「同一批调用是否传了相同参数」「某个具体请求对应哪条日志」这类关联查询, 但无法还原参数内容,日志库泄漏也不等于业务数据泄漏。
- 敏感工具(上游标记
sensitivity='sensitive'): 往往涉及资金、隐私、合规,出事时必须能还原现场,因此存全量—— 但先脱敏再存。
PII 脱敏
governance/redact.service.ts(61 行)在入库前完成脱敏, 而不是查询时脱敏——这个顺序选择很关键:一旦明文落盘,就已经泄漏了。
const RULES: Array<{ re: RegExp; mask: (m: string) => string }> = [
{ re: /\b1[3-9]\d{9}\b/g, mask: (m) => m.slice(0, 3) + '****' + m.slice(-4) }, // 手机号
{ re: /[\w.+-]+@[\w-]+\.[\w.]+/g, mask: (m) => m[0] + '***' + m.slice(m.indexOf('@')) },// 邮箱
{ re: /\b\d{17}[\dXx]\b/g, mask: (m) => m.slice(0, 6) + '********' + m.slice(-4) }, // 身份证
{ re: /\b\d{16,19}\b/g, mask: (m) => m.slice(0, 4) + '****' + m.slice(-4) }, // 银行卡
];
redactDeep(value: unknown): unknown {
if (typeof value === 'string') return this.redactString(value);
if (Array.isArray(value)) return value.map((v) => this.redactDeep(v));
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = this.redactDeep(v);
return out;
}
return value;
}- 规则顺序有意义:身份证(18 位)必须排在银行卡(16~19 位)之前, 否则身份证号会先被银行卡规则吃掉、掩成错误的格式。
- 保留首尾:掩码保留前 3~6 位与后 4 位,既能人工识别是哪一个, 又不暴露完整号码。这是合规与可用性的常见折中。
- 递归全量:
redactDeep处理任意深度嵌套的 JSON, 数组与对象都会下钻,字符串叶子节点逐条规则替换。 - 脱敏标记:处理过的记录
redacted = 1, 审计查询页可据此过滤,也让「这条日志是脱敏后的」这件事对审计员透明。
正则脱敏是尽力而为,不是保证。姓名、地址、自定义编号等无固定格式的 PII 无法被规则覆盖。真正敏感的数据应当在上游工具侧就不返回, 而不是指望网关兜底。
索引设计:以「安全部门的问题」为输入
audit_logs 上有 7 条索引,实体注释里明确写了设计方法论:
每条索引对应审计员会问的一个问题。
| 索引 | 字段 | 回答的问题 |
|---|---|---|
idx_audit_ts | ts | "最近一小时发生了什么"(时间轴回放) |
idx_audit_user_ts | user_name, ts | "张三上周都调了什么" |
idx_audit_tool_ts | tool, ts | "finance__refund 这个工具谁在用" |
idx_audit_up_ts | upstream, ts | "crm 上游的调用量趋势" |
idx_audit_sensitive | sensitive, ts | "把所有敏感调用列出来"(合规专项检查) |
idx_audit_key_ts | key_id, ts | "这把已泄漏的 Key 在被吊销前做了什么" |
idx_audit_req | request_id | "用这个 requestId 串起日志与工单" |
复合索引一律是 (维度, ts) 的顺序——因为几乎所有查询都是「某维度 + 时间倒序分页」,
这个顺序能让 SQLite 直接用索引完成排序,避免额外的 SORT 步骤。
只增不改
AuditLog 实体注释:「只增不改:代码层不提供 UPDATE/DELETE 业务路径。」
整个代码库里没有任何一处对 audit_logs 执行 update 或 delete。
唯一的删除方式是数据库文件级的物理操作(手动删库),而那需要操作系统权限,
不在应用层攻击面内。
配合 change_logs(记录管理员改了什么),形成双向可追溯:
调用行为可查,配置变更也可查——包括「谁把审计开关关了」这件事本身。
变更留痕
ChangeLogService.record() 是所有管理面写操作的统一出口:
await this.changeLog.record({
actor: req.headers['x-admin-user'] || 'system',
actorIp: req.ip,
targetType: 'role_rules', // 7 种之一
targetId: 'support',
action: 'update', // 8 种之一
beforeJson: redactDeep(before), // 变更前完整对象(脱敏后)
afterJson: redactDeep(after),
diffSummary: 'support 角色新增 deny crm__delete_record', // 人类可读
});diffSummary 是刻意冗余的:before/after JSON 虽然完整,但审计员不会去逐字段比对。
一句话的人类可读摘要让「变更记录」页可以像看流水账一样快速扫过。
auto-disable 专供系统自动操作(F9 crash loop)使用,与人工的
disable 明确区分。
数据模型
11 张表,8 个实体文件,全部由 TypeORM 映射,单文件 SQLite 承载。
表清单
| # | 表名 | 实体类 | 源文件 | 职责 |
|---|---|---|---|---|
| 1 | upstreams | Upstream | upstream.entity.ts | 上游 MCP Server 配置(含加密凭据、健康状态) |
| 2 | tools_snapshot | ToolSnapshot | tool.entity.ts | 工具快照,支撑冷启动降级 |
| 3 | tool_overrides | ToolOverride | tool.entity.ts | 工具级治理覆写(描述/开关/限流) |
| 4 | roles | Role | identity.entity.ts | 角色定义 |
| 5 | role_rules | RoleRule | identity.entity.ts | 角色的有序通配符规则 |
| 6 | api_keys | ApiKey | identity.entity.ts | MCP 通道凭据(哈希存储) |
| 7 | audit_logs | AuditLog | audit.entity.ts | 调用台账,只增不改,7 条索引 |
| 8 | change_logs | ChangeLog | audit.entity.ts | 配置变更留痕,含 before/after 快照 |
| 9 | settings | Setting | setting.entity.ts | 键值型全局设置,13 项内置默认 |
| 10 | circuit_breaker_events | CircuitBreakerEvent | breaker-event.entity.ts | 熔断状态跃迁历史(P1-C) |
| 11 | backups | Backup | backup.entity.ts | 备份记录与状态(P1-A) |
upstreams
| 列 | 类型 | 说明 |
|---|---|---|
id | integer PK | 自增主键 |
name | text unique | 命名空间前缀,受 /^[a-z][a-z0-9_-]{1,31}$/ 约束 |
display_name | text | 后台展示名,可含中文与空格 |
transport | text | stdio | http |
url | text null | http 上游地址,注册时过 SSRF 校验 |
headers_enc | text null | AES-256-GCM 加密的请求头 JSON |
command | text null | stdio 可执行文件 |
args_json | text null | stdio 参数数组 JSON |
cwd | text null | stdio 工作目录 |
env_enc | text null | 加密的自定义环境变量(与白名单 env 合并后传给子进程) |
sensitivity | text | normal | sensitive,决定审计是否存全量参数 |
enabled | integer | 0/1。F9 crash loop 会自动置 0 |
owner | text | 责任人 / 团队,用于告警定位 |
timeout_ms | integer null | 逐上游超时覆写,null 走 settings 默认 20000 |
concurrency | integer null | 逐上游并发覆写,null 走 settings 默认 20 |
remark | text | 备注 |
last_status | text | up | down | unknown,健康检查写入 |
last_error | text null | 最近一次错误(经 safeMessage 净化),crash loop 时为 'crash_loop' |
last_checked_at | integer null | 最近探活时间戳(ms) |
created_at / updated_at | integer | 毫秒时间戳 |
全库时间戳统一用 integer 毫秒,不用 SQLite 的 text 日期。
理由:JS Date.now() 直接可存、比较与排序是整数运算更快、
不受时区与格式歧义影响;展示层再格式化。
实体属性用 camelCase,数据库列名通过 @Column({ name: 'snake_case' }) 显式映射。
tools_snapshot / tool_overrides
full_name PK · upstream(索引)·
original_name · title · description ·
input_schema(JSON text)· first_seen_at · last_seen_at
主键直接用全名而非自增 id:全名本身就是天然唯一键, 省一次 join,也让「按上游批量替换」可以直接用
WHERE upstream = ?。
full_name PK · description ·
enabled · rate_qpm · cache_ttl_s ·
note · updated_by · updated_at
与快照分表而非合并:快照由系统写、覆写由人写, 生命周期与所有权完全不同。上游刷新会整体替换快照,绝不能连带清掉人工覆写。
roles / role_rules / api_keys
| 表 | 关键列 |
|---|---|
roles |
name PK · description · is_builtin(内置角色不可删)· created_at |
role_rules |
id PK · role · effect(deny/allow) · pattern · sort · memo索引 idx_rules_role (role, sort)
— 规则永远按「角色 + 顺序」整批读取,这条复合索引正好覆盖 |
api_keys |
id PK · key_hash unique · key_prefix · name ·
user_name · team · role · profile ·
expires_at · revoked · revoked_at · revoke_reason ·
last_used_at · created_by · created_at |
api_keys.role 是外键语义但无数据库级约束——
删除角色前由控制器业务校验「是否还有未吊销的 Key 绑定此角色」,
有则拒绝删除(返回 409)。选择业务校验而非 FK 级联,是为了给出明确的错误信息而不是静默级联删除。
settings(13 项内置默认)
| key | 默认值 | 含义 |
|---|---|---|
ratelimit.default_qpm | 30 | 令牌桶默认速率(次/分钟) |
ratelimit.burst | 30 | 桶容量,决定允许的突发量 |
upstream.timeout_ms | 20000 | 上游调用默认超时 |
upstream.concurrency | 20 | 每上游默认并发上限 |
audit.enabled | 1 | 审计总开关 |
audit.args_full_threshold | 4096 | 全量参数存储的字节阈值 |
registry.refresh_interval_s | 300 | 注册表定时刷新间隔 |
session.idle_timeout_s | 1800 | 会话空闲回收阈值(30 分钟) |
rbac.mode | enforce | F1:enforce 拒绝 / observe 影子放行 |
backup.enabled | 1 | P1-A 自动备份开关 |
backup.cron | 0 3 * * * | 备份调度表达式(默认每天 03:00) |
backup.tz | Asia/Shanghai | cron 的时区 |
backup.keep | 7 | 滚动保留份数,超出自动清理最旧的 |
写入策略:seed 阶段只插入不存在的 key,绝不覆盖已有值——
否则每次重启都会把管理员调好的参数打回默认。修改走
PUT /admin/api/settings,受 SETTINGS_WHITELIST 约束(未知 key 直接 400),
并全量写 change_logs。
circuit_breaker_events / backups
(ts) · (upstream, ts) · (event, ts),
分别服务时间轴、单上游历史、按类型统计三种查询。(ts) · (status, ts)。
status='failed' 的记录会触发 IM 告警(P1-D)。seed 的幂等设计
database/seed.ts(143 行)可以重复执行任意次,行为分类处理:
| 对象 | 策略 | 理由 |
|---|---|---|
角色 roles | upsert | 不存在则建,存在则更新 description,但不动 is_builtin |
规则 role_rules | 整体替换 | 先 DELETE WHERE role=? 再批量插入。
规则是有序的集合,增量 patch 极易出错;整体替换保证与 ROLE_SEEDS 完全一致 |
设置 settings | 只插不改 | 绝不覆盖管理员调过的值 |
| API Key | 仅当 count === 0 | 库里一把 Key 都没有时才签发一把开发用 Key, 并把明文打印到 stdout 一次;已有 Key 则完全跳过 |
「规则整体替换」意味着:如果你在后台改过内置角色的规则,重跑 seed 会把改动冲掉。 需要定制时请新建角色,而不是改内置角色。生产环境建议把 seed 从启动流程中移除, 只在首次部署时手动执行一次。
P1-A · 数据库热备份
governance/backup.service.ts(269 行)用
better-sqlite3 的在线备份 API,不停机、不加全局锁:
async run(trigger: 'manual' | 'cron', actor = 'system'): Promise<Backup> {
const t0 = Date.now();
const filename = `gateway-${fmtTs(new Date())}-${trigger}.db`;
const absPath = join(this.backupDir, filename);
try {
// SQLite 在线备份 API:逐页复制,期间写入不阻塞
this.dataSource.driver.master.backup(absPath);
const bytes = statSync(absPath).size;
const rec = await this.repo.save({
ts: Date.now(), filename, absPath, bytes,
durationMs: Date.now() - t0, trigger, status: 'ok', actor,
});
await this.prune(); // 滚动保留 backup.keep 份
return rec;
} catch (e) {
await this.repo.save({ …, status: 'failed', errorMsg: safeMessage(e) });
this.im.backupFailed(trigger, safeMessage(e)); // P1-D
throw e;
}
}- 为什么不用 cp
- WAL 模式下主库文件与
-wal是分开的,cp单拷主库会丢掉尚未 checkpoint 的最近写入,得到的是一个不一致的快照。db.backup()走 SQLite 官方在线备份协议,产物是自洽的完整库 - cron 热更新
- 改
backup.cron/backup.tz后node-cron任务会被销毁重建,无需重启进程 - 滚动保留
prune()按ts倒序保留backup.keep份, 多余的同时删除文件与记录;清理失败会调im.backupPruneFailed()- 下载
GET /admin/api/backups/:id/download以附件流返回, 受 AdminTokenGuard 保护- 命名规则
gateway-20260913-030000-cron.db—— 时间戳 + 触发方式,文件名本身即可读,方便在文件系统层面直接挑选
恢复流程:停网关 → 把备份文件覆盖到 data/gateway.db →
删除残留的 gateway.db-wal 与 gateway.db-shm(否则会与新主库冲突)
→ 启动。建议恢复前先把当前 data/ 整目录另存一份。
MCP 传输层
协议握手、方法分派、会话生命周期与 SSE 通知流——对模型那一侧的全部细节。
三个 HTTP 端点,一个路径
| 方法 | 路径 | 语义 |
|---|---|---|
| POST | /mcp |
承载全部 JSON-RPC 消息。响应可能是 application/json(普通请求)
或 text/event-stream(需要流式返回时) |
| GET | /mcp |
升级为 SSE 长连接,服务端主动推送 notifications/tools/list_changed;
每 25s 一条心跳注释帧 |
| DELETE | /mcp |
客户端主动终止会话,服务端清理 Map 条目并关闭 SSE Subject |
这正是 MCP Streamable HTTP 传输的标准形态:单一路径、多方法复用,
取代了早期版本里 /sse + /messages 的双端点设计。
进入分派前的四道校验
- Origin 校验(防 DNS rebinding)
浏览器发起的跨源请求带
Origin头。若请求带 Origin 但不在MGW_ALLOWED_ORIGINS(默认http://localhost:5173,http://127.0.0.1:5173)内,直接 403。 非浏览器客户端通常不带 Origin,此时放行(配置留空即完全不校验)。 DNS rebinding 攻击正是靠浏览器把一个恶意域名解析到 127.0.0.1 来打本地服务,Origin 校验是最有效的阻断手段。 - Accept 头校验
MCP 规范要求客户端声明同时接受 JSON 与 SSE:
Accept: application/json, text/event-stream。缺失则 406, 并返回明确的错误消息指导客户端修正——这能挡掉大量"随手 curl 一下"的误用。 - Bearer 鉴权
解析
Authorization: Bearer mgw_live_…→ApiKeyService.verify()。 失败返回 401,且带WWW-Authenticate: Bearer resource_metadata="{MGW_PUBLIC_ORIGIN}/.well-known/oauth-protected-resource", 让支持 OAuth 发现流程的客户端能自动走后续握手。同时写一条auth_fail审计。 - 会话校验
除
initialize外,所有方法都要求Mcp-Session-Id头对应一个活跃会话。 会话不存在或已过期 → 404(MCP 规范指定的状态码,客户端据此重新 initialize)。
协议版本协商
const PROTOCOL_VERSION = '2025-06-18'; // 网关首选
const FALLBACK_PROTOCOL = '2025-03-26'; // 客户端不认识首选版本时回落
// initialize 处理
const requested = params?.protocolVersion;
const negotiated = requested === PROTOCOL_VERSION ? PROTOCOL_VERSION : FALLBACK_PROTOCOL;
return {
jsonrpc: '2.0', id,
result: {
protocolVersion: negotiated,
capabilities: { tools: { listChanged: true } }, // ★ 声明支持工具变更推送
serverInfo: { name: 'mcp-gateway', version: '2.0.0' },
instructions: '…', // 给模型的使用说明
},
};
// 响应头下发会话 ID
res.setHeader('mcp-session-id', sessionId);capabilities.tools.listChanged = true 是关键声明:
它告诉客户端「工具集可能变,请订阅通知」。客户端收到
notifications/tools/list_changed 后应重新 tools/list。
方法分派表
| method | 需要会话 | 处理 |
|---|---|---|
initialize | 否(创建会话) | 版本协商 + 下发 Mcp-Session-Id,写 initialize 审计 |
notifications/initialized | 是 | 握手完成通知,无响应体(JSON-RPC notification 语义) |
initialized | 是 | 兼容部分客户端发的裸名,与上者等价 |
ping | 是 | 返回空 result: {},刷新会话 lastActiveAt |
tools/list | 是 | 构建 allowedSet → registry.listFor(allowed) → 写 tools_list 审计 |
tools/call | 是 | zod 校验 params.name / params.arguments → router.routeCall() |
| 其它 | — | JSON-RPC error -32601 Method not found |
不支持的能力:resources/*、prompts/*、logging/*、
completion/* 均未实现,capabilities 里也不声明。
网关的定位是工具治理,资源与提示词的治理是另一个问题域。
客户端请求这些方法会得到 -32601,符合规范的优雅降级。
会话管理
transport/session.manager.ts(119 行)用进程内 Map 持有会话:
interface Session {
id: string; // uuid,即 Mcp-Session-Id
keyId: number;
role: string;
userName: string | null;
team: string | null;
callerIp: string | null;
clientInfo: string | null; // initialize 时客户端自报的 name/version
createdAt: number;
lastActiveAt: number; // 每次请求刷新
stream$: Subject<SseEvent>; // 该会话的通知流
res?: Response; // 当前挂着的 SSE 响应对象
}- 容量上限
MGW_MAX_SESSIONS(默认 300)。达到上限时新initialize被拒, 并触发ImService.sessionLimitReached()告警—— 这既防资源耗尽,也是"Key 被滥用"的信号- 空闲回收
- 定时器扫描,
now - lastActiveAt > MGW_SESSION_IDLE_TIMEOUT_S(默认 1800s) 的会话被清理,stream$complete - 活跃数指标
- Gauge
mgw_sessions_active - 广播
broadcastListChanged()遍历全部会话,向每个stream$push 一条notifications/tools/list_changed;对已断开的响应做 try/catch 静默跳过- 优雅排空
drain()在进程退出时向所有会话发送关闭事件并 complete Subject, 让客户端明确知道是服务端主动关闭而非网络故障
会话在进程内 → 无法水平扩容。多实例部署时,
initialize 与后续请求必须落到同一实例(sticky session),
且一个实例上的工具变更无法广播到另一个实例的会话。
单实例部署是这个设计的隐含前提。
SSE 心跳与推流
@Get()
async sse(@Req() req, @Res() res: Response) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // ★ 关键:告诉 Nginx 不要缓冲
});
const session = this.sessions.get(req.headers['mcp-session-id']);
const sub = session.stream$.subscribe((ev) => {
res.write(`event: message\ndata: ${JSON.stringify(ev)}\n\n`);
});
const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);
req.on('close', () => { clearInterval(heartbeat); sub.unsubscribe(); });
}- 25s 心跳:以
:开头的是 SSE 注释帧,客户端会忽略内容但保持连接活跃。 选 25s 是因为常见的中间代理(Nginxproxy_read_timeout默认 60s、 各类云 LB 多为 30~60s)都会在 30s 以上空闲时断连。 X-Accel-Buffering: no:没有这一头,Nginx 会把 SSE 响应缓冲起来, 客户端收不到实时事件——这是 SSE 部署最经典的坑。no-transform:阻止代理压缩或改写响应体。- MetricsMiddleware 跳过 SSE:检测到
Content-Type: text/event-stream时不记录延时直方图, 否则一条几小时的长连接会把 p99 拉成天文数字。
tools/list 的响应构造
const allowed = this.rbac.buildAllowedSet(caller.role, this.registry.allToolNames(), this.registry.version);
const tools = this.registry.listFor(allowed); // 已应用覆写、已过滤 disabled
return {
result: {
tools: tools.map((t) => ({
name: t.fullName, // ★ 暴露全名,不是原名
title: t.def.title,
description: t.def.description, // 可能已被 tool_overrides 覆写
inputSchema: t.def.inputSchema,
})),
},
};注意这里不返回 nextCursor——注册表规模在企业场景下(数百工具)
一次返回完全可接受,分页反而会让 RBAC 过滤逻辑复杂化(分页边界与权限边界不重合)。
参数校验与异常归一化
tools/call 的 params 用 zod 校验:
const CallParams = z.object({
name: z.string().min(1).max(200),
arguments: z.record(z.unknown()).optional().default({}),
});
// common/zod-exception.filter.ts —— 全局过滤器
@Catch(ZodError)
export class ZodExceptionFilter implements ExceptionFilter {
catch(err: ZodError, host: ArgumentsHost) {
const first = err.issues[0];
const msg = first
? `${first.path.join('.')}: ${first.message}`
: '参数校验失败';
host.switchToHttp().getResponse()
.status(400).json({ statusCode: 400, message: msg, error: 'Bad Request' });
}
}价值:zod 原生的 ZodError 结构冗长且嵌套,直接抛给客户端会得到一个 500 +
一大坨 JSON。过滤器把它归一化为一条指向具体字段的人类可读消息,
既方便调试,也避免暴露内部 schema 细节。
管理后台 API
9 个控制器、统一前缀 /admin/api、统一 AdminTokenGuard 保护。
通用约定
- 前缀
/admin/api(全局setGlobalPrefix之外单独挂载)- 鉴权
- 类级
@UseGuards(AdminTokenGuard);Authorization: Bearer <ADMIN_TOKEN> - 操作人
x-admin-user头,写进change_logs.actor- 请求体校验
- zod schema +
ZodExceptionFilter→ 400 - 分页
?page=1&size=50,响应{ items, total, page, size }- 时间范围
?from=&to=(毫秒时间戳)或?days=7- 错误响应
{ statusCode, message, error };消息经safeMessage()净化- 写操作副作用
- 落库 →
ChangeLogService.record()→ 刷新内存态 →SessionManager.broadcastListChanged()
上游管理 upstream.controller.ts
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/api/upstreams | 列表,含解密后不含凭据的安全视图 + 实时健康状态 |
| POST | /admin/api/upstreams | 创建。先真实试连并拉工具列表,失败返回 502 且不落库;名称过 NAME_RE;URL 过 SSRF;凭据 AES 加密;描述过 SUSPICIOUS_DESC 注入检测 |
| PUT | /admin/api/upstreams/:name | 更新(含凭据轮换);写 change_logs(before/after 脱敏) |
| DEL | /admin/api/upstreams/:name | 删除:断开连接 → 移除注册表条目 → bump() → 删库 |
| POST | /admin/api/upstreams/:name/probe | 即时探活,返回 { ok, latencyMs, toolCount, error } |
| POST | /admin/api/upstreams/:name/refresh | 强制刷新工具列表,返回新增/移除/变更的工具 diff |
工具目录 tool.controller.ts
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/api/tools |
工具列表,支持 ?q=(全名/描述模糊)、?upstream=、?overridden=1(只看有覆写的) |
| GET | /admin/api/tools/:fullName |
详情,同时返回 rawDescription(原值)与当前生效描述,便于对比与还原 |
| PUT | /admin/api/tools/:fullName/description | 覆写描述 |
| PUT | /admin/api/tools/:fullName/enabled | 单独上/下线该工具 |
| PUT | /admin/api/tools/:fullName/ratelimit | 设置工具级 rate_qpm |
权限矩阵 permission.controller.ts(327 行,最重的控制器)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/api/roles | 角色列表,附带每个角色的规则数与绑定 Key 数 |
| POST | /admin/api/roles | 新建角色(is_builtin = 0) |
| DEL | /admin/api/roles/:name |
删除。两条硬校验:内置角色不可删、仍有未吊销 Key 绑定时不可删(409) |
| GET | /admin/api/roles/:name/rules | 读取有序规则集 |
| PUT | /admin/api/roles/:name/rules |
整体替换规则集。支持 ?dryRun=1:只算不落库,返回与正式写入相同的 diff 结果。
写入时 normalizeSort() 自动把 deny 组排到 allow 组之前;每条 pattern 过 ReDoS 校验 |
| POST | /admin/api/roles/:name/rules/rollback |
F2:从 change_logs.before_json 恢复上一版规则,并再记一条 action='rollback' |
| POST | /admin/api/permissions/preview |
F3:影响面预览,返回 PreviewResult(12 个字段,见 09 鉴权与 RBAC) |
| GET | /admin/api/permissions/matrix |
角色 × 工具(或 × 上游)的权限矩阵,供前端网格视图渲染 |
API Key key.controller.ts
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /admin/api/keys |
签发。响应里含唯一一次明文;入参:name / userName / team / role / profile / expiresAt |
| GET | /admin/api/keys |
列表,支持 ?user= ?role= ?revoked=0|1;只返回 key_prefix 与 key_hash,永不返回明文 |
| POST | /admin/api/keys/:id/revoke |
F4 吊销。reason 枚举 leak/offboard/rotate/other;写吊销黑名单缓存,≤2s 全链路生效 |
| POST | /admin/api/keys/:id/rotate |
轮换。graceMinutes 内新旧 Key 并存,宽限期到后自动吊销旧 Key;返回新 Key 明文 |
审计与变更 audit.controller.ts
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/api/audit |
13 个过滤参数:user · tool · upstream ·
role · sensitive · ok · keyword ·
from · to · event · errorCode ·
page · size |
| GET | /admin/api/audit/:id |
单条详情,含 argsFull(若为敏感工具)与未净化的 errorMsg 原始堆栈 |
| GET | /admin/api/audit/export |
CSV 导出,接受与列表相同的过滤参数。上限 10 万行,响应带 UTF-8 BOM
\uFEFF(否则 Excel 打开中文乱码) |
| GET | /admin/api/audit/changes |
变更记录查询。过滤参数用 snake_case:?target_type=role_rules&actor= |
/admin/api/audit/changes 的查询参数是 target_type(下划线),
与其它端点的 camelCase 风格不一致——因为该参数名直接对应数据库列名。
前端 api/index.ts 已做映射,直接调 API 时需注意。
统计 stats.controller.ts
| 方法 | 路径 | 返回 |
|---|---|---|
| GET | /admin/api/stats/overview?days=7 |
totalCalls · totalFails · sensitiveCount ·
topTools · byUpstream · failTop · topUsers ·
p95ByUpstream · rbacMode · wouldForbidCount · wouldForbidTop |
| GET | /admin/api/stats/timeseries?days=7&bucket=hour |
时间序列,bucket 支持 hour | day;无数据的桶会补齐零值,
前端画图不需要额外处理断点 |
P95 的算法值得一提——SQLite 没有 PERCENTILE_CONT,实现用的是有序偏移取值:
-- 先数总量
SELECT COUNT(*) AS n FROM audit_logs
WHERE upstream = ? AND ts BETWEEN ? AND ? AND duration_ms IS NOT NULL;
-- 再取第 floor(n * 0.95) 条(已按耗时升序)
SELECT duration_ms FROM audit_logs
WHERE upstream = ? AND ts BETWEEN ? AND ? AND duration_ms IS NOT NULL
ORDER BY duration_ms
LIMIT 1 OFFSET ?; -- ? = floor(n * 0.95)两次查询都命中 idx_audit_up_ts;相比把全部耗时拉进内存排序,
这让 P95 的内存占用与数据量无关。
熔断 breaker.controller.ts
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/api/breaker/status |
所有上游的实时熔断态:{ upstream, state, fails, openedAt, retryAt } |
| GET | /admin/api/breaker/events |
历史跃迁记录,支持 ?upstream=&event=&from=&to=&page=&size= |
| GET | /admin/api/breaker/summary?days=7 |
按上游聚合:熔断次数、累计 open 时长、最近一次跃迁 |
| SSE | /admin/api/breaker/events/stream?token=… |
P1-F 实时推流。@Sse() 装饰器 +
merge(breaker$, interval(25_000).pipe(map(() => ({ type: 'ping' }))));
因 EventSource 无法带 Header,此路径用 query 传 token |
备份 backup.controller.ts
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/api/backups | 备份记录列表(分页) |
| GET | /admin/api/backups/summary |
汇总:总份数、总字节、最近成功时间、连续失败次数、下次 cron 触发时间 |
| POST | /admin/api/backups/run |
立即执行一次(trigger='manual',actor 取 x-admin-user) |
| GET | /admin/api/backups/:id/download |
以附件流下载 .db 文件 |
系统 system.controller.ts
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | /healthz | 无 | 健康探针,8 个字段(见 18 可观测性)。供 K8s liveness/readiness |
| GET | /metrics | 无 | JSON 格式指标,供前端仪表盘直接消费 |
| GET | /metrics.prom | 无 | Prometheus text format 0.0.4(P1-B) |
| GET | /admin/api/system/registry | Token | 注册表状态:版本号、工具总数、按上游分组的条目数、各上游连接态 |
| POST | /admin/api/system/registry/refresh | Token | 强制全量刷新所有上游的工具列表 |
| GET | /admin/api/settings | Token | 读取全部 settings(合并默认值) |
| PUT | /admin/api/settings | Token | 批量更新。未知 key 直接 400(SETTINGS_WHITELIST 校验),
防止拼写错误静默生成一条无效配置;成功则热生效并写 change_logs |
三个探针端点不鉴权是刻意设计(K8s / Prometheus 抓取器通常不便携带凭据),
代价是它们暴露了运行态信息。生产部署必须用网络层手段限制访问:
Nginx location /metrics { allow 10.0.0.0/8; deny all; },
或 K8s NetworkPolicy 只放行监控命名空间。
前端架构
Vue3 + Vite + UnoCSS + Pinia,无 UI 组件库,10 个管理页 + 1 个登录页。
应用装配
// frontend/src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import 'virtual:uno.css'; // UnoCSS 生成的原子类
import './styles/base.css'; // 全局观感(背景光晕/滚动条/字体)
createApp(App).use(createPinia()).use(router).mount('#app');App.vue 只做三件事:挂载 .mgw-backdrop 背景层、
包裹 <router-view> 的 <transition name="fade" mode="out-in">、
渲染全局 <ToastHost />。子组件不得自行定义全局背景色。
路由
const routes: RouteRecordRaw[] = [
{ path: '/login', name: 'login', component: () => import('../views/Login.vue'),
meta: { public: true } },
{ path: '/', component: () => import('../layouts/AdminLayout.vue'), children: [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue'),
meta: { title: '仪表盘', icon: 'gauge' } },
// … 其余 9 页 …
]},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
];
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.public) {
if (to.name === 'login' && auth.authenticated) return { name: 'dashboard' };
return true;
}
if (!auth.authenticated) return { name: 'login', query: { redirect: to.fullPath } };
return true;
});
/** 侧边栏导航项从路由表派生,避免两处维护 */
export const navItems = routes[1].children!
.filter((r) => r.meta?.title)
.map((r) => ({ name: r.name, path: `/${r.path}`, title: r.meta!.title, icon: r.meta!.icon }));- hash 模式(
createWebHashHistory):静态托管无需 rewrite 规则即可直达子路由,dist/丢到任意 Nginx / OSS / GitHub Pages 都能跑。 - 全部视图懒加载:首屏只下载 Login + AdminLayout + Dashboard 三个 chunk。
- navItems 单一来源:侧边栏直接从路由表派生,加一个页面只需改路由表一处。
- 登录重定向:未登录访问受保护页时把原路径塞进
?redirect=,登录后跳回。
布局 AdminLayout
- 侧栏
- 展开
248px/ 折叠76px(只留图标),折叠态存 localStorage - 顶栏
h-16,含面包屑、全局搜索入口、健康徽章、用户菜单- 健康徽章
- 每 8s 轮询
GET /healthz, 用chip-ok/chip-err显示status与上游连通数; 请求失败时徽章变红,是「后端挂了」最直观的视觉信号 - 内容区
<router-view>包transition name="fade" mode="out-in", 页面切换有 150ms 淡入淡出- 滚动
body { overflow: hidden },滚动只发生在 main 容器内, 保证侧栏与顶栏始终固定
API 客户端
frontend/src/api/client.ts(159 行)是一层薄封装,刻意不引 axios:
const TOKEN_KEY = 'mgw_admin_token';
const USER_KEY = 'mgw_admin_user';
export class ApiError extends Error {
constructor(public status: number, message: string, public body?: unknown) { super(message); }
}
export const tokenStore = {
get token() { return localStorage.getItem(TOKEN_KEY) || ''; },
get user() { return localStorage.getItem(USER_KEY) || ''; },
set(token: string, user: string) { /* 写两个键 */ },
clear() { /* 删两个键 */ },
};
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) { onUnauthorized = fn; }
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`/admin/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenStore.token}`,
'x-admin-user': tokenStore.user,
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) { tokenStore.clear(); onUnauthorized?.(); }
if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))).message || res.statusText);
return res.status === 204 ? (undefined as T) : res.json();
}
export const http = {
get: <T>(p: string) => request<T>('GET', p),
post: <T>(p: string, b?: any) => request<T>('POST', p, b),
put: <T>(p: string, b?: any) => request<T>('PUT', p, b),
del: <T>(p: string) => request<T>('DELETE', p),
};
/** CSV 导出:blob + a.download,避免浏览器直接打开乱码文本 */
export async function downloadCsv(path: string, filename: string) { … }frontend/src/api/index.ts(390 行)集中定义全部 TypeScript 接口
(Upstream / ToolItem / RoleItem / RuleItem /
PreviewResult / KeyItem / IssuedKey / AuditItem /
StatsOverview / TimeseriesPoint / Health /
RegistryStatus / Settings / BreakerStatus / BackupItem …),
并按业务域组织成一个 api 对象:
export const api = {
upstreams: { list, create, update, remove, probe, refresh },
tools: { list, detail, setDescription, setEnabled, setRatelimit },
permissions: { roles, createRole, deleteRole, rules, saveRules, rollback, preview, matrix },
keys: { issue, list, revoke, rotate },
audit: { list, detail, exportCsv, changes },
stats: { overview, timeseries },
system: { health, metrics, registry, registryRefresh, settings, saveSettings },
breaker: { status, events, summary, streamUrl },
backup: { list, summary, run, downloadUrl },
};页面只调 api.xxx.yyy(),不直接拼 URL。这让接口变更时只需改一处,
也让 vue-tsc 能在构建期发现字段不匹配。
10 个管理页职责
| 路由 | 视图 | 标题 / 图标 | 核心内容 |
|---|---|---|---|
/dashboard | Dashboard.vue | 仪表盘 · gauge | 总调用/失败/敏感调用数字卡、按上游分布、Top 工具、Top 用户、失败 Top、 P95 延时、时序折线(纯 SVG 手绘,无图表库)、RBAC 模式徽章与 wouldForbid 提示 |
/upstreams | Upstreams.vue | 上游服务 · server | 上游列表(健康状态徽章 / 工具数 / 延时)、新建与编辑弹窗(按 transport 切换字段)、 探活、强制刷新、启停、删除 |
/tools | Tools.vue | 工具目录 · tool | 全量工具表(全名 / 上游 / 描述 / 是否被覆写 / 是否启用)、搜索与上游过滤、 描述覆写编辑(并排显示原描述)、单独上下线、工具级限流设置 |
/permissions | Permissions.vue | 权限矩阵 · shield | 角色 CRUD、有序规则编辑器(拖拽排序 / dryRun 预览 / 一键回滚)、 影响面预览面板(gained / lost / 受影响 Key / 近 7 日调用量)、角色 × 工具矩阵网格 |
/keys | Keys.vue | API Key · key | Key 列表(前缀 / 用户 / 团队 / 角色 / 过期 / 最近使用)、签发弹窗、 明文一次性展示弹窗、吊销(带 reason 选择)、轮换(带宽限期) |
/audit | Audit.vue | 审计日志 · list | 13 维过滤 + 分页表格、详情抽屉(参数摘要/全量、耗时、排队时长、错误码、原始堆栈)、CSV 导出 |
/changes | Changes.vue | 变更记录 · history | 按 targetType / actor / 时间过滤,展示 diffSummary 与可展开的 before/after JSON 对比 |
/reports | Reports.vue | 数据报表 · chart | 更长周期的趋势分析:按天/小时聚合、上游健康度排名、敏感调用占比、错误码分布 |
/breaker | Breaker.vue | 熔断事件 · shield | 实时状态卡片(三态徽章 + 距重试时间)、SSE 实时事件流(新事件自动置顶)、 历史事件表、按上游汇总 |
/backups | Backups.vue | 数据库备份 · box | 备份列表(大小/耗时/触发方式/状态)、汇总卡、立即备份按钮、下载、cron 配置入口 |
自研组件(6 件)
teleport to="body" + glass-card + .mask 过渡;
role="dialog" aria-modal="true";Esc 关闭;内部 scrollbar-thinuseToast() composable;success / error / warn / info 四态,自动消失name prop 取;不用图标字体,避免额外请求与 FOUT状态管理
只有一个 Pinia store —— stores/auth.ts:
export const useAuthStore = defineStore('auth', () => {
const token = ref(tokenStore.token);
const user = ref(tokenStore.user);
const authenticated = computed(() => !!token.value);
function login(t: string, u: string) { tokenStore.set(t, u); token.value = t; user.value = u; }
function logout() { tokenStore.clear(); token.value = ''; user.value = ''; }
return { token, user, authenticated, login, logout };
});其余页面数据都是局部状态(ref / reactive),不进全局 store。
理由是这些数据没有跨页共享需求,放全局只会增加失效同步的复杂度。
main.ts 里调 setUnauthorizedHandler(() => { auth.logout(); router.push('/login'); })
把 401 处理接到 store 上,形成闭环。
Vite 配置要点
// frontend/vite.config.ts
export default defineConfig({
plugins: [vue(), UnoCSS()],
server: {
port: 5173,
proxy: {
'/admin/api': { target: 'http://127.0.0.1:8080', changeOrigin: true },
'/mcp': { target: 'http://127.0.0.1:8080', changeOrigin: true },
'/healthz': { target: 'http://127.0.0.1:8080', changeOrigin: true },
},
},
build: { outDir: 'dist', sourcemap: false },
});开发时前端直接请求同源相对路径,由 Vite 代理转发,不存在 CORS 问题。
生产部署时把 dist/ 交给 Nginx,同样反代这三个前缀到后端即可——
前后端的相对路径约定在开发与生产环境完全一致,这是选择代理而非配置
baseURL 的主要原因。
设计体系:黑白毛玻璃
整套视觉语言沉淀在 frontend/uno.config.ts 的 shortcuts 里,改主题只动这一处。
四条设计原则
#08080a + 两处极低透明度白色径向光晕 + 48px 网格,
营造纵深而非死黑。纯黑背景会让玻璃面失去可对比的参照物,毛玻璃效果就不成立了white/4~8 + backdrop-blur-xl +
1px white/10 描边 + 顶部 1px 渐变高光 → 磨砂玻璃的厚度感墨色色板
// frontend/uno.config.ts
theme: {
colors: {
ink: {
900: '#08080a', // 页面底
800: '#0d0d10', // 深面板
700: '#141418', // 卡片
600: '#1c1c22', // 悬浮/激活
},
},
},实际使用中直接引用 ink-* 的地方很少——绝大多数表面都是
white/X 半透明叠加,这样背景光晕才能透过来,形成真正的"玻璃"。
ink-* 主要用于需要不透明底的场景(如输入框聚焦态 bg-black/35)。
shortcuts 全清单
| 类名 | 展开 |
|---|---|
glass |
bg-white/[0.045] backdrop-blur-xl border border-white/10 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.45)] |
glass-panel |
bg-white/[0.03] backdrop-blur-2xl border border-white/[0.08](更浅,用于大面积容器) |
glass-inset |
bg-black/20 border border-white/[0.06] rounded-xl(内嵌区块,视觉上"凹下去") |
glass-sheen |
before:content-empty before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-white/25 before:to-transparent |
glass-card | glass glass-sheen relative overflow-hidden(卡片的标准写法) |
glass-hover | hover:bg-white/[0.07] hover:border-white/[0.16] transition(叠加在 glass-card 上) |
text-title / text-bodytext-muted / text-faint |
text-white/90 · text-white/70 · text-white/45 · text-white/28 |
btn |
inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg text-[13px] font-medium transition select-none disabled:opacity-40 disabled:pointer-events-none |
btn-primary |
btn bg-white text-black hover:bg-white/85 active:scale-[0.98] shadow-[0_2px_12px_rgba(255,255,255,0.15)] |
btn-ghost | btn bg-white/[0.04] border border-white/10 text-white/80 hover:bg-white/[0.08] |
btn-danger | btn bg-red-500/12 border border-red-500/30 text-red-300 hover:bg-red-500/20 |
btn-sm / btn-icon | 尺寸变体(更小的 padding / 正方形) |
seg-group / seg-itemseg-item-active |
分段控制器(如时间范围切换 hour/day),激活态白底黑字 |
input / input-sm |
w-full px-3 py-2 rounded-lg bg-black/25 border border-white/10 text-white/85 placeholder-white/25 focus:border-white/35 focus:bg-black/35 outline-none transition |
label / select | 表单标签与下拉,与 input 同一视觉族 |
th / td / tr-hover |
表头(小号大写灰字)· 单元格(py-2.5 px-3 border-b border-white/[0.06])· 行悬停 hover:bg-white/[0.035] |
chip | inline-flex items-center px-2 py-0.5 rounded-full text-[11px] border(基础徽章) |
chip-on / chip-off | 白底黑字(启用)/ 半透明灰(停用) |
chip-ok / chip-err / chip-warn |
emerald / red / amber 三套语义色,各含 bg-*/12 + border-*/28 + text-*/70 |
nav-item / nav-item-active |
侧栏项;激活态带 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] 内发光 + bg-white/[0.07] |
page-title / stat-num |
页标题(text-[19px] font-semibold)· 统计大数字(text-[26px] font-semibold tabular-nums) |
scrollbar-thin | 细滚动条(8px,thumb white/12) |
页面只写语义类名(glass-card、btn-primary、chip-ok),
不写具体的透明度与阴影值。这样"改主题"就等于"改 shortcuts",一次生效全站。
本文档站的样式就是按同样思路重构的:所有观感收敛到 CSS 变量,
明暗切换只换一套变量值。
base.css 承担的部分
原子类表达不了的全局观感放在 frontend/src/styles/base.css(119 行):
:root {
color-scheme: dark;
--mgw-bg: #08080a;
--mgw-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--mgw-mono: 'SF Mono', 'JetBrains Mono', 'Fira Code', Menlo, Consolas, 'Liberation Mono', monospace;
}
/* 三层径向光晕 */
.mgw-backdrop {
position: fixed; inset: 0; z-index: -1; pointer-events: none;
background:
radial-gradient(60rem 40rem at 12% -8%, rgba(255,255,255,.055), transparent 60%),
radial-gradient(50rem 40rem at 108% 12%, rgba(255,255,255,.035), transparent 60%),
radial-gradient(40rem 30rem at 50% 120%, rgba(255,255,255,.028), transparent 60%);
}
/* 48px 网格 + 径向渐隐遮罩 */
.mgw-backdrop::after {
content: ''; position: absolute; inset: 0;
background-image:
linear-gradient(rgba(255,255,255,.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.028) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, #000 20%, transparent 78%);
}
body { overflow: hidden; } /* 滚动交给布局内部容器 */
.mono { font-family: var(--mgw-mono); }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 8px; }
::selection { background: #fff; color: #000; }
:focus-visible { outline: 1px solid rgba(255,255,255,.45); outline-offset: 2px; }
.fade-enter-active, .fade-leave-active { transition: opacity .15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.mask-enter-active, .mask-leave-active { transition: opacity .2s ease; }
.mask-enter-from, .mask-leave-to { opacity: 0; }三个光晕的位置是刻意错开的(左上 / 右中 / 下中),
配合 mask-image 的径向渐隐,让网格只在视觉中心可见、边缘自然消融——
否则满屏网格会显得很"技术演示"而非"产品"。
本文档站的亮色主题
项目本身只实现了暗色(color-scheme: dark + UnoCSS dark: 'class' 预设,
未提供亮色切换)。本文档站在保留同一套设计语言的前提下新增了亮色主题,
实现方式是把所有观感收敛到 CSS 自定义属性:
| 令牌 | 暗色 | 亮色 |
|---|---|---|
--bg | #08080a | #f2f2f4 |
--glass-bg | rgba(255,255,255,.045) | rgba(255,255,255,.62) |
--glass-border | rgba(255,255,255,.10) | rgba(0,0,0,.09) |
--text-title | rgba(255,255,255,.92) | rgba(0,0,0,.90) |
--text-body | rgba(255,255,255,.72) | rgba(0,0,0,.68) |
--accent | #ffffff(主按钮白底黑字) | #0a0a0c(主按钮黑底白字) |
--inset-bg | rgba(0,0,0,.32) | rgba(0,0,0,.035) |
--grid-line | rgba(255,255,255,.028) | rgba(0,0,0,.035) |
切换逻辑(tech/docs/assets/app.js):
读写 localStorage['mgw_docs_theme'],无记录时跟随
prefers-color-scheme,并监听系统主题变化;
同时更新 <meta name="theme-color"> 以适配移动端浏览器地址栏。
亮色下毛玻璃的关键调整:玻璃面的不透明度要显著提高
(.045 → .62)。原因是亮背景上低透明度的白色叠加几乎不可见,
必须靠更高的白度 + 更明显的阴影才能维持"面"的层次感。
backdrop-blur 保持不变,磨砂质感依然存在。
配置参考
三级优先级、20 个环境变量、13 项运行时设置。所有默认值均取自源码。
三级优先级
// backend/src/config/config.service.ts
const str = (env, jsonVal, def: string) => env ?? (jsonVal != null ? String(jsonVal) : def);
const num = (env, jsonVal, def: number) => Number(env ?? jsonVal ?? def);
const list = (env, jsonVal, def: string[]) => {
if (env != null && env.trim() !== '') return env.split(',').map(s => s.trim()).filter(Boolean);
if (Array.isArray(jsonVal)) return jsonVal.map(s => String(s).trim()).filter(Boolean);
return def;
};
// gateway.json 不存在或解析失败 → 返回 {},只打一条 warn,绝不阻断启动
function readJsonConfig(): Record<string, any> { … }刻意不引第三方配置库(no config / no cosmiconfig / no dotenv): 合并逻辑只有 20 行,确定性远高于任何黑盒库,也不会引入额外的启动开销与安全面。 列表型配置统一用逗号分隔。
环境变量全表
监听
| 变量 | 默认值 | 说明 |
|---|---|---|
MGW_PORT | 8080 | HTTP 监听端口 |
MGW_HOST | 127.0.0.1 | 绑定地址。默认只监听回环,对外服务需显式改为 0.0.0.0,
此时必须同时配好防火墙与反代 |
MGW_PUBLIC_ORIGIN | http://127.0.0.1:8080 | 对外基址,用于拼接 WWW-Authenticate 里的 OAuth 保护资源元数据 URL |
MGW_ALLOWED_ORIGINS | 空数组 | 允许的浏览器 Origin,逗号分隔,防 DNS rebinding。留空 = 不校验非浏览器客户端。
.env.example 里预置了 http://localhost:5173,http://127.0.0.1:5173 |
数据
| 变量 | 默认值 | 说明 |
|---|---|---|
MGW_DATA_DIR | ./data | SQLite 与备份目录,相对 backend/ 或绝对路径。
启动时自动 mkdirSync(recursive) 创建,并额外创建 backup/ 子目录 |
MGW_CONFIG | ./config/gateway.json | 中间层配置文件路径 |
MGW_DB_SYNCHRONIZE | 1 | 实体自动建表。MVP 零迁移上手;生产建议置 0 并改用 migrations, 否则实体字段变更会直接 ALTER 生产库 |
安全(生产必填)
| 变量 | 默认值 | 说明 |
|---|---|---|
MGW_SECRET | 开发兜底 | AES-256-GCM 主密钥,推荐 64 位 hex(openssl rand -hex 32)。
变更此值将导致历史加密的上游凭据无法解密,需全部重新录入 |
MGW_SECRET_FILE | 空 | 从文件读取主密钥,优先级高于 MGW_SECRET。用于 Docker/K8s secret 挂载 |
ADMIN_TOKEN | mgw_admin_dev_token | 管理后台令牌。未设置时回落到开发默认值,禁止用于生产 |
ADMIN_TOKEN_FILE | 空 | 同上,文件形式,优先级更高 |
MGW_OUTBOUND_ALLOWED_CIDRS |
10.0.0.0/8, 127.0.0.1/32, 192.168.0.0/16 |
出站 SSRF 白名单,逗号分隔。169.254.0.0/16 与 127.0.0.0/8
永久拒绝,无需也无法通过此变量放开 |
MGW_SECRET 未设置时,代码使用固定兜底值 'a'.repeat(64)
并向 stderr 打一条 warn:
{"level":"warn","msg":"MGW_SECRET 未设置,使用开发兜底主密钥,禁止用于生产"}
这意味着不配密钥时"加密"形同虚设——任何人拿到 db 文件都能解密全部上游凭据。
生产部署检查清单的第一条就应该是它。
会话 / 上游 / 审计 / 告警
| 变量 | 默认值 | 说明 |
|---|---|---|
MGW_MAX_SESSIONS | 300 | 并发会话上限,超出拒绝新 initialize 并 IM 告警 |
MGW_SESSION_IDLE_TIMEOUT_S | 1800 | 会话空闲回收阈值(秒) |
MGW_STDIO_MAX | 8 | stdio 上游子进程总量上限 |
MGW_REGISTRY_REFRESH_INTERVAL_S | 300 | 注册表定时刷新间隔(秒) |
MGW_AUDIT_FLUSH_MS | 1000 | 审计攒批的时间触发阈值 |
MGW_AUDIT_FLUSH_SIZE | 100 | 审计攒批的条数触发阈值 |
MGW_IM_WEBHOOK | 空 | IM 告警 Webhook(企业微信 / 飞书 / Slack 通用 markdown 格式)。留空 = 不推送,
此时 ImService 只写结构化日志 |
NODE_ENV | development | development | production | test |
gateway.json 示例
backend/config/gateway.json 适合放「不敏感、不常变、想随代码库版本管理」的配置:
{
"port": 8080,
"host": "127.0.0.1",
"publicOrigin": "http://127.0.0.1:8080",
"allowedOrigins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"outboundAllowedCidrs": ["10.0.0.0/8", "192.168.0.0/16"],
"maxSessions": 300,
"sessionIdleTimeoutS": 1800,
"stdioMax": 8,
"registryRefreshIntervalS": 300,
"auditFlushMs": 1000,
"auditFlushSize": 100,
"dataDir": "./data"
}绝不要把 secret / adminToken 写进 gateway.json——
它在版本库里。这两个值只能走环境变量或 *_FILE。
ConfigService 也确实没有从 json 读取这两项的分支,
只有 readFileSecret(process.env.X, 'X_FILE') 一条路径。
运行时设置(后台可改,热生效)
与「启动时确定、改了要重启」的环境变量不同,settings 表里的 13 项
可以在后台直接改并立即生效。划分标准是:运维日常需要调的用 settings,
部署时确定的用环境变量。
完整的 13 项默认值见 12 数据模型 · settings。
修改入口:PUT /admin/api/settings 或后台「系统设置」页,
受 SETTINGS_WHITELIST 约束,每次修改都写 change_logs。
session.idle_timeout_s 与 registry.refresh_interval_s
同时存在于 settings 与环境变量两处。读取顺序是
settings 优先(运行期可覆盖),环境变量作为 settings 缺省时的初值来源。
若发现改了环境变量没生效,先检查 settings 表里是否已有值。
三种 env 喂入方式
后端不内置 dotenv,直接读进程环境变量。因此需要外部机制把 .env 喂进去:
| 场景 | 做法 |
|---|---|
| 仓库 start.sh | ./start.sh 内部执行 set -a; source backend/.env; set +a,
在当前 shell 加载后自动 export 给子进程。本地开发最省事的方式 |
| 手动本地 | cp .env.example .env && set -a && source .env && set +a && npm run start:dev |
| systemd | 服务单元里写 EnvironmentFile=/etc/mcp-gateway.env——
直接兼容 .env.example 的格式,无需转换 |
| Docker / K8s | docker run --env-file .env,或 compose 的 env_file:;
敏感项建议改用 secret 挂载 + MGW_SECRET_FILE / ADMIN_TOKEN_FILE |
可观测性
三个探针端点、11 个自定义指标、结构化日志、事件循环监控与 IM 告警。
GET /healthz
不鉴权,供 K8s liveness / readiness 与负载均衡健康检查使用。返回 8 个字段:
{
"status": "ok", // ok | degraded | down
"uptime_s": 12843, // 进程运行秒数
"version": "2.0.0",
"upstreams": { "total": 4, "up": 3, "down": 1 },
"registry": { "tools": 87, "version": 23 },
"sessions": 12,
"audit_queue": 0, // 持续 > 0 说明写入跟不上
"el_p99_ms": 3.2 // 事件循环延迟 p99
}status 的判定逻辑:全部上游 up → ok;部分 down → degraded;
全部 down → down。即使 degraded 也应返回 HTTP 200——
网关自身是健康的,只是某些上游不可用,把它从负载均衡摘掉会扩大故障面。
GET /metrics.prom · 11 个自定义指标
使用独立的 prom-client Registry(非全局默认),避免与其它库的指标串味;
全部指标带 mgw_ 前缀。
| 指标名 | 类型 | 标签 | 含义 |
|---|---|---|---|
mgw_tool_calls_total | Counter | upstream, status |
工具调用总数,status 为 ok 或具体错误码 |
mgw_tool_call_duration_ms | Histogram | upstream |
工具调用耗时。buckets [50,100,250,500,1000,2500,5000,10000,20000,30000] |
mgw_upstream_breaker_state | Gauge | upstream |
熔断态:0=closed · 1=half-open · 2=open。
数值化设计让 PromQL 可以直接 max() 与告警比较 |
mgw_registry_tools_total | Gauge | — | 注册表工具总数 |
mgw_registry_version | Gauge | — | 注册表版本戳。changes(mgw_registry_version[10m]) 可观察抖动频率 |
mgw_sessions_active | Gauge | — | 活跃会话数 |
mgw_upstreams_connected | Gauge | — | 已连接上游数 |
mgw_audit_queue_depth | Gauge | — | 审计待写队列深度,持续 > 0 是写入瓶颈的信号 |
mgw_ratelimit_buckets | Gauge | — | 令牌桶数量,反映 Key × 工具的组合基数 |
mgw_http_requests_total | Counter | method, route, status |
P1-E HTTP 入口 QPS。status 归类为 2xx/4xx/5xx |
mgw_http_request_duration_ms | Histogram | method, route |
P1-E HTTP 入口延时。buckets [1,5,10,25,50,100,250,500,1000,2500,5000,10000,20000,30000] |
此外还调用了 collectDefaultMetrics({ register, prefix: 'mgw_', eventLoopMonitoringPrecision: 10 }),
自动带上 Node 运行时指标(GC、堆内存、事件循环延迟等),同样加 mgw_ 前缀。
HTTP 埋点中间件
governance/metrics.middleware.ts(71 行)有三个关键处理:
/** 1) 排除探针端点,避免自我观测污染业务指标 */
function isExcluded(path: string): boolean {
return path === '/metrics' || path === '/metrics.prom' || path === '/healthz'
|| path.startsWith('/metrics/');
}
/** 2) 路由归一化:用 baseUrl + route.path,而非原始 URL */
function normalizeRoute(req): string {
// '/admin/api/keys/42/revoke' → '/admin/api/keys/:id/revoke'
return req.baseUrl + (req.route?.path ?? '') || '__unmatched__';
}
/** 3) status 归类到百位,控制标签基数 */
const statusLabel = `${Math.floor(res.statusCode / 100) * 100}`; // '200' / '404' / '500'
// 4) SSE 长连接跳过延时埋点
if (res.getHeader('content-type')?.includes('text/event-stream')) return;标签基数(cardinality)是 Prometheus 的头号杀手。这两个处理是必须的:
若直接用 req.originalUrl 作为 route 标签,
/admin/api/keys/1/revoke、/admin/api/keys/2/revoke …
会各自成为一个独立时间序列,几万把 Key 就是几万个序列,Prometheus 内存直接爆掉。
req.route.path 拿到的是 Express 匹配到的模式串(/keys/:id/revoke),
基数恒定。
同理,status 归类到百位而非精确值,把 40+ 种可能压到 5 种。
未匹配任何路由的请求统一记为 __unmatched__,
避免扫描器探测 /wp-admin 之类的随机路径制造海量序列。
Gauge 的取值策略:不用定时器周期更新,而是在每次 Prometheus 抓取前调
refreshRuntime() 现算。这样指标永远是抓取瞬间的真实值,
也不会因为定时器与抓取周期不同步而出现锯齿。
GET /metrics(JSON)
与 /metrics.prom 同源数据,但组织成前端友好的 JSON 结构,
供仪表盘直接消费,不需要前端实现 Prometheus 文本解析。
这是一个刻意的双出口设计:机器读 text format,人(前端)读 JSON。
F7 · 事件循环延迟监控
common/event-loop-monitor.ts(97 行)用 Node 内置的
perf_hooks.monitorEventLoopDelay:
const RESOLUTION_MS = 20; // 采样精度
const SAMPLE_INTERVAL_MS = 5_000; // 每 5s 读一次
const ALERT_P99_MS = 200; // p99 超过 200ms 告警
const ALERT_COOLDOWN_MS = 5 * 60_000; // 5 分钟冷却,防告警风暴
const history = monitorEventLoopDelay({ resolution: RESOLUTION_MS });
history.enable();
setInterval(() => {
const p99 = history.percentile(99) / 1e6; // ns → ms
const p50 = history.percentile(50) / 1e6;
healthReporter.setEventLoop({ p50, p99 }); // 供 /healthz 的 el_p99_ms
history.reset(); // ★ 必须重置,否则被历史数据钝化
if (p99 > ALERT_P99_MS && now - lastAlert > ALERT_COOLDOWN_MS) {
lastAlert = now;
im.eventLoopSlow(p99);
logger.warn({ event: 'event_loop_slow', p99_ms: p99 });
}
}, SAMPLE_INTERVAL_MS);为什么这个指标重要:Node 是单线程的,事件循环被阻塞时 所有请求都会一起变慢,但每个请求各自的耗时看起来只是"稍微慢了一点", 很难从业务指标定位。事件循环 p99 是唯一能直接反映"进程整体卡住了"的指标。
history.reset() 是容易被漏掉的关键一行:不重置的话,
直方图会累积从启动至今的全部样本,一次 10 分钟的卡顿会被之后几小时的正常数据稀释到看不见——
监控钝化,告警永远不触发。
导出接口:startEventLoopMonitor() · stopEventLoopMonitor() ·
getEventLoopPercentiles() · registerEventLoopAlert() ·
__triggerAlertForTest()(供冒烟脚本验证告警链路)。
结构化日志
common/logger.ts(27 行)刻意极简:单行 JSON,写 stderr。
export const logger = {
info: (o: object) => write('info', o),
warn: (o: object) => write('warn', o),
error: (o: object) => write('error', o),
debug: (o: object) => { if (process.env.NODE_ENV !== 'production') write('debug', o); },
};
function write(level: string, o: object): void {
process.stderr.write(JSON.stringify({
level, ts: Date.now(), ...o,
}) + '\n');
}- 为什么是 stderr
- stdout 在很多部署形态下被用作数据通道(尤其 stdio MCP 场景);
日志走 stderr 是 12-factor 的推荐做法,也便于
2>&1统一重定向 - 为什么是单行 JSON
- 可被 Loki / ELK / CloudWatch 直接结构化解析,无需 grok 规则; 一行一条保证并发写入不会交错
- 为什么不用 winston/pino
- 需求只有"打一行 JSON",27 行自研代码零依赖、零配置、零学习成本
- 格式化脚本
- scripts/mgw-fmt.py 把 JSON 日志流转成人类可读的彩色对齐格式,
本地排查时用
./start.sh 2>&1 | python3 scripts/mgw-fmt.py
错误消息净化
common/safe-message.ts(33 行)在任何错误文本返回给模型之前做净化:
| 剥离内容 | 为什么必须剥 |
|---|---|
堆栈(at … 行) | 暴露文件路径、框架版本、内部函数名 |
IP:port | 暴露内网拓扑,为横向移动提供地图 |
| SQL 片段 | 暴露表结构与查询逻辑,辅助注入 |
| 绝对路径 | 暴露部署结构与用户名 |
Bearer xxx | 直接泄漏凭据——上游 401 响应里常回显请求头 |
| 截断 200 字符 | 限制信息量,也防止超长错误撑爆模型上下文 |
净化后的文本进 audit_logs.error_msg?不。
净化文本用于返回给模型;error_msg 字段存的是原始错误,
仅在后台审计详情页(管理员已鉴权)可见。两者职责不同,不能混用。
IM 告警
notify/im.service.ts(94 行)向 MGW_IM_WEBHOOK 推送 markdown 消息,
带 5 分钟同类抑制(SUPPRESS_MS = 5 * 60_000):
同一告警类型在冷却期内只推一次,避免上游持续故障时把群聊刷爆。
| 告警方法 | 触发条件 |
|---|---|
circuitOpened(upstream) | 熔断从 closed/half-open 跃迁到 open |
circuitClosed(upstream) | 探针成功,从 half-open 恢复 closed |
upstreamRecovered(upstream) | 健康检查发现从 down 恢复 up |
sessionLimitReached() | 活跃会话数达到 MGW_MAX_SESSIONS |
auditFallbackGrowing(size) | 审计写库失败,JSONL 兜底文件在增长 |
eventLoopSlow(p99) | F7 事件循环 p99 > 200ms |
stdioCrashLoop(upstream) | F9 stdio 上游 5 分钟内崩溃 5 次,已自动禁用 |
backupFailed(trigger, err) | P1-D 备份执行失败 |
backupPruneFailed(err) | P1-D 滚动清理失败(磁盘可能已满) |
Webhook 未配置时,全部方法降级为写结构化日志,不抛错—— 告警通道不可用不应该影响主业务流程。
P1-G · Grafana 看板
docs/grafana/ 提供开箱即用的监控栈配置:
- prometheus.yml
- 抓取配置模板,指向
http://127.0.0.1:8080/metrics.prom, 含推荐的scrape_interval与 job 命名 - gateway-overview.json
- Grafana 仪表盘 JSON,可直接 Import。 面板覆盖:调用 QPS 与错误率、按上游的 P95/P99 延时、熔断状态时间线、 活跃会话数、审计队列深度、事件循环 p99、Node 堆内存与 GC
- README.md
- docker-compose 起 Prometheus + Grafana 的步骤与导入说明
几条实用的 PromQL:
# 工具调用错误率(5 分钟窗口)
sum(rate(mgw_tool_calls_total{status!="ok"}[5m]))
/ sum(rate(mgw_tool_calls_total[5m]))
# 按上游的 P95 延时
histogram_quantile(0.95,
sum by (le, upstream) (rate(mgw_tool_call_duration_ms_bucket[5m])))
# 当前处于熔断态的上游
mgw_upstream_breaker_state == 2
# 审计写入是否跟不上(持续大于 0 即告警)
mgw_audit_queue_depth > 0
# HTTP 5xx 比例
sum(rate(mgw_http_requests_total{status="500"}[5m]))
/ sum(rate(mgw_http_requests_total[5m]))错误码契约
治理链每一步的失败都对应一个稳定错误码,前端与模型据此行动。
11 个错误码
| 错误码 | 返回给模型的文本 | kind | 产生位置与含义 |
|---|---|---|---|
UNAUTHORIZED | 未授权 | — | transport 层:Bearer 缺失 / 格式错 / Key 不存在 / 已吊销 / 已过期。以 HTTP 401 返回,不进治理链 |
FORBIDDEN | 无权调用该工具 | client-error | GATE 2:enforce 模式下 allowedSet 未命中。不计入熔断 |
WOULD_FORBID | 影子模式下放行,实际无权限(仅审计标记) | 不失败 | F1:observe 模式下未命中 allow 时照常放行,
仅写 rbac_would_forbid 审计(resultOk=1)。
这个错误码永远不会返回给模型,它只存在于审计与统计里 |
TOOL_NOT_FOUND | 工具不存在或已下线 | client-error | GATE 1:注册表无此全名,或 entry.enabled = false(被覆写单独下线) |
RATE_LIMITED | 调用过于频繁,请稍后重试 | client-error | GATE 3:令牌桶空。附带 meta.retryAfterS 指导退避 |
UPSTREAM_DOWN | 上游服务暂不可用 | upstream-error | 两处:GATE 1(上游 enabled=0 或记录不存在)与 GATE 4(熔断 open)。
连接建立失败也归此类 |
UPSTREAM_BUSY | 上游繁忙,请稍后重试 | upstream-error | GATE 5:并发等待队列已满(> 50)。附带 meta.queueMs |
UPSTREAM_TIMEOUT | 上游响应超时 | upstream-error | GATE 6:AbortController 触发。计入熔断 |
UPSTREAM_ERROR | 工具执行失败 | upstream-error | GATE 6:上游返回了错误 / 抛异常。计入熔断 |
BAD_ARGS | 参数不合法 | client-error | GATE 6 前置:zod 或上游 schema 校验失败。不计入熔断——参数错是调用方的问题 |
INTERNAL | 网关内部错误 | upstream-error | 兜底:任何未被上述分类捕获的异常。出现即意味着代码有 bug,应当告警排查 |
kind 的意义:错误归因
export class GatewayToolError extends Error {
constructor(
public readonly code: ErrorCodeType,
public readonly safeMessage: string,
public readonly rawError?: string,
public readonly kind: 'client-error' | 'upstream-error' = 'upstream-error',
public readonly meta: Record<string, unknown> = {},
) {
super(`[${code}] ${safeMessage}`); // ★ 固定格式
}
/** 客户端问题不计入熔断 */
get isClientError(): boolean { return this.kind === 'client-error'; }
}kind 决定这个失败该怪谁,进而决定是否计入熔断统计:
FORBIDDEN · RATE_LIMITED · BAD_ARGS · TOOL_NOT_FOUND
调用方的问题。上游完全健康, 如果把这些计入熔断,一个乱传参数的模型就能把整个上游搞挂。
UPSTREAM_DOWN · UPSTREAM_BUSY · UPSTREAM_TIMEOUT ·
UPSTREAM_ERROR · INTERNAL
服务侧的问题。连续 5 次即触发熔断,保护上游也保护自己。
isError vs JSON-RPC error
MCP 规范对两类失败有明确区分,网关严格遵守:
| 失败类型 | 返回形态 | 适用场景 |
|---|---|---|
| 工具业务失败 | { result: { isError: true, content: [{ type:'text', text:'[CODE] 描述' }] } } |
治理链任何一道的拒绝、上游执行失败、参数不合法。 让模型看到失败原因并有机会调整策略(换工具、改参数、稍后重试) |
| 协议级失败 | { error: { code: -32601, message: 'Method not found' } } |
方法不存在、JSON-RPC 结构非法、请求体解析失败。 这类错误模型无法通过调整参数解决,属于客户端实现问题 |
| 传输级失败 | HTTP 状态码 401 / 403 / 404 / 406 + JSON 错误体 | 鉴权失败、Origin 被拒、会话不存在、Accept 头不合规。发生在 JSON-RPC 解析之前 |
把「工具执行失败」包成 JSON-RPC error 是一个常见错误实现。
后果是客户端 SDK 会把它当作协议故障处理,可能直接断开连接或抛出异常,
模型完全看不到失败原因,也就无法自我纠正。
用 isError: true 才能让失败信息进入模型的上下文。
HTTP 状态码对照
| 状态码 | 场景 | 通道 |
|---|---|---|
200 | 正常响应(含 isError: true 的工具失败) | MCP |
202 | notification 类消息(无响应体) | MCP |
400 | JSON-RPC 结构非法 / zod 校验失败 / settings 未知 key | 两者 |
401 | Key 无效或已吊销 / Admin Token 错误(带 WWW-Authenticate) | 两者 |
403 | Origin 不在白名单(DNS rebinding 防护) | MCP |
404 | 会话不存在或已过期 / Admin 资源未找到 | 两者 |
406 | Accept 头缺少 application/json 或 text/event-stream | MCP |
409 | 名称冲突 / 删除仍被引用的角色 / 上游名已存在 | Admin |
429 | (预留)入口级限流 | — |
500 | 未捕获异常 | 两者 |
502 | 创建上游时试连失败 | Admin |
安全须知与生产加固
代码里已经做了的防护,以及部署时必须由你补上的部分。
代码内建的防护
| 威胁 | 防护机制 | 实现位置 |
|---|---|---|
| 凭据明文存储 | API Key 只存 SHA-256;上游凭据 AES-256-GCM 加密 | auth/crypto.service.ts |
| 令牌时序侧信道 | crypto.timingSafeEqual 常量时间比较 | admin/guards/admin-token.guard.ts |
| SSRF / 云元数据泄漏 | CIDR 白名单 + ALWAYS_DENY 永久拒绝段 + redirect:'manual' | gateway/ssrf.ts |
| 子进程读取网关密钥 | stdio env 白名单,屏蔽 MGW_* / ADMIN_TOKEN / NODE_OPTIONS | gateway/upstream-manager.service.ts |
| DNS rebinding | 浏览器请求强制 Origin 白名单校验 | transport/mcp.controller.ts |
| 错误消息泄漏内网信息 | safeMessage() 剥离堆栈 / IP:port / SQL / 路径 / Bearer,截断 200 字符 | common/safe-message.ts |
| ReDoS(正则拒绝服务) | pattern 长度上限 200 + 嵌套量词检测 | auth/rbac.service.ts |
| PII 落盘 | 入库前递归正则脱敏,四类 PII | governance/redact.service.ts |
| Prompt 注入(供应链) | 上游工具描述过 SUSPICIOUS_DESC 检测并告警 | admin/upstream.controller.ts |
| 越权调用(列表/调用不一致) | tools/list 与 tools/call 共用同一 buildAllowedSet() | gateway/router.service.ts |
| 资源耗尽 | 会话上限 · stdio 子进程上限 · 限流桶 · 并发队列上限 · CSV 导出 10 万行上限 | 多处 |
| 审计丢失 / 篡改 | 攒批失败落 JSONL + 启动回放(重命名不删除);audit_logs 无 UPDATE/DELETE 路径 | governance/audit.service.ts |
| 配置误改无法追溯 | 所有写操作进 change_logs,含 before/after 与 actor/IP | admin/change-log.service.ts |
| Prometheus 基数爆炸 | 路由归一化为 req.route.path + status 归类百位 + __unmatched__ 兜底 | governance/metrics.middleware.ts |
生产部署必做清单
- 设置
MGW_SECRETopenssl rand -hex 32,通过MGW_SECRET_FILE挂载。 不设的话会用固定兜底值'a'.repeat(64),加密等于没加密。 一旦确定不要再改——改了历史凭据全部无法解密。 - 设置
ADMIN_TOKEN至少 32 字节随机串。默认值mgw_admin_dev_token是公开的,等于没有鉴权。 - 关闭自动建表
MGW_DB_SYNCHRONIZE=0,改用受控的 migration 流程。 - 收紧 SSRF 白名单
默认含
10.0.0.0/8与192.168.0.0/16两个大段。 生产应精确到实际网段,如10.20.30.0/24。 - 显式配置
MGW_ALLOWED_ORIGINS留空意味着不做 Origin 校验。若网关可能被浏览器访问,必须列出确切来源。 - 限制探针端点的网络访问
/healthz·/metrics·/metrics.prom均不鉴权。 Nginxallow/deny或 K8s NetworkPolicy 只放行监控系统网段。 - 绑定地址与反向代理
保持
MGW_HOST=127.0.0.1,由 Nginx / 网关做 TLS 终止与对外暴露。 不要让 NestJS 直接面对公网。 - 配置 IM 告警
MGW_IM_WEBHOOK。没有告警的熔断等于没熔断——问题发生了但没人知道。 - 验证备份可用
手动跑一次
POST /admin/api/backups/run,下载后实际恢复一次到测试环境。 没有验证过的备份不是备份。 - 切换 RBAC 模式前先看影子数据
上线新角色规则时先
rbac.mode=observe跑几天, 确认wouldForbidTop里没有误伤再切enforce。 - data 目录权限
chmod 700 backend/data。db 文件里含 Key 哈希、加密凭据与全部审计记录。 - 吊销所有开发期 Key seed 签发的第一把 Key 是开发用途,上线前应吊销并为每个业务方单独签发, 做到一把 Key 一个责任人。
已知残余风险
| 风险 | 说明与缓解 |
|---|---|
| Admin Token 是全局单一令牌 | 无法区分不同管理员的权限层级,泄漏即全线失守。x-admin-user 是自报的、不可信。
缓解:放在反代之后并加 IP 白名单 / mTLS / 内网 VPN;定期轮换;
依赖 change_logs 做事后追责 |
| SSE 端点用 query 传 token | 浏览器 EventSource 不支持自定义 Header,
/admin/api/breaker/events/stream?token= 的 token 会进访问日志。
缓解:该端点只读且只含熔断事件;在 Nginx 侧对该路径关闭 access_log 或过滤 query |
| 正则脱敏不完备 | 姓名、地址、自定义业务编号无固定格式,规则无法覆盖。
缓解:真正的敏感数据应在上游工具侧就不返回;
把敏感上游标记为 sensitivity='sensitive' 以启用更严格的审计与查询过滤 |
| 会话不可水平扩容 | 进程内 Map。多实例部署需要 sticky session,且工具变更广播不跨实例。
缓解:单实例 + 快速重启;或改造 SessionManager 为 Redis 后端 |
| 限流与熔断状态在内存 | 进程重启后令牌桶补满、熔断归零。 影响可接受:重启本身是低频事件,且重启后上游状态需要重新探测本就是合理的 |
| SQLite 单写者 | 审计攒批缓解了这个瓶颈,但极高写入量(> 数千 QPS 持续)下仍可能触及上限,
表现为 mgw_audit_queue_depth 持续升高与 JSONL 兜底增长。
缓解:监控该指标;必要时迁移到 PostgreSQL(TypeORM 换驱动即可,实体无需改动) |
迭代交付史
代码里大量注释以 F1 / P1-C 之类的代号引用需求,这份对照表让你读注释时不再一头雾水。
P0 · 六项
| 代号 | 特性 | 实现要点与涉及文件 |
|---|---|---|
| F1 | RBAC 影子模式 | settings.rbac.mode = enforce | observe。observe 下未命中 allow 照常放行,
但写 rbac_would_forbid 审计(errorCode=WOULD_FORBID, resultOk=1)。
仪表盘显示当前模式徽章与 wouldForbidCount / wouldForbidTop。auth/rbac.service.ts · gateway/router.service.ts · admin/stats.controller.ts |
| F2 | 权限矩阵一键回滚 | POST /admin/api/roles/:name/rules/rollback。从 change_logs.before_json
恢复上一版规则集,并再记一条 action='rollback' 的新变更(回滚本身也留痕)。admin/permission.controller.ts · admin/change-log.service.ts |
| F3 | 影响面预览量化 | POST /admin/api/permissions/preview → PreviewResult(12 字段):
gained / lost 工具清单、受影响 Key 明细与数量、近 7 日调用量、
lostCalls7d(切 enforce 后会失败多少次)。
走 RbacService.computeWithRules() 独立路径,不写库、不污染线上缓存。admin/permission.controller.ts · auth/rbac.service.ts |
| F4 | Key 吊销 ≤2s 即时失效 | 吊销时主动写入短 TTL 黑名单缓存,而非依赖查库结果缓存的自然过期。
会话每次调用都重新 verify(),因此进行中的会话也立即失效。auth/apikey.service.ts · admin/key.controller.ts |
| F7 | 事件循环延迟监控 | perf_hooks.monitorEventLoopDelay,resolution 20ms、每 5s 采样、
p99 > 200ms 告警、5min 冷却、每次采样后 history.reset() 防钝化。
p99 进 /healthz 的 el_p99_ms 字段。common/event-loop-monitor.ts · notify/im.service.ts |
| F9 | stdio crash loop 自动熔断 | 5 分钟窗口内崩溃 5 次 → enabled=0 + lastError='crash_loop' +
change_logs.action='auto-disable' + IM 告警。三个动作缺一不可:止血、留痕、通知人。gateway/upstream-manager.service.ts |
P1 首批 · 三项
| 代号 | 特性 | 实现要点 |
|---|---|---|
| P1-C | 熔断事件面板 | 新增 circuit_breaker_events 表(11 张表里的第 10 张)记录每次状态跃迁:
event / from_state / to_state / fails_in_window /
open_ms / reason。配套 BreakerEventsService、
3 条索引、后台「熔断事件」页与 summary 聚合。database/entities/breaker-event.entity.ts · governance/breaker-events.service.ts · admin/breaker.controller.ts |
| P1-A | 数据库自动备份 | 用 better-sqlite3 的 db.backup() 在线热备份(不停机、不加全局锁),
node-cron 按 backup.cron / backup.tz 调度且支持热更新,
滚动保留 backup.keep 份,新增 backups 表记录每次执行。governance/backup.service.ts · database/entities/backup.entity.ts · admin/backup.controller.ts |
| P1-B | Prometheus 标准指标 | 新增 GET /metrics.prom(text format 0.0.4),
使用独立 Registry + mgw_ 前缀 + collectDefaultMetrics。
原有 /metrics(JSON,供前端)保留,形成"机器读 text、前端读 JSON"的双出口。governance/metrics.service.ts · admin/system.controller.ts |
P1 收尾 · 四项
| 代号 | 特性 | 实现要点 |
|---|---|---|
| P1-D | 备份失败 IM 告警 | im.backupFailed(trigger, err) 与 im.backupPruneFailed(err)。
备份失败但没人知道 = 没有备份,所以告警是备份能力的必要组成而非附加项。notify/im.service.ts · governance/backup.service.ts |
| P1-E | HTTP 入口 QPS / 延时埋点 | 新增 mgw_http_requests_total{method,route,status} 与
mgw_http_request_duration_ms{method,route}。关键是路由归一化
(用 req.route.path 而非原始 URL)与 status 归类到百位,
防止 Prometheus 标签基数爆炸;排除 /metrics* 与 /healthz 自我观测;
SSE 长连接跳过延时埋点。governance/metrics.middleware.ts |
| P1-F | 熔断面板 SSE 实时推流 | @Sse('events/stream') +
merge(breaker$, interval(25_000).pipe(map(() => ({ type: 'ping' }))))。
因 EventSource 无法带 Header,AdminTokenGuard 对以
/stream 结尾的路径开放 ?token= query 鉴权。admin/breaker.controller.ts · admin/guards/admin-token.guard.ts · frontend/src/views/Breaker.vue |
| P1-G | Grafana 看板与 Prometheus 模板 | docs/grafana/ 下提供 prometheus.yml(抓取配置)、
gateway-overview.json(可直接 Import 的仪表盘)与 README.md(部署步骤)。docs/grafana/ |
演进脉络
把这三批交付串起来看,能读出一条清晰的主线:
- 先让功能跑通(MVP):双通道、治理链、注册表、11 张表的主体、10 个管理页。
- P0 补"敢不敢改"的能力:F1 影子模式 + F2 回滚 + F3 影响面预览三件套, 本质是把高风险的权限变更变成低风险的可预演、可量化、可撤销的操作。 F4(吊销即时生效)与 F9(crash loop)是止血能力,F7 是自我感知能力。
- P1 补"能不能运维"的能力:C(熔断可追溯)+ A(数据可恢复)+ B(指标可采集) 是运维三要素;D/E/F/G 则是把这三要素闭环—— 备份要会告警、指标要能进 Prometheus、事件要能实时看到、看板要开箱即用。
这条脉络对做内部平台的同学有普适参考价值: 功能可用只是起点,"敢改"和"能运维"才是内部平台能不能真正被采用的分水岭。 很多平台的失败不是因为功能不够,而是因为运维者不敢动它、动坏了救不回来。
运维与脚本
部署形态、日常操作手册与 scripts/ 工具箱。
scripts/ 工具箱
| 脚本 | 类型 | 用途 |
|---|---|---|
smoke-test.sh | bash | 基础连通冒烟:healthz → initialize → tools/list → tools/call |
smoke-p0.sh | bash | 验证 P0 六项特性,含 __triggerAlertForTest 类的白盒钩子 |
smoke-p1.sh | bash | 验证 P1 首批:熔断事件、自动备份、Prometheus 指标 |
smoke-p1b.sh | bash | 验证 P1 收尾:备份告警、HTTP 埋点、SSE 推流、Grafana 模板 |
smoke-domestic.sh | bash | 内网 / 国产化环境适配验证(镜像源、系统库、字体等) |
mock-mcp-server.mjs | node | 本地假上游 MCP Server。没有真实上游时联调的必备工具, 可模拟慢响应、错误、工具列表变更 |
seed-calls.py | python3 | 直接向 gateway.db 灌入模拟审计记录,让仪表盘 / 报表 / 审计页有数据可看。
执行前先停网关,避免 WAL 写冲突 |
seed-dashboard.sh | bash | 组合调用,一键准备演示数据 |
mgw-fmt.py | python3 | 把单行 JSON 日志流转成彩色对齐的人类可读格式:
./start.sh 2>&1 | python3 scripts/mgw-fmt.py |
Nginx 反向代理
upstream mgw_backend {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name mcp.example.com;
# ── 管理后台静态资源(frontend/dist)──
root /opt/mcp-gateway/frontend/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html; # hash 路由其实不需要,但留着更稳
}
# ── MCP 端点:必须支持 SSE ──
location /mcp {
proxy_pass http://mgw_backend;
proxy_http_version 1.1;
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;
# ★ SSE 三件套:关缓冲、长超时、保持连接
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Connection '';
chunked_transfer_encoding off;
}
# ── 管理 API ──
location /admin/api/ {
proxy_pass http://mgw_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 8m;
}
# ── 熔断事件 SSE(同样要关缓冲)──
location ~ ^/admin/api/breaker/events/stream {
proxy_pass http://mgw_backend;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_set_header Connection '';
access_log off; # ★ token 在 query 里,不要记进访问日志
}
# ── 探针端点:仅限内网 ──
location ~ ^/(healthz|metrics|metrics\.prom)$ {
allow 10.0.0.0/8;
allow 127.0.0.1;
deny all;
proxy_pass http://mgw_backend;
}
}proxy_buffering off 是 SSE 能否工作的决定因素。
后端已经发了 X-Accel-Buffering: no 响应头,但显式在 Nginx 配置里关掉更可靠
(某些版本或全局配置会覆盖该头)。症状是:客户端连上了 SSE 但永远收不到事件,
直到连接关闭时一次性收到全部内容。
systemd 服务单元
# /etc/systemd/system/mcp-gateway.service
[Unit]
Description=MCP Gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mcpgw
Group=mcpgw
WorkingDirectory=/opt/mcp-gateway/backend
# .env.example 格式可直接被 EnvironmentFile 消费
EnvironmentFile=/etc/mcp-gateway.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node dist/main
Restart=always
RestartSec=3
# ── 加固 ──
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/mcp-gateway/backend/data
LimitNOFILE=65536
# 优雅停机:给审计队列排空的时间
TimeoutStopSec=20
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target/etc/mcp-gateway.env 权限设为 600 且属主为 mcpgw。
ReadWritePaths 只放开 data/,配合 ProtectSystem=strict
即使进程被攻破也无法篡改代码或系统文件。
后端在 main.ts 里注册了 SIGTERM / SIGINT 处理:
停止接收新连接 → 排空进行中的调用 → SessionManager.drain() 通知所有会话 →
AuditService.flush() 写完队列 → 关闭数据库连接。TimeoutStopSec=20
给这个流程留足时间。
容器化要点
- 原生模块编译:
better-sqlite3需要在目标架构上编译。 多阶段构建时,builder 阶段需要python3/make/g++, runtime 阶段只需node:20-slim+ 编译好的node_modules。 - 密钥用文件而非 env:设置
MGW_SECRET_FILE=/run/secrets/mgw_secret与ADMIN_TOKEN_FILE=/run/secrets/admin_token。 env 形式的密钥会出现在docker inspect、/proc/*/environ与容器编排的审计日志里。 - data 目录必须挂卷:
-v mgw-data:/app/backend/data。 否则容器重建即丢失全部配置与审计记录。 - 时区:
backup.tz默认Asia/Shanghai,node-cron自带时区处理,不需要设置容器TZ; 但日志时间戳是 UTC 毫秒,展示层转换。 - 健康检查:
HEALTHCHECK CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1。
日常操作手册
| 场景 | 操作 |
|---|---|
| 接入新上游 | 后台「上游服务」→ 新建 → 填 transport 与凭据 → 提交时自动试连。
失败返回 502 且不落库,按提示修正后重试。成功后工具自动进注册表并广播 list_changed |
| 新业务方接入 | 「API Key」→ 签发(填 name / userName / team / role / 过期时间)→ 复制明文交给对方(只显示一次)。一个业务方一把 Key,绝不共用 |
| Key 泄漏应急 | 「API Key」→ 吊销 → reason 选 leak。≤2s 全链路失效。
随后到「审计日志」按 key_id 过滤,核查泄漏窗口内的全部调用 |
| 收紧某角色权限 | 切 rbac.mode=observe → 改规则(先 dryRun)→
看「影响面预览」的 lostCalls7d → 观察几天 wouldForbidTop →
切回 enforce。出问题一键回滚 |
| 某工具需要单独限流 | 「工具目录」→ 找到工具 → 设置 rate_qpm。
不需要动上游,也不影响该上游的其它工具 |
| 某上游频繁熔断 | 「熔断事件」页看跃迁历史与 reason → 「审计日志」按 upstream 过滤看错误码分布 →
若是 UPSTREAM_TIMEOUT 集中,调大该上游的 timeout_ms;
若是 UPSTREAM_BUSY,调大 concurrency |
| 数据库恢复 | 停服务 → 备份当前 data/ 整目录 → 用目标备份覆盖 gateway.db →
删除 gateway.db-wal 与 gateway.db-shm → 启动 → 验证 |
| 升级版本 | 先手动备份(POST /admin/api/backups/run)→ git pull →
两个包分别 npm ci → backend: npm run build ·
frontend: npm run build → 重启 → 跑 scripts/smoke-test.sh |
| 合规导出 | 「审计日志」→ 设好时间与维度过滤 → 导出 CSV(上限 10 万行,含 UTF-8 BOM,Excel 直开不乱码)。 超量请分批按时间窗导出 |
容量与清理
audit_logs 是唯一会无界增长的表。代码里刻意没有提供删除路径
(只增不改是审计的基本要求),因此清理必须由运维显式执行:
# 1) 先看当前占用
sqlite3 backend/data/gateway.db \
"SELECT COUNT(*) AS rows, page_count*page_size/1024/1024 AS mb FROM audit_logs, pragma_page_count(), pragma_page_size();"
# 2) 导出超期数据留档(走 Admin API 更安全,带鉴权与脱敏)
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://127.0.0.1:8080/admin/api/audit/export?from=0&to=$(( $(date +%s%3N) - 180*86400000 ))" \
-o audit-archive-$(date +%Y%m%d).csv
# 3) 停服务后归档并回收空间(DELETE 不会自动缩小文件)
sqlite3 backend/data/gateway.db "DELETE FROM audit_logs WHERE ts < $(( $(date +%s%3N) - 180*86400000 ));"
sqlite3 backend/data/gateway.db "VACUUM;"VACUUM 需要约等于库大小的临时空间且会独占写锁,
必须在停服务时执行。另外请先确认这一步符合你所在组织的审计留存合规要求——
某些行业要求调用记录留存 3 年甚至更久。
另外两类会自动清理的数据:backups 由 backup.keep 滚动保留(默认 7 份);
audit-fallback.jsonl 回放后被重命名为 .replayed.<ts>,
需要人工定期清理这些残留文件。
常见问题与排查
按"症状 → 原因 → 处置"组织,覆盖接入、开发、运行三类场景。
接入类
| 症状 | 原因 | 处置 |
|---|---|---|
返回 401,带 WWW-Authenticate |
Bearer 缺失 / 拼错 / Key 已吊销或过期 | 确认 Header 是 Authorization: Bearer mgw_live_…(注意 Bearer 后有一个空格);
到「API Key」页核对 prefix 与 revoked 状态 |
| 返回 406 Not Acceptable | Accept 头不合规 |
必须同时包含两种类型:Accept: application/json, text/event-stream。
这是 MCP Streamable HTTP 的强制要求 |
| 返回 403,无响应体 | 浏览器请求的 Origin 不在 MGW_ALLOWED_ORIGINS 内 |
把实际来源加进白名单;非浏览器客户端通常不发 Origin,不会触发此校验 |
返回 404,initialize 之后的请求全失败 |
没回传 Mcp-Session-Id,或会话已因空闲被回收(默认 30 分钟) |
把 initialize 响应头里的 mcp-session-id 存下来,后续每个请求都带上;
收到 404 应重新 initialize |
tools/list 返回空数组 |
① 没有 enabled 的上游 ② 注册表为空 ③ 该 Key 的角色规则把所有工具都 deny 了 | 依次查:GET /admin/api/system/registry 看工具总数与版本 →
「上游服务」看连接状态 → 「权限矩阵」用该角色跑一次 dryRun 看 allowed 集合 |
看得到工具但调用返回 FORBIDDEN |
理论上不应发生(可见即可调契约)。若出现,说明两次判定之间规则或注册表变了 | allowedSet 缓存有 60s TTL,等一分钟重试;持续出现则是 bug,请查
registryVersion 与 rulesVersion 是否在抖动 |
调用返回 [TOOL_NOT_FOUND] 但后台能看到工具 |
用了原名而非全名 | 必须传 upstream__tool 形式的全名,如 wiki__search,不是 search |
模型频繁收到 RATE_LIMITED |
默认 30 QPM 对该场景偏低 | 三种粒度可选:改全局 ratelimit.default_qpm ·
改该工具的 tool_overrides.rate_qpm · 给该业务方多发几把 Key 分摊桶 |
开发类
| 症状 | 原因 | 处置 |
|---|---|---|
npm install 卡在 better-sqlite3 |
原生模块编译失败,缺 python3 / make / 编译器 | macOS 装 Xcode Command Line Tools(xcode-select --install);
Debian/Ubuntu 装 build-essential python3;
切换 Node 大版本后需 npm rebuild better-sqlite3 |
启动报 ERR_REQUIRE_ESM |
在 CommonJS 里静态 import 了 MCP SDK | SDK 是 ESM-only,必须用 await import('…') 动态加载。
参考 upstream-manager.service.ts 的写法,类型用 typeof import(…) 声明 |
| 环境变量改了没生效 | 后端不内置 dotenv,变量必须存在于进程环境中 | 用 ./start.sh(会自动 source),或手动
set -a && source .env && set +a 后再 npm run start:dev。
另外注意:session.idle_timeout_s 等 settings 项优先级高于环境变量 |
| 前端 401,但 Token 是对的 | Vite 代理没配上,或后端不在 :8080 | 确认 vite.config.ts 的 proxy 三项(/admin/api、/mcp、/healthz)
指向实际后端地址;改了 MGW_PORT 要同步改代理 |
端口被占用,start.sh 直接退出 |
脚本刻意做端口预检,避免误跑到备用端口造成"改了没生效"的错觉 | ./start.sh --stop 清理,或 ./start.sh --force 自动停旧起新 |
| 重跑 seed 后自定义规则丢了 | seed 对 role_rules 采用整体替换策略 |
这是设计行为。定制请新建角色而非改内置角色; 生产环境把 seed 移出启动流程,只在首次部署时执行一次 |
| 仪表盘 / 报表页空白无数据 | 库里没有审计记录 | 跑 python3 scripts/seed-calls.py 灌演示数据(先停网关),
或用 scripts/mock-mcp-server.mjs 起假上游后真实调几次 |
运行类
| 症状 | 排查路径 | 处置 |
|---|---|---|
上游刚配好就自动被禁用,lastError='crash_loop' |
F9 触发:stdio 子进程 5 分钟内崩溃 5 次。查 change_logs
里 action='auto-disable' 的记录看时间线 |
手工在服务器上执行同样的 command + args 看真实报错(常见:命令不在 PATH、 依赖未装、Node 版本不符)。注意子进程 env 是白名单隔离的,本地能跑不代表网关里能跑—— 缺的变量要在上游配置的自定义 env 里显式补上。修好后手动启用 |
所有调用都返回 UPSTREAM_DOWN |
熔断处于 open,或上游 enabled=0 |
「熔断事件」页看跃迁时间与 reason → 「上游服务」手动 probe。
open 态默认 60s 后自动进入 half-open 放一个探针,不需要人工干预;
若探针持续失败会继续 open |
mgw_audit_queue_depth 持续 > 0 |
审计写入跟不上产生速度。SQLite 单写者到达上限,或磁盘 IO 慢 | 先调大 MGW_AUDIT_FLUSH_SIZE(如 100 → 500)减少事务次数;
检查磁盘 IO 与是否触发了 JSONL 兜底;长期高写入量应规划迁移 PostgreSQL |
data/audit-fallback.jsonl 在增长 |
写库持续失败:磁盘满、库文件权限、WAL 损坏 | 查 df -h 与目录权限(应为运行用户可写)。修复后重启进程触发回放,
回放成功会重命名为 .replayed.<ts>;确认无误后清理这些残留文件 |
/healthz 的 el_p99_ms 长期偏高 |
F7:事件循环被阻塞。常见元凶是同步 IO、超大 JSON 序列化、正则回溯 | 查是否有人在审计页导出了超大 CSV、是否有 pattern 触发了回溯
(NESTED_QUANTIFIER 校验挡掉了大部分,但超长 * 串仍可能慢);
用 --cpu-prof 抓一次 profile 定位 |
| 耗时高但不知道慢在哪 | 看审计详情里的 duration_ms 与 queue_ms 组合 |
duration 高 + queue 低 = 上游本身慢(调 timeout_ms 或找上游优化);
duration 高 + queue 高 = 并发不够(调大该上游 concurrency;
stdio 上游受 ≤2 硬限,只能加实例或改 http transport) |
| 后台「熔断事件」页不实时更新 | P1-F SSE 被反代缓冲,或 ?token= 没带上 |
Nginx 对应 location 加 proxy_buffering off + proxy_read_timeout 3600s;
浏览器控制台看 EventSource 是否收到 type:'ping' 心跳(25s 一次),
收到心跳说明连接正常只是没有事件 |
| 改了上游配置但模型看到的工具描述没变 | 客户端缓存了旧的 tools/list 结果,或未订阅通知 |
网关侧确认 mgw_registry_version 已递增、SSE 已广播
tools/list_changed;客户端需实现 capabilities.tools.listChanged
的处理,或强制重连会话 |
改了 MGW_SECRET 后所有上游连不上 |
历史加密凭据无法用新密钥解密 | 不可逆。只能逐个上游重新录入凭据。 因此密钥轮换前必须先导出全部上游配置,并安排停机窗口 |
排查三板斧
- 先定位是哪一层
GET /healthz一眼看清全局:上游 up/down、注册表工具数与版本、活跃会话数、 审计队列深度、事件循环 p99。80% 的问题在这一步就能确定方向。 - 再拿 requestId 串起来
每次调用都有唯一
request_id,同时出现在返回体、结构化日志与audit_logs里。拿到它就能把「模型看到的错误」「网关打的日志」「库里的记录」三者对齐—— 这是idx_audit_req索引存在的意义。 - 最后看错误码的 kind
client-error(FORBIDDEN / RATE_LIMITED / BAD_ARGS / TOOL_NOT_FOUND) → 调用方的问题,去查 Key、角色规则、参数;upstream-error(DOWN / BUSY / TIMEOUT / ERROR) → 服务侧的问题,去查上游健康、并发配置、熔断历史。 错误码分类本身就在告诉你该往哪个方向查。