Compare commits
7 Commits
380a78283e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed6f70bab | |||
| feb99faaf3 | |||
| 663999f173 | |||
| bafae32c76 | |||
| eed1acd454 | |||
| 4348ee799b | |||
| f212b68c2c |
@@ -5,10 +5,12 @@ 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.18.0.0/16
|
||||
TRUSTED_PROXY_IPS=172.30.0.0/24
|
||||
SENTINEL_FAILSAFE_MODE=closed
|
||||
APP_PORT=7000
|
||||
UVICORN_WORKERS=4
|
||||
ALERT_WEBHOOK_URL=
|
||||
ALERT_THRESHOLD_COUNT=5
|
||||
ALERT_THRESHOLD_SECONDS=300
|
||||
ARCHIVE_DAYS=90
|
||||
ARCHIVE_SCHEDULER_LOCK_KEY=2026030502
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ wheels/
|
||||
# Node-generated files
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
.env
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
FROM python:3.13-slim AS builder
|
||||
FROM python:3.13-slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.13-slim-bookworm
|
||||
WORKDIR /app
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY app/ ./app/
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7000", "--workers", "4"]
|
||||
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${APP_PORT:-7000} --workers ${UVICORN_WORKERS:-4}"]
|
||||
|
||||
77
PRD.md
77
PRD.md
@@ -24,16 +24,16 @@
|
||||
### 2.1 流量链路
|
||||
|
||||
```
|
||||
调用方 (Client)
|
||||
│
|
||||
│ HTTPS (443)
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Nginx │
|
||||
│ 职责:TLS终止 / 路径路由 / │
|
||||
│ 静态文件 / 内网鉴权 / 粗粒度限流 │
|
||||
└────────────────┬────────────────────────┘
|
||||
│ HTTP 内网转发
|
||||
调用方 (Client)
|
||||
│
|
||||
│ HTTP (80)
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Nginx │
|
||||
│ 职责:路径路由 / │
|
||||
│ 静态文件 / 内网鉴权 / 粗粒度限流 │
|
||||
└────────────────┬────────────────────────┘
|
||||
│ HTTP 内网转发
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Key-IP Sentinel App │
|
||||
@@ -65,7 +65,7 @@
|
||||
| 缓存层 | **Redis 7+** | Token-IP 绑定热数据,TTL 7 天 |
|
||||
| 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 |
|
||||
| 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA,打包后由 Nginx 直接托管 |
|
||||
| 外层网关 | **Nginx** | TLS 终止、路径隔离、静态文件、`limit_req_zone` 限流 |
|
||||
| 外层网关 | **Nginx** | 路径隔离、静态文件、`limit_req_zone` 限流 |
|
||||
| 部署方式 | **Docker Compose** | 共 4 个容器:nginx / sentinel-app / redis / postgres |
|
||||
|
||||
***
|
||||
@@ -122,26 +122,25 @@
|
||||
在 `nginx.conf` 中需要实现以下配置:
|
||||
|
||||
```nginx
|
||||
# 1. TLS 终止(HTTPS → HTTP 转发给 sentinel-app)
|
||||
# 2. 代理路径:/ 全部转发给 sentinel-app:7000
|
||||
# 3. 管理后台访问限制
|
||||
location /admin/ {
|
||||
allow 10.0.0.0/8; # 内网 IP 段
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
proxy_pass http://sentinel-app:7000;
|
||||
}
|
||||
# 4. 静态文件(前端 UI)
|
||||
location /admin/ui/ {
|
||||
root /etc/nginx/html;
|
||||
try_files $uri $uri/ /admin/ui/index.html;
|
||||
}
|
||||
# 5. 基础限流
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
||||
# 6. 强制写入真实 IP(防客户端伪造)
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值
|
||||
```
|
||||
# 1. 代理路径:/ 全部转发给 sentinel-app:7000
|
||||
# 2. 管理后台访问限制
|
||||
location /admin/ {
|
||||
allow 10.0.0.0/8; # 内网 IP 段
|
||||
allow 192.168.0.0/16;
|
||||
deny all;
|
||||
proxy_pass http://sentinel-app:7000;
|
||||
}
|
||||
# 3. 静态文件(前端 UI)
|
||||
location /admin/ui/ {
|
||||
root /etc/nginx/html;
|
||||
try_files $uri $uri/ /admin/ui/index.html;
|
||||
}
|
||||
# 4. 基础限流
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
||||
# 5. 强制写入真实 IP(防客户端伪造)
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值
|
||||
```
|
||||
|
||||
### 4.2 Sentinel App 反向代理模块
|
||||
- **受信 IP Header**:只读取 `X-Real-IP`(Nginx 写入的),忽略请求中原始的 `X-Forwarded-For`。
|
||||
@@ -337,15 +336,13 @@ version: '3.8'
|
||||
services:
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: sentinel-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
||||
image: nginx:alpine
|
||||
container_name: sentinel-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
||||
depends_on:
|
||||
- sentinel-app
|
||||
networks:
|
||||
|
||||
462
README.md
462
README.md
@@ -1,18 +1,18 @@
|
||||
# Key-IP Sentinel
|
||||
|
||||
Key-IP Sentinel is a FastAPI-based reverse proxy that enforces first-use IP binding for model API keys before traffic reaches a downstream New API service.
|
||||
Key-IP Sentinel 是一个基于 FastAPI 的反向代理,用于在请求到达下游 New API 服务之前,对模型 API Key 执行“首次使用绑定来源 IP”的控制。
|
||||
|
||||
## Features
|
||||
## 功能特性
|
||||
|
||||
- First-use bind with HMAC-SHA256 token hashing, Redis cache-aside, and PostgreSQL CIDR matching.
|
||||
- Streaming reverse proxy built on `httpx.AsyncClient` and FastAPI `StreamingResponse`.
|
||||
- Trusted proxy IP extraction that only accepts `X-Real-IP` from configured upstream networks.
|
||||
- Redis-backed intercept alert counters with webhook delivery and PostgreSQL audit logs.
|
||||
- Admin API protected by JWT and Redis-backed login lockout.
|
||||
- Vue 3 + Element Plus admin console for dashboarding, binding operations, audit logs, and live runtime settings.
|
||||
- Docker Compose deployment with Nginx, app, Redis, and PostgreSQL.
|
||||
- 首次使用自动绑定,使用 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。
|
||||
|
||||
## Repository Layout
|
||||
## 仓库结构
|
||||
|
||||
```text
|
||||
sentinel/
|
||||
@@ -26,64 +26,241 @@ sentinel/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Runtime Notes
|
||||
## 运行说明
|
||||
|
||||
- Redis stores binding cache, alert counters, daily dashboard metrics, and mutable runtime settings.
|
||||
- PostgreSQL stores authoritative token bindings and intercept logs.
|
||||
- Archive retention removes inactive bindings from the active table after `ARCHIVE_DAYS`. A later request from the same token will bind again on first use.
|
||||
- `SENTINEL_FAILSAFE_MODE=closed` rejects requests when both Redis and PostgreSQL are unavailable. `open` allows traffic through.
|
||||
- Redis 用于存储绑定缓存、告警计数、每日看板指标和可变运行时设置。
|
||||
- PostgreSQL 用于存储权威绑定记录和拦截日志。
|
||||
- 归档保留机制会在绑定超过 `ARCHIVE_DAYS` 不活跃后,从活动表中移除;同一 token 后续再次请求时会重新进行首次绑定。
|
||||
- `SENTINEL_FAILSAFE_MODE=closed` 表示在 Redis 和 PostgreSQL 同时不可用时拒绝请求;`open` 表示放行。
|
||||
- 绑定规则支持三种模式:`single`(单个 IP 或单个 CIDR)、`multiple`(多个离散 IP)、`all`(允许全部来源 IP)。
|
||||
|
||||
## Local Development
|
||||
## Sentinel 与 New API 的关系
|
||||
|
||||
### Backend
|
||||
Sentinel 和 New API 预期是以两套独立的 Docker Compose 项目部署:
|
||||
|
||||
1. Install `uv` and ensure Python 3.13 is available.
|
||||
2. Create the environment and sync dependencies:
|
||||
- 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. Copy `.env.example` to `.env` and update secrets plus addresses.
|
||||
4. Start PostgreSQL and Redis.
|
||||
5. Run the API:
|
||||
3. 将 `.env.example` 复制为 `.env`,并填写密钥与连接地址
|
||||
4. 启动 PostgreSQL 和 Redis
|
||||
5. 启动 API:
|
||||
|
||||
```bash
|
||||
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000
|
||||
```
|
||||
|
||||
### Frontend
|
||||
### 前端
|
||||
|
||||
1. Install dependencies:
|
||||
1. 安装依赖:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start Vite dev server:
|
||||
2. 启动 Vite 开发服务器:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`.
|
||||
Vite 开发代理会把 `/admin/api/*` 转发到 `http://127.0.0.1:7000`。
|
||||
|
||||
## Dependency Management
|
||||
如果你更习惯从仓库根目录启动,也可以直接执行 `uv run main.py`,它会以 `APP_PORT`(默认 `7000`)启动同一个 FastAPI 应用。
|
||||
|
||||
- Local Python development uses `uv` via [`pyproject.toml`](/d:/project/sentinel/pyproject.toml).
|
||||
- Container builds still use [`requirements.txt`](/d:/project/sentinel/requirements.txt) because the Dockerfile is intentionally minimal and matches the delivery requirements.
|
||||
## 依赖管理
|
||||
|
||||
## Production Deployment
|
||||
- 本地 Python 开发依赖通过 [`pyproject.toml`](/c:/project/sentinel/pyproject.toml) 和 `uv` 管理
|
||||
- 容器运行镜像使用 [`requirements.txt`](/c:/project/sentinel/requirements.txt) 安装 Python 依赖
|
||||
- 应用源码通过 Compose 在运行时挂载,因此离线机器不需要为后端代码改动频繁重建镜像
|
||||
|
||||
### 1. Prepare environment
|
||||
## 离线部署模型
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Replace `SENTINEL_HMAC_SECRET`, `ADMIN_PASSWORD`, and `ADMIN_JWT_SECRET`.
|
||||
3. Verify `DOWNSTREAM_URL` points to the internal New API service.
|
||||
4. Keep `PG_DSN` aligned with the fixed PostgreSQL container password in `docker-compose.yml`, or update both together.
|
||||
如果你的生产机器无法访问外网,建议按下面的方式使用本仓库:
|
||||
|
||||
### 2. Build the frontend bundle
|
||||
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
|
||||
@@ -92,29 +269,182 @@ npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
This produces `frontend/dist`, which Nginx serves at `/admin/ui/`.
|
||||
然后把以下内容复制到离线机器:
|
||||
|
||||
### 3. Provide TLS assets
|
||||
- 整个仓库工作目录
|
||||
- `key-ip-sentinel-latest.tar`
|
||||
- `sentinel-support-images.tar`(如果需要)
|
||||
|
||||
Place certificate files at:
|
||||
|
||||
- `nginx/ssl/server.crt`
|
||||
- `nginx/ssl/server.key`
|
||||
|
||||
### 4. Start the stack
|
||||
### 在离线机器上导入镜像
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker load -i key-ip-sentinel-latest.tar
|
||||
docker load -i sentinel-support-images.tar
|
||||
```
|
||||
|
||||
Services:
|
||||
### 在离线机器上启动
|
||||
|
||||
- `https://<host>/` forwards model API traffic through Sentinel.
|
||||
- `https://<host>/admin/ui/` serves the admin console.
|
||||
- `https://<host>/admin/api/*` serves the admin API.
|
||||
- `https://<host>/health` exposes the app health check.
|
||||
在 `.env`、`frontend/dist` 和 `shared_network` 都准备好之后,执行:
|
||||
|
||||
## Admin API Summary
|
||||
```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`
|
||||
@@ -128,20 +458,24 @@ Services:
|
||||
- `GET /admin/api/settings`
|
||||
- `PUT /admin/api/settings`
|
||||
|
||||
All admin endpoints except `/admin/api/login` require `Authorization: Bearer <jwt>`.
|
||||
除 `/admin/api/login` 外,所有管理接口都需要:
|
||||
|
||||
## Key Implementation Details
|
||||
```text
|
||||
Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
- `app/proxy/handler.py` keeps the downstream response fully streamed, including SSE responses.
|
||||
- `app/core/ip_utils.py` never trusts client-supplied `X-Forwarded-For`.
|
||||
- `app/services/binding_service.py` batches `last_used_at` updates every 5 seconds through an `asyncio.Queue`.
|
||||
- `app/services/alert_service.py` pushes webhooks once the Redis counter reaches the configured threshold.
|
||||
- `app/services/archive_service.py` prunes stale bindings on a scheduler interval.
|
||||
## 关键实现细节
|
||||
|
||||
## Suggested Smoke Checks
|
||||
- `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` returns `{"status":"ok"}`.
|
||||
2. A first request with a new bearer token creates a binding in PostgreSQL and Redis.
|
||||
3. A second request from the same IP is allowed and refreshes `last_used_at`.
|
||||
4. A request from a different IP is rejected with `403` and creates an `intercept_logs` record.
|
||||
5. `/admin/api/login` returns a JWT and the frontend can load `/admin/api/dashboard`.
|
||||
## 建议的冒烟检查
|
||||
|
||||
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`
|
||||
|
||||
@@ -28,6 +28,8 @@ def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> B
|
||||
id=binding.id,
|
||||
token_display=binding.token_display,
|
||||
bound_ip=str(binding.bound_ip),
|
||||
binding_mode=binding.binding_mode,
|
||||
allowed_ips=[str(item) for item in binding.allowed_ips],
|
||||
status=binding.status,
|
||||
status_label=binding_service.status_label(binding.status),
|
||||
first_used_at=binding.first_used_at,
|
||||
@@ -70,7 +72,13 @@ def log_admin_action(request: Request, settings: Settings, action: str, binding_
|
||||
|
||||
|
||||
async def commit_binding_cache(binding: TokenBinding, binding_service: BindingService) -> None:
|
||||
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
|
||||
await binding_service.sync_binding_cache(
|
||||
binding.token_hash,
|
||||
str(binding.bound_ip),
|
||||
binding.binding_mode,
|
||||
[str(item) for item in binding.allowed_ips],
|
||||
binding.status,
|
||||
)
|
||||
|
||||
|
||||
async def update_binding_status(
|
||||
@@ -138,7 +146,9 @@ async def update_bound_ip(
|
||||
binding_service: BindingService = Depends(get_binding_service),
|
||||
):
|
||||
binding = await get_binding_or_404(session, payload.id)
|
||||
binding.bound_ip = payload.bound_ip
|
||||
binding.binding_mode = payload.binding_mode
|
||||
binding.allowed_ips = payload.allowed_ips
|
||||
binding.bound_ip = binding_service.build_bound_ip_display(payload.binding_mode, payload.allowed_ips)
|
||||
await session.commit()
|
||||
await commit_binding_cache(binding, binding_service)
|
||||
log_admin_action(request, settings, "update_ip", payload.id)
|
||||
|
||||
@@ -76,7 +76,7 @@ async def build_recent_intercepts(session: AsyncSession) -> list[InterceptLogIte
|
||||
InterceptLogItem(
|
||||
id=item.id,
|
||||
token_display=item.token_display,
|
||||
bound_ip=str(item.bound_ip),
|
||||
bound_ip=item.bound_ip,
|
||||
attempt_ip=str(item.attempt_ip),
|
||||
alerted=item.alerted,
|
||||
intercepted_at=item.intercepted_at,
|
||||
|
||||
@@ -38,7 +38,7 @@ def to_log_item(item: InterceptLog) -> InterceptLogItem:
|
||||
return InterceptLogItem(
|
||||
id=item.id,
|
||||
token_display=item.token_display,
|
||||
bound_ip=str(item.bound_ip),
|
||||
bound_ip=item.bound_ip,
|
||||
attempt_ip=str(item.attempt_ip),
|
||||
alerted=item.alerted,
|
||||
intercepted_at=item.intercepted_at,
|
||||
@@ -47,13 +47,13 @@ def to_log_item(item: InterceptLog) -> InterceptLogItem:
|
||||
|
||||
def write_log_csv(buffer: io.StringIO, logs: list[InterceptLog]) -> None:
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
|
||||
writer.writerow(["id", "token_display", "binding_rule", "attempt_ip", "alerted", "intercepted_at"])
|
||||
for item in logs:
|
||||
writer.writerow(
|
||||
[
|
||||
item.id,
|
||||
item.token_display,
|
||||
str(item.bound_ip),
|
||||
item.bound_ip,
|
||||
str(item.attempt_ip),
|
||||
item.alerted,
|
||||
item.intercepted_at.isoformat(),
|
||||
|
||||
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
|
||||
sentinel_hmac_secret: str = Field(alias="SENTINEL_HMAC_SECRET", min_length=32)
|
||||
admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=8)
|
||||
admin_jwt_secret: str = Field(alias="ADMIN_JWT_SECRET", min_length=16)
|
||||
trusted_proxy_ips: tuple[str, ...] = Field(default_factory=tuple, alias="TRUSTED_PROXY_IPS")
|
||||
trusted_proxy_ips_raw: str = Field(default="", alias="TRUSTED_PROXY_IPS")
|
||||
sentinel_failsafe_mode: Literal["open", "closed"] = Field(
|
||||
default="closed",
|
||||
alias="SENTINEL_FAILSAFE_MODE",
|
||||
@@ -54,6 +54,7 @@ class Settings(BaseSettings):
|
||||
admin_jwt_expire_hours: int = 8
|
||||
archive_job_interval_minutes: int = 60
|
||||
archive_batch_size: int = 500
|
||||
archive_scheduler_lock_key: int = Field(default=2026030502, alias="ARCHIVE_SCHEDULER_LOCK_KEY")
|
||||
metrics_ttl_days: int = 30
|
||||
webhook_timeout_seconds: int = 5
|
||||
|
||||
@@ -62,17 +63,10 @@ class Settings(BaseSettings):
|
||||
def normalize_downstream_url(cls, value: str) -> str:
|
||||
return value.rstrip("/")
|
||||
|
||||
@field_validator("trusted_proxy_ips", mode="before")
|
||||
@classmethod
|
||||
def split_proxy_ips(cls, value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return tuple()
|
||||
if isinstance(value, str):
|
||||
parts = [item.strip() for item in value.split(",")]
|
||||
return tuple(item for item in parts if item)
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return tuple(str(item).strip() for item in value if str(item).strip())
|
||||
return (str(value).strip(),)
|
||||
@property
|
||||
def trusted_proxy_ips(self) -> tuple[str, ...]:
|
||||
parts = [item.strip() for item in self.trusted_proxy_ips_raw.split(",")]
|
||||
return tuple(item for item in parts if item)
|
||||
|
||||
@cached_property
|
||||
def trusted_proxy_networks(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Mapping
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
@@ -34,6 +35,19 @@ def extract_bearer_token(authorization: str | None) -> str | None:
|
||||
return token.strip()
|
||||
|
||||
|
||||
def extract_request_token(headers: Mapping[str, str]) -> tuple[str | None, str | None]:
|
||||
bearer_token = extract_bearer_token(headers.get("authorization"))
|
||||
if bearer_token:
|
||||
return bearer_token, "authorization"
|
||||
|
||||
for header_name in ("x-api-key", "api-key"):
|
||||
header_value = headers.get(header_name)
|
||||
if header_value and header_value.strip():
|
||||
return header_value.strip(), header_name
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def verify_admin_password(password: str, settings: Settings) -> bool:
|
||||
return hmac.compare_digest(password, settings.admin_password)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from redis.asyncio import from_url as redis_from_url
|
||||
from app.api import auth, bindings, dashboard, logs, settings as settings_api
|
||||
from app.config import RUNTIME_SETTINGS_REDIS_KEY, RuntimeSettings, Settings, get_settings
|
||||
from app.models import intercept_log, token_binding # noqa: F401
|
||||
from app.models.db import close_db, get_session_factory, init_db
|
||||
from app.models.db import close_db, ensure_schema_compatibility, get_engine, get_session_factory, init_db
|
||||
from app.proxy.handler import router as proxy_router
|
||||
from app.services.alert_service import AlertService
|
||||
from app.services.archive_service import ArchiveService
|
||||
@@ -70,6 +70,8 @@ def configure_logging() -> None:
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(logging.INFO)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
configure_logging()
|
||||
@@ -100,6 +102,7 @@ async def load_runtime_settings(redis: Redis | None, settings: Settings) -> Runt
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
init_db(settings)
|
||||
await ensure_schema_compatibility()
|
||||
session_factory = get_session_factory()
|
||||
|
||||
redis: Redis | None = redis_from_url(
|
||||
@@ -152,6 +155,7 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
archive_service = ArchiveService(
|
||||
settings=settings,
|
||||
engine=get_engine(),
|
||||
session_factory=session_factory,
|
||||
binding_service=binding_service,
|
||||
runtime_settings_getter=lambda: app.state.runtime_settings,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
SCHEMA_COMPATIBILITY_LOCK_KEY = 2026030501
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -40,6 +43,35 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def ensure_schema_compatibility() -> None:
|
||||
engine = get_engine()
|
||||
statements = [
|
||||
"DROP INDEX IF EXISTS idx_token_bindings_ip",
|
||||
"ALTER TABLE token_bindings ALTER COLUMN bound_ip TYPE TEXT USING bound_ip::text",
|
||||
"ALTER TABLE intercept_logs ALTER COLUMN bound_ip TYPE TEXT USING bound_ip::text",
|
||||
"ALTER TABLE token_bindings ADD COLUMN IF NOT EXISTS binding_mode VARCHAR(16) DEFAULT 'single'",
|
||||
"ALTER TABLE token_bindings ADD COLUMN IF NOT EXISTS allowed_ips JSONB DEFAULT '[]'::jsonb",
|
||||
"UPDATE token_bindings SET binding_mode = 'single' WHERE binding_mode IS NULL OR binding_mode = ''",
|
||||
"""
|
||||
UPDATE token_bindings
|
||||
SET allowed_ips = jsonb_build_array(bound_ip)
|
||||
WHERE allowed_ips IS NULL OR allowed_ips = '[]'::jsonb
|
||||
""",
|
||||
"ALTER TABLE token_bindings ALTER COLUMN binding_mode SET NOT NULL",
|
||||
"ALTER TABLE token_bindings ALTER COLUMN allowed_ips SET NOT NULL",
|
||||
"ALTER TABLE token_bindings ALTER COLUMN binding_mode SET DEFAULT 'single'",
|
||||
"ALTER TABLE token_bindings ALTER COLUMN allowed_ips SET DEFAULT '[]'::jsonb",
|
||||
"CREATE INDEX IF NOT EXISTS idx_token_bindings_ip ON token_bindings(bound_ip)",
|
||||
]
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:lock_key)"),
|
||||
{"lock_key": SCHEMA_COMPATIBILITY_LOCK_KEY},
|
||||
)
|
||||
for statement in statements:
|
||||
await connection.execute(text(statement))
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import CIDR, INET
|
||||
from sqlalchemy import Boolean, DateTime, Index, String, Text, func, text
|
||||
from sqlalchemy.dialects.postgresql import INET
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.db import Base
|
||||
@@ -19,7 +19,7 @@ class InterceptLog(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
token_display: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
bound_ip: Mapped[str] = mapped_column(CIDR, nullable=False)
|
||||
bound_ip: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attempt_ip: Mapped[str] = mapped_column(INET, nullable=False)
|
||||
alerted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("FALSE"))
|
||||
intercepted_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -2,27 +2,42 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, SmallInteger, String, func, text
|
||||
from sqlalchemy.dialects.postgresql import CIDR
|
||||
from sqlalchemy import DateTime, Index, SmallInteger, String, Text, func, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.db import Base
|
||||
|
||||
STATUS_ACTIVE = 1
|
||||
STATUS_BANNED = 2
|
||||
BINDING_MODE_SINGLE = "single"
|
||||
BINDING_MODE_MULTIPLE = "multiple"
|
||||
BINDING_MODE_ALL = "all"
|
||||
|
||||
|
||||
class TokenBinding(Base):
|
||||
__tablename__ = "token_bindings"
|
||||
__table_args__ = (
|
||||
Index("idx_token_bindings_hash", "token_hash"),
|
||||
Index("idx_token_bindings_ip", "bound_ip", postgresql_using="gist", postgresql_ops={"bound_ip": "inet_ops"}),
|
||||
Index("idx_token_bindings_ip", "bound_ip"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
token_display: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
bound_ip: Mapped[str] = mapped_column(CIDR, nullable=False)
|
||||
bound_ip: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
binding_mode: Mapped[str] = mapped_column(
|
||||
String(16),
|
||||
nullable=False,
|
||||
default=BINDING_MODE_SINGLE,
|
||||
server_default=text("'single'"),
|
||||
)
|
||||
allowed_ips: Mapped[list[str]] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=list,
|
||||
server_default=text("'[]'::jsonb"),
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
SmallInteger,
|
||||
nullable=False,
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.ip_utils import extract_client_ip
|
||||
from app.core.security import extract_bearer_token
|
||||
from app.core.security import extract_request_token
|
||||
from app.dependencies import get_alert_service, get_binding_service, get_settings
|
||||
from app.services.alert_service import AlertService
|
||||
from app.services.binding_service import BindingService
|
||||
@@ -56,7 +56,7 @@ async def reverse_proxy(
|
||||
alert_service: AlertService = Depends(get_alert_service),
|
||||
):
|
||||
client_ip = extract_client_ip(request, settings)
|
||||
token = extract_bearer_token(request.headers.get("authorization"))
|
||||
token, token_source = extract_request_token(request.headers)
|
||||
|
||||
if token:
|
||||
binding_result = await binding_service.evaluate_token_binding(token, client_ip)
|
||||
@@ -75,6 +75,7 @@ async def reverse_proxy(
|
||||
status_code=binding_result.status_code,
|
||||
content={"detail": binding_result.detail},
|
||||
)
|
||||
logger.debug("Token binding check passed.", extra={"client_ip": client_ip, "token_source": token_source})
|
||||
else:
|
||||
await binding_service.increment_request_metric("allowed")
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.models.token_binding import BINDING_MODE_ALL, BINDING_MODE_MULTIPLE, BINDING_MODE_SINGLE
|
||||
|
||||
|
||||
class BindingItem(BaseModel):
|
||||
@@ -11,6 +14,8 @@ class BindingItem(BaseModel):
|
||||
id: int
|
||||
token_display: str
|
||||
bound_ip: str
|
||||
binding_mode: str
|
||||
allowed_ips: list[str]
|
||||
status: int
|
||||
status_label: str
|
||||
first_used_at: datetime
|
||||
@@ -31,12 +36,32 @@ class BindingActionRequest(BaseModel):
|
||||
|
||||
class BindingIPUpdateRequest(BaseModel):
|
||||
id: int = Field(gt=0)
|
||||
bound_ip: str = Field(min_length=3, max_length=64)
|
||||
binding_mode: str = Field(default=BINDING_MODE_SINGLE)
|
||||
allowed_ips: list[str] = Field(default_factory=list)
|
||||
|
||||
@field_validator("bound_ip")
|
||||
@classmethod
|
||||
def validate_bound_ip(cls, value: str) -> str:
|
||||
from ipaddress import ip_network
|
||||
@model_validator(mode="after")
|
||||
def validate_binding_rule(self):
|
||||
allowed_ips = [item.strip() for item in self.allowed_ips if item.strip()]
|
||||
|
||||
ip_network(value, strict=False)
|
||||
return value
|
||||
if self.binding_mode == BINDING_MODE_ALL:
|
||||
self.allowed_ips = []
|
||||
return self
|
||||
|
||||
if self.binding_mode == BINDING_MODE_SINGLE:
|
||||
if len(allowed_ips) != 1:
|
||||
raise ValueError("Single binding mode requires exactly one IP or CIDR.")
|
||||
ip_network(allowed_ips[0], strict=False)
|
||||
self.allowed_ips = allowed_ips
|
||||
return self
|
||||
|
||||
if self.binding_mode == BINDING_MODE_MULTIPLE:
|
||||
if not allowed_ips:
|
||||
raise ValueError("Multiple binding mode requires at least one IP.")
|
||||
normalized: list[str] = []
|
||||
for item in allowed_ips:
|
||||
ip_address(item)
|
||||
normalized.append(item)
|
||||
self.allowed_ips = normalized
|
||||
return self
|
||||
|
||||
raise ValueError("Unsupported binding mode.")
|
||||
|
||||
@@ -5,9 +5,9 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Callable
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import RuntimeSettings, Settings
|
||||
from app.models.token_binding import TokenBinding
|
||||
@@ -20,33 +20,45 @@ class ArchiveService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
engine: AsyncEngine,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
binding_service: BindingService,
|
||||
runtime_settings_getter: Callable[[], RuntimeSettings],
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.engine = engine
|
||||
self.session_factory = session_factory
|
||||
self.binding_service = binding_service
|
||||
self.runtime_settings_getter = runtime_settings_getter
|
||||
self.scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
self._leader_connection: AsyncConnection | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.scheduler.running:
|
||||
return
|
||||
self.scheduler.add_job(
|
||||
self.archive_inactive_bindings,
|
||||
trigger="interval",
|
||||
minutes=self.settings.archive_job_interval_minutes,
|
||||
id="archive-inactive-bindings",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
self.scheduler.start()
|
||||
if not await self._acquire_leader_lock():
|
||||
logger.info("Archive scheduler leader lock not acquired; skipping local scheduler start.")
|
||||
return
|
||||
try:
|
||||
self.scheduler.add_job(
|
||||
self.archive_inactive_bindings,
|
||||
trigger="interval",
|
||||
minutes=self.settings.archive_job_interval_minutes,
|
||||
id="archive-inactive-bindings",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
self.scheduler.start()
|
||||
except Exception:
|
||||
await self._release_leader_lock()
|
||||
raise
|
||||
logger.info("Archive scheduler started on current worker.")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=False)
|
||||
await self._release_leader_lock()
|
||||
|
||||
async def archive_inactive_bindings(self) -> int:
|
||||
runtime_settings = self.runtime_settings_getter()
|
||||
@@ -82,3 +94,43 @@ class ArchiveService:
|
||||
if total_archived:
|
||||
logger.info("Archived inactive bindings.", extra={"count": total_archived})
|
||||
return total_archived
|
||||
|
||||
async def _acquire_leader_lock(self) -> bool:
|
||||
if self._leader_connection is not None:
|
||||
return True
|
||||
|
||||
connection = await self.engine.connect()
|
||||
try:
|
||||
acquired = bool(
|
||||
await connection.scalar(
|
||||
text("SELECT pg_try_advisory_lock(:lock_key)"),
|
||||
{"lock_key": self.settings.archive_scheduler_lock_key},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
await connection.close()
|
||||
logger.exception("Failed to acquire archive scheduler leader lock.")
|
||||
return False
|
||||
|
||||
if not acquired:
|
||||
await connection.close()
|
||||
return False
|
||||
|
||||
self._leader_connection = connection
|
||||
return True
|
||||
|
||||
async def _release_leader_lock(self) -> None:
|
||||
if self._leader_connection is None:
|
||||
return
|
||||
|
||||
connection = self._leader_connection
|
||||
self._leader_connection = None
|
||||
try:
|
||||
await connection.execute(
|
||||
text("SELECT pg_advisory_unlock(:lock_key)"),
|
||||
{"lock_key": self.settings.archive_scheduler_lock_key},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to release archive scheduler leader lock cleanly.")
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
@@ -5,18 +5,25 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, timedelta
|
||||
from datetime import date, timedelta
|
||||
from typing import Callable
|
||||
|
||||
from redis.asyncio import Redis
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import RuntimeSettings, Settings
|
||||
from app.core.ip_utils import is_ip_in_network
|
||||
from app.core.security import hash_token, mask_token
|
||||
from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding
|
||||
from app.models.token_binding import (
|
||||
BINDING_MODE_ALL,
|
||||
BINDING_MODE_MULTIPLE,
|
||||
BINDING_MODE_SINGLE,
|
||||
STATUS_ACTIVE,
|
||||
STATUS_BANNED,
|
||||
TokenBinding,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +34,8 @@ class BindingRecord:
|
||||
token_hash: str
|
||||
token_display: str
|
||||
bound_ip: str
|
||||
binding_mode: str
|
||||
allowed_ips: list[str]
|
||||
status: int
|
||||
ip_matched: bool
|
||||
|
||||
@@ -104,42 +113,101 @@ class BindingService:
|
||||
def metrics_key(self, target_date: date) -> str:
|
||||
return f"sentinel:metrics:{target_date.isoformat()}"
|
||||
|
||||
def build_bound_ip_display(self, binding_mode: str, allowed_ips: list[str]) -> str:
|
||||
if binding_mode == BINDING_MODE_ALL:
|
||||
return "ALL"
|
||||
if not allowed_ips:
|
||||
return "-"
|
||||
if binding_mode == BINDING_MODE_MULTIPLE:
|
||||
return ", ".join(allowed_ips)
|
||||
return allowed_ips[0]
|
||||
|
||||
def is_client_allowed(self, client_ip: str, binding_mode: str, allowed_ips: list[str]) -> bool:
|
||||
if binding_mode == BINDING_MODE_ALL:
|
||||
return True
|
||||
return any(is_ip_in_network(client_ip, item) for item in allowed_ips)
|
||||
|
||||
def to_binding_record(self, binding: TokenBinding, client_ip: str) -> BindingRecord:
|
||||
allowed_ips = [str(item) for item in binding.allowed_ips]
|
||||
binding_mode = binding.binding_mode or BINDING_MODE_SINGLE
|
||||
return BindingRecord(
|
||||
id=binding.id,
|
||||
token_hash=binding.token_hash,
|
||||
token_display=binding.token_display,
|
||||
bound_ip=binding.bound_ip,
|
||||
binding_mode=binding_mode,
|
||||
allowed_ips=allowed_ips,
|
||||
status=binding.status,
|
||||
ip_matched=self.is_client_allowed(client_ip, binding_mode, allowed_ips),
|
||||
)
|
||||
|
||||
def denied_result(
|
||||
self,
|
||||
token_hash: str,
|
||||
token_display: str,
|
||||
bound_ip: str,
|
||||
detail: str,
|
||||
*,
|
||||
should_alert: bool = True,
|
||||
status_code: int = 403,
|
||||
) -> BindingCheckResult:
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=status_code,
|
||||
detail=detail,
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=bound_ip,
|
||||
should_alert=should_alert,
|
||||
)
|
||||
|
||||
def allowed_result(
|
||||
self,
|
||||
token_hash: str,
|
||||
token_display: str,
|
||||
bound_ip: str,
|
||||
detail: str,
|
||||
*,
|
||||
newly_bound: bool = False,
|
||||
) -> BindingCheckResult:
|
||||
return BindingCheckResult(
|
||||
allowed=True,
|
||||
status_code=200,
|
||||
detail=detail,
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=bound_ip,
|
||||
newly_bound=newly_bound,
|
||||
)
|
||||
|
||||
def evaluate_existing_record(
|
||||
self,
|
||||
record: BindingRecord,
|
||||
token_hash: str,
|
||||
token_display: str,
|
||||
detail: str,
|
||||
) -> BindingCheckResult:
|
||||
if record.status == STATUS_BANNED:
|
||||
return self.denied_result(token_hash, token_display, record.bound_ip, "Token is banned.")
|
||||
if record.ip_matched:
|
||||
self.record_last_used(token_hash)
|
||||
return self.allowed_result(token_hash, token_display, record.bound_ip, detail)
|
||||
return self.denied_result(
|
||||
token_hash,
|
||||
token_display,
|
||||
record.bound_ip,
|
||||
"Client IP does not match the allowed binding rule.",
|
||||
)
|
||||
|
||||
async def evaluate_token_binding(self, token: str, client_ip: str) -> BindingCheckResult:
|
||||
token_hash = hash_token(token, self.settings.sentinel_hmac_secret)
|
||||
token_display = mask_token(token)
|
||||
|
||||
cache_hit, cache_available = await self._load_binding_from_cache(token_hash)
|
||||
cache_hit, cache_available = await self._load_binding_from_cache(token_hash, client_ip)
|
||||
if cache_hit is not None:
|
||||
if cache_hit.status == STATUS_BANNED:
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=403,
|
||||
detail="Token is banned.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=cache_hit.bound_ip,
|
||||
should_alert=True,
|
||||
)
|
||||
if is_ip_in_network(client_ip, cache_hit.bound_ip):
|
||||
if cache_hit.ip_matched:
|
||||
await self._touch_cache(token_hash)
|
||||
self.record_last_used(token_hash)
|
||||
return BindingCheckResult(
|
||||
allowed=True,
|
||||
status_code=200,
|
||||
detail="Allowed from cache.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=cache_hit.bound_ip,
|
||||
)
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=403,
|
||||
detail="Client IP does not match the bound CIDR.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=cache_hit.bound_ip,
|
||||
should_alert=True,
|
||||
)
|
||||
return self.evaluate_existing_record(cache_hit, token_hash, token_display, "Allowed from cache.")
|
||||
|
||||
if not cache_available:
|
||||
logger.warning("Redis is unavailable. Falling back to PostgreSQL for token binding.")
|
||||
@@ -159,36 +227,8 @@ class BindingService:
|
||||
return self._handle_backend_failure(token_hash, token_display)
|
||||
|
||||
if record is not None:
|
||||
await self.sync_binding_cache(record.token_hash, record.bound_ip, record.status)
|
||||
if record.status == STATUS_BANNED:
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=403,
|
||||
detail="Token is banned.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=record.bound_ip,
|
||||
should_alert=True,
|
||||
)
|
||||
if record.ip_matched:
|
||||
self.record_last_used(token_hash)
|
||||
return BindingCheckResult(
|
||||
allowed=True,
|
||||
status_code=200,
|
||||
detail="Allowed from PostgreSQL.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=record.bound_ip,
|
||||
)
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=403,
|
||||
detail="Client IP does not match the bound CIDR.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=record.bound_ip,
|
||||
should_alert=True,
|
||||
)
|
||||
await self.sync_binding_cache(record.token_hash, record.bound_ip, record.binding_mode, record.allowed_ips, record.status)
|
||||
return self.evaluate_existing_record(record, token_hash, token_display, "Allowed from PostgreSQL.")
|
||||
|
||||
try:
|
||||
created = await self._create_binding(token_hash, token_display, client_ip)
|
||||
@@ -202,52 +242,42 @@ class BindingService:
|
||||
return self._handle_backend_failure(token_hash, token_display)
|
||||
if existing is None:
|
||||
return self._handle_backend_failure(token_hash, token_display)
|
||||
await self.sync_binding_cache(existing.token_hash, existing.bound_ip, existing.status)
|
||||
if existing.status == STATUS_BANNED:
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=403,
|
||||
detail="Token is banned.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=existing.bound_ip,
|
||||
should_alert=True,
|
||||
)
|
||||
if existing.ip_matched:
|
||||
self.record_last_used(token_hash)
|
||||
return BindingCheckResult(
|
||||
allowed=True,
|
||||
status_code=200,
|
||||
detail="Allowed after concurrent bind resolution.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=existing.bound_ip,
|
||||
)
|
||||
return BindingCheckResult(
|
||||
allowed=False,
|
||||
status_code=403,
|
||||
detail="Client IP does not match the bound CIDR.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=existing.bound_ip,
|
||||
should_alert=True,
|
||||
await self.sync_binding_cache(
|
||||
existing.token_hash,
|
||||
existing.bound_ip,
|
||||
existing.binding_mode,
|
||||
existing.allowed_ips,
|
||||
existing.status,
|
||||
)
|
||||
return self.evaluate_existing_record(existing, token_hash, token_display, "Allowed after concurrent bind resolution.")
|
||||
|
||||
await self.sync_binding_cache(created.token_hash, created.bound_ip, created.status)
|
||||
return BindingCheckResult(
|
||||
allowed=True,
|
||||
status_code=200,
|
||||
detail="First-use bind created.",
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=created.bound_ip,
|
||||
newly_bound=True,
|
||||
await self.sync_binding_cache(
|
||||
created.token_hash,
|
||||
created.bound_ip,
|
||||
created.binding_mode,
|
||||
created.allowed_ips,
|
||||
created.status,
|
||||
)
|
||||
return self.allowed_result(token_hash, token_display, created.bound_ip, "First-use bind created.", newly_bound=True)
|
||||
|
||||
async def sync_binding_cache(self, token_hash: str, bound_ip: str, status_code: int) -> None:
|
||||
async def sync_binding_cache(
|
||||
self,
|
||||
token_hash: str,
|
||||
bound_ip: str,
|
||||
binding_mode: str,
|
||||
allowed_ips: list[str],
|
||||
status_code: int,
|
||||
) -> None:
|
||||
if self.redis is None:
|
||||
return
|
||||
payload = json.dumps({"bound_ip": bound_ip, "status": status_code})
|
||||
payload = json.dumps(
|
||||
{
|
||||
"bound_ip": bound_ip,
|
||||
"binding_mode": binding_mode,
|
||||
"allowed_ips": allowed_ips,
|
||||
"status": status_code,
|
||||
}
|
||||
)
|
||||
try:
|
||||
await self.redis.set(self.cache_key(token_hash), payload, ex=self.settings.redis_binding_ttl_seconds)
|
||||
except Exception:
|
||||
@@ -336,7 +366,7 @@ class BindingService:
|
||||
)
|
||||
return series
|
||||
|
||||
async def _load_binding_from_cache(self, token_hash: str) -> tuple[BindingRecord | None, bool]:
|
||||
async def _load_binding_from_cache(self, token_hash: str, client_ip: str) -> tuple[BindingRecord | None, bool]:
|
||||
if self.redis is None:
|
||||
return None, False
|
||||
try:
|
||||
@@ -348,14 +378,18 @@ class BindingService:
|
||||
return None, True
|
||||
|
||||
data = json.loads(raw)
|
||||
allowed_ips = [str(item) for item in data.get("allowed_ips", [])]
|
||||
binding_mode = str(data.get("binding_mode", BINDING_MODE_SINGLE))
|
||||
return (
|
||||
BindingRecord(
|
||||
id=0,
|
||||
token_hash=token_hash,
|
||||
token_display="",
|
||||
bound_ip=data["bound_ip"],
|
||||
bound_ip=str(data.get("bound_ip", self.build_bound_ip_display(binding_mode, allowed_ips))),
|
||||
binding_mode=binding_mode,
|
||||
allowed_ips=allowed_ips,
|
||||
status=int(data["status"]),
|
||||
ip_matched=False,
|
||||
ip_matched=self.is_client_allowed(client_ip, binding_mode, allowed_ips),
|
||||
),
|
||||
True,
|
||||
)
|
||||
@@ -369,69 +403,33 @@ class BindingService:
|
||||
logger.warning("Failed to extend binding cache TTL.", extra={"token_hash": token_hash})
|
||||
|
||||
async def _load_binding_from_db(self, token_hash: str, client_ip: str) -> BindingRecord | None:
|
||||
query = text(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
token_hash,
|
||||
token_display,
|
||||
bound_ip::text AS bound_ip,
|
||||
status,
|
||||
CAST(:client_ip AS inet) << bound_ip AS ip_matched
|
||||
FROM token_bindings
|
||||
WHERE token_hash = :token_hash
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
async with self.session_factory() as session:
|
||||
result = await session.execute(query, {"token_hash": token_hash, "client_ip": client_ip})
|
||||
row = result.mappings().first()
|
||||
if row is None:
|
||||
binding = await session.scalar(select(TokenBinding).where(TokenBinding.token_hash == token_hash).limit(1))
|
||||
if binding is None:
|
||||
return None
|
||||
return BindingRecord(
|
||||
id=int(row["id"]),
|
||||
token_hash=str(row["token_hash"]),
|
||||
token_display=str(row["token_display"]),
|
||||
bound_ip=str(row["bound_ip"]),
|
||||
status=int(row["status"]),
|
||||
ip_matched=bool(row["ip_matched"]),
|
||||
)
|
||||
return self.to_binding_record(binding, client_ip)
|
||||
|
||||
async def _create_binding(self, token_hash: str, token_display: str, client_ip: str) -> BindingRecord | None:
|
||||
statement = text(
|
||||
"""
|
||||
INSERT INTO token_bindings (token_hash, token_display, bound_ip, status)
|
||||
VALUES (:token_hash, :token_display, CAST(:bound_ip AS cidr), :status)
|
||||
ON CONFLICT (token_hash) DO NOTHING
|
||||
RETURNING id, token_hash, token_display, bound_ip::text AS bound_ip, status
|
||||
"""
|
||||
)
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
result = await session.execute(
|
||||
statement,
|
||||
{
|
||||
"token_hash": token_hash,
|
||||
"token_display": token_display,
|
||||
"bound_ip": client_ip,
|
||||
"status": STATUS_ACTIVE,
|
||||
},
|
||||
binding = TokenBinding(
|
||||
token_hash=token_hash,
|
||||
token_display=token_display,
|
||||
bound_ip=client_ip,
|
||||
binding_mode=BINDING_MODE_SINGLE,
|
||||
allowed_ips=[client_ip],
|
||||
status=STATUS_ACTIVE,
|
||||
)
|
||||
row = result.mappings().first()
|
||||
session.add(binding)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
except SQLAlchemyError:
|
||||
await session.refresh(binding)
|
||||
except SQLAlchemyError as exc:
|
||||
await session.rollback()
|
||||
if "duplicate key" in str(exc).lower() or "unique" in str(exc).lower():
|
||||
return None
|
||||
raise
|
||||
if row is None:
|
||||
return None
|
||||
return BindingRecord(
|
||||
id=int(row["id"]),
|
||||
token_hash=str(row["token_hash"]),
|
||||
token_display=str(row["token_display"]),
|
||||
bound_ip=str(row["bound_ip"]),
|
||||
status=int(row["status"]),
|
||||
ip_matched=True,
|
||||
)
|
||||
return self.to_binding_record(binding, client_ip)
|
||||
|
||||
def _handle_backend_failure(self, token_hash: str, token_display: str) -> BindingCheckResult:
|
||||
runtime_settings = self.runtime_settings_getter()
|
||||
|
||||
@@ -4,20 +4,22 @@ CREATE TABLE token_bindings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
token_display VARCHAR(20) NOT NULL,
|
||||
bound_ip CIDR NOT NULL,
|
||||
bound_ip TEXT NOT NULL,
|
||||
binding_mode VARCHAR(16) NOT NULL DEFAULT 'single',
|
||||
allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
first_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_token_bindings_hash ON token_bindings(token_hash);
|
||||
CREATE INDEX idx_token_bindings_ip ON token_bindings USING GIST (bound_ip inet_ops);
|
||||
CREATE INDEX idx_token_bindings_ip ON token_bindings(bound_ip);
|
||||
|
||||
CREATE TABLE intercept_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
token_display VARCHAR(20) NOT NULL,
|
||||
bound_ip CIDR NOT NULL,
|
||||
bound_ip TEXT NOT NULL,
|
||||
attempt_ip INET NOT NULL,
|
||||
alerted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
intercepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
|
||||
@@ -2,34 +2,29 @@ services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: sentinel-nginx
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
- sentinel-app
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
||||
networks:
|
||||
- sentinel-net
|
||||
|
||||
sentinel-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: key-ip-sentinel:latest
|
||||
container_name: sentinel-app
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./app:/app/app:ro
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
networks:
|
||||
- sentinel-net
|
||||
- llm-shared-net
|
||||
sentinel-net:
|
||||
ipv4_address: 172.30.0.10
|
||||
shared_network:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -49,7 +44,7 @@ services:
|
||||
- sentinel-net
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
image: postgres:16
|
||||
container_name: sentinel-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -69,5 +64,8 @@ volumes:
|
||||
networks:
|
||||
sentinel-net:
|
||||
driver: bridge
|
||||
llm-shared-net:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.30.0.0/24
|
||||
shared_network:
|
||||
external: true
|
||||
|
||||
1889
frontend/package-lock.json
generated
Normal file
1889
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,14 +14,14 @@ let clearAnnouncementTimer
|
||||
let unsubscribeAnnouncements = () => {}
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' },
|
||||
{ label: 'Bindings', name: 'bindings', icon: 'Connection' },
|
||||
{ label: 'Logs', name: 'logs', icon: 'WarningFilled' },
|
||||
{ label: 'Settings', name: 'settings', icon: 'Setting' },
|
||||
{ label: '总览看板', name: 'dashboard', icon: 'DataAnalysis' },
|
||||
{ label: '绑定管理', name: 'bindings', icon: 'Connection' },
|
||||
{ label: '拦截日志', name: 'logs', icon: 'WarningFilled' },
|
||||
{ label: '运行设置', name: 'settings', icon: 'Setting' },
|
||||
]
|
||||
|
||||
const hideShell = computed(() => Boolean(route.meta.public))
|
||||
const currentSection = computed(() => route.meta.kicker || 'Operations')
|
||||
const currentSection = computed(() => route.meta.kicker || '控制台')
|
||||
|
||||
function updateClock() {
|
||||
clockLabel.value = new Intl.DateTimeFormat(undefined, {
|
||||
@@ -65,7 +65,7 @@ onBeforeUnmount(() => {
|
||||
<router-view v-if="hideShell" />
|
||||
|
||||
<div v-else class="shell">
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
<a class="skip-link" href="#main-content">跳转到主要内容</a>
|
||||
<div class="shell-glow shell-glow--mint" />
|
||||
<div class="shell-glow shell-glow--amber" />
|
||||
|
||||
@@ -74,8 +74,8 @@ onBeforeUnmount(() => {
|
||||
<div class="brand-mark">S</div>
|
||||
<div>
|
||||
<p class="eyebrow">Key-IP Sentinel</p>
|
||||
<h1 class="brand-title">Control Plane</h1>
|
||||
<p class="brand-subtitle">First-use bind enforcement edge</p>
|
||||
<h1 class="brand-title">安全控制台</h1>
|
||||
<p class="brand-subtitle">API Key 首次使用 IP 绑定网关</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,23 +93,23 @@ onBeforeUnmount(() => {
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-note">
|
||||
<p class="eyebrow">Operating mode</p>
|
||||
<h3>Zero-trust token perimeter</h3>
|
||||
<p class="eyebrow">当前能力</p>
|
||||
<h3>绑定、审计、告警一体化</h3>
|
||||
<p class="muted">
|
||||
Every API key is pinned to the first observed client address or CIDR and inspected at the edge.
|
||||
所有请求先经过边界网关,首次调用自动绑定来源地址,后续按 IP 或 CIDR 持续校验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rail-grid">
|
||||
<div class="rail-card">
|
||||
<span class="rail-label">Surface</span>
|
||||
<strong>Admin UI</strong>
|
||||
<span class="rail-meta">JWT protected</span>
|
||||
<span class="rail-label">入口</span>
|
||||
<strong>管理后台</strong>
|
||||
<span class="rail-meta">JWT 鉴权</span>
|
||||
</div>
|
||||
<div class="rail-card">
|
||||
<span class="rail-label">Proxy</span>
|
||||
<strong>Streaming</strong>
|
||||
<span class="rail-meta">SSE passthrough</span>
|
||||
<span class="rail-label">网关</span>
|
||||
<strong>流式代理</strong>
|
||||
<span class="rail-meta">支持 SSE 透传</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -119,20 +119,20 @@ onBeforeUnmount(() => {
|
||||
<div class="header-copy">
|
||||
<p class="eyebrow">{{ currentSection }}</p>
|
||||
<h2 id="page-title" class="page-title">{{ route.meta.title || 'Sentinel' }}</h2>
|
||||
<p class="muted header-note">Edge policy, runtime settings, and operator visibility in one secure surface.</p>
|
||||
<p class="muted header-note">围绕绑定记录、拦截日志和运行设置的统一运维入口。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-chip-group">
|
||||
<div class="header-chip">
|
||||
<span class="header-chip-label">Mode</span>
|
||||
<strong>Secure Proxy</strong>
|
||||
<span class="header-chip-label">模式</span>
|
||||
<strong>安全代理</strong>
|
||||
</div>
|
||||
<div class="header-chip" aria-live="polite">
|
||||
<span class="header-chip-label">Updated</span>
|
||||
<span class="header-chip-label">时间</span>
|
||||
<strong>{{ clockLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="logout">Logout</el-button>
|
||||
<el-button type="primary" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -147,10 +147,10 @@ onBeforeUnmount(() => {
|
||||
.shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
grid-template-columns: 276px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.shell-sidebar,
|
||||
@@ -162,8 +162,8 @@ onBeforeUnmount(() => {
|
||||
.shell-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
padding: 28px;
|
||||
gap: 20px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
@@ -175,19 +175,19 @@ onBeforeUnmount(() => {
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(17, 231, 181, 0.95), rgba(21, 132, 214, 0.95));
|
||||
color: #071016;
|
||||
font-size: 1.45rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #6ea7ff, #86c8ff);
|
||||
color: #ffffff;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-title,
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 2vw, 2.1rem);
|
||||
font-size: clamp(1.2rem, 1.6vw, 1.6rem);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
@@ -199,19 +199,20 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
color: var(--sentinel-ink-soft);
|
||||
text-decoration: none;
|
||||
transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.is-active {
|
||||
color: var(--sentinel-ink);
|
||||
background: rgba(7, 176, 147, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(7, 176, 147, 0.18);
|
||||
transform: translateX(4px);
|
||||
background: rgba(114, 163, 255, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(114, 163, 255, 0.2);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@@ -220,15 +221,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
.sidebar-note {
|
||||
margin-top: auto;
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(10, 26, 35, 0.8));
|
||||
color: #f3fffd;
|
||||
background: linear-gradient(180deg, rgba(244, 248, 255, 0.98), rgba(235, 243, 255, 0.92));
|
||||
color: var(--sentinel-ink);
|
||||
border: 1px solid rgba(122, 164, 255, 0.18);
|
||||
}
|
||||
|
||||
.sidebar-note h3 {
|
||||
margin: 10px 0;
|
||||
font-size: 1.15rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.shell-main {
|
||||
@@ -236,7 +238,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -244,8 +246,8 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 22px 26px;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -257,7 +259,7 @@ onBeforeUnmount(() => {
|
||||
.shell-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -272,19 +274,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.shell-glow--mint {
|
||||
top: 80px;
|
||||
right: 160px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
background: rgba(17, 231, 181, 0.22);
|
||||
top: 60px;
|
||||
right: 120px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: rgba(132, 196, 255, 0.2);
|
||||
}
|
||||
|
||||
.shell-glow--amber {
|
||||
bottom: 100px;
|
||||
left: 420px;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: rgba(255, 170, 76, 0.18);
|
||||
bottom: 80px;
|
||||
left: 360px;
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
background: rgba(177, 221, 255, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
|
||||
@@ -16,7 +16,7 @@ const router = createRouter({
|
||||
component: Login,
|
||||
meta: {
|
||||
public: true,
|
||||
title: 'Admin Login',
|
||||
title: '管理员登录',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -28,8 +28,8 @@ const router = createRouter({
|
||||
name: 'dashboard',
|
||||
component: Dashboard,
|
||||
meta: {
|
||||
title: 'Traffic Pulse',
|
||||
kicker: 'Observability',
|
||||
title: '总览看板',
|
||||
kicker: '运行概览',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -37,8 +37,8 @@ const router = createRouter({
|
||||
name: 'bindings',
|
||||
component: Bindings,
|
||||
meta: {
|
||||
title: 'Token Bindings',
|
||||
kicker: 'Control',
|
||||
title: '绑定管理',
|
||||
kicker: '绑定控制',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -46,8 +46,8 @@ const router = createRouter({
|
||||
name: 'logs',
|
||||
component: Logs,
|
||||
meta: {
|
||||
title: 'Intercept Logs',
|
||||
kicker: 'Audit',
|
||||
title: '拦截日志',
|
||||
kicker: '审计追踪',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -55,8 +55,8 @@ const router = createRouter({
|
||||
name: 'settings',
|
||||
component: Settings,
|
||||
meta: {
|
||||
title: 'Runtime Settings',
|
||||
kicker: 'Operations',
|
||||
title: '运行设置',
|
||||
kicker: '运行配置',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
:root {
|
||||
--sentinel-bg: #08131c;
|
||||
--sentinel-bg-soft: #102734;
|
||||
--sentinel-panel: rgba(252, 255, 255, 0.82);
|
||||
--sentinel-panel-strong: rgba(255, 255, 255, 0.9);
|
||||
--sentinel-border: rgba(255, 255, 255, 0.24);
|
||||
--sentinel-ink: #09161e;
|
||||
--sentinel-ink-soft: #57717d;
|
||||
--sentinel-accent: #07b093;
|
||||
--sentinel-accent-deep: #0d7e8b;
|
||||
--sentinel-warn: #ef7f41;
|
||||
--sentinel-danger: #dc4f53;
|
||||
--sentinel-shadow: 0 30px 80px rgba(2, 12, 18, 0.22);
|
||||
--el-color-primary: #0b9e88;
|
||||
--el-color-success: #1aa36f;
|
||||
--el-color-warning: #ef7f41;
|
||||
--el-color-danger: #dc4f53;
|
||||
--sentinel-bg: #eef5ff;
|
||||
--sentinel-bg-soft: #dfeefe;
|
||||
--sentinel-panel: rgba(255, 255, 255, 0.9);
|
||||
--sentinel-panel-strong: rgba(255, 255, 255, 0.96);
|
||||
--sentinel-border: rgba(113, 157, 226, 0.18);
|
||||
--sentinel-ink: #17324d;
|
||||
--sentinel-ink-soft: #66809c;
|
||||
--sentinel-accent: #4d8ff7;
|
||||
--sentinel-accent-deep: #2d6fd5;
|
||||
--sentinel-warn: #f29a44;
|
||||
--sentinel-danger: #df5b67;
|
||||
--sentinel-shadow: 0 20px 48px rgba(46, 92, 146, 0.12);
|
||||
--el-color-primary: #4d8ff7;
|
||||
--el-color-success: #36a980;
|
||||
--el-color-warning: #f29a44;
|
||||
--el-color-danger: #df5b67;
|
||||
color: var(--sentinel-ink);
|
||||
font-family: "Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC", sans-serif;
|
||||
font-family: "PingFang SC", "Microsoft YaHei UI", "Segoe UI Variable", "Noto Sans SC", sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -32,11 +32,11 @@
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
color-scheme: dark;
|
||||
color-scheme: light;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(12, 193, 152, 0.22), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(255, 170, 76, 0.18), transparent 30%),
|
||||
linear-gradient(180deg, #09131d 0%, #0d1d29 35%, #112d3d 100%);
|
||||
radial-gradient(circle at top left, rgba(146, 198, 255, 0.48), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(215, 234, 255, 0.72), transparent 32%),
|
||||
linear-gradient(180deg, #f4f8ff 0%, #edf5ff 40%, #e5f0fd 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -62,10 +62,10 @@ body::before {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
linear-gradient(rgba(77, 143, 247, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(77, 143, 247, 0.05) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 95%);
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.32), transparent 95%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -105,22 +105,22 @@ body::before {
|
||||
.panel {
|
||||
background: var(--sentinel-panel);
|
||||
border: 1px solid var(--sentinel-border);
|
||||
border-radius: 28px;
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--sentinel-shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(250, 255, 252, 0.74));
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 249, 255, 0.84));
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--sentinel-accent-deep);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -130,19 +130,19 @@ body::before {
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
position: relative;
|
||||
padding: 26px;
|
||||
padding: 20px 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ body::before {
|
||||
right: -40px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: radial-gradient(circle, rgba(7, 176, 147, 0.28), transparent 70%);
|
||||
background: radial-gradient(circle, rgba(98, 168, 255, 0.22), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -187,24 +187,24 @@ body::before {
|
||||
.page-title,
|
||||
.login-stage h1 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.16rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metric-card--enhanced {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(244, 252, 249, 0.78));
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(243, 248, 255, 0.88));
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
@@ -214,20 +214,20 @@ body::before {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(7, 176, 147, 0.16), transparent 70%);
|
||||
background: radial-gradient(circle, rgba(98, 168, 255, 0.14), transparent 70%);
|
||||
}
|
||||
|
||||
.metric-card[data-accent="amber"]::before {
|
||||
background: radial-gradient(circle, rgba(239, 127, 65, 0.16), transparent 70%);
|
||||
background: radial-gradient(circle, rgba(242, 154, 68, 0.18), transparent 70%);
|
||||
}
|
||||
|
||||
.metric-card[data-accent="slate"]::before {
|
||||
background: radial-gradient(circle, rgba(54, 97, 135, 0.16), transparent 70%);
|
||||
background: radial-gradient(circle, rgba(113, 157, 226, 0.16), transparent 70%);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin: 10px 0 0;
|
||||
font-size: clamp(1.8rem, 3vw, 2.5rem);
|
||||
font-size: clamp(1.45rem, 2.3vw, 2rem);
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -240,7 +240,7 @@ body::before {
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.content-grid--balanced {
|
||||
@@ -250,7 +250,7 @@ body::before {
|
||||
.chart-card,
|
||||
.table-card,
|
||||
.form-card {
|
||||
padding: 24px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.chart-surface {
|
||||
@@ -294,7 +294,7 @@ body::before {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -310,21 +310,21 @@ body::before {
|
||||
|
||||
.data-table .el-table {
|
||||
--el-table-border-color: rgba(9, 22, 30, 0.08);
|
||||
--el-table-header-bg-color: rgba(7, 176, 147, 0.08);
|
||||
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05);
|
||||
border-radius: 18px;
|
||||
--el-table-header-bg-color: rgba(92, 151, 255, 0.1);
|
||||
--el-table-row-hover-bg-color: rgba(92, 151, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
min-height: 44px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-date-editor .el-input__wrapper {
|
||||
min-height: 44px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.soft-grid {
|
||||
@@ -333,15 +333,15 @@ body::before {
|
||||
}
|
||||
|
||||
.support-card {
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(243, 251, 248, 0.72));
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(242, 247, 255, 0.82));
|
||||
border: 1px solid rgba(113, 157, 226, 0.14);
|
||||
}
|
||||
|
||||
.support-card h4 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 1.08rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.support-card p {
|
||||
@@ -384,10 +384,10 @@ body::before {
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(12, 24, 33, 0.8));
|
||||
color: #f2fffd;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(80, 134, 236, 0.95), rgba(70, 123, 224, 0.9));
|
||||
color: #f7fbff;
|
||||
}
|
||||
|
||||
.insight-value {
|
||||
@@ -425,8 +425,8 @@ body::before {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-stage,
|
||||
@@ -436,20 +436,20 @@ body::before {
|
||||
}
|
||||
|
||||
.login-stage {
|
||||
padding: 42px;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
gap: 22px;
|
||||
justify-content: space-between;
|
||||
color: #f7fffe;
|
||||
color: #eef6ff;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(17, 231, 181, 0.24), transparent 28%),
|
||||
linear-gradient(160deg, rgba(8, 24, 34, 0.95), rgba(15, 37, 50, 0.92));
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.24), transparent 30%),
|
||||
linear-gradient(160deg, rgba(92, 151, 255, 0.98), rgba(109, 176, 255, 0.92));
|
||||
}
|
||||
|
||||
.login-stage h1 {
|
||||
margin: 12px 0;
|
||||
font-size: clamp(2.4rem, 4vw, 4rem);
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
line-height: 0.96;
|
||||
}
|
||||
|
||||
@@ -462,14 +462,14 @@ body::before {
|
||||
.login-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 36px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card-inner {
|
||||
width: min(100%, 460px);
|
||||
padding: 34px;
|
||||
padding: 28px;
|
||||
background: var(--sentinel-panel-strong);
|
||||
border-radius: 32px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid var(--sentinel-border);
|
||||
box-shadow: var(--sentinel-shadow);
|
||||
}
|
||||
@@ -480,10 +480,10 @@ body::before {
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(7, 176, 147, 0.12);
|
||||
background: rgba(92, 151, 255, 0.12);
|
||||
color: var(--sentinel-accent-deep);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -516,10 +516,10 @@ body::before {
|
||||
.rail-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.44);
|
||||
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
border: 1px solid rgba(113, 157, 226, 0.14);
|
||||
}
|
||||
|
||||
.rail-label,
|
||||
@@ -554,10 +554,10 @@ body::before {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 140px;
|
||||
padding: 10px 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
border: 1px solid rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(113, 157, 226, 0.16);
|
||||
}
|
||||
|
||||
.header-chip strong {
|
||||
@@ -571,16 +571,16 @@ body::before {
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 255, 252, 0.64));
|
||||
border: 1px solid rgba(255, 255, 255, 0.36);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(243, 248, 255, 0.78));
|
||||
border: 1px solid rgba(113, 157, 226, 0.16);
|
||||
}
|
||||
|
||||
.hero-stat strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.15rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -618,7 +618,7 @@ body::before {
|
||||
|
||||
.filter-label {
|
||||
color: var(--sentinel-ink);
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -630,6 +630,184 @@ body::before {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.binding-workbench {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.binding-head {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.binding-head-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.binding-head-copy p,
|
||||
.binding-head-copy h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binding-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.binding-summary-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(242, 250, 247, 0.58));
|
||||
border: 1px solid rgba(9, 22, 30, 0.08);
|
||||
}
|
||||
|
||||
.binding-summary-card strong {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.binding-summary-label {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--sentinel-ink-soft);
|
||||
}
|
||||
|
||||
.binding-summary-card--warn {
|
||||
background: linear-gradient(180deg, rgba(255, 246, 238, 0.86), rgba(255, 239, 225, 0.74));
|
||||
}
|
||||
|
||||
.binding-summary-card--danger {
|
||||
background: linear-gradient(180deg, rgba(255, 240, 241, 0.88), rgba(255, 229, 231, 0.74));
|
||||
}
|
||||
|
||||
.binding-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.9fr) minmax(190px, 1.1fr) minmax(140px, 0.7fr) minmax(120px, 0.5fr) auto;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(9, 29, 41, 0.98), rgba(11, 32, 45, 0.88));
|
||||
}
|
||||
|
||||
.field-page-size {
|
||||
width: min(100%, 140px);
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.binding-filter-grid .filter-label {
|
||||
color: rgba(247, 255, 254, 0.88);
|
||||
}
|
||||
|
||||
.binding-filter-grid .el-input__wrapper,
|
||||
.binding-filter-grid .el-select__wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.binding-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.binding-table-note {
|
||||
color: var(--sentinel-ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.binding-table .el-table {
|
||||
--el-table-border-color: rgba(9, 22, 30, 0.08);
|
||||
--el-table-header-bg-color: rgba(8, 31, 45, 0.95);
|
||||
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05);
|
||||
--el-table-header-text-color: rgba(247, 255, 254, 0.86);
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.binding-table .el-table th.el-table__cell {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.binding-table .el-table td.el-table__cell {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.binding-row--banned {
|
||||
--el-table-tr-bg-color: rgba(220, 79, 83, 0.06);
|
||||
}
|
||||
|
||||
.binding-row--dormant {
|
||||
--el-table-tr-bg-color: rgba(239, 127, 65, 0.06);
|
||||
}
|
||||
|
||||
.binding-token-cell,
|
||||
.binding-health-cell,
|
||||
.binding-activity-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.binding-token-main,
|
||||
.binding-ip-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.binding-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(9, 22, 30, 0.06);
|
||||
color: var(--sentinel-ink-soft);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.binding-ip-cell code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(9, 22, 30, 0.07);
|
||||
color: #08202d;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.binding-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.binding-action-row .el-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.form-feedback {
|
||||
margin: 12px 0 0;
|
||||
color: var(--sentinel-danger);
|
||||
@@ -670,6 +848,16 @@ body::before {
|
||||
.hero-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.binding-summary-strip,
|
||||
.binding-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -684,9 +872,24 @@ body::before {
|
||||
.chart-card,
|
||||
.table-card,
|
||||
.form-card,
|
||||
.hero-panel {
|
||||
.hero-panel,
|
||||
.binding-workbench {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.binding-summary-strip,
|
||||
.binding-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.binding-table-toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.binding-ip-line,
|
||||
.binding-action-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
EditPen,
|
||||
Lock,
|
||||
RefreshRight,
|
||||
Search,
|
||||
SwitchButton,
|
||||
Unlock,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction'
|
||||
import {
|
||||
@@ -13,9 +22,10 @@ import {
|
||||
unbindBinding,
|
||||
updateBindingIp,
|
||||
} from '../api'
|
||||
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
||||
import { formatCompactNumber, formatDateTime } from '../utils/formatters'
|
||||
|
||||
const defaultPageSize = 20
|
||||
const staleWindowDays = 30
|
||||
const dialogVisible = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
@@ -23,7 +33,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const form = reactive({
|
||||
id: null,
|
||||
bound_ip: '',
|
||||
binding_mode: 'single',
|
||||
allowed_ips_text: '',
|
||||
})
|
||||
const filters = reactive({
|
||||
token_suffix: '',
|
||||
@@ -33,33 +44,20 @@ const filters = reactive({
|
||||
page_size: defaultPageSize,
|
||||
})
|
||||
const { loading, run } = useAsyncAction()
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
|
||||
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
||||
const visibleProtectedRate = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return 0
|
||||
const attentionCount = computed(() => rows.value.filter((item) => item.status === 2 || isDormant(item)).length)
|
||||
const currentWindowLabel = computed(() => {
|
||||
if (!total.value) {
|
||||
return '0-0'
|
||||
}
|
||||
return activeCount.value / rows.value.length
|
||||
const start = (filters.page - 1) * filters.page_size + 1
|
||||
const end = Math.min(filters.page * filters.page_size, total.value)
|
||||
return `${start}-${end}`
|
||||
})
|
||||
const opsCards = [
|
||||
{
|
||||
eyebrow: 'Unbind',
|
||||
title: 'Reset first-use bind',
|
||||
note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Edit CIDR',
|
||||
title: 'Handle endpoint changes',
|
||||
note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Ban',
|
||||
title: 'Freeze compromised tokens',
|
||||
note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.',
|
||||
},
|
||||
]
|
||||
|
||||
function parsePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
@@ -128,12 +126,76 @@ function requestParams() {
|
||||
}
|
||||
}
|
||||
|
||||
function elapsedDays(value) {
|
||||
if (!value) {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
return Math.floor((Date.now() - new Date(value).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
function isDormant(row) {
|
||||
return elapsedDays(row.last_used_at) >= staleWindowDays
|
||||
}
|
||||
|
||||
function formatLastSeen(value) {
|
||||
if (!value) {
|
||||
return '暂无记录'
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor((Date.now() - new Date(value).getTime()) / 3600000)
|
||||
if (elapsedHours < 1) {
|
||||
return '1 小时内活跃'
|
||||
}
|
||||
if (elapsedHours < 24) {
|
||||
return `${elapsedHours} 小时前活跃`
|
||||
}
|
||||
|
||||
const days = Math.floor(elapsedHours / 24)
|
||||
if (days < staleWindowDays) {
|
||||
return `${days} 天前活跃`
|
||||
}
|
||||
return `沉寂 ${days} 天`
|
||||
}
|
||||
|
||||
function statusTone(row) {
|
||||
if (row.status === 2) {
|
||||
return 'danger'
|
||||
}
|
||||
return isDormant(row) ? 'warning' : 'success'
|
||||
}
|
||||
|
||||
function statusText(row) {
|
||||
if (row.status === 2) {
|
||||
return '已封禁'
|
||||
}
|
||||
return isDormant(row) ? '沉寂' : '正常'
|
||||
}
|
||||
|
||||
function rowClassName({ row }) {
|
||||
if (row.status === 2) {
|
||||
return 'binding-row--banned'
|
||||
}
|
||||
if (isDormant(row)) {
|
||||
return 'binding-row--dormant'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function copyValue(value, label) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(value))
|
||||
ElMessage.success(`${label}已复制。`)
|
||||
} catch {
|
||||
ElMessage.error(`复制${label}失败。`)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindings() {
|
||||
await run(async () => {
|
||||
const data = await fetchBindings(requestParams())
|
||||
rows.value = data.items
|
||||
total.value = data.total
|
||||
}, 'Failed to load bindings.')
|
||||
}, '加载绑定列表失败。')
|
||||
}
|
||||
|
||||
async function refreshBindings() {
|
||||
@@ -167,18 +229,52 @@ async function searchBindings() {
|
||||
|
||||
function openEdit(row) {
|
||||
form.id = row.id
|
||||
form.bound_ip = row.bound_ip
|
||||
form.binding_mode = row.binding_mode
|
||||
form.allowed_ips_text = (row.allowed_ips || []).join('\n')
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function normalizeAllowedIpText(value) {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function bindingModeLabel(mode) {
|
||||
if (mode === 'all') {
|
||||
return '全部放行'
|
||||
}
|
||||
if (mode === 'multiple') {
|
||||
return '多 IP'
|
||||
}
|
||||
return '单地址'
|
||||
}
|
||||
|
||||
function bindingRuleText(row) {
|
||||
if (row.binding_mode === 'all') {
|
||||
return '全部 IP 放行'
|
||||
}
|
||||
return row.bound_ip
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!form.bound_ip) {
|
||||
ElMessage.warning('Provide a CIDR or single IP.')
|
||||
const allowedIps = normalizeAllowedIpText(form.allowed_ips_text)
|
||||
if (form.binding_mode !== 'all' && !allowedIps.length) {
|
||||
ElMessage.warning('请填写至少一个 IP 或 CIDR。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
|
||||
ElMessage.success('Binding updated.')
|
||||
await run(
|
||||
() =>
|
||||
updateBindingIp({
|
||||
id: form.id,
|
||||
binding_mode: form.binding_mode,
|
||||
allowed_ips: allowedIps,
|
||||
}),
|
||||
'更新绑定失败。',
|
||||
)
|
||||
ElMessage.success('绑定规则已更新。')
|
||||
dialogVisible.value = false
|
||||
await refreshBindings()
|
||||
} catch {}
|
||||
@@ -186,12 +282,12 @@ async function submitEdit() {
|
||||
|
||||
async function confirmAction(title, action) {
|
||||
try {
|
||||
await ElMessageBox.confirm(title, 'Confirm action', {
|
||||
confirmButtonText: 'Confirm',
|
||||
cancelButtonText: 'Cancel',
|
||||
await ElMessageBox.confirm(title, '确认操作', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await run(action, 'Operation failed.')
|
||||
await run(action, '操作失败。')
|
||||
await refreshBindings()
|
||||
} catch (error) {
|
||||
if (error === 'cancel') {
|
||||
@@ -205,6 +301,12 @@ async function onPageChange(value) {
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
async function onPageSizeChange(value) {
|
||||
filters.page_size = value
|
||||
filters.page = 1
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
@@ -218,210 +320,274 @@ watch(
|
||||
<template>
|
||||
<div class="page-grid">
|
||||
<PageHero
|
||||
eyebrow="Binding control"
|
||||
title="Inspect first-use bindings and intervene without touching proxy workers"
|
||||
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state."
|
||||
eyebrow="绑定控制"
|
||||
title="围绕绑定表格完成查询、核对与处置"
|
||||
description="按 Token 尾号或绑定地址快速检索,确认最近活跃时间后直接编辑规则、解绑或封禁。"
|
||||
>
|
||||
<template #aside>
|
||||
<div class="hero-stat-pair">
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Visible active share</span>
|
||||
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
||||
<span class="eyebrow">匹配总数</span>
|
||||
<strong>{{ formatCompactNumber(total) }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Page volume</span>
|
||||
<strong>{{ formatCompactNumber(rows.length) }}</strong>
|
||||
<span class="eyebrow">待关注</span>
|
||||
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<el-button :icon="RefreshRight" plain @click="refreshBindings">刷新</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricTile
|
||||
eyebrow="Visible rows"
|
||||
:value="formatCompactNumber(rows.length)"
|
||||
note="Records loaded on the current page."
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Matching total"
|
||||
:value="formatCompactNumber(total)"
|
||||
note="Bindings matching current filters."
|
||||
accent="mint"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Active rows"
|
||||
:value="formatCompactNumber(activeCount)"
|
||||
note="Active items visible in the current slice."
|
||||
accent="mint"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Banned rows"
|
||||
:value="formatCompactNumber(bannedCount)"
|
||||
note="Banned items currently visible in the table."
|
||||
accent="amber"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="content-grid content-grid--balanced">
|
||||
<article class="table-card panel">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="binding-token-suffix">Token Suffix</label>
|
||||
<el-input
|
||||
id="binding-token-suffix"
|
||||
v-model="filters.token_suffix"
|
||||
aria-label="Filter by token suffix"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_token_suffix"
|
||||
placeholder="Token suffix..."
|
||||
@keyup.enter="searchBindings"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-md">
|
||||
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label>
|
||||
<el-input
|
||||
id="binding-ip-filter"
|
||||
v-model="filters.ip"
|
||||
aria-label="Filter by bound CIDR"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_ip_filter"
|
||||
placeholder="192.168.1.0/24..."
|
||||
@keyup.enter="searchBindings"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-status">
|
||||
<label class="filter-label" for="binding-status-filter">Status</label>
|
||||
<el-select
|
||||
id="binding-status-filter"
|
||||
v-model="filters.status"
|
||||
aria-label="Filter by binding status"
|
||||
clearable
|
||||
placeholder="Status..."
|
||||
>
|
||||
<el-option label="Active" :value="1" />
|
||||
<el-option label="Banned" :value="2" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</el-button>
|
||||
</div>
|
||||
<section class="binding-workbench panel">
|
||||
<div class="binding-head">
|
||||
<div class="binding-head-copy">
|
||||
<p class="eyebrow">绑定列表</p>
|
||||
<h3 class="section-title">聚焦表格本身,减少干扰信息</h3>
|
||||
<p class="muted">支持单地址、多个 IP 与全部放行三种规则,页面只保留高频查询与处置动作。</p>
|
||||
</div>
|
||||
|
||||
<div class="data-table table-block">
|
||||
<el-table :data="rows" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="token_display" label="Token" min-width="170" />
|
||||
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
|
||||
<el-table-column label="Status" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
|
||||
{{ row.status_label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="first_used_at" label="First used" min-width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_used_at" label="Last used" min-width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" min-width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="toolbar-left">
|
||||
<el-button @click="openEdit(row)">Edit CIDR</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
||||
>
|
||||
Remove Binding
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
type="warning"
|
||||
plain
|
||||
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
|
||||
>
|
||||
Ban Token
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="success"
|
||||
plain
|
||||
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
|
||||
>
|
||||
Restore Token
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="binding-summary-strip" aria-label="Binding summary">
|
||||
<article class="binding-summary-card">
|
||||
<span class="binding-summary-label">当前范围</span>
|
||||
<strong>{{ currentWindowLabel }}</strong>
|
||||
<span class="muted">共 {{ formatCompactNumber(total) }} 条</span>
|
||||
</article>
|
||||
<article class="binding-summary-card">
|
||||
<span class="binding-summary-label">当前页正常</span>
|
||||
<strong>{{ formatCompactNumber(activeCount) }}</strong>
|
||||
<span class="muted">可继续放行</span>
|
||||
</article>
|
||||
<article class="binding-summary-card binding-summary-card--warn">
|
||||
<span class="binding-summary-label">需要关注</span>
|
||||
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||
<span class="muted">封禁或长期不活跃</span>
|
||||
</article>
|
||||
<article class="binding-summary-card binding-summary-card--danger">
|
||||
<span class="binding-summary-label">已封禁</span>
|
||||
<strong>{{ formatCompactNumber(bannedCount) }}</strong>
|
||||
<span class="muted">已阻断</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="filters.page"
|
||||
:page-size="filters.page_size"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="soft-grid">
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Operator guide</p>
|
||||
<h4>Choose the least disruptive action first</h4>
|
||||
<p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
|
||||
</article>
|
||||
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Quick reference</p>
|
||||
<ul class="support-list" role="list">
|
||||
<li v-for="item in opsCards" :key="item.title" class="support-list-item">
|
||||
<span class="eyebrow">{{ item.eyebrow }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ item.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Visible ratio</p>
|
||||
<div class="support-kpi">
|
||||
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
||||
<p>Active records across the current page view.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="CIDR or single IP">
|
||||
<div class="binding-filter-grid">
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="binding-token-suffix">Token 尾号</label>
|
||||
<el-input
|
||||
v-model="form.bound_ip"
|
||||
id="binding-token-suffix"
|
||||
v-model="filters.token_suffix"
|
||||
aria-label="Filter by token suffix"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_token_suffix"
|
||||
placeholder="输入 Token 尾号"
|
||||
@keyup.enter="searchBindings"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="filter-field field-md">
|
||||
<label class="filter-label" for="binding-ip-filter">绑定 IP / CIDR</label>
|
||||
<el-input
|
||||
id="binding-ip-filter"
|
||||
v-model="filters.ip"
|
||||
aria-label="Filter by bound CIDR"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_ip_filter"
|
||||
placeholder="192.168.1.0/24"
|
||||
@keyup.enter="searchBindings"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Connection /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="filter-field field-status">
|
||||
<label class="filter-label" for="binding-status-filter">状态</label>
|
||||
<el-select
|
||||
id="binding-status-filter"
|
||||
v-model="filters.status"
|
||||
aria-label="Filter by binding status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
>
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="封禁" :value="2" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="filter-field field-page-size">
|
||||
<label class="filter-label" for="binding-page-size">每页条数</label>
|
||||
<el-select
|
||||
id="binding-page-size"
|
||||
v-model="filters.page_size"
|
||||
aria-label="Bindings page size"
|
||||
@change="onPageSizeChange"
|
||||
>
|
||||
<el-option v-for="size in pageSizeOptions" :key="size" :label="`${size} 条`" :value="size" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button :icon="RefreshRight" plain :loading="loading" @click="refreshBindings">重新加载</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="searchBindings">应用筛选</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="binding-table-toolbar">
|
||||
<div class="inline-meta">
|
||||
<span class="status-chip">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
当前匹配 {{ formatCompactNumber(total) }} 条绑定
|
||||
</span>
|
||||
<span class="binding-table-note">“沉寂”表示 {{ staleWindowDays }} 天及以上没有请求,规则支持单地址、多 IP 与全部放行。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table binding-table">
|
||||
<el-table :data="rows" :row-class-name="rowClassName" v-loading="loading">
|
||||
<el-table-column label="绑定对象" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-token-cell">
|
||||
<div class="binding-token-main">
|
||||
<strong>{{ row.token_display }}</strong>
|
||||
<span class="binding-id">#{{ row.id }}</span>
|
||||
</div>
|
||||
<span class="muted">首次使用:{{ formatDateTime(row.first_used_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="绑定地址" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-ip-cell">
|
||||
<div class="binding-ip-line">
|
||||
<code>{{ bindingRuleText(row) }}</code>
|
||||
<el-button text :icon="CopyDocument" @click="copyValue(bindingRuleText(row), '绑定规则')">复制</el-button>
|
||||
</div>
|
||||
<span class="muted">{{ bindingModeLabel(row.binding_mode) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="健康状态" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-health-cell">
|
||||
<el-tag :type="statusTone(row)" round effect="dark">
|
||||
{{ statusText(row) }}
|
||||
</el-tag>
|
||||
<span class="muted">{{ row.status === 1 ? '活动绑定' : '禁止放行' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近活动" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-activity-cell">
|
||||
<strong>{{ formatLastSeen(row.last_used_at) }}</strong>
|
||||
<span class="muted">{{ formatDateTime(row.last_used_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" min-width="360" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-action-row">
|
||||
<el-button :icon="EditPen" @click="openEdit(row)">编辑规则</el-button>
|
||||
<el-button
|
||||
:icon="row.status === 1 ? Lock : Unlock"
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="
|
||||
confirmAction(
|
||||
row.status === 1 ? '确认封禁这个 Token 吗?' : '确认恢复这个 Token 吗?',
|
||||
() => (row.status === 1 ? banBinding(row.id) : unbanBinding(row.id)),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ row.status === 1 ? '封禁' : '恢复' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:icon="SwitchButton"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmAction('确认解绑并允许下次重新首绑吗?', () => unbindBinding(row.id))"
|
||||
>
|
||||
解绑
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">第 {{ filters.page }} / {{ pageCount }} 页</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:current-page="filters.page"
|
||||
:page-size="filters.page_size"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="更新绑定规则" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="规则模式">
|
||||
<el-radio-group v-model="form.binding_mode" class="binding-mode-group">
|
||||
<el-radio-button value="single">单地址</el-radio-button>
|
||||
<el-radio-button value="multiple">多个 IP</el-radio-button>
|
||||
<el-radio-button value="all">全部放行</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.binding_mode === 'single'" label="IP 或 CIDR">
|
||||
<el-input
|
||||
v-model="form.allowed_ips_text"
|
||||
autocomplete="off"
|
||||
name="bound_ip"
|
||||
placeholder="192.168.1.0/24"
|
||||
@keyup.enter="submitEdit"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-else-if="form.binding_mode === 'multiple'" label="多个 IP">
|
||||
<el-input
|
||||
v-model="form.allowed_ips_text"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
autocomplete="off"
|
||||
name="allowed_ips"
|
||||
placeholder="每行一个 IP,例如 192.168.1.10 192.168.1.11"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-else
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="全部放行后,这个 Token 不再校验来源 IP。仅建议在确有必要的内部场景中使用。"
|
||||
/>
|
||||
|
||||
<p class="muted">
|
||||
单地址模式支持单个 IP 或一个 CIDR;多 IP 模式按逐行 IP 精确放行;全部放行表示跳过来源地址校验。
|
||||
</p>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||||
<el-button type="primary" @click="submitEdit">Save</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,8 @@ async function renderChart() {
|
||||
|
||||
chart ||= echarts.init(chartElement.value)
|
||||
chart.setOption({
|
||||
animationDuration: 500,
|
||||
color: ['#0b9e88', '#ef7f41'],
|
||||
animationDuration: 400,
|
||||
color: ['#4d8ff7', '#f29a44'],
|
||||
grid: {
|
||||
left: 24,
|
||||
right: 24,
|
||||
@@ -57,13 +57,13 @@ async function renderChart() {
|
||||
legend: {
|
||||
top: 0,
|
||||
textStyle: {
|
||||
color: '#516a75',
|
||||
color: '#5f7893',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(8, 24, 34, 0.9)',
|
||||
backgroundColor: 'rgba(34, 67, 108, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#f7fffe',
|
||||
@@ -73,30 +73,30 @@ async function renderChart() {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dashboard.value.trend.map((item) => item.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.18)' } },
|
||||
axisLabel: { color: '#516a75', fontWeight: 600 },
|
||||
axisLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.16)' } },
|
||||
axisLabel: { color: '#5f7893', fontWeight: 600 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.08)' } },
|
||||
axisLabel: { color: '#516a75' },
|
||||
splitLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.08)' } },
|
||||
axisLabel: { color: '#5f7893' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Allowed',
|
||||
name: '放行',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
areaStyle: { color: 'rgba(11, 158, 136, 0.14)' },
|
||||
areaStyle: { color: 'rgba(77, 143, 247, 0.14)' },
|
||||
lineStyle: { width: 3 },
|
||||
data: dashboard.value.trend.map((item) => item.allowed),
|
||||
},
|
||||
{
|
||||
name: 'Intercepted',
|
||||
name: '拦截',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
areaStyle: { color: 'rgba(239, 127, 65, 0.12)' },
|
||||
areaStyle: { color: 'rgba(242, 154, 68, 0.12)' },
|
||||
lineStyle: { width: 3 },
|
||||
data: dashboard.value.trend.map((item) => item.intercepted),
|
||||
},
|
||||
@@ -108,7 +108,7 @@ async function loadDashboard() {
|
||||
await run(async () => {
|
||||
dashboard.value = await fetchDashboard()
|
||||
await renderChart()
|
||||
}, 'Failed to load dashboard.')
|
||||
}, '加载看板失败。')
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
@@ -139,51 +139,51 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="page-grid">
|
||||
<PageHero
|
||||
eyebrow="Traffic pulse"
|
||||
title="Edge decisions and security drift in one pass"
|
||||
description="The dashboard combines live proxy metrics with persisted intercept records so security events remain visible even if Redis rolls over."
|
||||
eyebrow="运行概览"
|
||||
title="在一个页面里查看放行、拦截与绑定状态"
|
||||
description="看板汇总今日代理结果、绑定规模和最近拦截记录,便于快速判断系统是否稳定运行。"
|
||||
>
|
||||
<template #aside>
|
||||
<div class="hero-stat-pair">
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Intercept rate</span>
|
||||
<span class="eyebrow">拦截率</span>
|
||||
<strong>{{ formatPercent(interceptRate) }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Active share</span>
|
||||
<span class="eyebrow">活跃占比</span>
|
||||
<strong>{{ formatPercent(bindingCoverage) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<el-button :loading="loading" type="primary" plain @click="refreshDashboard">Refresh Dashboard</el-button>
|
||||
<el-button :loading="loading" type="primary" plain @click="refreshDashboard">刷新看板</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricTile
|
||||
eyebrow="Today"
|
||||
eyebrow="今日总量"
|
||||
:value="formatCompactNumber(dashboard.today.total)"
|
||||
note="Total edge decisions recorded today."
|
||||
note="今天经过网关处理的请求总数。"
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Allowed"
|
||||
eyebrow="放行请求"
|
||||
:value="formatCompactNumber(dashboard.today.allowed)"
|
||||
note="Requests that passed binding enforcement."
|
||||
accent="mint"
|
||||
note="通过绑定校验并成功转发的请求。"
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Intercepted"
|
||||
eyebrow="拦截请求"
|
||||
:value="formatCompactNumber(dashboard.today.intercepted)"
|
||||
note="Requests blocked for CIDR mismatch or banned keys."
|
||||
note="因 IP 不匹配或 Token 被封禁而拦截。"
|
||||
accent="amber"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Bindings"
|
||||
eyebrow="当前绑定"
|
||||
:value="formatCompactNumber(dashboard.bindings.active)"
|
||||
:note="`Active bindings, with ${formatCompactNumber(dashboard.bindings.banned)} banned keys in reserve.`"
|
||||
:note="`活跃绑定 ${formatCompactNumber(dashboard.bindings.active)} 条,封禁 ${formatCompactNumber(dashboard.bindings.banned)} 条。`"
|
||||
accent="slate"
|
||||
/>
|
||||
</section>
|
||||
@@ -192,24 +192,24 @@ onBeforeUnmount(() => {
|
||||
<article class="chart-card panel">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">7-day trend</p>
|
||||
<h3 class="section-title">Allowed vs intercepted flow</h3>
|
||||
<p class="eyebrow">7 日趋势</p>
|
||||
<h3 class="section-title">近 7 天放行与拦截趋势</h3>
|
||||
</div>
|
||||
<div class="inline-meta">
|
||||
<el-tag round effect="plain" type="success">30s auto refresh</el-tag>
|
||||
<span class="muted">Redis metrics with PostgreSQL intercept backfill.</span>
|
||||
<el-tag round effect="plain" type="primary">30 秒自动刷新</el-tag>
|
||||
<span class="muted">结合 Redis 指标与 PostgreSQL 日志统计。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-surface" />
|
||||
<div class="trend-summary">
|
||||
<p class="eyebrow">Trend table</p>
|
||||
<p class="eyebrow">趋势明细</p>
|
||||
<div class="trend-table-wrap">
|
||||
<table class="trend-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Allowed</th>
|
||||
<th scope="col">Intercepted</th>
|
||||
<th scope="col">日期</th>
|
||||
<th scope="col">放行</th>
|
||||
<th scope="col">拦截</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -226,12 +226,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
<article class="table-card panel">
|
||||
<div class="table-toolbar-block">
|
||||
<p class="eyebrow">Recent blocks</p>
|
||||
<h3 class="section-title">Latest intercepted requests</h3>
|
||||
<p class="muted">Operators can triage repeated misuse and verify whether alert escalation has already fired.</p>
|
||||
<p class="eyebrow">最新事件</p>
|
||||
<h3 class="section-title">最近拦截记录</h3>
|
||||
<p class="muted">用于快速确认异常来源、告警状态和是否需要进一步处置。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">No intercepts recorded yet.</div>
|
||||
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">当前还没有拦截记录。</div>
|
||||
|
||||
<div v-else class="table-stack table-stack--spaced">
|
||||
<article
|
||||
@@ -242,11 +242,11 @@ onBeforeUnmount(() => {
|
||||
<div class="toolbar">
|
||||
<strong>{{ item.token_display }}</strong>
|
||||
<el-tag :type="item.alerted ? 'danger' : 'warning'" round>
|
||||
{{ item.alerted ? 'Alerted' : 'Pending' }}
|
||||
{{ item.alerted ? '已告警' : '待观察' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p>
|
||||
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p>
|
||||
<p class="insight-note">绑定地址:{{ item.bound_ip }}</p>
|
||||
<p class="insight-note">尝试地址:{{ item.attempt_ip }}</p>
|
||||
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -13,33 +13,33 @@ const form = reactive({
|
||||
const { clearError, errorMessage, loading, run } = useAsyncAction()
|
||||
const loginSignals = [
|
||||
{
|
||||
eyebrow: 'Proxy path',
|
||||
title: 'Streaming request relay',
|
||||
note: 'Headers and body pass through to the downstream API without buffering full model responses.',
|
||||
eyebrow: '代理链路',
|
||||
title: '流式请求透传',
|
||||
note: '请求头与响应体直接转发到下游服务,兼容流式返回。',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Key policy',
|
||||
title: 'First-use IP binding',
|
||||
note: 'Every bearer token is pinned to a trusted client IP or CIDR on its first successful call.',
|
||||
eyebrow: '绑定策略',
|
||||
title: '首次使用自动绑定',
|
||||
note: 'Bearer Token 首次成功调用时绑定来源 IP 或 CIDR,后续持续校验。',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Operator safety',
|
||||
title: 'JWT + lockout',
|
||||
note: 'Admin login is rate-limited by source IP and issues an 8-hour signed token on success.',
|
||||
eyebrow: '后台安全',
|
||||
title: 'JWT 与限流保护',
|
||||
note: '管理端登录按来源 IP 限流,成功后签发 8 小时令牌。',
|
||||
},
|
||||
]
|
||||
|
||||
async function submit() {
|
||||
if (!form.password) {
|
||||
ElMessage.warning('Enter the admin password first.')
|
||||
ElMessage.warning('请先输入管理员密码。')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
clearError()
|
||||
const data = await run(() => login(form.password), 'Login failed.')
|
||||
const data = await run(() => login(form.password), '登录失败。')
|
||||
setAuthToken(data.access_token)
|
||||
ElMessage.success('Authentication complete.')
|
||||
ElMessage.success('登录成功。')
|
||||
await router.push({ name: 'dashboard' })
|
||||
} catch {}
|
||||
}
|
||||
@@ -49,11 +49,10 @@ async function submit() {
|
||||
<div class="login-shell">
|
||||
<section class="login-stage panel">
|
||||
<div class="login-stage-copy">
|
||||
<p class="eyebrow">Edge enforcement</p>
|
||||
<p class="eyebrow">边界网关</p>
|
||||
<h1>Key-IP Sentinel</h1>
|
||||
<p class="login-copy">
|
||||
Lock every model API key to its first trusted origin. Monitor drift, inspect misuse, and react from one
|
||||
hardened control surface.
|
||||
将每个模型 API Key 固定到首次可信来源地址,在一个后台里完成绑定、查看、拦截与处置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -68,23 +67,23 @@ async function submit() {
|
||||
<div class="stack">
|
||||
<div class="status-chip status-chip--strong">
|
||||
<el-icon><Lock /></el-icon>
|
||||
Zero-trust perimeter
|
||||
首次使用 IP 绑定
|
||||
</div>
|
||||
<div class="status-chip">
|
||||
<el-icon><Connection /></el-icon>
|
||||
Live downstream relay
|
||||
下游请求实时透传
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-card">
|
||||
<div class="login-card-inner">
|
||||
<p class="eyebrow">Admin access</p>
|
||||
<h2 class="section-title">Secure Operator Login</h2>
|
||||
<p class="muted">Use the runtime password from your deployment environment to obtain an 8-hour admin token.</p>
|
||||
<p class="eyebrow">管理员入口</p>
|
||||
<h2 class="section-title">登录控制台</h2>
|
||||
<p class="muted">使用部署环境中的管理员密码登录,系统会签发 8 小时后台访问令牌。</p>
|
||||
|
||||
<el-form label-position="top" @submit.prevent="submit">
|
||||
<el-form-item label="Admin password">
|
||||
<el-form-item label="管理员密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:aria-describedby="errorMessage ? 'login-error' : undefined"
|
||||
@@ -93,7 +92,7 @@ async function submit() {
|
||||
size="large"
|
||||
autocomplete="current-password"
|
||||
name="admin_password"
|
||||
placeholder="Enter deployment password"
|
||||
placeholder="请输入部署密码"
|
||||
@input="clearError"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -101,15 +100,15 @@ async function submit() {
|
||||
<p v-if="errorMessage" id="login-error" class="form-feedback" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<el-button native-type="submit" type="primary" size="large" :loading="loading" class="w-full">
|
||||
Enter Control Plane
|
||||
进入控制台
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="login-divider" />
|
||||
|
||||
<div class="login-footer-note">
|
||||
<span class="eyebrow">Security note</span>
|
||||
<p>Failed admin attempts are rate-limited by client IP before a JWT is issued.</p>
|
||||
<span class="eyebrow">安全提示</span>
|
||||
<p>后台登录失败会按客户端 IP 限流,避免暴力尝试。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -128,34 +127,35 @@ async function submit() {
|
||||
|
||||
.login-signal-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-signal-card {
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 16px 18px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.08));
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.login-signal-card h3 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 1.08rem;
|
||||
margin: 8px 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.login-signal-card p:last-child {
|
||||
margin: 0;
|
||||
color: rgba(247, 255, 254, 0.78);
|
||||
color: rgba(247, 255, 254, 0.82);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.status-chip--strong {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
height: 1px;
|
||||
margin: 24px 0 18px;
|
||||
background: linear-gradient(90deg, rgba(9, 22, 30, 0.06), rgba(11, 158, 136, 0.28), rgba(9, 22, 30, 0.06));
|
||||
margin: 22px 0 16px;
|
||||
background: linear-gradient(90deg, rgba(23, 50, 77, 0.05), rgba(77, 143, 247, 0.28), rgba(23, 50, 77, 0.05));
|
||||
}
|
||||
|
||||
.login-footer-note p {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction'
|
||||
import { exportLogs, fetchLogs } from '../api'
|
||||
@@ -25,20 +24,7 @@ const { loading: exporting, run: runExport } = useAsyncAction()
|
||||
|
||||
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
|
||||
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
|
||||
const pendingCount = computed(() => rows.value.length - alertedCount.value)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
||||
const intelCards = [
|
||||
{
|
||||
eyebrow: 'Escalation',
|
||||
title: 'Alerted rows',
|
||||
note: 'Rows marked alerted already crossed the Redis threshold window and were included in a webhook escalation.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Forensics',
|
||||
title: 'Attempt IP review',
|
||||
note: 'Correlate repeated attempt IPs with internal NAT ranges or unknown external addresses before acting on the token.',
|
||||
},
|
||||
]
|
||||
|
||||
function parsePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
@@ -107,7 +93,7 @@ async function loadLogs() {
|
||||
const data = await fetchLogs(requestParams())
|
||||
rows.value = data.items
|
||||
total.value = data.total
|
||||
}, 'Failed to load logs.')
|
||||
}, '加载日志失败。')
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
@@ -136,7 +122,7 @@ async function handleExport() {
|
||||
start_time: filters.time_range?.[0] || undefined,
|
||||
end_time: filters.time_range?.[1] || undefined,
|
||||
}),
|
||||
'Failed to export logs.',
|
||||
'导出日志失败。',
|
||||
)
|
||||
downloadBlob(blob, 'sentinel-logs.csv')
|
||||
} catch {}
|
||||
@@ -173,61 +159,34 @@ watch(
|
||||
<template>
|
||||
<div class="page-grid">
|
||||
<PageHero
|
||||
eyebrow="Audit trail"
|
||||
title="Review blocked requests, escalation state, and repeated misuse patterns"
|
||||
description="Intercept records stay in PostgreSQL even if Redis counters reset, so operators can reconstruct activity across the full retention window."
|
||||
eyebrow="审计追踪"
|
||||
title="查看拦截记录、来源地址和告警状态"
|
||||
description="所有拦截结果都会落库保存,便于按时间、Token 和尝试来源地址进行回溯。"
|
||||
>
|
||||
<template #aside>
|
||||
<div class="hero-stat-pair">
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Alerted on page</span>
|
||||
<span class="eyebrow">已告警</span>
|
||||
<strong>{{ formatCompactNumber(alertedCount) }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Unique IPs</span>
|
||||
<span class="eyebrow">来源地址</span>
|
||||
<strong>{{ formatCompactNumber(uniqueAttempts) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<el-button type="primary" plain :loading="exporting" @click="handleExport">Export CSV</el-button>
|
||||
<el-button type="primary" plain :loading="exporting" @click="handleExport">导出 CSV</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricTile
|
||||
eyebrow="Page rows"
|
||||
:value="formatCompactNumber(rows.length)"
|
||||
note="Intercept rows visible in the current result page."
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Matching total"
|
||||
:value="formatCompactNumber(total)"
|
||||
note="Persisted intercepts matching the active filters."
|
||||
accent="amber"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Alerted"
|
||||
:value="formatCompactNumber(alertedCount)"
|
||||
note="Visible rows that already triggered webhook escalation."
|
||||
accent="amber"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Attempt sources"
|
||||
:value="formatCompactNumber(uniqueAttempts)"
|
||||
note="Distinct attempt IPs visible on the current page."
|
||||
accent="mint"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="content-grid content-grid--balanced">
|
||||
<section class="content-grid">
|
||||
<article class="table-card panel">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="log-token-filter">Masked Token</label>
|
||||
<label class="filter-label" for="log-token-filter">脱敏 Token</label>
|
||||
<el-input
|
||||
id="log-token-filter"
|
||||
v-model="filters.token"
|
||||
@@ -235,12 +194,12 @@ watch(
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="log_token_filter"
|
||||
placeholder="Masked token..."
|
||||
placeholder="输入脱敏 Token"
|
||||
@keyup.enter="searchLogs"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="log-attempt-ip-filter">Attempt IP</label>
|
||||
<label class="filter-label" for="log-attempt-ip-filter">尝试来源 IP</label>
|
||||
<el-input
|
||||
id="log-attempt-ip-filter"
|
||||
v-model="filters.attempt_ip"
|
||||
@@ -248,43 +207,43 @@ watch(
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="log_attempt_ip_filter"
|
||||
placeholder="10.0.0.8..."
|
||||
placeholder="例如 10.0.0.8"
|
||||
@keyup.enter="searchLogs"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-range">
|
||||
<label class="filter-label" for="log-time-range">Time Range</label>
|
||||
<label class="filter-label" for="log-time-range">时间范围</label>
|
||||
<el-date-picker
|
||||
id="log-time-range"
|
||||
v-model="filters.time_range"
|
||||
aria-label="Filter by intercepted time range"
|
||||
type="datetimerange"
|
||||
range-separator="to"
|
||||
start-placeholder="Start Time"
|
||||
end-placeholder="End Time"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="searchLogs">Search Logs</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="searchLogs">查询日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table table-block">
|
||||
<el-table :data="rows" v-loading="loading">
|
||||
<el-table-column prop="intercepted_at" label="Time" min-width="190">
|
||||
<el-table-column prop="intercepted_at" label="时间" min-width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="token_display" label="Token" min-width="170" />
|
||||
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
|
||||
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
|
||||
<el-table-column label="Alerted" width="120">
|
||||
<el-table-column prop="bound_ip" label="绑定地址" min-width="170" />
|
||||
<el-table-column prop="attempt_ip" label="尝试地址" min-width="160" />
|
||||
<el-table-column label="告警状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
|
||||
{{ row.alerted ? 'Yes' : 'No' }}
|
||||
{{ row.alerted ? '已告警' : '未告警' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -292,7 +251,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
||||
<span class="muted">第 {{ filters.page }} / {{ pageCount }} 页</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
@@ -303,27 +262,6 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="soft-grid">
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">On this page</p>
|
||||
<div class="support-kpi">
|
||||
<strong>{{ formatCompactNumber(pendingCount) }}</strong>
|
||||
<p>Visible rows still below the escalation threshold or not yet marked as alerted.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Incident review</p>
|
||||
<ul class="support-list" role="list">
|
||||
<li v-for="item in intelCards" :key="item.title" class="support-list-item">
|
||||
<span class="eyebrow">{{ item.eyebrow }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ item.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,19 +21,20 @@ const { loading, run } = useAsyncAction()
|
||||
const { loading: saving, run: runSave } = useAsyncAction()
|
||||
|
||||
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
|
||||
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
|
||||
const webhookState = computed(() => (form.alert_webhook_url ? '已配置' : '未启用'))
|
||||
const failsafeLabel = computed(() => (form.failsafe_mode === 'closed' ? '安全优先' : '连续性优先'))
|
||||
const hasUnsavedChanges = computed(() => Boolean(initialSnapshot.value) && buildSnapshot() !== initialSnapshot.value)
|
||||
const modeCards = computed(() => [
|
||||
{
|
||||
eyebrow: 'Closed mode',
|
||||
title: 'Protect the perimeter',
|
||||
note: 'Reject traffic if Redis and PostgreSQL are both unavailable. Choose this when abuse prevention has priority over service continuity.',
|
||||
eyebrow: 'Closed 模式',
|
||||
title: '优先保证安全',
|
||||
note: '当 Redis 与 PostgreSQL 都不可用时,直接拒绝请求。适合安全优先的生产环境。',
|
||||
active: form.failsafe_mode === 'closed',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Open mode',
|
||||
title: 'Preserve business flow',
|
||||
note: 'Allow traffic to continue when the full binding backend is down. Choose this only when continuity requirements outweigh policy enforcement.',
|
||||
eyebrow: 'Open 模式',
|
||||
title: '优先保证连续性',
|
||||
note: '当绑定后端不可用时仍允许请求继续转发,仅在业务连续性优先时使用。',
|
||||
active: form.failsafe_mode === 'open',
|
||||
},
|
||||
])
|
||||
@@ -57,7 +58,7 @@ function confirmDiscardChanges() {
|
||||
return true
|
||||
}
|
||||
|
||||
return window.confirm('You have unsaved runtime settings. Leave this page and discard them?')
|
||||
return window.confirm('当前设置尚未保存,确定离开并放弃修改吗?')
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event) {
|
||||
@@ -78,7 +79,7 @@ async function loadSettings() {
|
||||
form.archive_days = data.archive_days
|
||||
form.failsafe_mode = data.failsafe_mode
|
||||
syncSnapshot()
|
||||
}, 'Failed to load runtime settings.')
|
||||
}, '加载运行设置失败。')
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
@@ -92,10 +93,10 @@ async function saveSettings() {
|
||||
archive_days: form.archive_days,
|
||||
failsafe_mode: form.failsafe_mode,
|
||||
}),
|
||||
'Failed to update runtime settings.',
|
||||
'更新运行设置失败。',
|
||||
)
|
||||
syncSnapshot()
|
||||
ElMessage.success('Runtime settings updated.')
|
||||
ElMessage.success('运行设置已更新。')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -114,15 +115,15 @@ onBeforeRouteLeave(() => confirmDiscardChanges())
|
||||
<template>
|
||||
<div class="page-grid">
|
||||
<PageHero
|
||||
eyebrow="Runtime controls"
|
||||
title="Adjust alerting and retention without redeploying the app"
|
||||
description="These values are persisted in Redis and applied live by the proxy, alerting, and archive scheduler services."
|
||||
eyebrow="运行配置"
|
||||
title="在线调整告警、归档与故障处理策略"
|
||||
description="这些配置会写入 Redis 并实时生效,无需重新部署服务。"
|
||||
>
|
||||
<template #aside>
|
||||
<div class="hero-stat-pair">
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Failsafe</span>
|
||||
<strong>{{ form.failsafe_mode }}</strong>
|
||||
<span class="eyebrow">故障策略</span>
|
||||
<strong>{{ failsafeLabel }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Webhook</span>
|
||||
@@ -133,81 +134,81 @@ onBeforeRouteLeave(() => confirmDiscardChanges())
|
||||
|
||||
<template #actions>
|
||||
<el-button type="primary" :disabled="loading || !hasUnsavedChanges" :loading="saving" @click="saveSettings">
|
||||
Save Runtime Settings
|
||||
保存设置
|
||||
</el-button>
|
||||
</template>
|
||||
</PageHero>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricTile
|
||||
eyebrow="Threshold count"
|
||||
eyebrow="告警阈值"
|
||||
:value="formatCompactNumber(form.alert_threshold_count)"
|
||||
note="Intercepts needed before alert escalation fires."
|
||||
note="达到该拦截次数后触发告警。"
|
||||
accent="amber"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Threshold window"
|
||||
eyebrow="统计窗口"
|
||||
:value="`${formatCompactNumber(thresholdMinutes)}m`"
|
||||
note="Rolling window used by the Redis alert counter."
|
||||
note="Redis 统计告警次数使用的时间窗口。"
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Archive after"
|
||||
eyebrow="归档周期"
|
||||
:value="`${formatCompactNumber(form.archive_days)}d`"
|
||||
note="Bindings older than this are pruned from the active table."
|
||||
accent="mint"
|
||||
note="超过该时间未活跃的绑定将从活动表归档。"
|
||||
accent="slate"
|
||||
/>
|
||||
<MetricTile
|
||||
eyebrow="Delivery"
|
||||
eyebrow="通知状态"
|
||||
:value="webhookState"
|
||||
note="Webhook POST is optional and can be disabled."
|
||||
note="Webhook 可选配置,不影响核心代理功能。"
|
||||
accent="slate"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="content-grid content-grid--balanced">
|
||||
<article class="form-card panel">
|
||||
<p class="eyebrow">Alert window</p>
|
||||
<h3 class="section-title">Thresholds and Webhook Delivery</h3>
|
||||
<p class="eyebrow">告警配置</p>
|
||||
<h3 class="section-title">阈值与 Webhook 通知</h3>
|
||||
|
||||
<el-form label-position="top" v-loading="loading">
|
||||
<el-form-item label="Webhook URL">
|
||||
<el-form-item label="Webhook 地址">
|
||||
<el-input
|
||||
v-model="form.alert_webhook_url"
|
||||
autocomplete="off"
|
||||
name="alert_webhook_url"
|
||||
placeholder="https://hooks.example.internal/sentinel..."
|
||||
placeholder="https://hooks.example.internal/sentinel"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Intercept count threshold">
|
||||
<el-form-item label="拦截次数阈值">
|
||||
<el-input-number v-model="form.alert_threshold_count" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Threshold window (seconds)">
|
||||
<el-form-item label="统计窗口(秒)">
|
||||
<el-slider v-model="form.alert_threshold_seconds" :min="60" :max="3600" :step="30" show-input />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Failsafe mode">
|
||||
<el-form-item label="故障处理模式">
|
||||
<el-radio-group v-model="form.failsafe_mode">
|
||||
<el-radio-button value="closed">Closed</el-radio-button>
|
||||
<el-radio-button value="open">Open</el-radio-button>
|
||||
<el-radio-button value="closed">安全优先</el-radio-button>
|
||||
<el-radio-button value="open">连续性优先</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="form-feedback form-feedback--status" role="status">
|
||||
{{ hasUnsavedChanges ? 'You have unsaved runtime changes.' : 'Runtime settings are in sync.' }}
|
||||
{{ hasUnsavedChanges ? '当前有未保存的修改。' : '当前运行设置已同步。' }}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<aside class="soft-grid">
|
||||
<article class="form-card panel">
|
||||
<p class="eyebrow">Retention</p>
|
||||
<h3 class="section-title">Archive Stale Bindings</h3>
|
||||
<p class="eyebrow">归档策略</p>
|
||||
<h3 class="section-title">归档长期不活跃绑定</h3>
|
||||
|
||||
<el-form label-position="top" v-loading="loading">
|
||||
<el-form-item label="Archive inactive bindings after N days">
|
||||
<el-form-item label="超过 N 天未活跃后归档">
|
||||
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
16
main.py
16
main.py
@@ -1,5 +1,17 @@
|
||||
def main():
|
||||
print("Hello from sentinel!")
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=int(os.getenv("APP_PORT", "7000")),
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
worker_processes auto;
|
||||
worker_processes 8;
|
||||
|
||||
events {
|
||||
worker_connections 4096;
|
||||
@@ -17,26 +17,13 @@ http {
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
||||
|
||||
upstream sentinel_app {
|
||||
server sentinel-app:7000;
|
||||
server 172.30.0.10:7000;
|
||||
keepalive 128;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 3000;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
client_max_body_size 32m;
|
||||
proxy_http_version 1.1;
|
||||
@@ -64,8 +51,8 @@ http {
|
||||
proxy_pass http://sentinel_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
@@ -73,8 +60,8 @@ http {
|
||||
proxy_pass http://sentinel_app/health;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
}
|
||||
|
||||
location / {
|
||||
@@ -82,8 +69,8 @@ http {
|
||||
proxy_pass http://sentinel_app;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user