482 lines
15 KiB
Markdown
482 lines
15 KiB
Markdown
# 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 在运行时挂载,因此离线机器不需要为后端代码改动频繁重建镜像
|
||
|
||
## 离线部署模型
|
||
|
||
如果你的生产机器无法访问外网,建议按下面的方式使用本仓库:
|
||
|
||
1. 在有网机器上构建 `key-ip-sentinel:latest` 镜像
|
||
2. 将镜像导出为 tar 包
|
||
3. 在离线机器上导入镜像
|
||
4. 将仓库文件整体复制到离线机器
|
||
5. 使用 `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 运行镜像:
|
||
|
||
```bash
|
||
docker build -t key-ip-sentinel:latest .
|
||
docker save -o key-ip-sentinel-latest.tar key-ip-sentinel:latest
|
||
```
|
||
|
||
如果离线机器无法拉取公共镜像,也需要一并导出:
|
||
|
||
```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
|
||
```
|
||
|
||
如果管理后台前端尚未构建,也请在有网机器上提前构建:
|
||
|
||
```bash
|
||
cd frontend
|
||
npm install
|
||
npm run build
|
||
cd ..
|
||
```
|
||
|
||
然后把以下内容复制到离线机器:
|
||
|
||
- 整个仓库工作目录
|
||
- `key-ip-sentinel-latest.tar`
|
||
- `sentinel-support-images.tar`(如果需要)
|
||
|
||
### 在离线机器上导入镜像
|
||
|
||
```bash
|
||
docker load -i key-ip-sentinel-latest.tar
|
||
docker load -i sentinel-support-images.tar
|
||
```
|
||
|
||
### 在离线机器上启动
|
||
|
||
在 `.env`、`frontend/dist` 和 `shared_network` 都准备好之后,执行:
|
||
|
||
```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` 一并复制过去。
|
||
|
||
### 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
|
||
docker compose up -d
|
||
```
|
||
|
||
只有在有网机器且你明确需要重建镜像时,才使用 `docker compose up --build -d`。
|
||
|
||
服务入口如下:
|
||
|
||
- `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 API,Sentinel 就看不到流量,也不会生成绑定。
|
||
|
||
## 客户端应该连接哪个端口
|
||
|
||
按当前仓库中的 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`
|