# 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://:3000/`:模型 API 请求通过 Sentinel 转发 - `http://:3000/admin/ui/`:管理后台前端 - `http://:3000/admin/api/*`:管理后台 API - `http://:3000/health`:健康检查 ### 7. 验证跨 compose 通信与真实 IP 当两套服务都启动后: 1. 从另一台局域网机器访问 `http://:3000/health`,确认返回 `{"status":"ok"}` 2. 打开 `http://:3000/admin/ui/`,使用 `ADMIN_PASSWORD` 登录 3. 向 Sentinel 发送一条真实模型请求,而不是直接访问 New API 4. 到 `Bindings` 页面确认 token 已出现并生成绑定规则 5. 确认记录下来的绑定 IP 是真实局域网客户端 IP,而不是 Docker bridge 地址 示例测试请求: ```bash curl http://:3000/v1/models \ -H "Authorization: Bearer " ``` 如果客户端仍然直接请求 New API,Sentinel 就看不到流量,也不会生成绑定。 ## 客户端应该连接哪个端口 按当前仓库中的 Linux 生产 compose: - Sentinel 对外端口:`3000` - New API 容器内部端口:通常是 `3000` 这意味着: - 客户端应当请求 `http://: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 ``` ## 关键实现细节 - `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`