Files
sentinel/README.md

482 lines
15 KiB
Markdown
Raw Permalink Normal View History

# Key-IP Sentinel
Key-IP Sentinel 是一个基于 FastAPI 的反向代理,用于在请求到达下游 New API 服务之前,对模型 API Key 执行“首次使用绑定来源 IP”的控制。
## 功能特性
- 首次使用自动绑定,使用 HMAC-SHA256 对 token 做哈希,结合 Redis cache-aside 与 PostgreSQL 存储绑定规则。
- 基于 `httpx.AsyncClient` 和 FastAPI `StreamingResponse` 的流式反向代理,支持流式响应透传。
- 可信代理 IP 提取逻辑,只接受来自指定上游网络的 `X-Real-IP`
- 基于 Redis 的拦截计数、Webhook 告警,以及 PostgreSQL 审计日志。
- 管理后台登录使用 JWT并带有 Redis 登录失败锁定机制。
- 使用 Vue 3 + Element Plus 的管理后台,可查看看板、绑定、审计日志和运行时设置。
- 支持 Docker Compose 部署,包含 Nginx、应用、Redis 和 PostgreSQL。
## 仓库结构
```text
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 网络通信
流量链路如下:
```text
客户端 / 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: host`
- `nginx` 直接监听宿主机 `3000` 端口
- `sentinel-app` 保留在内部 bridge 网络,并使用固定 IP
- `sentinel-app` 同时加入 `shared_network`,用于访问 New API
- `new-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 使用同一个外部网络名。当前仓库约定如下:
```text
shared_network
```
在 Sentinel 这套 compose 中:
- `sentinel-app` 加入 `shared_network`
- `nginx` 通过 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`,例如:
```text
DOWNSTREAM_URL=http://my-newapi:3000
```
## New API 的两种常见接入方式
实际部署中New API 通常有两种接法。
### 方式 A生产机上New API 运行在独立 compose 中
这是推荐的生产方案。
New API 继续使用它自己的 compose 项目,并通常同时加入:
- `default`
- `shared_network`
这样一来New API 既可以继续使用它自己的内部网络访问自身依赖,又可以通过 `shared_network` 把服务名暴露给 Sentinel。
示例 New API compose 片段:
```yaml
services:
new-api:
image: your-new-api-image
networks:
- default
- shared_network
networks:
shared_network:
external: true
```
在这种情况下Sentinel 依旧使用:
```text
DOWNSTREAM_URL=http://new-api:3000
```
### 方式 B测试机上New API 直接通过 `docker run` 启动
在测试机上,你不一定会使用第二套 compose也可以直接用 `docker run` 启动一个独立的 New API 容器,只要它加入 `shared_network` 即可。
示例:
```bash
docker run -d \
--name new-api \
--network shared_network \
your-new-api-image
```
注意:
- 容器名或可解析主机名必须与 Sentinel 中 `DOWNSTREAM_URL` 的主机名一致
- 如果容器名不是 `new-api`,就要同步修改 `.env`
- `DOWNSTREAM_URL` 里的端口仍然应当写容器内部监听端口
例如:
```text
DOWNSTREAM_URL=http://new-api:3000
```
如果容器名不同:
```text
DOWNSTREAM_URL=http://new-api-test:3000
```
## 本地开发
### 后端
1. 安装 `uv`,并确保本机具备 Python 3.13
2. 创建虚拟环境并同步依赖:
```bash
uv sync
```
3.`.env.example` 复制为 `.env`,并填写密钥与连接地址
4. 启动 PostgreSQL 和 Redis
5. 启动 API
```bash
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000
```
### 前端
1. 安装依赖:
```bash
cd frontend
npm install
```
2. 启动 Vite 开发服务器:
```bash
npm run dev
```
Vite 开发代理会把 `/admin/api/*` 转发到 `http://127.0.0.1:7000`
如果你更习惯从仓库根目录启动,也可以直接执行 `uv run main.py`,它会以 `APP_PORT`(默认 `7000`)启动同一个 FastAPI 应用。
## 依赖管理
- 本地 Python 开发依赖通过 [`pyproject.toml`](/c:/project/sentinel/pyproject.toml) 和 `uv` 管理
- 容器运行镜像使用 [`requirements.txt`](/c:/project/sentinel/requirements.txt) 安装 Python 依赖
- 应用源码通过 Compose 在运行时挂载,因此离线机器不需要为后端代码改动频繁重建镜像
2026-03-04 16:04:19 +08:00
## 离线部署模型
2026-03-04 16:04:19 +08:00
如果你的生产机器无法访问外网,建议按下面的方式使用本仓库:
2026-03-04 16:04:19 +08:00
1. 在有网机器上构建 `key-ip-sentinel:latest` 镜像
2. 将镜像导出为 tar 包
3. 在离线机器上导入镜像
4. 将仓库文件整体复制到离线机器
5. 使用 `docker compose up -d` 启动,而不是 `docker compose up --build -d`
2026-03-04 16:04:19 +08:00
这套方式之所以可行,是因为:
2026-03-04 16:04:19 +08:00
- `Dockerfile` 只负责安装 Python 依赖
- `docker-compose.yml` 会在运行时挂载 `./app`
- 离线机器只需要预构建镜像和仓库文件即可运行
2026-03-04 16:04:19 +08:00
需要注意的限制:
2026-03-04 16:04:19 +08:00
- 如果你修改了 `requirements.txt`,必须回到有网机器重新构建并导出镜像
- 如果你只修改了 `app/` 下的后端代码,通常不需要重建镜像,重启容器即可
- `frontend/dist` 必须提前构建好,因为 Nginx 会直接从仓库目录提供前端静态文件
- `nginx:alpine``redis:7-alpine``postgres:16` 这些公共镜像,在离线机器上也必须事先准备好
2026-03-04 16:04:19 +08:00
### 在有网机器上准备镜像
2026-03-04 16:04:19 +08:00
构建并导出 Sentinel 运行镜像:
2026-03-04 16:04:19 +08:00
```bash
docker build -t key-ip-sentinel:latest .
docker save -o key-ip-sentinel-latest.tar key-ip-sentinel:latest
```
如果离线机器无法拉取公共镜像,也需要一并导出:
2026-03-04 16:04:19 +08:00
```bash
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
```
如果管理后台前端尚未构建,也请在有网机器上提前构建:
2026-03-04 16:04:19 +08:00
```bash
cd frontend
npm install
npm run build
cd ..
```
然后把以下内容复制到离线机器:
2026-03-04 16:04:19 +08:00
- 整个仓库工作目录
2026-03-04 16:04:19 +08:00
- `key-ip-sentinel-latest.tar`
- `sentinel-support-images.tar`(如果需要)
2026-03-04 16:04:19 +08:00
### 在离线机器上导入镜像
2026-03-04 16:04:19 +08:00
```bash
docker load -i key-ip-sentinel-latest.tar
docker load -i sentinel-support-images.tar
```
### 在离线机器上启动
2026-03-04 16:04:19 +08:00
`.env``frontend/dist``shared_network` 都准备好之后,执行:
2026-03-04 16:04:19 +08:00
```bash
docker compose up -d
```
## 生产部署
### 1. 创建共享 Docker 网络
在 Docker 主机上先创建一次外部网络:
```bash
docker network create shared_network
```
两套 compose 都必须引用这个完全相同的外部网络名。
### 2. 确保 New API 加入共享网络
在 New API 项目中,为 New API 服务加入这个外部网络。
最小示例:
```yaml
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 环境变量
1.`.env.example` 复制为 `.env`
2. 替换 `SENTINEL_HMAC_SECRET``ADMIN_PASSWORD``ADMIN_JWT_SECRET`
3. 确认 `DOWNSTREAM_URL` 指向 `shared_network` 上的 New API 服务名
4. 确认 `PG_DSN``docker-compose.yml` 中 PostgreSQL 密码保持一致,如有修改需同时调整
Sentinel 的 `.env` 示例:
```text
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 前端产物
```bash
cd frontend
npm install
npm run build
cd ..
```
构建完成后会生成 `frontend/dist`Nginx 会将其作为 `/admin/ui/` 的静态站点目录。
如果目标主机离线,请在有网机器上先完成这一步,并把 `frontend/dist` 一并复制过去。
2026-03-04 16:04:19 +08:00
### 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 服务栈
```bash
2026-03-04 16:04:19 +08:00
docker compose up -d
```
只有在有网机器且你明确需要重建镜像时,才使用 `docker compose up --build -d`
2026-03-04 16:04:19 +08:00
服务入口如下:
- `http://<host>:3000/`:模型 API 请求通过 Sentinel 转发
- `http://<host>:3000/admin/ui/`:管理后台前端
- `http://<host>:3000/admin/api/*`:管理后台 API
- `http://<host>:3000/health`:健康检查
### 7. 验证跨 compose 通信与真实 IP
当两套服务都启动后:
1. 从另一台局域网机器访问 `http://<host>:3000/health`,确认返回 `{"status":"ok"}`
2. 打开 `http://<host>:3000/admin/ui/`,使用 `ADMIN_PASSWORD` 登录
3. 向 Sentinel 发送一条真实模型请求,而不是直接访问 New API
4.`Bindings` 页面确认 token 已出现并生成绑定规则
5. 确认记录下来的绑定 IP 是真实局域网客户端 IP而不是 Docker bridge 地址
示例测试请求:
```bash
curl http://<host>:3000/v1/models \
-H "Authorization: Bearer <your_api_key>"
```
如果客户端仍然直接请求 New APISentinel 就看不到流量,也不会生成绑定。
## 客户端应该连接哪个端口
按当前仓库中的 Linux 生产 compose
- Sentinel 对外端口:`3000`
- New API 容器内部端口:通常是 `3000`
这意味着:
- 客户端应当请求 `http://<host>:3000/...`
- Sentinel 会在内部转发到 `http://new-api:3000`
不要把客户端直接指向 New API 的宿主机端口,否则会绕过 Sentinel。
## 如何做到业务无感上线
如果你希望现有客户端配置完全不改Sentinel 就必须接管原来客户端已经在使用的那个对外地址和端口。
典型切换方式如下:
1. 保留 New API 在内部共享网络中运行
2. 停止把 New API 直接暴露给最终用户
3. 让 Sentinel 暴露原来的对外地址和端口
4.`DOWNSTREAM_URL` 持续指向 `shared_network` 上的内部 New API 服务
例如,如果现有客户端一直使用 `http://host:3000`,那生产切换时就应让 Sentinel 接管这个 `3000`,并让 New API 变成内部服务。
当前仓库中的 [`docker-compose.yml`](/c:/project/sentinel/docker-compose.yml) 已经按这种 Linux 生产方式调整Nginx 直接使用宿主机网络监听 `3000`,而 New API 保持内部访问。
## 管理后台 API 概览
- `POST /admin/api/login`
- `GET /admin/api/dashboard`
- `GET /admin/api/bindings`
- `POST /admin/api/bindings/unbind`
- `PUT /admin/api/bindings/ip`
- `POST /admin/api/bindings/ban`
- `POST /admin/api/bindings/unban`
- `GET /admin/api/logs`
- `GET /admin/api/logs/export`
- `GET /admin/api/settings`
- `PUT /admin/api/settings`
`/admin/api/login` 外,所有管理接口都需要:
```text
Authorization: Bearer <jwt>
```
## 关键实现细节
- `app/proxy/handler.py` 会完整流式透传下游响应,包括 SSE
- `app/core/ip_utils.py` 不信任客户端自己传来的 `X-Forwarded-For`
- `app/services/binding_service.py` 会通过 `asyncio.Queue` 每 5 秒批量刷新一次 `last_used_at`
- `app/services/alert_service.py` 会在 Redis 计数达到阈值后推送 Webhook
- `app/services/archive_service.py` 会定时归档过期绑定
## 建议的冒烟检查
1. `GET /health` 返回 `{"status":"ok"}`
2. 使用一个新的 Bearer Token 发起首次请求后,应在 PostgreSQL 和 Redis 中创建绑定
3. 同一 IP 的第二次请求应被放行,并刷新 `last_used_at`
4. 来自不同 IP 的请求应返回 `403`,并写入 `intercept_logs`,除非绑定规则是 `all`
5. `/admin/api/login` 应返回 JWT前端应能正常加载 `/admin/api/dashboard`