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。

仓库结构

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 或单个 CIDRmultiple(多个离散 IPall(允许全部来源 IP

Sentinel 与 New API 的关系

Sentinel 和 New API 预期是以两套独立的 Docker Compose 项目部署:

  • Sentinel 这套 compose 包含 nginxsentinel-appredispostgres
  • 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: 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 使用同一个外部网络名。当前仓库约定如下:

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 的 .envDOWNSTREAM_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 项目,并通常同时加入:

  • default
  • shared_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

本地开发

后端

  1. 安装 uv,并确保本机具备 Python 3.13
  2. 创建虚拟环境并同步依赖:
uv sync
  1. .env.example 复制为 .env,并填写密钥与连接地址
  2. 启动 PostgreSQL 和 Redis
  3. 启动 API
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000

前端

  1. 安装依赖:
cd frontend
npm install
  1. 启动 Vite 开发服务器:
npm run dev

Vite 开发代理会把 /admin/api/* 转发到 http://127.0.0.1:7000

如果你更习惯从仓库根目录启动,也可以直接执行 uv run main.py,它会以 APP_PORT(默认 7000)启动同一个 FastAPI 应用。

依赖管理

  • 本地 Python 开发依赖通过 pyproject.tomluv 管理
  • 容器运行镜像使用 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:alpineredis:7-alpinepostgres: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.tar
  • sentinel-support-images.tar(如果需要)

在离线机器上导入镜像

docker load -i key-ip-sentinel-latest.tar
docker load -i sentinel-support-images.tar

在离线机器上启动

.envfrontend/distshared_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 可以同时加入 defaultshared_network
  • 在测试机上,也可以不使用第二套 compose而改用 docker run,但容器仍然必须加入 shared_network

3. 准备 Sentinel 环境变量

  1. .env.example 复制为 .env
  2. 替换 SENTINEL_HMAC_SECRETADMIN_PASSWORDADMIN_JWT_SECRET
  3. 确认 DOWNSTREAM_URL 指向 shared_network 上的 New API 服务名
  4. 确认 PG_DSNdocker-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/distNginx 会将其作为 /admin/ui/ 的静态站点目录。

如果目标主机离线,请在有网机器上先完成这一步,并把 frontend/dist 一并复制过去。

5. 确认 Sentinel compose 启动前提

  • 必须先构建前端;如果缺少 frontend/dist,则无法访问 /admin/ui/
  • 必须提前创建外部网络 shared_network
  • 如果主机无法联网,必须事先准备好 key-ip-sentinel:latestnginx:alpineredis:7-alpinepostgres: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/*:管理后台 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 地址

示例测试请求:

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 已经按这种 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 外,所有管理接口都需要:

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
Description
No description provided
Readme 255 KiB
Languages
Python 48.3%
Vue 35.7%
CSS 10.4%
JavaScript 5.2%
HTML 0.2%
Other 0.2%