Compare commits

..

7 Commits

31 changed files with 3602 additions and 924 deletions

View File

@@ -5,10 +5,12 @@ PG_DSN=postgresql+asyncpg://sentinel:password@postgres:5432/sentinel
SENTINEL_HMAC_SECRET=replace-with-a-random-32-byte-secret SENTINEL_HMAC_SECRET=replace-with-a-random-32-byte-secret
ADMIN_PASSWORD=replace-with-a-strong-password ADMIN_PASSWORD=replace-with-a-strong-password
ADMIN_JWT_SECRET=replace-with-a-random-jwt-secret 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 SENTINEL_FAILSAFE_MODE=closed
APP_PORT=7000 APP_PORT=7000
UVICORN_WORKERS=4
ALERT_WEBHOOK_URL= ALERT_WEBHOOK_URL=
ALERT_THRESHOLD_COUNT=5 ALERT_THRESHOLD_COUNT=5
ALERT_THRESHOLD_SECONDS=300 ALERT_THRESHOLD_SECONDS=300
ARCHIVE_DAYS=90 ARCHIVE_DAYS=90
ARCHIVE_SCHEDULER_LOCK_KEY=2026030502

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ wheels/
# Node-generated files # Node-generated files
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
.env

View File

@@ -1,10 +1,9 @@
FROM python:3.13-slim AS builder FROM python:3.13-slim-bookworm AS builder
WORKDIR /build WORKDIR /build
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r 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 WORKDIR /app
COPY --from=builder /install /usr/local COPY --from=builder /install /usr/local
COPY app/ ./app/ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${APP_PORT:-7000} --workers ${UVICORN_WORKERS:-4}"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7000", "--workers", "4"]

77
PRD.md
View File

@@ -24,16 +24,16 @@
### 2.1 流量链路 ### 2.1 流量链路
``` ```
调用方 (Client) 调用方 (Client)
│ HTTPS (443) │ HTTP (80)
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Nginx │ │ Nginx │
│ 职责:TLS终止 / 路径路由 / │ │ 职责:路径路由 /
│ 静态文件 / 内网鉴权 / 粗粒度限流 │ │ 静态文件 / 内网鉴权 / 粗粒度限流 │
└────────────────┬────────────────────────┘ └────────────────┬────────────────────────┘
│ HTTP 内网转发 │ HTTP 内网转发
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Key-IP Sentinel App │ │ Key-IP Sentinel App │
@@ -65,7 +65,7 @@
| 缓存层 | **Redis 7+** | Token-IP 绑定热数据TTL 7 天 | | 缓存层 | **Redis 7+** | Token-IP 绑定热数据TTL 7 天 |
| 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 | | 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 |
| 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA打包后由 Nginx 直接托管 | | 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA打包后由 Nginx 直接托管 |
| 外层网关 | **Nginx** | TLS 终止、路径隔离、静态文件、`limit_req_zone` 限流 | | 外层网关 | **Nginx** | 路径隔离、静态文件、`limit_req_zone` 限流 |
| 部署方式 | **Docker Compose** | 共 4 个容器nginx / sentinel-app / redis / postgres | | 部署方式 | **Docker Compose** | 共 4 个容器nginx / sentinel-app / redis / postgres |
*** ***
@@ -122,26 +122,25 @@
`nginx.conf` 中需要实现以下配置: `nginx.conf` 中需要实现以下配置:
```nginx ```nginx
# 1. TLS 终止HTTPS → HTTP 转发给 sentinel-app # 1. 代理路径:/ 全部转发给 sentinel-app:7000
# 2. 代理路径:/ 全部转发给 sentinel-app:7000 # 2. 管理后台访问限制
# 3. 管理后台访问限制 location /admin/ {
location /admin/ { allow 10.0.0.0/8; # 内网 IP 段
allow 10.0.0.0/8; # 内网 IP 段 allow 192.168.0.0/16;
allow 192.168.0.0/16; deny all;
deny all; proxy_pass http://sentinel-app:7000;
proxy_pass http://sentinel-app:7000; }
} # 3. 静态文件(前端 UI
# 4. 静态文件(前端 UI location /admin/ui/ {
location /admin/ui/ { root /etc/nginx/html;
root /etc/nginx/html; try_files $uri $uri/ /admin/ui/index.html;
try_files $uri $uri/ /admin/ui/index.html; }
} # 4. 基础限流
# 5. 基础限流 limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; # 5. 强制写入真实 IP防客户端伪造
# 6. 强制写入真实 IP防客户端伪造 proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值
proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值 ```
```
### 4.2 Sentinel App 反向代理模块 ### 4.2 Sentinel App 反向代理模块
- **受信 IP Header**:只读取 `X-Real-IP`Nginx 写入的),忽略请求中原始的 `X-Forwarded-For` - **受信 IP Header**:只读取 `X-Real-IP`Nginx 写入的),忽略请求中原始的 `X-Forwarded-For`
@@ -337,15 +336,13 @@ version: '3.8'
services: services:
nginx: nginx:
image: nginx:alpine image: nginx:alpine
container_name: sentinel-nginx container_name: sentinel-nginx
ports: ports:
- "80:80" - "80:80"
- "443:443" volumes:
volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./frontend/dist:/etc/nginx/html/admin/ui:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
depends_on: depends_on:
- sentinel-app - sentinel-app
networks: networks:

462
README.md
View File

@@ -1,18 +1,18 @@
# Key-IP Sentinel # 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. - 首次使用自动绑定,使用 HMAC-SHA256 token 做哈希,结合 Redis cache-aside PostgreSQL 存储绑定规则。
- Streaming reverse proxy built on `httpx.AsyncClient` and FastAPI `StreamingResponse`. - 基于 `httpx.AsyncClient` FastAPI `StreamingResponse` 的流式反向代理,支持流式响应透传。
- Trusted proxy IP extraction that only accepts `X-Real-IP` from configured upstream networks. - 可信代理 IP 提取逻辑,只接受来自指定上游网络的 `X-Real-IP`
- Redis-backed intercept alert counters with webhook delivery and PostgreSQL audit logs. - 基于 Redis 的拦截计数、Webhook 告警,以及 PostgreSQL 审计日志。
- Admin API protected by JWT and Redis-backed login lockout. - 管理后台登录使用 JWT并带有 Redis 登录失败锁定机制。
- Vue 3 + Element Plus admin console for dashboarding, binding operations, audit logs, and live runtime settings. - 使用 Vue 3 + Element Plus 的管理后台,可查看看板、绑定、审计日志和运行时设置。
- Docker Compose deployment with Nginx, app, Redis, and PostgreSQL. - 支持 Docker Compose 部署,包含 Nginx、应用、Redis 和 PostgreSQL
## Repository Layout ## 仓库结构
```text ```text
sentinel/ sentinel/
@@ -26,64 +26,241 @@ sentinel/
└── README.md └── README.md
``` ```
## Runtime Notes ## 运行说明
- Redis stores binding cache, alert counters, daily dashboard metrics, and mutable runtime settings. - Redis 用于存储绑定缓存、告警计数、每日看板指标和可变运行时设置。
- PostgreSQL stores authoritative token bindings and intercept logs. - PostgreSQL 用于存储权威绑定记录和拦截日志。
- 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. - 归档保留机制会在绑定超过 `ARCHIVE_DAYS` 不活跃后,从活动表中移除;同一 token 后续再次请求时会重新进行首次绑定。
- `SENTINEL_FAILSAFE_MODE=closed` rejects requests when both Redis and PostgreSQL are unavailable. `open` allows traffic through. - `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. - Sentinel 这套 compose 包含 `nginx``sentinel-app``redis``postgres`
2. Create the environment and sync dependencies: - 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 ```bash
uv sync uv sync
``` ```
3. Copy `.env.example` to `.env` and update secrets plus addresses. 3. `.env.example` 复制为 `.env`,并填写密钥与连接地址
4. Start PostgreSQL and Redis. 4. 启动 PostgreSQL Redis
5. Run the API: 5. 启动 API
```bash ```bash
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000 uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000
``` ```
### Frontend ### 前端
1. Install dependencies: 1. 安装依赖:
```bash ```bash
cd frontend cd frontend
npm install npm install
``` ```
2. Start Vite dev server: 2. 启动 Vite 开发服务器:
```bash ```bash
npm run dev 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 ```bash
cd frontend cd frontend
@@ -92,29 +269,182 @@ npm run build
cd .. 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 ```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. `.env``frontend/dist``shared_network` 都准备好之后,执行:
- `https://<host>/admin/ui/` serves the admin console.
- `https://<host>/admin/api/*` serves the admin API.
- `https://<host>/health` exposes the app health check.
## 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 APISentinel 就看不到流量,也不会生成绑定。
## 客户端应该连接哪个端口
按当前仓库中的 Linux 生产 compose
- Sentinel 对外端口:`3000`
- New API 容器内部端口:通常是 `3000`
这意味着:
- 客户端应当请求 `http://<host>:3000/...`
- Sentinel 会在内部转发到 `http://new-api:3000`
不要把客户端直接指向 New API 的宿主机端口,否则会绕过 Sentinel。
## 如何做到业务无感上线
如果你希望现有客户端配置完全不改Sentinel 就必须接管原来客户端已经在使用的那个对外地址和端口。
典型切换方式如下:
1. 保留 New API 在内部共享网络中运行
2. 停止把 New API 直接暴露给最终用户
3. 让 Sentinel 暴露原来的对外地址和端口
4.`DOWNSTREAM_URL` 持续指向 `shared_network` 上的内部 New API 服务
例如,如果现有客户端一直使用 `http://host:3000`,那生产切换时就应让 Sentinel 接管这个 `3000`,并让 New API 变成内部服务。
当前仓库中的 [`docker-compose.yml`](/c:/project/sentinel/docker-compose.yml) 已经按这种 Linux 生产方式调整Nginx 直接使用宿主机网络监听 `3000`,而 New API 保持内部访问。
## 管理后台 API 概览
- `POST /admin/api/login` - `POST /admin/api/login`
- `GET /admin/api/dashboard` - `GET /admin/api/dashboard`
@@ -128,20 +458,24 @@ Services:
- `GET /admin/api/settings` - `GET /admin/api/settings`
- `PUT /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`. 1. `GET /health` 返回 `{"status":"ok"}`
4. A request from a different IP is rejected with `403` and creates an `intercept_logs` record. 2. 使用一个新的 Bearer Token 发起首次请求后,应在 PostgreSQL 和 Redis 中创建绑定
5. `/admin/api/login` returns a JWT and the frontend can load `/admin/api/dashboard`. 3. 同一 IP 的第二次请求应被放行,并刷新 `last_used_at`
4. 来自不同 IP 的请求应返回 `403`,并写入 `intercept_logs`,除非绑定规则是 `all`
5. `/admin/api/login` 应返回 JWT前端应能正常加载 `/admin/api/dashboard`

View File

