Key-IP Sentinel
Key-IP Sentinel 是一个基于 FastAPI 的反向代理,用于在请求到达下游 New API 服务之前,对模型 API Key 执行“首次使用绑定来源 IP”的控制。
功能特性
- 首次使用自动绑定,使用 HMAC-SHA256 对 token 做哈希,结合 Redis cache-aside 与 PostgreSQL 存储绑定规则。
- 基于
httpx.AsyncClient和 FastAPIStreamingResponse的流式反向代理,支持流式响应透传。 - 可信代理 IP 提取逻辑,只接受来自指定上游网络的
X-Real-IP。 - 基于 Redis 的拦截计数、Webhook 告警,以及 PostgreSQL 审计日志。
- 管理后台登录使用 JWT,并带有 Redis 登录失败锁定机制。
- 使用 Vue 3 + Element Plus 的管理后台,可查看看板、绑定、审计日志和运行时设置。
- 支持 Docker Compose 部署,包含 Nginx、应用、Redis 和 PostgreSQL。
仓库结构
sentinel/
├── app/
├── db/
├── nginx/
├── frontend/
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
└── README.md
运行说明
- Redis 用于存储绑定缓存、告警计数、每日看板指标和可变运行时设置。
- PostgreSQL 用于存储权威绑定记录和拦截日志。
- 归档保留机制会在绑定超过
ARCHIVE_DAYS不活跃后,从活动表中移除;同一 token 后续再次请求时会重新进行首次绑定。 SENTINEL_FAILSAFE_MODE=closed表示在 Redis 和 PostgreSQL 同时不可用时拒绝请求;open表示放行。- 绑定规则支持三种模式:
single(单个 IP 或单个 CIDR)、multiple(多个离散 IP)、all(允许全部来源 IP)。
Sentinel 与 New API 的关系
Sentinel 和 New API 预期是以两套独立的 Docker Compose 项目部署:
- Sentinel 这套 compose 包含
nginx、sentinel-app、redis、postgres - New API 那套 compose 包含你现有的 New API 服务及其自身依赖
- 两套服务通过一个共享的外部 Docker 网络通信
流量链路如下:
客户端 / SDK
|
| 请求发往 Sentinel 对外入口
v
Sentinel nginx -> sentinel-app -> New API 服务 -> 模型后端
|
+-> redis / postgres
最关键的一点是:客户端必须请求 Sentinel,而不是直接请求 New API,否则 IP 绑定不会生效。
Linux 上获取真实客户端 IP
如果你希望在 Linux 部署机上记录真实的局域网客户端 IP,不要再通过 Docker bridge 的 3000:80 这种端口发布方式暴露公网入口。
推荐生产拓扑如下:
nginx使用network_mode: hostnginx直接监听宿主机3000端口sentinel-app保留在内部 bridge 网络,并使用固定 IPsentinel-app同时加入shared_network,用于访问 New APInew-api保持内部可达,不再直接暴露给客户端
这样设计的原因:
- Docker
ports:发布端口时,客户端入口这一跳通常会经过 NAT - 这会导致容器内看到的是类似
172.28.x.x的桥接地址,而不是真实客户端 IP shared_network只负责 Sentinel 和 New API 之间的内部通信,不决定客户端入口进来的源地址
当 nginx 使用 network_mode: host 时,它直接接收宿主机上的真实入站连接,因此可以把真实来源 IP 通过 X-Real-IP 转发给 sentinel-app。
推荐部署拓扑
两套 compose 使用同一个外部网络名。当前仓库约定如下:
shared_network
在 Sentinel 这套 compose 中:
sentinel-app加入shared_networknginx通过 Linux 宿主机网络暴露外部入口DOWNSTREAM_URL指向shared_network上 New API 的服务名
在 New API 那套 compose 中:
- New API 容器也必须加入
shared_network - New API 的服务名必须与 Sentinel 中
DOWNSTREAM_URL的主机名一致
例如:
- New API compose 服务名:
new-api - New API 容器内部监听端口:
3000 - Sentinel 的
.env:DOWNSTREAM_URL=http://new-api:3000
如果你的 New API 服务名不同,就相应修改 DOWNSTREAM_URL,例如:
DOWNSTREAM_URL=http://my-newapi:3000
New API 的两种常见接入方式
实际部署中,New API 通常有两种接法。
方式 A:生产机上,New API 运行在独立 compose 中
这是推荐的生产方案。
New API 继续使用它自己的 compose 项目,并通常同时加入:
defaultshared_network
这样一来,New API 既可以继续使用它自己的内部网络访问自身依赖,又可以通过 shared_network 把服务名暴露给 Sentinel。
示例 New API compose 片段:
services:
new-api:
image: your-new-api-image
networks:
- default
- shared_network
networks:
shared_network:
external: true
在这种情况下,Sentinel 依旧使用:
DOWNSTREAM_URL=http://new-api:3000
方式 B:测试机上,New API 直接通过 docker run 启动
在测试机上,你不一定会使用第二套 compose,也可以直接用 docker run 启动一个独立的 New API 容器,只要它加入 shared_network 即可。
示例:
docker run -d \
--name new-api \
--network shared_network \
your-new-api-image
注意:
- 容器名或可解析主机名必须与 Sentinel 中
DOWNSTREAM_URL的主机名一致 - 如果容器名不是
new-api,就要同步修改.env DOWNSTREAM_URL里的端口仍然应当写容器内部监听端口
例如:
DOWNSTREAM_URL=http://new-api:3000
如果容器名不同:
DOWNSTREAM_URL=http://new-api-test:3000
本地开发
后端
- 安装
uv,并确保本机具备 Python 3.13 - 创建虚拟环境并同步依赖:
uv sync
- 将
.env.example复制为.env,并填写密钥与连接地址 - 启动 PostgreSQL 和 Redis
- 启动 API:
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000
前端
- 安装依赖:
cd frontend
npm install
- 启动 Vite 开发服务器:
npm run dev
Vite 开发代理会把 /admin/api/* 转发到 http://127.0.0.1:7000。
如果你更习惯从仓库根目录启动,也可以直接执行 uv run main.py,它会以 APP_PORT(默认 7000)启动同一个 FastAPI 应用。
依赖管理
- 本地 Python 开发依赖通过
pyproject.toml和uv管理 - 容器运行镜像使用
requirements.txt安装 Python 依赖 - 应用源码通过 Compose 在运行时挂载,因此离线机器不需要为后端代码改动频繁重建镜像
离线部署模型
如果你的生产机器无法访问外网,建议按下面的方式使用本仓库:
- 在有网机器上构建
key-ip-sentinel:latest镜像 - 将镜像导出为 tar 包
- 在离线机器上导入镜像
- 将仓库文件整体复制到离线机器
- 使用
docker compose up -d启动,而不是docker compose up --build -d
这套方式之所以可行,是因为:
Dockerfile只负责安装 Python 依赖docker-compose.yml会在运行时挂载./app- 离线机器只需要预构建镜像和仓库文件即可运行
需要注意的限制:
- 如果你修改了
requirements.txt,必须回到有网机器重新构建并导出镜像 - 如果你只修改了
app/下的后端代码,通常不需要重建镜像,重启容器即可 frontend/dist必须提前构建好,因为 Nginx 会直接从仓库目录提供前端静态文件nginx:alpine、redis:7-alpine、postgres:16这些公共镜像,在离线机器上也必须事先准备好
在有网机器上准备镜像
构建并导出 Sentinel 运行镜像:
docker build -t key-ip-sentinel:latest .
docker save -o key-ip-sentinel-latest.tar key-ip-sentinel:latest
如果离线机器无法拉取公共镜像,也需要一并导出:
docker pull nginx:alpine
docker pull redis:7-alpine
docker pull postgres:16
docker save -o sentinel-support-images.tar nginx:alpine redis:7-alpine postgres:16
如果管理后台前端尚未构建,也请在有网机器上提前构建:
cd frontend
npm install
npm run build
cd ..
然后把以下内容复制到离线机器:
- 整个仓库工作目录
key-ip-sentinel-latest.tarsentinel-support-images.tar(如果需要)
在离线机器上导入镜像
docker load -i key-ip-sentinel-latest.tar
docker load -i sentinel-support-images.tar
在离线机器上启动
在 .env、frontend/dist 和 shared_network 都准备好之后,执行:
docker compose up -d
生产部署
1. 创建共享 Docker 网络
在 Docker 主机上先创建一次外部网络:
docker network create shared_network
两套 compose 都必须引用这个完全相同的外部网络名。
2. 确保 New API 加入共享网络
在 New API 项目中,为 New API 服务加入这个外部网络。
最小示例:
services:
new-api:
image: your-new-api-image
networks:
- default
- shared_network
networks:
shared_network:
external: true
重要说明:
- 这里的
new-api是 Sentinel 在共享网络中解析到的服务名 DOWNSTREAM_URL中的端口必须写容器内部监听端口,而不是宿主机映射端口- 如果 New API 容器内部监听
3000,就写http://new-api:3000 - 在生产机上,New API 可以同时加入
default和shared_network - 在测试机上,也可以不使用第二套 compose,而改用
docker run,但容器仍然必须加入shared_network
3. 准备 Sentinel 环境变量
- 将
.env.example复制为.env - 替换
SENTINEL_HMAC_SECRET、ADMIN_PASSWORD、ADMIN_JWT_SECRET - 确认
DOWNSTREAM_URL指向shared_network上的 New API 服务名 - 确认
PG_DSN与docker-compose.yml中 PostgreSQL 密码保持一致,如有修改需同时调整
Sentinel 的 .env 示例:
DOWNSTREAM_URL=http://new-api:3000
REDIS_ADDR=redis://redis:6379
REDIS_PASSWORD=
PG_DSN=postgresql+asyncpg://sentinel:password@postgres:5432/sentinel
SENTINEL_HMAC_SECRET=replace-with-a-random-32-byte-secret
ADMIN_PASSWORD=replace-with-a-strong-password
ADMIN_JWT_SECRET=replace-with-a-random-jwt-secret
TRUSTED_PROXY_IPS=172.30.0.0/24
SENTINEL_FAILSAFE_MODE=closed
APP_PORT=7000
ALERT_WEBHOOK_URL=
ALERT_THRESHOLD_COUNT=5
ALERT_THRESHOLD_SECONDS=300
ARCHIVE_DAYS=90
说明:
TRUSTED_PROXY_IPS应与 Sentinel 内部 bridge 网络的网段一致,用来信任nginx这一跳代理- 如果 Docker 重新创建网络并导致网段变化,就需要同步修改
- 当前仓库中的生产 compose 已固定
sentinel-net=172.30.0.0/24,因此默认应写TRUSTED_PROXY_IPS=172.30.0.0/24
4. 构建 Sentinel 前端产物
cd frontend
npm install
npm run build
cd ..
构建完成后会生成 frontend/dist,Nginx 会将其作为 /admin/ui/ 的静态站点目录。
如果目标主机离线,请在有网机器上先完成这一步,并把 frontend/dist 一并复制过去。
5. 确认 Sentinel compose 启动前提
- 必须先构建前端;如果缺少
frontend/dist,则无法访问/admin/ui/ - 必须提前创建外部网络
shared_network - 如果主机无法联网,必须事先准备好
key-ip-sentinel:latest、nginx:alpine、redis:7-alpine、postgres:16 - 当前这份生产 compose 假定宿主机是 Linux,因为对外入口使用了
network_mode: host
6. 启动 Sentinel 服务栈
docker compose up -d
只有在有网机器且你明确需要重建镜像时,才使用 docker compose up --build -d。
服务入口如下:
http://<host>:3000/:模型 API 请求通过 Sentinel 转发http://<host>:3000/admin/ui/:管理后台前端http://<host>:3000/admin/api/*:管理后台 APIhttp://<host>:3000/health:健康检查
7. 验证跨 compose 通信与真实 IP
当两套服务都启动后:
- 从另一台局域网机器访问
http://<host>:3000/health,确认返回{"status":"ok"} - 打开
http://<host>:3000/admin/ui/,使用ADMIN_PASSWORD登录 - 向 Sentinel 发送一条真实模型请求,而不是直接访问 New API
- 到
Bindings页面确认 token 已出现并生成绑定规则 - 确认记录下来的绑定 IP 是真实局域网客户端 IP,而不是 Docker bridge 地址
示例测试请求:
curl http://<host>:3000/v1/models \
-H "Authorization: Bearer <your_api_key>"
如果客户端仍然直接请求 New API,Sentinel 就看不到流量,也不会生成绑定。
客户端应该连接哪个端口
按当前仓库中的 Linux 生产 compose:
- Sentinel 对外端口:
3000 - New API 容器内部端口:通常是
3000
这意味着:
- 客户端应当请求
http://<host>:3000/... - Sentinel 会在内部转发到
http://new-api:3000
不要把客户端直接指向 New API 的宿主机端口,否则会绕过 Sentinel。
如何做到业务无感上线
如果你希望现有客户端配置完全不改,Sentinel 就必须接管原来客户端已经在使用的那个对外地址和端口。
典型切换方式如下:
- 保留 New API 在内部共享网络中运行
- 停止把 New API 直接暴露给最终用户
- 让 Sentinel 暴露原来的对外地址和端口
- 让
DOWNSTREAM_URL持续指向shared_network上的内部 New API 服务
例如,如果现有客户端一直使用 http://host:3000,那生产切换时就应让 Sentinel 接管这个 3000,并让 New API 变成内部服务。
当前仓库中的 docker-compose.yml 已经按这种 Linux 生产方式调整:Nginx 直接使用宿主机网络监听 3000,而 New API 保持内部访问。
管理后台 API 概览
POST /admin/api/loginGET /admin/api/dashboardGET /admin/api/bindingsPOST /admin/api/bindings/unbindPUT /admin/api/bindings/ipPOST /admin/api/bindings/banPOST /admin/api/bindings/unbanGET /admin/api/logsGET /admin/api/logs/exportGET /admin/api/settingsPUT /admin/api/settings
除 /admin/api/login 外,所有管理接口都需要:
Authorization: Bearer <jwt>
关键实现细节
app/proxy/handler.py会完整流式透传下游响应,包括 SSEapp/core/ip_utils.py不信任客户端自己传来的X-Forwarded-Forapp/services/binding_service.py会通过asyncio.Queue每 5 秒批量刷新一次last_used_atapp/services/alert_service.py会在 Redis 计数达到阈值后推送 Webhookapp/services/archive_service.py会定时归档过期绑定
建议的冒烟检查
GET /health返回{"status":"ok"}- 使用一个新的 Bearer Token 发起首次请求后,应在 PostgreSQL 和 Redis 中创建绑定
- 同一 IP 的第二次请求应被放行,并刷新
last_used_at - 来自不同 IP 的请求应返回
403,并写入intercept_logs,除非绑定规则是all /admin/api/login应返回 JWT,前端应能正常加载/admin/api/dashboard