Files
sentinel/README.md

482 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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`