@@ -28,6 +28,8 @@ def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> B
id=binding.id, id=binding.id,
token_display=binding.token_display, token_display=binding.token_display,
bound_ip=str(binding.bound_ip), 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=binding.status,
status_label=binding_service.status_label(binding.status), status_label=binding_service.status_label(binding.status),
first_used_at=binding.first_used_at, 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: 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( async def update_binding_status(
@@ -138,7 +146,9 @@ async def update_bound_ip(
binding_service: BindingService = Depends(get_binding_service), binding_service: BindingService = Depends(get_binding_service),
): ):
binding = await get_binding_or_404(session, payload.id) 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 session.commit()
await commit_binding_cache(binding, binding_service) await commit_binding_cache(binding, binding_service)
log_admin_action(request, settings, "update_ip", payload.id) log_admin_action(request, settings, "update_ip", payload.id)

View File

@@ -76,7 +76,7 @@ async def build_recent_intercepts(session: AsyncSession) -> list[InterceptLogIte
InterceptLogItem( InterceptLogItem(
id=item.id, id=item.id,
token_display=item.token_display, token_display=item.token_display,
bound_ip=str(item.bound_ip), bound_ip=item.bound_ip,
attempt_ip=str(item.attempt_ip), attempt_ip=str(item.attempt_ip),
alerted=item.alerted, alerted=item.alerted,
intercepted_at=item.intercepted_at, intercepted_at=item.intercepted_at,

View File

@@ -38,7 +38,7 @@ def to_log_item(item: InterceptLog) -> InterceptLogItem:
return InterceptLogItem( return InterceptLogItem(
id=item.id, id=item.id,
token_display=item.token_display, token_display=item.token_display,
bound_ip=str(item.bound_ip), bound_ip=item.bound_ip,
attempt_ip=str(item.attempt_ip), attempt_ip=str(item.attempt_ip),
alerted=item.alerted, alerted=item.alerted,
intercepted_at=item.intercepted_at, 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: def write_log_csv(buffer: io.StringIO, logs: list[InterceptLog]) -> None:
writer = csv.writer(buffer) 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: for item in logs:
writer.writerow( writer.writerow(
[ [
item.id, item.id,
item.token_display, item.token_display,
str(item.bound_ip), item.bound_ip,
str(item.attempt_ip), str(item.attempt_ip),
item.alerted, item.alerted,
item.intercepted_at.isoformat(), item.intercepted_at.isoformat(),

View File

@@ -33,7 +33,7 @@ class Settings(BaseSettings):
sentinel_hmac_secret: str = Field(alias="SENTINEL_HMAC_SECRET", min_length=32) sentinel_hmac_secret: str = Field(alias="SENTINEL_HMAC_SECRET", min_length=32)
admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=8) admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=8)
admin_jwt_secret: str = Field(alias="ADMIN_JWT_SECRET", min_length=16) 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( sentinel_failsafe_mode: Literal["open", "closed"] = Field(
default="closed", default="closed",
alias="SENTINEL_FAILSAFE_MODE", alias="SENTINEL_FAILSAFE_MODE",
@@ -54,6 +54,7 @@ class Settings(BaseSettings):
admin_jwt_expire_hours: int = 8 admin_jwt_expire_hours: int = 8
archive_job_interval_minutes: int = 60 archive_job_interval_minutes: int = 60
archive_batch_size: int = 500 archive_batch_size: int = 500
archive_scheduler_lock_key: int = Field(default=2026030502, alias="ARCHIVE_SCHEDULER_LOCK_KEY")
metrics_ttl_days: int = 30 metrics_ttl_days: int = 30
webhook_timeout_seconds: int = 5 webhook_timeout_seconds: int = 5
@@ -62,17 +63,10 @@ class Settings(BaseSettings):
def normalize_downstream_url(cls, value: str) -> str: def normalize_downstream_url(cls, value: str) -> str:
return value.rstrip("/") return value.rstrip("/")
@field_validator("trusted_proxy_ips", mode="before") @property
@classmethod def trusted_proxy_ips(self) -> tuple[str, ...]:
def split_proxy_ips(cls, value: object) -> tuple[str, ...]: parts = [item.strip() for item in self.trusted_proxy_ips_raw.split(",")]
if value is None: return tuple(item for item in parts if item)
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(),)
@cached_property @cached_property
def trusted_proxy_networks(self): def trusted_proxy_networks(self):

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import hmac import hmac
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Mapping
from fastapi import HTTPException, status from fastapi import HTTPException, status
from jose import JWTError, jwt from jose import JWTError, jwt
@@ -34,6 +35,19 @@ def extract_bearer_token(authorization: str | None) -> str | None:
return token.strip() 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: def verify_admin_password(password: str, settings: Settings) -> bool:
return hmac.compare_digest(password, settings.admin_password) return hmac.compare_digest(password, settings.admin_password)

View File

@@ -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.api import auth, bindings, dashboard, logs, settings as settings_api
from app.config import RUNTIME_SETTINGS_REDIS_KEY, RuntimeSettings, Settings, get_settings 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 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.proxy.handler import router as proxy_router
from app.services.alert_service import AlertService from app.services.alert_service import AlertService
from app.services.archive_service import ArchiveService from app.services.archive_service import ArchiveService
@@ -70,6 +70,8 @@ def configure_logging() -> None:
root_logger.handlers.clear() root_logger.handlers.clear()
root_logger.addHandler(handler) root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
configure_logging() configure_logging()
@@ -100,6 +102,7 @@ async def load_runtime_settings(redis: Redis | None, settings: Settings) -> Runt
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
init_db(settings) init_db(settings)
await ensure_schema_compatibility()
session_factory = get_session_factory() session_factory = get_session_factory()
redis: Redis | None = redis_from_url( redis: Redis | None = redis_from_url(
@@ -152,6 +155,7 @@ async def lifespan(app: FastAPI):
) )
archive_service = ArchiveService( archive_service = ArchiveService(
settings=settings, settings=settings,
engine=get_engine(),
session_factory=session_factory, session_factory=session_factory,
binding_service=binding_service, binding_service=binding_service,
runtime_settings_getter=lambda: app.state.runtime_settings, runtime_settings_getter=lambda: app.state.runtime_settings,

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.config import Settings from app.config import Settings
SCHEMA_COMPATIBILITY_LOCK_KEY = 2026030501
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@@ -40,6 +43,35 @@ def get_session_factory() -> async_sessionmaker[AsyncSession]:
return _session_factory 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: async def close_db() -> None:
global _engine, _session_factory global _engine, _session_factory
if _engine is not None: if _engine is not None:

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, String, func, text from sqlalchemy import Boolean, DateTime, Index, String, Text, func, text
from sqlalchemy.dialects.postgresql import CIDR, INET from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.db import Base from app.models.db import Base
@@ -19,7 +19,7 @@ class InterceptLog(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False) token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
token_display: Mapped[str] = mapped_column(String(20), 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) attempt_ip: Mapped[str] = mapped_column(INET, nullable=False)
alerted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("FALSE")) alerted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("FALSE"))
intercepted_at: Mapped[datetime] = mapped_column( intercepted_at: Mapped[datetime] = mapped_column(

View File

@@ -2,27 +2,42 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Index, SmallInteger, String, func, text from sqlalchemy import DateTime, Index, SmallInteger, String, Text, func, text
from sqlalchemy.dialects.postgresql import CIDR from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.db import Base from app.models.db import Base
STATUS_ACTIVE = 1 STATUS_ACTIVE = 1
STATUS_BANNED = 2 STATUS_BANNED = 2
BINDING_MODE_SINGLE = "single"
BINDING_MODE_MULTIPLE = "multiple"
BINDING_MODE_ALL = "all"
class TokenBinding(Base): class TokenBinding(Base):
__tablename__ = "token_bindings" __tablename__ = "token_bindings"
__table_args__ = ( __table_args__ = (
Index("idx_token_bindings_hash", "token_hash"), 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) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
token_display: Mapped[str] = mapped_column(String(20), 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( status: Mapped[int] = mapped_column(
SmallInteger, SmallInteger,
nullable=False, nullable=False,

View File

@@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from app.config import Settings from app.config import Settings
from app.core.ip_utils import extract_client_ip 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.dependencies import get_alert_service, get_binding_service, get_settings
from app.services.alert_service import AlertService from app.services.alert_service import AlertService
from app.services.binding_service import BindingService from app.services.binding_service import BindingService
@@ -56,7 +56,7 @@ async def reverse_proxy(
alert_service: AlertService = Depends(get_alert_service), alert_service: AlertService = Depends(get_alert_service),
): ):
client_ip = extract_client_ip(request, settings) 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: if token:
binding_result = await binding_service.evaluate_token_binding(token, client_ip) 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, status_code=binding_result.status_code,
content={"detail": binding_result.detail}, content={"detail": binding_result.detail},
) )
logger.debug("Token binding check passed.", extra={"client_ip": client_ip, "token_source": token_source})
else: else:
await binding_service.increment_request_metric("allowed") await binding_service.increment_request_metric("allowed")

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime 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): class BindingItem(BaseModel):
@@ -11,6 +14,8 @@ class BindingItem(BaseModel):
id: int id: int
token_display: str token_display: str
bound_ip: str bound_ip: str
binding_mode: str
allowed_ips: list[str]
status: int status: int
status_label: str status_label: str
first_used_at: datetime first_used_at: datetime
@@ -31,12 +36,32 @@ class BindingActionRequest(BaseModel):
class BindingIPUpdateRequest(BaseModel): class BindingIPUpdateRequest(BaseModel):
id: int = Field(gt=0) 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") @model_validator(mode="after")
@classmethod def validate_binding_rule(self):
def validate_bound_ip(cls, value: str) -> str: allowed_ips = [item.strip() for item in self.allowed_ips if item.strip()]
from ipaddress import ip_network
ip_network(value, strict=False) if self.binding_mode == BINDING_MODE_ALL:
return value 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.")

View File

@@ -5,9 +5,9 @@ from datetime import UTC, datetime, timedelta
from typing import Callable from typing import Callable
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import delete, select from sqlalchemy import delete, select, text
from sqlalchemy.exc import SQLAlchemyError 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.config import RuntimeSettings, Settings
from app.models.token_binding import TokenBinding from app.models.token_binding import TokenBinding
@@ -20,33 +20,45 @@ class ArchiveService:
def __init__( def __init__(
self, self,
settings: Settings, settings: Settings,
engine: AsyncEngine,
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
binding_service: BindingService, binding_service: BindingService,
runtime_settings_getter: Callable[[], RuntimeSettings], runtime_settings_getter: Callable[[], RuntimeSettings],
) -> None: ) -> None:
self.settings = settings self.settings = settings
self.engine = engine
self.session_factory = session_factory self.session_factory = session_factory
self.binding_service = binding_service self.binding_service = binding_service
self.runtime_settings_getter = runtime_settings_getter self.runtime_settings_getter = runtime_settings_getter
self.scheduler = AsyncIOScheduler(timezone="UTC") self.scheduler = AsyncIOScheduler(timezone="UTC")
self._leader_connection: AsyncConnection | None = None
async def start(self) -> None: async def start(self) -> None:
if self.scheduler.running: if self.scheduler.running:
return return
self.scheduler.add_job( if not await self._acquire_leader_lock():
self.archive_inactive_bindings, logger.info("Archive scheduler leader lock not acquired; skipping local scheduler start.")
trigger="interval", return
minutes=self.settings.archive_job_interval_minutes, try:
id="archive-inactive-bindings", self.scheduler.add_job(
replace_existing=True, self.archive_inactive_bindings,
max_instances=1, trigger="interval",
coalesce=True, minutes=self.settings.archive_job_interval_minutes,
) id="archive-inactive-bindings",
self.scheduler.start() 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: async def stop(self) -> None:
if self.scheduler.running: if self.scheduler.running:
self.scheduler.shutdown(wait=False) self.scheduler.shutdown(wait=False)
await self._release_leader_lock()
async def archive_inactive_bindings(self) -> int: async def archive_inactive_bindings(self) -> int:
runtime_settings = self.runtime_settings_getter() runtime_settings = self.runtime_settings_getter()
@@ -82,3 +94,43 @@ class ArchiveService:
if total_archived: if total_archived:
logger.info("Archived inactive bindings.", extra={"count": total_archived}) logger.info("Archived inactive bindings.", extra={"count": total_archived})
return 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()

View File

@@ -5,18 +5,25 @@ import json
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, date, timedelta from datetime import date, timedelta
from typing import Callable from typing import Callable
from redis.asyncio import Redis 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.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import RuntimeSettings, Settings from app.config import RuntimeSettings, Settings
from app.core.ip_utils import is_ip_in_network from app.core.ip_utils import is_ip_in_network
from app.core.security import hash_token, mask_token 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__) logger = logging.getLogger(__name__)
@@ -27,6 +34,8 @@ class BindingRecord:
token_hash: str token_hash: str
token_display: str token_display: str
bound_ip: str bound_ip: str
binding_mode: str
allowed_ips: list[str]
status: int status: int
ip_matched: bool ip_matched: bool
@@ -104,42 +113,101 @@ class BindingService:
def metrics_key(self, target_date: date) -> str: def metrics_key(self, target_date: date) -> str:
return f"sentinel:metrics:{target_date.isoformat()}" 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: async def evaluate_token_binding(self, token: str, client_ip: str) -> BindingCheckResult:
token_hash = hash_token(token, self.settings.sentinel_hmac_secret) token_hash = hash_token(token, self.settings.sentinel_hmac_secret)
token_display = mask_token(token) 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 is not None:
if cache_hit.status == STATUS_BANNED: if cache_hit.ip_matched:
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):
await self._touch_cache(token_hash) await self._touch_cache(token_hash)
self.record_last_used(token_hash) return self.evaluate_existing_record(cache_hit, token_hash, token_display, "Allowed from cache.")
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,
)
if not cache_available: if not cache_available:
logger.warning("Redis is unavailable. Falling back to PostgreSQL for token binding.") 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) return self._handle_backend_failure(token_hash, token_display)
if record is not None: if record is not None:
await self.sync_binding_cache(record.token_hash, record.bound_ip, record.status) await self.sync_binding_cache(record.token_hash, record.bound_ip, record.binding_mode, record.allowed_ips, record.status)
if record.status == STATUS_BANNED: return self.evaluate_existing_record(record, token_hash, token_display, "Allowed from PostgreSQL.")
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,
)
try: try:
created = await self._create_binding(token_hash, token_display, client_ip) 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) return self._handle_backend_failure(token_hash, token_display)
if existing is None: if existing is None:
return self._handle_backend_failure(token_hash, token_display) return self._handle_backend_failure(token_hash, token_display)
await self.sync_binding_cache(existing.token_hash, existing.bound_ip, existing.status) await self.sync_binding_cache(
if existing.status == STATUS_BANNED: existing.token_hash,
return BindingCheckResult( existing.bound_ip,
allowed=False, existing.binding_mode,
status_code=403, existing.allowed_ips,
detail="Token is banned.", existing.status,
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,
) )
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) await self.sync_binding_cache(
return BindingCheckResult( created.token_hash,
allowed=True, created.bound_ip,
status_code=200, created.binding_mode,
detail="First-use bind created.", created.allowed_ips,
token_hash=token_hash, created.status,
token_display=token_display,
bound_ip=created.bound_ip,
newly_bound=True,
) )
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: if self.redis is None:
return 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: try:
await self.redis.set(self.cache_key(token_hash), payload, ex=self.settings.redis_binding_ttl_seconds) await self.redis.set(self.cache_key(token_hash), payload, ex=self.settings.redis_binding_ttl_seconds)
except Exception: except Exception:
@@ -336,7 +366,7 @@ class BindingService:
) )
return series 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: if self.redis is None:
return None, False return None, False
try: try:
@@ -348,14 +378,18 @@ class BindingService:
return None, True return None, True
data = json.loads(raw) 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 ( return (
BindingRecord( BindingRecord(
id=0, id=0,
token_hash=token_hash, token_hash=token_hash,
token_display="", 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"]), status=int(data["status"]),
ip_matched=False, ip_matched=self.is_client_allowed(client_ip, binding_mode, allowed_ips),
), ),
True, True,
) )
@@ -369,69 +403,33 @@ class BindingService:
logger.warning("Failed to extend binding cache TTL.", extra={"token_hash": token_hash}) 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: 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: async with self.session_factory() as session:
result = await session.execute(query, {"token_hash": token_hash, "client_ip": client_ip}) binding = await session.scalar(select(TokenBinding).where(TokenBinding.token_hash == token_hash).limit(1))
row = result.mappings().first() if binding is None:
if row is None:
return None return None
return BindingRecord( return self.to_binding_record(binding, client_ip)
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"]),
)
async def _create_binding(self, token_hash: str, token_display: str, client_ip: str) -> BindingRecord | None: 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: async with self.session_factory() as session:
try: try:
result = await session.execute( binding = TokenBinding(
statement, token_hash=token_hash,
{ token_display=token_display,
"token_hash": token_hash, bound_ip=client_ip,
"token_display": token_display, binding_mode=BINDING_MODE_SINGLE,
"bound_ip": client_ip, allowed_ips=[client_ip],
"status": STATUS_ACTIVE, status=STATUS_ACTIVE,
},
) )
row = result.mappings().first() session.add(binding)
await session.flush()
await session.commit() await session.commit()
except SQLAlchemyError: await session.refresh(binding)
except SQLAlchemyError as exc:
await session.rollback() await session.rollback()
if "duplicate key" in str(exc).lower() or "unique" in str(exc).lower():
return None
raise raise
if row is None: return self.to_binding_record(binding, client_ip)
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,
)
def _handle_backend_failure(self, token_hash: str, token_display: str) -> BindingCheckResult: def _handle_backend_failure(self, token_hash: str, token_display: str) -> BindingCheckResult:
runtime_settings = self.runtime_settings_getter() runtime_settings = self.runtime_settings_getter()

View File

@@ -4,20 +4,22 @@ CREATE TABLE token_bindings (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL UNIQUE, token_hash VARCHAR(64) NOT NULL UNIQUE,
token_display VARCHAR(20) NOT NULL, 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, status SMALLINT NOT NULL DEFAULT 1,
first_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), first_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_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_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 ( CREATE TABLE intercept_logs (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL, token_hash VARCHAR(64) NOT NULL,
token_display VARCHAR(20) NOT NULL, token_display VARCHAR(20) NOT NULL,
bound_ip CIDR NOT NULL, bound_ip TEXT NOT NULL,
attempt_ip INET NOT NULL, attempt_ip INET NOT NULL,
alerted BOOLEAN NOT NULL DEFAULT FALSE, alerted BOOLEAN NOT NULL DEFAULT FALSE,
intercepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() intercepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

View File

@@ -2,34 +2,29 @@ services:
nginx: nginx:
image: nginx:alpine image: nginx:alpine
container_name: sentinel-nginx container_name: sentinel-nginx
network_mode: host
restart: unless-stopped restart: unless-stopped
ports:
- "80:80"
- "443:443"
depends_on: depends_on:
- sentinel-app - sentinel-app
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./frontend/dist:/etc/nginx/html/admin/ui:ro - ./frontend/dist:/etc/nginx/html/admin/ui:ro
networks:
- sentinel-net
sentinel-app: sentinel-app:
build:
context: .
dockerfile: Dockerfile
image: key-ip-sentinel:latest image: key-ip-sentinel:latest
container_name: sentinel-app container_name: sentinel-app
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
volumes:
- ./app:/app/app:ro
depends_on: depends_on:
- redis - redis
- postgres - postgres
networks: networks:
- sentinel-net sentinel-net:
- llm-shared-net ipv4_address: 172.30.0.10
shared_network:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -49,7 +44,7 @@ services:
- sentinel-net - sentinel-net
postgres: postgres:
image: postgres:15 image: postgres:16
container_name: sentinel-postgres container_name: sentinel-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -69,5 +64,8 @@ volumes:
networks: networks:
sentinel-net: sentinel-net:
driver: bridge driver: bridge
llm-shared-net: ipam:
config:
- subnet: 172.30.0.0/24
shared_network:
external: true external: true

1889
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,14 +14,14 @@ let clearAnnouncementTimer
let unsubscribeAnnouncements = () => {} let unsubscribeAnnouncements = () => {}
const navItems = [ const navItems = [
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' }, { label: '总览看板', name: 'dashboard', icon: 'DataAnalysis' },
{ label: 'Bindings', name: 'bindings', icon: 'Connection' }, { label: '绑定管理', name: 'bindings', icon: 'Connection' },
{ label: 'Logs', name: 'logs', icon: 'WarningFilled' }, { label: '拦截日志', name: 'logs', icon: 'WarningFilled' },
{ label: 'Settings', name: 'settings', icon: 'Setting' }, { label: '运行设置', name: 'settings', icon: 'Setting' },
] ]
const hideShell = computed(() => Boolean(route.meta.public)) const hideShell = computed(() => Boolean(route.meta.public))
const currentSection = computed(() => route.meta.kicker || 'Operations') const currentSection = computed(() => route.meta.kicker || '控制台')
function updateClock() { function updateClock() {
clockLabel.value = new Intl.DateTimeFormat(undefined, { clockLabel.value = new Intl.DateTimeFormat(undefined, {
@@ -65,7 +65,7 @@ onBeforeUnmount(() => {
<router-view v-if="hideShell" /> <router-view v-if="hideShell" />
<div v-else class="shell"> <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--mint" />
<div class="shell-glow shell-glow--amber" /> <div class="shell-glow shell-glow--amber" />
@@ -74,8 +74,8 @@ onBeforeUnmount(() => {
<div class="brand-mark">S</div> <div class="brand-mark">S</div>
<div> <div>
<p class="eyebrow">Key-IP Sentinel</p> <p class="eyebrow">Key-IP Sentinel</p>
<h1 class="brand-title">Control Plane</h1> <h1 class="brand-title">安全控制台</h1>
<p class="brand-subtitle">First-use bind enforcement edge</p> <p class="brand-subtitle">API Key 首次使用 IP 绑定网关</p>
</div> </div>
</div> </div>
@@ -93,23 +93,23 @@ onBeforeUnmount(() => {
</nav> </nav>
<div class="sidebar-note"> <div class="sidebar-note">
<p class="eyebrow">Operating mode</p> <p class="eyebrow">当前能力</p>
<h3>Zero-trust token perimeter</h3> <h3>绑定审计告警一体化</h3>
<p class="muted"> <p class="muted">
Every API key is pinned to the first observed client address or CIDR and inspected at the edge. 所有请求先经过边界网关首次调用自动绑定来源地址后续按 IP CIDR 持续校验
</p> </p>
</div> </div>
<div class="rail-grid"> <div class="rail-grid">
<div class="rail-card"> <div class="rail-card">
<span class="rail-label">Surface</span> <span class="rail-label">入口</span>
<strong>Admin UI</strong> <strong>管理后台</strong>
<span class="rail-meta">JWT protected</span> <span class="rail-meta">JWT 鉴权</span>
</div> </div>
<div class="rail-card"> <div class="rail-card">
<span class="rail-label">Proxy</span> <span class="rail-label">网关</span>
<strong>Streaming</strong> <strong>流式代理</strong>
<span class="rail-meta">SSE passthrough</span> <span class="rail-meta">支持 SSE 透传</span>
</div> </div>
</div> </div>
</aside> </aside>
@@ -119,20 +119,20 @@ onBeforeUnmount(() => {
<div class="header-copy"> <div class="header-copy">
<p class="eyebrow">{{ currentSection }}</p> <p class="eyebrow">{{ currentSection }}</p>
<h2 id="page-title" class="page-title">{{ route.meta.title || 'Sentinel' }}</h2> <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>
<div class="header-actions"> <div class="header-actions">
<div class="header-chip-group"> <div class="header-chip-group">
<div class="header-chip"> <div class="header-chip">
<span class="header-chip-label">Mode</span> <span class="header-chip-label">模式</span>
<strong>Secure Proxy</strong> <strong>安全代理</strong>
</div> </div>
<div class="header-chip" aria-live="polite"> <div class="header-chip" aria-live="polite">
<span class="header-chip-label">Updated</span> <span class="header-chip-label">时间</span>
<strong>{{ clockLabel }}</strong> <strong>{{ clockLabel }}</strong>
</div> </div>
</div> </div>
<el-button type="primary" plain @click="logout">Logout</el-button> <el-button type="primary" plain @click="logout">退出登录</el-button>
</div> </div>
</header> </header>
@@ -147,10 +147,10 @@ onBeforeUnmount(() => {
.shell { .shell {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 276px minmax(0, 1fr);
gap: 24px; gap: 18px;
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 18px;
} }
.shell-sidebar, .shell-sidebar,
@@ -162,8 +162,8 @@ onBeforeUnmount(() => {
.shell-sidebar { .shell-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 28px; gap: 20px;
padding: 28px; padding: 22px;
} }
.brand-block { .brand-block {
@@ -175,19 +175,19 @@ onBeforeUnmount(() => {
.brand-mark { .brand-mark {
display: grid; display: grid;
place-items: center; place-items: center;
width: 56px; width: 48px;
height: 56px; height: 48px;
border-radius: 18px; border-radius: 16px;
background: linear-gradient(135deg, rgba(17, 231, 181, 0.95), rgba(21, 132, 214, 0.95)); background: linear-gradient(135deg, #6ea7ff, #86c8ff);
color: #071016; color: #ffffff;
font-size: 1.45rem; font-size: 1.2rem;
font-weight: 800; font-weight: 800;
} }
.brand-title, .brand-title,
.page-title { .page-title {
margin: 0; margin: 0;
font-size: clamp(1.5rem, 2vw, 2.1rem); font-size: clamp(1.2rem, 1.6vw, 1.6rem);
} }
.nav-list { .nav-list {
@@ -199,19 +199,20 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px 16px; padding: 12px 14px;
border-radius: 18px; border-radius: 16px;
color: var(--sentinel-ink-soft); color: var(--sentinel-ink-soft);
text-decoration: none; text-decoration: none;
transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease; transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease;
font-size: 0.95rem;
} }
.nav-link:hover, .nav-link:hover,
.nav-link.is-active { .nav-link.is-active {
color: var(--sentinel-ink); color: var(--sentinel-ink);
background: rgba(7, 176, 147, 0.14); background: rgba(114, 163, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(7, 176, 147, 0.18); box-shadow: inset 0 0 0 1px rgba(114, 163, 255, 0.2);
transform: translateX(4px); transform: translateX(3px);
} }
.nav-icon { .nav-icon {
@@ -220,15 +221,16 @@ onBeforeUnmount(() => {
.sidebar-note { .sidebar-note {
margin-top: auto; margin-top: auto;
padding: 18px; padding: 16px;
border-radius: 22px; border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(10, 26, 35, 0.8)); background: linear-gradient(180deg, rgba(244, 248, 255, 0.98), rgba(235, 243, 255, 0.92));
color: #f3fffd; color: var(--sentinel-ink);
border: 1px solid rgba(122, 164, 255, 0.18);
} }
.sidebar-note h3 { .sidebar-note h3 {
margin: 10px 0; margin: 10px 0;
font-size: 1.15rem; font-size: 1rem;
} }
.shell-main { .shell-main {
@@ -236,7 +238,7 @@ onBeforeUnmount(() => {
z-index: 1; z-index: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 18px;
min-width: 0; min-width: 0;
} }
@@ -244,8 +246,8 @@ onBeforeUnmount(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 16px;
padding: 22px 26px; padding: 18px 20px;
} }
.header-actions { .header-actions {
@@ -257,7 +259,7 @@ onBeforeUnmount(() => {
.shell-content { .shell-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 18px;
min-width: 0; min-width: 0;
} }
@@ -272,19 +274,19 @@ onBeforeUnmount(() => {
} }
.shell-glow--mint { .shell-glow--mint {
top: 80px; top: 60px;
right: 160px; right: 120px;
width: 240px; width: 220px;
height: 240px; height: 220px;
background: rgba(17, 231, 181, 0.22); background: rgba(132, 196, 255, 0.2);
} }
.shell-glow--amber { .shell-glow--amber {
bottom: 100px; bottom: 80px;
left: 420px; left: 360px;
width: 280px; width: 260px;
height: 280px; height: 260px;
background: rgba(255, 170, 76, 0.18); background: rgba(177, 221, 255, 0.18);
} }
@media (max-width: 1080px) { @media (max-width: 1080px) {

View File

@@ -16,7 +16,7 @@ const router = createRouter({
component: Login, component: Login,
meta: { meta: {
public: true, public: true,
title: 'Admin Login', title: '管理员登录',
}, },
}, },
{ {
@@ -28,8 +28,8 @@ const router = createRouter({
name: 'dashboard', name: 'dashboard',
component: Dashboard, component: Dashboard,
meta: { meta: {
title: 'Traffic Pulse', title: '总览看板',
kicker: 'Observability', kicker: '运行概览',
}, },
}, },
{ {
@@ -37,8 +37,8 @@ const router = createRouter({
name: 'bindings', name: 'bindings',
component: Bindings, component: Bindings,
meta: { meta: {
title: 'Token Bindings', title: '绑定管理',
kicker: 'Control', kicker: '绑定控制',
}, },
}, },
{ {
@@ -46,8 +46,8 @@ const router = createRouter({
name: 'logs', name: 'logs',
component: Logs, component: Logs,
meta: { meta: {
title: 'Intercept Logs', title: '拦截日志',
kicker: 'Audit', kicker: '审计追踪',
}, },
}, },
{ {
@@ -55,8 +55,8 @@ const router = createRouter({
name: 'settings', name: 'settings',
component: Settings, component: Settings,
meta: { meta: {
title: 'Runtime Settings', title: '运行设置',
kicker: 'Operations', kicker: '运行配置',
}, },
}, },
], ],

View File

@@ -1,22 +1,22 @@
:root { :root {
--sentinel-bg: #08131c; --sentinel-bg: #eef5ff;
--sentinel-bg-soft: #102734; --sentinel-bg-soft: #dfeefe;
--sentinel-panel: rgba(252, 255, 255, 0.82); --sentinel-panel: rgba(255, 255, 255, 0.9);
--sentinel-panel-strong: rgba(255, 255, 255, 0.9); --sentinel-panel-strong: rgba(255, 255, 255, 0.96);
--sentinel-border: rgba(255, 255, 255, 0.24); --sentinel-border: rgba(113, 157, 226, 0.18);
--sentinel-ink: #09161e; --sentinel-ink: #17324d;
--sentinel-ink-soft: #57717d; --sentinel-ink-soft: #66809c;
--sentinel-accent: #07b093; --sentinel-accent: #4d8ff7;
--sentinel-accent-deep: #0d7e8b; --sentinel-accent-deep: #2d6fd5;
--sentinel-warn: #ef7f41; --sentinel-warn: #f29a44;
--sentinel-danger: #dc4f53; --sentinel-danger: #df5b67;
--sentinel-shadow: 0 30px 80px rgba(2, 12, 18, 0.22); --sentinel-shadow: 0 20px 48px rgba(46, 92, 146, 0.12);
--el-color-primary: #0b9e88; --el-color-primary: #4d8ff7;
--el-color-success: #1aa36f; --el-color-success: #36a980;
--el-color-warning: #ef7f41; --el-color-warning: #f29a44;
--el-color-danger: #dc4f53; --el-color-danger: #df5b67;
color: var(--sentinel-ink); 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; line-height: 1.5;
font-weight: 400; font-weight: 400;
} }
@@ -32,11 +32,11 @@
html { html {
min-height: 100%; min-height: 100%;
color-scheme: dark; color-scheme: light;
background: background:
radial-gradient(circle at top left, rgba(12, 193, 152, 0.22), transparent 34%), radial-gradient(circle at top left, rgba(146, 198, 255, 0.48), transparent 34%),
radial-gradient(circle at top right, rgba(255, 170, 76, 0.18), transparent 30%), radial-gradient(circle at top right, rgba(215, 234, 255, 0.72), transparent 32%),
linear-gradient(180deg, #09131d 0%, #0d1d29 35%, #112d3d 100%); linear-gradient(180deg, #f4f8ff 0%, #edf5ff 40%, #e5f0fd 100%);
} }
body { body {
@@ -62,10 +62,10 @@ body::before {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: background:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), linear-gradient(rgba(77, 143, 247, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); linear-gradient(90deg, rgba(77, 143, 247, 0.05) 1px, transparent 1px);
background-size: 34px 34px; 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; pointer-events: none;
} }
@@ -105,22 +105,22 @@ body::before {
.panel { .panel {
background: var(--sentinel-panel); background: var(--sentinel-panel);
border: 1px solid var(--sentinel-border); border: 1px solid var(--sentinel-border);
border-radius: 28px; border-radius: 24px;
backdrop-filter: blur(18px); backdrop-filter: blur(12px);
box-shadow: var(--sentinel-shadow); box-shadow: var(--sentinel-shadow);
min-width: 0; min-width: 0;
} }
.glass-panel { .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 { .eyebrow {
margin: 0; margin: 0;
color: var(--sentinel-accent-deep); color: var(--sentinel-accent-deep);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.16em; letter-spacing: 0.14em;
font-size: 0.74rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
} }
@@ -130,19 +130,19 @@ body::before {
.page-grid { .page-grid {
display: grid; display: grid;
gap: 24px; gap: 18px;
} }
.hero-panel { .hero-panel {
position: relative; position: relative;
padding: 26px; padding: 20px 22px;
overflow: hidden; overflow: hidden;
} }
.hero-layout { .hero-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr); grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr);
gap: 20px; gap: 16px;
align-items: stretch; align-items: stretch;
} }
@@ -177,7 +177,7 @@ body::before {
right: -40px; right: -40px;
width: 220px; width: 220px;
height: 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; pointer-events: none;
} }
@@ -187,24 +187,24 @@ body::before {
.page-title, .page-title,
.login-stage h1 { .login-stage h1 {
margin: 10px 0 8px; margin: 10px 0 8px;
font-size: 1.4rem; font-size: 1.16rem;
text-wrap: balance; text-wrap: balance;
} }
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px; gap: 12px;
} }
.metric-card { .metric-card {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
padding: 20px; padding: 16px;
} }
.metric-card--enhanced { .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 { .metric-card::before {
@@ -214,20 +214,20 @@ body::before {
width: 140px; width: 140px;
height: 140px; height: 140px;
border-radius: 999px; 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 { .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 { .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 { .metric-value {
margin: 10px 0 0; margin: 10px 0 0;
font-size: clamp(1.8rem, 3vw, 2.5rem); font-size: clamp(1.45rem, 2.3vw, 2rem);
font-weight: 800; font-weight: 800;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -240,7 +240,7 @@ body::before {
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr); grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
gap: 24px; gap: 18px;
} }
.content-grid--balanced { .content-grid--balanced {
@@ -250,7 +250,7 @@ body::before {
.chart-card, .chart-card,
.table-card, .table-card,
.form-card { .form-card {
padding: 24px; padding: 18px;
} }
.chart-surface { .chart-surface {
@@ -294,7 +294,7 @@ body::before {
.toolbar { .toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 10px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
@@ -310,21 +310,21 @@ body::before {
.data-table .el-table { .data-table .el-table {
--el-table-border-color: rgba(9, 22, 30, 0.08); --el-table-border-color: rgba(9, 22, 30, 0.08);
--el-table-header-bg-color: rgba(7, 176, 147, 0.08); --el-table-header-bg-color: rgba(92, 151, 255, 0.1);
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05); --el-table-row-hover-bg-color: rgba(92, 151, 255, 0.05);
border-radius: 18px; border-radius: 16px;
overflow: hidden; overflow: hidden;
} }
.el-button { .el-button {
min-height: 44px; min-height: 38px;
} }
.el-input__wrapper, .el-input__wrapper,
.el-select__wrapper, .el-select__wrapper,
.el-textarea__inner, .el-textarea__inner,
.el-date-editor .el-input__wrapper { .el-date-editor .el-input__wrapper {
min-height: 44px; min-height: 38px;
} }
.soft-grid { .soft-grid {
@@ -333,15 +333,15 @@ body::before {
} }
.support-card { .support-card {
padding: 20px; padding: 16px;
border-radius: 24px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(243, 251, 248, 0.72)); background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(242, 247, 255, 0.82));
border: 1px solid rgba(255, 255, 255, 0.32); border: 1px solid rgba(113, 157, 226, 0.14);
} }
.support-card h4 { .support-card h4 {
margin: 10px 0 8px; margin: 10px 0 8px;
font-size: 1.08rem; font-size: 0.98rem;
} }
.support-card p { .support-card p {
@@ -384,10 +384,10 @@ body::before {
} }
.insight-card { .insight-card {
padding: 18px 20px; padding: 16px 18px;
border-radius: 22px; border-radius: 18px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(12, 24, 33, 0.8)); background: linear-gradient(180deg, rgba(80, 134, 236, 0.95), rgba(70, 123, 224, 0.9));
color: #f2fffd; color: #f7fbff;
} }
.insight-value { .insight-value {
@@ -425,8 +425,8 @@ body::before {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.1fr 0.9fr;
gap: 24px; gap: 18px;
padding: 24px; padding: 18px;
} }
.login-stage, .login-stage,
@@ -436,20 +436,20 @@ body::before {
} }
.login-stage { .login-stage {
padding: 42px; padding: 30px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 28px; gap: 22px;
justify-content: space-between; justify-content: space-between;
color: #f7fffe; color: #eef6ff;
background: background:
radial-gradient(circle at top left, rgba(17, 231, 181, 0.24), transparent 28%), radial-gradient(circle at top left, rgba(255, 255, 255, 0.24), transparent 30%),
linear-gradient(160deg, rgba(8, 24, 34, 0.95), rgba(15, 37, 50, 0.92)); linear-gradient(160deg, rgba(92, 151, 255, 0.98), rgba(109, 176, 255, 0.92));
} }
.login-stage h1 { .login-stage h1 {
margin: 12px 0; margin: 12px 0;
font-size: clamp(2.4rem, 4vw, 4rem); font-size: clamp(2rem, 3vw, 3rem);
line-height: 0.96; line-height: 0.96;
} }
@@ -462,14 +462,14 @@ body::before {
.login-card { .login-card {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 36px; padding: 24px;
} }
.login-card-inner { .login-card-inner {
width: min(100%, 460px); width: min(100%, 460px);
padding: 34px; padding: 28px;
background: var(--sentinel-panel-strong); background: var(--sentinel-panel-strong);
border-radius: 32px; border-radius: 26px;
border: 1px solid var(--sentinel-border); border: 1px solid var(--sentinel-border);
box-shadow: var(--sentinel-shadow); box-shadow: var(--sentinel-shadow);
} }
@@ -480,10 +480,10 @@ body::before {
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 999px; border-radius: 999px;
background: rgba(7, 176, 147, 0.12); background: rgba(92, 151, 255, 0.12);
color: var(--sentinel-accent-deep); color: var(--sentinel-accent-deep);
font-weight: 700; font-weight: 700;
font-size: 0.82rem; font-size: 0.78rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -516,10 +516,10 @@ body::before {
.rail-card { .rail-card {
display: grid; display: grid;
gap: 4px; gap: 4px;
padding: 14px 16px; padding: 12px 14px;
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.44); background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(255, 255, 255, 0.26); border: 1px solid rgba(113, 157, 226, 0.14);
} }
.rail-label, .rail-label,
@@ -554,10 +554,10 @@ body::before {
display: grid; display: grid;
gap: 2px; gap: 2px;
min-width: 140px; min-width: 140px;
padding: 10px 14px; padding: 9px 12px;
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.56); background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.26); border: 1px solid rgba(113, 157, 226, 0.16);
} }
.header-chip strong { .header-chip strong {
@@ -571,16 +571,16 @@ body::before {
} }
.hero-stat { .hero-stat {
padding: 14px 16px; padding: 12px 14px;
border-radius: 20px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 255, 252, 0.64)); background: linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(243, 248, 255, 0.78));
border: 1px solid rgba(255, 255, 255, 0.36); border: 1px solid rgba(113, 157, 226, 0.16);
} }
.hero-stat strong { .hero-stat strong {
display: block; display: block;
margin-top: 6px; margin-top: 6px;
font-size: 1.35rem; font-size: 1.15rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -618,7 +618,7 @@ body::before {
.filter-label { .filter-label {
color: var(--sentinel-ink); color: var(--sentinel-ink);
font-size: 0.82rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
} }
@@ -630,6 +630,184 @@ body::before {
margin-top: 18px; 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 { .form-feedback {
margin: 12px 0 0; margin: 12px 0 0;
color: var(--sentinel-danger); color: var(--sentinel-danger);
@@ -670,6 +848,16 @@ body::before {
.hero-actions { .hero-actions {
justify-content: flex-start; 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) { @media (max-width: 760px) {
@@ -684,9 +872,24 @@ body::before {
.chart-card, .chart-card,
.table-card, .table-card,
.form-card, .form-card,
.hero-panel { .hero-panel,
.binding-workbench {
padding: 18px; 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) { @media (max-width: 560px) {

View File

@@ -1,9 +1,18 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' 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 { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction' import { useAsyncAction } from '../composables/useAsyncAction'
import { import {
@@ -13,9 +22,10 @@ import {
unbindBinding, unbindBinding,
updateBindingIp, updateBindingIp,
} from '../api' } from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters' import { formatCompactNumber, formatDateTime } from '../utils/formatters'
const defaultPageSize = 20 const defaultPageSize = 20
const staleWindowDays = 30
const dialogVisible = ref(false) const dialogVisible = ref(false)
const rows = ref([]) const rows = ref([])
const total = ref(0) const total = ref(0)
@@ -23,7 +33,8 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const form = reactive({ const form = reactive({
id: null, id: null,
bound_ip: '', binding_mode: 'single',
allowed_ips_text: '',
}) })
const filters = reactive({ const filters = reactive({
token_suffix: '', token_suffix: '',
@@ -33,33 +44,20 @@ const filters = reactive({
page_size: defaultPageSize, page_size: defaultPageSize,
}) })
const { loading, run } = useAsyncAction() const { loading, run } = useAsyncAction()
const pageSizeOptions = [20, 50, 100]
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length) const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).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 pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
const visibleProtectedRate = computed(() => { const attentionCount = computed(() => rows.value.filter((item) => item.status === 2 || isDormant(item)).length)
if (!rows.value.length) { const currentWindowLabel = computed(() => {
return 0 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) { function parsePositiveInteger(value, fallbackValue) {
const parsed = Number.parseInt(value, 10) 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() { async function loadBindings() {
await run(async () => { await run(async () => {
const data = await fetchBindings(requestParams()) const data = await fetchBindings(requestParams())
rows.value = data.items rows.value = data.items
total.value = data.total total.value = data.total
}, 'Failed to load bindings.') }, '加载绑定列表失败。')
} }
async function refreshBindings() { async function refreshBindings() {
@@ -167,18 +229,52 @@ async function searchBindings() {
function openEdit(row) { function openEdit(row) {
form.id = row.id 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 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() { async function submitEdit() {
if (!form.bound_ip) { const allowedIps = normalizeAllowedIpText(form.allowed_ips_text)
ElMessage.warning('Provide a CIDR or single IP.') if (form.binding_mode !== 'all' && !allowedIps.length) {
ElMessage.warning('请填写至少一个 IP 或 CIDR。')
return return
} }
try { try {
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.') await run(
ElMessage.success('Binding updated.') () =>
updateBindingIp({
id: form.id,
binding_mode: form.binding_mode,
allowed_ips: allowedIps,
}),
'更新绑定失败。',
)
ElMessage.success('绑定规则已更新。')
dialogVisible.value = false dialogVisible.value = false
await refreshBindings() await refreshBindings()
} catch {} } catch {}
@@ -186,12 +282,12 @@ async function submitEdit() {
async function confirmAction(title, action) { async function confirmAction(title, action) {
try { try {
await ElMessageBox.confirm(title, 'Confirm action', { await ElMessageBox.confirm(title, '确认操作', {
confirmButtonText: 'Confirm', confirmButtonText: '确认',
cancelButtonText: 'Cancel', cancelButtonText: '取消',
type: 'warning', type: 'warning',
}) })
await run(action, 'Operation failed.') await run(action, '操作失败。')
await refreshBindings() await refreshBindings()
} catch (error) { } catch (error) {
if (error === 'cancel') { if (error === 'cancel') {
@@ -205,6 +301,12 @@ async function onPageChange(value) {
await syncBindings() await syncBindings()
} }
async function onPageSizeChange(value) {
filters.page_size = value
filters.page = 1
await syncBindings()
}
watch( watch(
() => route.query, () => route.query,
(query) => { (query) => {
@@ -218,210 +320,274 @@ watch(
<template> <template>
<div class="page-grid"> <div class="page-grid">
<PageHero <PageHero
eyebrow="Binding control" eyebrow="绑定控制"
title="Inspect first-use bindings and intervene without touching proxy workers" title="围绕绑定表格完成查询、核对与处置"
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state." description="按 Token 尾号或绑定地址快速检索,确认最近活跃时间后直接编辑规则、解绑或封禁。"
> >
<template #aside> <template #aside>
<div class="hero-stat-pair"> <div class="hero-stat-pair">
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Visible active share</span> <span class="eyebrow">匹配总数</span>
<strong>{{ formatPercent(visibleProtectedRate) }}</strong> <strong>{{ formatCompactNumber(total) }}</strong>
</div> </div>
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Page volume</span> <span class="eyebrow">待关注</span>
<strong>{{ formatCompactNumber(rows.length) }}</strong> <strong>{{ formatCompactNumber(attentionCount) }}</strong>
</div> </div>
</div> </div>
</template> </template>
<template #actions>
<el-button :icon="RefreshRight" plain @click="refreshBindings">刷新</el-button>
</template>
</PageHero> </PageHero>
<section class="metric-grid"> <section class="binding-workbench panel">
<MetricTile <div class="binding-head">
eyebrow="Visible rows" <div class="binding-head-copy">
:value="formatCompactNumber(rows.length)" <p class="eyebrow">绑定列表</p>
note="Records loaded on the current page." <h3 class="section-title">聚焦表格本身减少干扰信息</h3>
accent="slate" <p class="muted">支持单地址多个 IP 与全部放行三种规则页面只保留高频查询与处置动作</p>
/>
<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>
</div> </div>
<div class="binding-summary-strip" aria-label="Binding summary">
<div class="data-table table-block"> <article class="binding-summary-card">
<el-table :data="rows" v-loading="loading"> <span class="binding-summary-label">当前范围</span>
<el-table-column prop="id" label="ID" width="90" /> <strong>{{ currentWindowLabel }}</strong>
<el-table-column prop="token_display" label="Token" min-width="170" /> <span class="muted"> {{ formatCompactNumber(total) }} </span>
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" /> </article>
<el-table-column label="Status" width="120"> <article class="binding-summary-card">
<template #default="{ row }"> <span class="binding-summary-label">当前页正常</span>
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round> <strong>{{ formatCompactNumber(activeCount) }}</strong>
{{ row.status_label }} <span class="muted">可继续放行</span>
</el-tag> </article>
</template> <article class="binding-summary-card binding-summary-card--warn">
</el-table-column> <span class="binding-summary-label">需要关注</span>
<el-table-column prop="first_used_at" label="First used" min-width="190"> <strong>{{ formatCompactNumber(attentionCount) }}</strong>
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template> <span class="muted">封禁或长期不活跃</span>
</el-table-column> </article>
<el-table-column prop="last_used_at" label="Last used" min-width="190"> <article class="binding-summary-card binding-summary-card--danger">
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template> <span class="binding-summary-label">已封禁</span>
</el-table-column> <strong>{{ formatCompactNumber(bannedCount) }}</strong>
<el-table-column label="Actions" min-width="280" fixed="right"> <span class="muted">已阻断</span>
<template #default="{ row }"> </article>
<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> </div>
</div>
<div class="toolbar pagination-toolbar"> <div class="binding-filter-grid">
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span> <div class="filter-field field-sm">
<el-pagination <label class="filter-label" for="binding-token-suffix">Token 尾号</label>
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">
<el-input <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" autocomplete="off"
name="bound_ip" name="bound_ip"
placeholder="192.168.1.0/24" placeholder="192.168.1.0/24"
@keyup.enter="submitEdit" @keyup.enter="submitEdit"
/> />
</el-form-item> </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例如&#10;192.168.1.10&#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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEdit">Save</el-button> <el-button type="primary" @click="submitEdit">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>

View File

@@ -45,8 +45,8 @@ async function renderChart() {
chart ||= echarts.init(chartElement.value) chart ||= echarts.init(chartElement.value)
chart.setOption({ chart.setOption({
animationDuration: 500, animationDuration: 400,
color: ['#0b9e88', '#ef7f41'], color: ['#4d8ff7', '#f29a44'],
grid: { grid: {
left: 24, left: 24,
right: 24, right: 24,
@@ -57,13 +57,13 @@ async function renderChart() {
legend: { legend: {
top: 0, top: 0,
textStyle: { textStyle: {
color: '#516a75', color: '#5f7893',
fontWeight: 600, fontWeight: 600,
}, },
}, },
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: 'rgba(8, 24, 34, 0.9)', backgroundColor: 'rgba(34, 67, 108, 0.92)',
borderWidth: 0, borderWidth: 0,
textStyle: { textStyle: {
color: '#f7fffe', color: '#f7fffe',
@@ -73,30 +73,30 @@ async function renderChart() {
type: 'category', type: 'category',
boundaryGap: false, boundaryGap: false,
data: dashboard.value.trend.map((item) => item.date.slice(5)), data: dashboard.value.trend.map((item) => item.date.slice(5)),
axisLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.18)' } }, axisLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.16)' } },
axisLabel: { color: '#516a75', fontWeight: 600 }, axisLabel: { color: '#5f7893', fontWeight: 600 },
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
splitLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.08)' } }, splitLine: { lineStyle: { color: 'rgba(39, 74, 110, 0.08)' } },
axisLabel: { color: '#516a75' }, axisLabel: { color: '#5f7893' },
}, },
series: [ series: [
{ {
name: 'Allowed', name: '放行',
type: 'line', type: 'line',
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
areaStyle: { color: 'rgba(11, 158, 136, 0.14)' }, areaStyle: { color: 'rgba(77, 143, 247, 0.14)' },
lineStyle: { width: 3 }, lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.allowed), data: dashboard.value.trend.map((item) => item.allowed),
}, },
{ {
name: 'Intercepted', name: '拦截',
type: 'line', type: 'line',
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
areaStyle: { color: 'rgba(239, 127, 65, 0.12)' }, areaStyle: { color: 'rgba(242, 154, 68, 0.12)' },
lineStyle: { width: 3 }, lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.intercepted), data: dashboard.value.trend.map((item) => item.intercepted),
}, },
@@ -108,7 +108,7 @@ async function loadDashboard() {
await run(async () => { await run(async () => {
dashboard.value = await fetchDashboard() dashboard.value = await fetchDashboard()
await renderChart() await renderChart()
}, 'Failed to load dashboard.') }, '加载看板失败。')
} }
async function refreshDashboard() { async function refreshDashboard() {
@@ -139,51 +139,51 @@ onBeforeUnmount(() => {
<template> <template>
<div class="page-grid"> <div class="page-grid">
<PageHero <PageHero
eyebrow="Traffic pulse" eyebrow="运行概览"
title="Edge decisions and security drift in one pass" title="在一个页面里查看放行、拦截与绑定状态"
description="The dashboard combines live proxy metrics with persisted intercept records so security events remain visible even if Redis rolls over." description="看板汇总今日代理结果、绑定规模和最近拦截记录,便于快速判断系统是否稳定运行。"
> >
<template #aside> <template #aside>
<div class="hero-stat-pair"> <div class="hero-stat-pair">
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Intercept rate</span> <span class="eyebrow">拦截率</span>
<strong>{{ formatPercent(interceptRate) }}</strong> <strong>{{ formatPercent(interceptRate) }}</strong>
</div> </div>
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Active share</span> <span class="eyebrow">活跃占比</span>
<strong>{{ formatPercent(bindingCoverage) }}</strong> <strong>{{ formatPercent(bindingCoverage) }}</strong>
</div> </div>
</div> </div>
</template> </template>
<template #actions> <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> </template>
</PageHero> </PageHero>
<section class="metric-grid"> <section class="metric-grid">
<MetricTile <MetricTile
eyebrow="Today" eyebrow="今日总量"
:value="formatCompactNumber(dashboard.today.total)" :value="formatCompactNumber(dashboard.today.total)"
note="Total edge decisions recorded today." note="今天经过网关处理的请求总数。"
accent="slate" accent="slate"
/> />
<MetricTile <MetricTile
eyebrow="Allowed" eyebrow="放行请求"
:value="formatCompactNumber(dashboard.today.allowed)" :value="formatCompactNumber(dashboard.today.allowed)"
note="Requests that passed binding enforcement." note="通过绑定校验并成功转发的请求。"
accent="mint" accent="slate"
/> />
<MetricTile <MetricTile
eyebrow="Intercepted" eyebrow="拦截请求"
:value="formatCompactNumber(dashboard.today.intercepted)" :value="formatCompactNumber(dashboard.today.intercepted)"
note="Requests blocked for CIDR mismatch or banned keys." note="因 IP 不匹配或 Token 被封禁而拦截。"
accent="amber" accent="amber"
/> />
<MetricTile <MetricTile
eyebrow="Bindings" eyebrow="当前绑定"
:value="formatCompactNumber(dashboard.bindings.active)" :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" accent="slate"
/> />
</section> </section>
@@ -192,24 +192,24 @@ onBeforeUnmount(() => {
<article class="chart-card panel"> <article class="chart-card panel">
<div class="toolbar"> <div class="toolbar">
<div> <div>
<p class="eyebrow">7-day trend</p> <p class="eyebrow">7 日趋势</p>
<h3 class="section-title">Allowed vs intercepted flow</h3> <h3 class="section-title"> 7 天放行与拦截趋势</h3>
</div> </div>
<div class="inline-meta"> <div class="inline-meta">
<el-tag round effect="plain" type="success">30s auto refresh</el-tag> <el-tag round effect="plain" type="primary">30 秒自动刷新</el-tag>
<span class="muted">Redis metrics with PostgreSQL intercept backfill.</span> <span class="muted">结合 Redis 指标与 PostgreSQL 日志统计</span>
</div> </div>
</div> </div>
<div ref="chartElement" class="chart-surface" /> <div ref="chartElement" class="chart-surface" />
<div class="trend-summary"> <div class="trend-summary">
<p class="eyebrow">Trend table</p> <p class="eyebrow">趋势明细</p>
<div class="trend-table-wrap"> <div class="trend-table-wrap">
<table class="trend-table"> <table class="trend-table">
<thead> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">日期</th>
<th scope="col">Allowed</th> <th scope="col">放行</th>
<th scope="col">Intercepted</th> <th scope="col">拦截</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -226,12 +226,12 @@ onBeforeUnmount(() => {
<article class="table-card panel"> <article class="table-card panel">
<div class="table-toolbar-block"> <div class="table-toolbar-block">
<p class="eyebrow">Recent blocks</p> <p class="eyebrow">最新事件</p>
<h3 class="section-title">Latest intercepted requests</h3> <h3 class="section-title">最近拦截记录</h3>
<p class="muted">Operators can triage repeated misuse and verify whether alert escalation has already fired.</p> <p class="muted">用于快速确认异常来源告警状态和是否需要进一步处置</p>
</div> </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"> <div v-else class="table-stack table-stack--spaced">
<article <article
@@ -242,11 +242,11 @@ onBeforeUnmount(() => {
<div class="toolbar"> <div class="toolbar">
<strong>{{ item.token_display }}</strong> <strong>{{ item.token_display }}</strong>
<el-tag :type="item.alerted ? 'danger' : 'warning'" round> <el-tag :type="item.alerted ? 'danger' : 'warning'" round>
{{ item.alerted ? 'Alerted' : 'Pending' }} {{ item.alerted ? '已告警' : '待观察' }}
</el-tag> </el-tag>
</div> </div>
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p> <p class="insight-note">绑定地址{{ item.bound_ip }}</p>
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p> <p class="insight-note">尝试地址{{ item.attempt_ip }}</p>
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p> <p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
</article> </article>
</div> </div>

View File

@@ -13,33 +13,33 @@ const form = reactive({
const { clearError, errorMessage, loading, run } = useAsyncAction() const { clearError, errorMessage, loading, run } = useAsyncAction()
const loginSignals = [ const loginSignals = [
{ {
eyebrow: 'Proxy path', eyebrow: '代理链路',
title: 'Streaming request relay', title: '流式请求透传',
note: 'Headers and body pass through to the downstream API without buffering full model responses.', note: '请求头与响应体直接转发到下游服务,兼容流式返回。',
}, },
{ {
eyebrow: 'Key policy', eyebrow: '绑定策略',
title: 'First-use IP binding', title: '首次使用自动绑定',
note: 'Every bearer token is pinned to a trusted client IP or CIDR on its first successful call.', note: 'Bearer Token 首次成功调用时绑定来源 IP CIDR,后续持续校验。',
}, },
{ {
eyebrow: 'Operator safety', eyebrow: '后台安全',
title: 'JWT + lockout', title: 'JWT 与限流保护',
note: 'Admin login is rate-limited by source IP and issues an 8-hour signed token on success.', note: '管理端登录按来源 IP 限流,成功后签发 8 小时令牌。',
}, },
] ]
async function submit() { async function submit() {
if (!form.password) { if (!form.password) {
ElMessage.warning('Enter the admin password first.') ElMessage.warning('请先输入管理员密码。')
return return
} }
try { try {
clearError() clearError()
const data = await run(() => login(form.password), 'Login failed.') const data = await run(() => login(form.password), '登录失败。')
setAuthToken(data.access_token) setAuthToken(data.access_token)
ElMessage.success('Authentication complete.') ElMessage.success('登录成功。')
await router.push({ name: 'dashboard' }) await router.push({ name: 'dashboard' })
} catch {} } catch {}
} }
@@ -49,11 +49,10 @@ async function submit() {
<div class="login-shell"> <div class="login-shell">
<section class="login-stage panel"> <section class="login-stage panel">
<div class="login-stage-copy"> <div class="login-stage-copy">
<p class="eyebrow">Edge enforcement</p> <p class="eyebrow">边界网关</p>
<h1>Key-IP Sentinel</h1> <h1>Key-IP Sentinel</h1>
<p class="login-copy"> <p class="login-copy">
Lock every model API key to its first trusted origin. Monitor drift, inspect misuse, and react from one 将每个模型 API Key 固定到首次可信来源地址在一个后台里完成绑定查看拦截与处置
hardened control surface.
</p> </p>
</div> </div>
@@ -68,23 +67,23 @@ async function submit() {
<div class="stack"> <div class="stack">
<div class="status-chip status-chip--strong"> <div class="status-chip status-chip--strong">
<el-icon><Lock /></el-icon> <el-icon><Lock /></el-icon>
Zero-trust perimeter 首次使用 IP 绑定
</div> </div>
<div class="status-chip"> <div class="status-chip">
<el-icon><Connection /></el-icon> <el-icon><Connection /></el-icon>
Live downstream relay 下游请求实时透传
</div> </div>
</div> </div>
</section> </section>
<section class="login-card"> <section class="login-card">
<div class="login-card-inner"> <div class="login-card-inner">
<p class="eyebrow">Admin access</p> <p class="eyebrow">管理员入口</p>
<h2 class="section-title">Secure Operator Login</h2> <h2 class="section-title">登录控制台</h2>
<p class="muted">Use the runtime password from your deployment environment to obtain an 8-hour admin token.</p> <p class="muted">使用部署环境中的管理员密码登录系统会签发 8 小时后台访问令牌</p>
<el-form label-position="top" @submit.prevent="submit"> <el-form label-position="top" @submit.prevent="submit">
<el-form-item label="Admin password"> <el-form-item label="管理员密码">
<el-input <el-input
v-model="form.password" v-model="form.password"
:aria-describedby="errorMessage ? 'login-error' : undefined" :aria-describedby="errorMessage ? 'login-error' : undefined"
@@ -93,7 +92,7 @@ async function submit() {
size="large" size="large"
autocomplete="current-password" autocomplete="current-password"
name="admin_password" name="admin_password"
placeholder="Enter deployment password" placeholder="请输入部署密码"
@input="clearError" @input="clearError"
/> />
</el-form-item> </el-form-item>
@@ -101,15 +100,15 @@ async function submit() {
<p v-if="errorMessage" id="login-error" class="form-feedback" role="alert">{{ errorMessage }}</p> <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"> <el-button native-type="submit" type="primary" size="large" :loading="loading" class="w-full">
Enter Control Plane 进入控制台
</el-button> </el-button>
</el-form> </el-form>
<div class="login-divider" /> <div class="login-divider" />
<div class="login-footer-note"> <div class="login-footer-note">
<span class="eyebrow">Security note</span> <span class="eyebrow">安全提示</span>
<p>Failed admin attempts are rate-limited by client IP before a JWT is issued.</p> <p>后台登录失败会按客户端 IP 限流避免暴力尝试</p>
</div> </div>
</div> </div>
</section> </section>
@@ -128,34 +127,35 @@ async function submit() {
.login-signal-grid { .login-signal-grid {
display: grid; display: grid;
gap: 14px; gap: 12px;
} }
.login-signal-card { .login-signal-card {
padding: 18px 20px; padding: 16px 18px;
border-radius: 24px; border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)); background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.18);
} }
.login-signal-card h3 { .login-signal-card h3 {
margin: 10px 0 8px; margin: 8px 0 6px;
font-size: 1.08rem; font-size: 1rem;
} }
.login-signal-card p:last-child { .login-signal-card p:last-child {
margin: 0; margin: 0;
color: rgba(247, 255, 254, 0.78); color: rgba(247, 255, 254, 0.82);
font-size: 0.92rem;
} }
.status-chip--strong { .status-chip--strong {
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.22);
} }
.login-divider { .login-divider {
height: 1px; height: 1px;
margin: 24px 0 18px; margin: 22px 0 16px;
background: linear-gradient(90deg, rgba(9, 22, 30, 0.06), rgba(11, 158, 136, 0.28), rgba(9, 22, 30, 0.06)); 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 { .login-footer-note p {

View File

@@ -2,7 +2,6 @@
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction' import { useAsyncAction } from '../composables/useAsyncAction'
import { exportLogs, fetchLogs } from '../api' 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 alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size) 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 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) { function parsePositiveInteger(value, fallbackValue) {
const parsed = Number.parseInt(value, 10) const parsed = Number.parseInt(value, 10)
@@ -107,7 +93,7 @@ async function loadLogs() {
const data = await fetchLogs(requestParams()) const data = await fetchLogs(requestParams())
rows.value = data.items rows.value = data.items
total.value = data.total total.value = data.total
}, 'Failed to load logs.') }, '加载日志失败。')
} }
async function refreshLogs() { async function refreshLogs() {
@@ -136,7 +122,7 @@ async function handleExport() {
start_time: filters.time_range?.[0] || undefined, start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined, end_time: filters.time_range?.[1] || undefined,
}), }),
'Failed to export logs.', '导出日志失败。',
) )
downloadBlob(blob, 'sentinel-logs.csv') downloadBlob(blob, 'sentinel-logs.csv')
} catch {} } catch {}
@@ -173,61 +159,34 @@ watch(
<template> <template>
<div class="page-grid"> <div class="page-grid">
<PageHero <PageHero
eyebrow="Audit trail" eyebrow="审计追踪"
title="Review blocked requests, escalation state, and repeated misuse patterns" title="查看拦截记录、来源地址和告警状态"
description="Intercept records stay in PostgreSQL even if Redis counters reset, so operators can reconstruct activity across the full retention window." description="所有拦截结果都会落库保存便于按时间、Token 和尝试来源地址进行回溯。"
> >
<template #aside> <template #aside>
<div class="hero-stat-pair"> <div class="hero-stat-pair">
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Alerted on page</span> <span class="eyebrow">已告警</span>
<strong>{{ formatCompactNumber(alertedCount) }}</strong> <strong>{{ formatCompactNumber(alertedCount) }}</strong>
</div> </div>
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Unique IPs</span> <span class="eyebrow">来源地址</span>
<strong>{{ formatCompactNumber(uniqueAttempts) }}</strong> <strong>{{ formatCompactNumber(uniqueAttempts) }}</strong>
</div> </div>
</div> </div>
</template> </template>
<template #actions> <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> </template>
</PageHero> </PageHero>
<section class="metric-grid"> <section class="content-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">
<article class="table-card panel"> <article class="table-card panel">
<div class="toolbar"> <div class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<div class="filter-field field-sm"> <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 <el-input
id="log-token-filter" id="log-token-filter"
v-model="filters.token" v-model="filters.token"
@@ -235,12 +194,12 @@ watch(
autocomplete="off" autocomplete="off"
clearable clearable
name="log_token_filter" name="log_token_filter"
placeholder="Masked token..." placeholder="输入脱敏 Token"
@keyup.enter="searchLogs" @keyup.enter="searchLogs"
/> />
</div> </div>
<div class="filter-field field-sm"> <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 <el-input
id="log-attempt-ip-filter" id="log-attempt-ip-filter"
v-model="filters.attempt_ip" v-model="filters.attempt_ip"
@@ -248,43 +207,43 @@ watch(
autocomplete="off" autocomplete="off"
clearable clearable
name="log_attempt_ip_filter" name="log_attempt_ip_filter"
placeholder="10.0.0.8..." placeholder="例如 10.0.0.8"
@keyup.enter="searchLogs" @keyup.enter="searchLogs"
/> />
</div> </div>
<div class="filter-field field-range"> <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 <el-date-picker
id="log-time-range" id="log-time-range"
v-model="filters.time_range" v-model="filters.time_range"
aria-label="Filter by intercepted time range" aria-label="Filter by intercepted time range"
type="datetimerange" type="datetimerange"
range-separator="to" range-separator=""
start-placeholder="Start Time" start-placeholder="开始时间"
end-placeholder="End Time" end-placeholder="结束时间"
value-format="YYYY-MM-DDTHH:mm:ssZ" value-format="YYYY-MM-DDTHH:mm:ssZ"
/> />
</div> </div>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<el-button @click="resetFilters">Reset Filters</el-button> <el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :loading="loading" @click="searchLogs">Search Logs</el-button> <el-button type="primary" :loading="loading" @click="searchLogs">查询日志</el-button>
</div> </div>
</div> </div>
<div class="data-table table-block"> <div class="data-table table-block">
<el-table :data="rows" v-loading="loading"> <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> <template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" /> <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="bound_ip" label="绑定地址" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" /> <el-table-column prop="attempt_ip" label="尝试地址" min-width="160" />
<el-table-column label="Alerted" width="120"> <el-table-column label="告警状态" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round> <el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }} {{ row.alerted ? '已告警' : '未告警' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -292,7 +251,7 @@ watch(
</div> </div>
<div class="toolbar pagination-toolbar"> <div class="toolbar pagination-toolbar">
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span> <span class="muted"> {{ filters.page }} / {{ pageCount }} </span>
<el-pagination <el-pagination
background background
layout="prev, pager, next" layout="prev, pager, next"
@@ -303,27 +262,6 @@ watch(
/> />
</div> </div>
</article> </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> </section>
</div> </div>
</template> </template>

View File

@@ -21,19 +21,20 @@ const { loading, run } = useAsyncAction()
const { loading: saving, run: runSave } = useAsyncAction() const { loading: saving, run: runSave } = useAsyncAction()
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60)) 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 hasUnsavedChanges = computed(() => Boolean(initialSnapshot.value) && buildSnapshot() !== initialSnapshot.value)
const modeCards = computed(() => [ const modeCards = computed(() => [
{ {
eyebrow: 'Closed mode', eyebrow: 'Closed 模式',
title: 'Protect the perimeter', title: '优先保证安全',
note: 'Reject traffic if Redis and PostgreSQL are both unavailable. Choose this when abuse prevention has priority over service continuity.', note: ' Redis PostgreSQL 都不可用时,直接拒绝请求。适合安全优先的生产环境。',
active: form.failsafe_mode === 'closed', active: form.failsafe_mode === 'closed',
}, },
{ {
eyebrow: 'Open mode', eyebrow: 'Open 模式',
title: 'Preserve business flow', title: '优先保证连续性',
note: 'Allow traffic to continue when the full binding backend is down. Choose this only when continuity requirements outweigh policy enforcement.', note: '当绑定后端不可用时仍允许请求继续转发,仅在业务连续性优先时使用。',
active: form.failsafe_mode === 'open', active: form.failsafe_mode === 'open',
}, },
]) ])
@@ -57,7 +58,7 @@ function confirmDiscardChanges() {
return true return true
} }
return window.confirm('You have unsaved runtime settings. Leave this page and discard them?') return window.confirm('当前设置尚未保存,确定离开并放弃修改吗?')
} }
function handleBeforeUnload(event) { function handleBeforeUnload(event) {
@@ -78,7 +79,7 @@ async function loadSettings() {
form.archive_days = data.archive_days form.archive_days = data.archive_days
form.failsafe_mode = data.failsafe_mode form.failsafe_mode = data.failsafe_mode
syncSnapshot() syncSnapshot()
}, 'Failed to load runtime settings.') }, '加载运行设置失败。')
} }
async function saveSettings() { async function saveSettings() {
@@ -92,10 +93,10 @@ async function saveSettings() {
archive_days: form.archive_days, archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode, failsafe_mode: form.failsafe_mode,
}), }),
'Failed to update runtime settings.', '更新运行设置失败。',
) )
syncSnapshot() syncSnapshot()
ElMessage.success('Runtime settings updated.') ElMessage.success('运行设置已更新。')
} catch {} } catch {}
} }
@@ -114,15 +115,15 @@ onBeforeRouteLeave(() => confirmDiscardChanges())
<template> <template>
<div class="page-grid"> <div class="page-grid">
<PageHero <PageHero
eyebrow="Runtime controls" eyebrow="运行配置"
title="Adjust alerting and retention without redeploying the app" title="在线调整告警、归档与故障处理策略"
description="These values are persisted in Redis and applied live by the proxy, alerting, and archive scheduler services." description="这些配置会写入 Redis 并实时生效,无需重新部署服务。"
> >
<template #aside> <template #aside>
<div class="hero-stat-pair"> <div class="hero-stat-pair">
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Failsafe</span> <span class="eyebrow">故障策略</span>
<strong>{{ form.failsafe_mode }}</strong> <strong>{{ failsafeLabel }}</strong>
</div> </div>
<div class="hero-stat"> <div class="hero-stat">
<span class="eyebrow">Webhook</span> <span class="eyebrow">Webhook</span>
@@ -133,81 +134,81 @@ onBeforeRouteLeave(() => confirmDiscardChanges())
<template #actions> <template #actions>
<el-button type="primary" :disabled="loading || !hasUnsavedChanges" :loading="saving" @click="saveSettings"> <el-button type="primary" :disabled="loading || !hasUnsavedChanges" :loading="saving" @click="saveSettings">
Save Runtime Settings 保存设置
</el-button> </el-button>
</template> </template>
</PageHero> </PageHero>
<section class="metric-grid"> <section class="metric-grid">
<MetricTile <MetricTile
eyebrow="Threshold count" eyebrow="告警阈值"
:value="formatCompactNumber(form.alert_threshold_count)" :value="formatCompactNumber(form.alert_threshold_count)"
note="Intercepts needed before alert escalation fires." note="达到该拦截次数后触发告警。"
accent="amber" accent="amber"
/> />
<MetricTile <MetricTile
eyebrow="Threshold window" eyebrow="统计窗口"
:value="`${formatCompactNumber(thresholdMinutes)}m`" :value="`${formatCompactNumber(thresholdMinutes)}m`"
note="Rolling window used by the Redis alert counter." note="Redis 统计告警次数使用的时间窗口。"
accent="slate" accent="slate"
/> />
<MetricTile <MetricTile
eyebrow="Archive after" eyebrow="归档周期"
:value="`${formatCompactNumber(form.archive_days)}d`" :value="`${formatCompactNumber(form.archive_days)}d`"
note="Bindings older than this are pruned from the active table." note="超过该时间未活跃的绑定将从活动表归档。"
accent="mint" accent="slate"
/> />
<MetricTile <MetricTile
eyebrow="Delivery" eyebrow="通知状态"
:value="webhookState" :value="webhookState"
note="Webhook POST is optional and can be disabled." note="Webhook 可选配置,不影响核心代理功能。"
accent="slate" accent="slate"
/> />
</section> </section>
<section class="content-grid content-grid--balanced"> <section class="content-grid content-grid--balanced">
<article class="form-card panel"> <article class="form-card panel">
<p class="eyebrow">Alert window</p> <p class="eyebrow">告警配置</p>
<h3 class="section-title">Thresholds and Webhook Delivery</h3> <h3 class="section-title">阈值与 Webhook 通知</h3>
<el-form label-position="top" v-loading="loading"> <el-form label-position="top" v-loading="loading">
<el-form-item label="Webhook URL"> <el-form-item label="Webhook 地址">
<el-input <el-input
v-model="form.alert_webhook_url" v-model="form.alert_webhook_url"
autocomplete="off" autocomplete="off"
name="alert_webhook_url" name="alert_webhook_url"
placeholder="https://hooks.example.internal/sentinel..." placeholder="https://hooks.example.internal/sentinel"
/> />
</el-form-item> </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-input-number v-model="form.alert_threshold_count" :min="1" :max="100" />
</el-form-item> </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-slider v-model="form.alert_threshold_seconds" :min="60" :max="3600" :step="30" show-input />
</el-form-item> </el-form-item>
<el-form-item label="Failsafe mode"> <el-form-item label="故障处理模式">
<el-radio-group v-model="form.failsafe_mode"> <el-radio-group v-model="form.failsafe_mode">
<el-radio-button value="closed">Closed</el-radio-button> <el-radio-button value="closed">安全优先</el-radio-button>
<el-radio-button value="open">Open</el-radio-button> <el-radio-button value="open">连续性优先</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
<p class="form-feedback form-feedback--status" role="status"> <p class="form-feedback form-feedback--status" role="status">
{{ hasUnsavedChanges ? 'You have unsaved runtime changes.' : 'Runtime settings are in sync.' }} {{ hasUnsavedChanges ? '当前有未保存的修改。' : '当前运行设置已同步。' }}
</p> </p>
</article> </article>
<aside class="soft-grid"> <aside class="soft-grid">
<article class="form-card panel"> <article class="form-card panel">
<p class="eyebrow">Retention</p> <p class="eyebrow">归档策略</p>
<h3 class="section-title">Archive Stale Bindings</h3> <h3 class="section-title">归档长期不活跃绑定</h3>
<el-form label-position="top" v-loading="loading"> <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-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item> </el-form-item>
</el-form> </el-form>

16
main.py
View File

@@ -1,5 +1,17 @@
def main(): from __future__ import annotations
print("Hello from sentinel!")
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__": if __name__ == "__main__":

View File

@@ -1,4 +1,4 @@
worker_processes auto; worker_processes 8;
events { events {
worker_connections 4096; worker_connections 4096;
@@ -17,26 +17,13 @@ http {
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
upstream sentinel_app { upstream sentinel_app {
server sentinel-app:7000; server 172.30.0.10:7000;
keepalive 128; keepalive 128;
} }
server { server {
listen 80; listen 3000;
server_name _; 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; client_max_body_size 32m;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -64,8 +51,8 @@ http {
proxy_pass http://sentinel_app; proxy_pass http://sentinel_app;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto http;
proxy_set_header Connection ""; proxy_set_header Connection "";
} }
@@ -73,8 +60,8 @@ http {
proxy_pass http://sentinel_app/health; proxy_pass http://sentinel_app/health;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto http;
} }
location / { location / {
@@ -82,8 +69,8 @@ http {
proxy_pass http://sentinel_app; proxy_pass http://sentinel_app;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto http;
proxy_set_header Connection ""; proxy_set_header Connection "";
} }
} }