Compare commits
4 Commits
380a78283e
...
bafae32c76
| Author | SHA1 | Date | |
|---|---|---|---|
| bafae32c76 | |||
| eed1acd454 | |||
| 4348ee799b | |||
| f212b68c2c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ wheels/
|
|||||||
# Node-generated files
|
# Node-generated files
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +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/
|
COPY app/ ./app/
|
||||||
|
|||||||
19
PRD.md
19
PRD.md
@@ -26,11 +26,11 @@
|
|||||||
```
|
```
|
||||||
调用方 (Client)
|
调用方 (Client)
|
||||||
│
|
│
|
||||||
│ HTTPS (443)
|
│ HTTP (80)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ Nginx │
|
│ Nginx │
|
||||||
│ 职责:TLS终止 / 路径路由 / │
|
│ 职责:路径路由 / │
|
||||||
│ 静态文件 / 内网鉴权 / 粗粒度限流 │
|
│ 静态文件 / 内网鉴权 / 粗粒度限流 │
|
||||||
└────────────────┬────────────────────────┘
|
└────────────────┬────────────────────────┘
|
||||||
│ HTTP 内网转发
|
│ HTTP 内网转发
|
||||||
@@ -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,23 +122,22 @@
|
|||||||
在 `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;
|
||||||
}
|
}
|
||||||
# 4. 静态文件(前端 UI)
|
# 3. 静态文件(前端 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;
|
||||||
}
|
}
|
||||||
# 5. 基础限流
|
# 4. 基础限流
|
||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
||||||
# 6. 强制写入真实 IP(防客户端伪造)
|
# 5. 强制写入真实 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; # 覆盖,不信任客户端传入的值
|
||||||
```
|
```
|
||||||
@@ -341,10 +340,8 @@ services:
|
|||||||
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
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
|
||||||
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- sentinel-app
|
- sentinel-app
|
||||||
|
|||||||
257
README.md
257
README.md
@@ -32,6 +32,127 @@ sentinel/
|
|||||||
- PostgreSQL stores authoritative token bindings and intercept logs.
|
- PostgreSQL stores authoritative token bindings and intercept logs.
|
||||||
- Archive retention removes inactive bindings from the active table after `ARCHIVE_DAYS`. A later request from the same token will bind again on first use.
|
- Archive retention removes inactive bindings from the active table after `ARCHIVE_DAYS`. A later request from the same token will bind again on first use.
|
||||||
- `SENTINEL_FAILSAFE_MODE=closed` rejects requests when both Redis and PostgreSQL are unavailable. `open` allows traffic through.
|
- `SENTINEL_FAILSAFE_MODE=closed` rejects requests when both Redis and PostgreSQL are unavailable. `open` allows traffic through.
|
||||||
|
- Binding rules support `single` (single IP or single CIDR), `multiple` (multiple discrete IPs), and `all` (allow all source IPs).
|
||||||
|
|
||||||
|
## Sentinel and New API Relationship
|
||||||
|
|
||||||
|
Sentinel and New API are expected to run as **two separate Docker Compose projects**:
|
||||||
|
|
||||||
|
- The **Sentinel compose** contains `nginx`, `sentinel-app`, `redis`, and `postgres`.
|
||||||
|
- The **New API compose** contains your existing New API service and its own dependencies.
|
||||||
|
- The two stacks communicate through a **shared external Docker network**.
|
||||||
|
|
||||||
|
Traffic flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client / SDK
|
||||||
|
|
|
||||||
|
| request to Sentinel public endpoint
|
||||||
|
v
|
||||||
|
Sentinel nginx -> sentinel-app -> New API service -> model backend
|
||||||
|
|
|
||||||
|
+-> redis / postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
The key point is: **clients should call Sentinel, not call New API directly**, otherwise IP binding will not take effect.
|
||||||
|
|
||||||
|
## Recommended Deployment Topology
|
||||||
|
|
||||||
|
Use one external network name for both compose projects. This repository currently uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shared_network
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Sentinel compose:
|
||||||
|
|
||||||
|
- `sentinel-app` joins `shared_network`
|
||||||
|
- `nginx` exposes the public entrypoint
|
||||||
|
- `DOWNSTREAM_URL` points to the **New API service name on that shared network**
|
||||||
|
|
||||||
|
In the New API compose:
|
||||||
|
|
||||||
|
- The New API container must also join `shared_network`
|
||||||
|
- The New API service name must match what Sentinel uses in `DOWNSTREAM_URL`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
- New API compose service name: `new-api`
|
||||||
|
- New API internal container port: `3000`
|
||||||
|
- Sentinel `.env`: `DOWNSTREAM_URL=http://new-api:3000`
|
||||||
|
|
||||||
|
If your New API service is named differently, change `DOWNSTREAM_URL` accordingly, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOWNSTREAM_URL=http://my-newapi:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common New API Connection Patterns
|
||||||
|
|
||||||
|
In practice, you may run New API in either of these two ways.
|
||||||
|
|
||||||
|
### Pattern A: Production machine, New API in its own compose
|
||||||
|
|
||||||
|
This is the recommended production arrangement.
|
||||||
|
|
||||||
|
New API keeps its own compose project and typically joins:
|
||||||
|
|
||||||
|
- `default`
|
||||||
|
- `shared_network`
|
||||||
|
|
||||||
|
That means New API can continue to use its own internal compose network for its own dependencies, while also exposing its service name to Sentinel through `shared_network`.
|
||||||
|
|
||||||
|
Example New API compose fragment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
new-api:
|
||||||
|
image: your-new-api-image
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- shared_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
shared_network:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
With this setup, Sentinel still uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOWNSTREAM_URL=http://new-api:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern B: Test machine, New API started as a standalone container
|
||||||
|
|
||||||
|
On a test machine, you may not use a second compose project at all. Instead, you can start a standalone New API container with `docker run`, as long as that container also joins `shared_network`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name new-api \
|
||||||
|
--network shared_network \
|
||||||
|
your-new-api-image
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- The container name or reachable hostname must match what Sentinel uses in `DOWNSTREAM_URL`.
|
||||||
|
- If the container is not named `new-api`, then adjust `.env` accordingly.
|
||||||
|
- The port in `DOWNSTREAM_URL` is still the New API container's internal listening port.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOWNSTREAM_URL=http://new-api:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
or, if your standalone container is named differently:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOWNSTREAM_URL=http://new-api-test:3000
|
||||||
|
```
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
@@ -69,6 +190,8 @@ npm run dev
|
|||||||
|
|
||||||
The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`.
|
The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`.
|
||||||
|
|
||||||
|
If you prefer the repository root entrypoint, `uv run main.py` now starts the same FastAPI app on `APP_PORT` (default `7000`).
|
||||||
|
|
||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
- Local Python development uses `uv` via [`pyproject.toml`](/d:/project/sentinel/pyproject.toml).
|
- Local Python development uses `uv` via [`pyproject.toml`](/d:/project/sentinel/pyproject.toml).
|
||||||
@@ -76,14 +199,75 @@ The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`.
|
|||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
### 1. Prepare environment
|
### 1. Create the shared Docker network
|
||||||
|
|
||||||
|
Create the external network once on the Docker host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create shared_network
|
||||||
|
```
|
||||||
|
|
||||||
|
Both compose projects must reference this exact same external network name.
|
||||||
|
|
||||||
|
### 2. Make sure New API joins the shared network
|
||||||
|
|
||||||
|
In the **New API** project, add the external network to the New API service.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
new-api:
|
||||||
|
image: your-new-api-image
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- shared_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
shared_network:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- `new-api` here is the **service name** that Sentinel will resolve on the shared network.
|
||||||
|
- The port in `DOWNSTREAM_URL` must be the **container internal port**, not the host published port.
|
||||||
|
- If New API already listens on `3000` inside the container, use `http://new-api:3000`.
|
||||||
|
- On a production host, New API can keep both `default` and `shared_network` at the same time.
|
||||||
|
- On a test host, you can skip a second compose project and use `docker run`, but the container must still join `shared_network`.
|
||||||
|
|
||||||
|
### 3. Prepare Sentinel environment
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env`.
|
1. Copy `.env.example` to `.env`.
|
||||||
2. Replace `SENTINEL_HMAC_SECRET`, `ADMIN_PASSWORD`, and `ADMIN_JWT_SECRET`.
|
2. Replace `SENTINEL_HMAC_SECRET`, `ADMIN_PASSWORD`, and `ADMIN_JWT_SECRET`.
|
||||||
3. Verify `DOWNSTREAM_URL` points to the internal New API service.
|
3. Verify `DOWNSTREAM_URL` points to the New API **service name on `shared_network`**.
|
||||||
4. Keep `PG_DSN` aligned with the fixed PostgreSQL container password in `docker-compose.yml`, or update both together.
|
4. Keep `PG_DSN` aligned with the fixed PostgreSQL container password in `docker-compose.yml`, or update both together.
|
||||||
|
|
||||||
### 2. Build the frontend bundle
|
Example `.env` for Sentinel:
|
||||||
|
|
||||||
|
```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.24.0.0/16
|
||||||
|
SENTINEL_FAILSAFE_MODE=closed
|
||||||
|
APP_PORT=7000
|
||||||
|
ALERT_WEBHOOK_URL=
|
||||||
|
ALERT_THRESHOLD_COUNT=5
|
||||||
|
ALERT_THRESHOLD_SECONDS=300
|
||||||
|
ARCHIVE_DAYS=90
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `TRUSTED_PROXY_IPS` should match the Docker subnet used by the Sentinel internal network.
|
||||||
|
- If Docker recreates the compose network with a different subnet, update this value.
|
||||||
|
|
||||||
|
### 4. Build the Sentinel frontend bundle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
@@ -94,14 +278,12 @@ cd ..
|
|||||||
|
|
||||||
This produces `frontend/dist`, which Nginx serves at `/admin/ui/`.
|
This produces `frontend/dist`, which Nginx serves at `/admin/ui/`.
|
||||||
|
|
||||||
### 3. Provide TLS assets
|
### 5. Confirm Sentinel compose prerequisites
|
||||||
|
|
||||||
Place certificate files at:
|
- Build the frontend first. If `frontend/dist` is missing, `/admin/ui/` cannot be served by Nginx.
|
||||||
|
- Ensure the external Docker network `shared_network` already exists before starting Sentinel.
|
||||||
|
|
||||||
- `nginx/ssl/server.crt`
|
### 6. Start the Sentinel stack
|
||||||
- `nginx/ssl/server.key`
|
|
||||||
|
|
||||||
### 4. Start the stack
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
@@ -109,10 +291,57 @@ docker compose up --build -d
|
|||||||
|
|
||||||
Services:
|
Services:
|
||||||
|
|
||||||
- `https://<host>/` forwards model API traffic through Sentinel.
|
- `http://<host>/` forwards model API traffic through Sentinel.
|
||||||
- `https://<host>/admin/ui/` serves the admin console.
|
- `http://<host>/admin/ui/` serves the admin console.
|
||||||
- `https://<host>/admin/api/*` serves the admin API.
|
- `http://<host>/admin/api/*` serves the admin API.
|
||||||
- `https://<host>/health` exposes the app health check.
|
- `http://<host>/health` exposes the app health check.
|
||||||
|
|
||||||
|
### 7. Verify cross-compose connectivity
|
||||||
|
|
||||||
|
After both compose stacks are running:
|
||||||
|
|
||||||
|
1. Open `http://<host>:8016/health` and confirm it returns `{"status":"ok"}`.
|
||||||
|
2. Open `http://<host>:8016/admin/ui/` and log in with `ADMIN_PASSWORD`.
|
||||||
|
3. Send a real model API request to Sentinel, not to New API directly.
|
||||||
|
4. Check the `Bindings` page and confirm the token appears with a recorded binding rule.
|
||||||
|
|
||||||
|
Example test request:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://<host>:8016/v1/models \
|
||||||
|
-H "Authorization: Bearer <your_api_key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If your client still points directly to New API, Sentinel will not see the request and no binding will be created.
|
||||||
|
|
||||||
|
## Which Port Should Clients Use?
|
||||||
|
|
||||||
|
With the current example compose in this repository:
|
||||||
|
|
||||||
|
- Sentinel public port: `8016`
|
||||||
|
- New API internal container port: usually `3000`
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- **For testing now**, clients should call `http://<host>:8016/...`
|
||||||
|
- **Sentinel forwards internally** to `http://new-api:3000`
|
||||||
|
|
||||||
|
Do **not** point clients at host port `3000` if that bypasses Sentinel.
|
||||||
|
|
||||||
|
## How To Go Live Without Changing Client Config
|
||||||
|
|
||||||
|
If you want existing clients to stay unchanged, Sentinel must take over the **original external entrypoint** that clients already use.
|
||||||
|
|
||||||
|
Typical cutover strategy:
|
||||||
|
|
||||||
|
1. Keep New API on the shared internal Docker network.
|
||||||
|
2. Stop exposing New API directly to users.
|
||||||
|
3. Expose Sentinel on the old public host/port instead.
|
||||||
|
4. Keep `DOWNSTREAM_URL` pointing to the internal New API service on `shared_network`.
|
||||||
|
|
||||||
|
For example, if users currently call `http://host:3000`, then in production you should eventually expose Sentinel on that old public port and make New API internal-only.
|
||||||
|
|
||||||
|
The current `8016:80` mapping in [`docker-compose.yml`](/d:/project/sentinel/docker-compose.yml) is a **local test mapping**, not the only valid production setup.
|
||||||
|
|
||||||
## Admin API Summary
|
## Admin API Summary
|
||||||
|
|
||||||
@@ -143,5 +372,5 @@ All admin endpoints except `/admin/api/login` require `Authorization: Bearer <jw
|
|||||||
1. `GET /health` returns `{"status":"ok"}`.
|
1. `GET /health` returns `{"status":"ok"}`.
|
||||||
2. A first request with a new bearer token creates a binding in PostgreSQL and Redis.
|
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`.
|
3. A second request from the same IP is allowed and refreshes `last_used_at`.
|
||||||
4. A request from a different IP is rejected with `403` and creates an `intercept_logs` record.
|
4. A request from a different IP is rejected with `403` and creates an `intercept_logs` record, unless the binding rule is `all`.
|
||||||
5. `/admin/api/login` returns a JWT and the frontend can load `/admin/api/dashboard`.
|
5. `/admin/api/login` returns a JWT and the frontend can load `/admin/api/dashboard`.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -62,17 +62,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()
|
|
||||||
if isinstance(value, str):
|
|
||||||
parts = [item.strip() for item in value.split(",")]
|
|
||||||
return tuple(item for item in parts if item)
|
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):
|
||||||
|
|||||||
@@ -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_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
|
||||||
@@ -100,6 +100,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(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -40,6 +41,31 @@ 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:
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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()
|
||||||
raise
|
if "duplicate key" in str(exc).lower() or "unique" in str(exc).lower():
|
||||||
if row is None:
|
|
||||||
return None
|
return None
|
||||||
return BindingRecord(
|
raise
|
||||||
id=int(row["id"]),
|
return self.to_binding_record(binding, client_ip)
|
||||||
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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ services:
|
|||||||
container_name: sentinel-nginx
|
container_name: sentinel-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8016: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:
|
networks:
|
||||||
- sentinel-net
|
- sentinel-net
|
||||||
@@ -29,7 +27,7 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
networks:
|
networks:
|
||||||
- sentinel-net
|
- sentinel-net
|
||||||
- llm-shared-net
|
- shared_network
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -49,7 +47,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 +67,5 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
sentinel-net:
|
sentinel-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
llm-shared-net:
|
shared_network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
1889
frontend/package-lock.json
generated
Normal file
1889
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,14 +14,14 @@ let clearAnnouncementTimer
|
|||||||
let unsubscribeAnnouncements = () => {}
|
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) {
|
||||||
|
|||||||
@@ -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: '运行配置',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,57 +320,61 @@ 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>
|
||||||
/>
|
</div>
|
||||||
<MetricTile
|
<div class="binding-summary-strip" aria-label="Binding summary">
|
||||||
eyebrow="Matching total"
|
<article class="binding-summary-card">
|
||||||
:value="formatCompactNumber(total)"
|
<span class="binding-summary-label">当前范围</span>
|
||||||
note="Bindings matching current filters."
|
<strong>{{ currentWindowLabel }}</strong>
|
||||||
accent="mint"
|
<span class="muted">共 {{ formatCompactNumber(total) }} 条</span>
|
||||||
/>
|
</article>
|
||||||
<MetricTile
|
<article class="binding-summary-card">
|
||||||
eyebrow="Active rows"
|
<span class="binding-summary-label">当前页正常</span>
|
||||||
:value="formatCompactNumber(activeCount)"
|
<strong>{{ formatCompactNumber(activeCount) }}</strong>
|
||||||
note="Active items visible in the current slice."
|
<span class="muted">可继续放行</span>
|
||||||
accent="mint"
|
</article>
|
||||||
/>
|
<article class="binding-summary-card binding-summary-card--warn">
|
||||||
<MetricTile
|
<span class="binding-summary-label">需要关注</span>
|
||||||
eyebrow="Banned rows"
|
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||||
:value="formatCompactNumber(bannedCount)"
|
<span class="muted">封禁或长期不活跃</span>
|
||||||
note="Banned items currently visible in the table."
|
</article>
|
||||||
accent="amber"
|
<article class="binding-summary-card binding-summary-card--danger">
|
||||||
/>
|
<span class="binding-summary-label">已封禁</span>
|
||||||
</section>
|
<strong>{{ formatCompactNumber(bannedCount) }}</strong>
|
||||||
|
<span class="muted">已阻断</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="content-grid content-grid--balanced">
|
<div class="binding-filter-grid">
|
||||||
<article class="table-card panel">
|
|
||||||
<div class="toolbar">
|
|
||||||
<div class="toolbar-left">
|
|
||||||
<div class="filter-field field-sm">
|
<div class="filter-field field-sm">
|
||||||
<label class="filter-label" for="binding-token-suffix">Token Suffix</label>
|
<label class="filter-label" for="binding-token-suffix">Token 尾号</label>
|
||||||
<el-input
|
<el-input
|
||||||
id="binding-token-suffix"
|
id="binding-token-suffix"
|
||||||
v-model="filters.token_suffix"
|
v-model="filters.token_suffix"
|
||||||
@@ -276,12 +382,17 @@ watch(
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
clearable
|
clearable
|
||||||
name="binding_token_suffix"
|
name="binding_token_suffix"
|
||||||
placeholder="Token suffix..."
|
placeholder="输入 Token 尾号"
|
||||||
@keyup.enter="searchBindings"
|
@keyup.enter="searchBindings"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-field field-md">
|
<div class="filter-field field-md">
|
||||||
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label>
|
<label class="filter-label" for="binding-ip-filter">绑定 IP / CIDR</label>
|
||||||
<el-input
|
<el-input
|
||||||
id="binding-ip-filter"
|
id="binding-ip-filter"
|
||||||
v-model="filters.ip"
|
v-model="filters.ip"
|
||||||
@@ -289,75 +400,128 @@ watch(
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
clearable
|
clearable
|
||||||
name="binding_ip_filter"
|
name="binding_ip_filter"
|
||||||
placeholder="192.168.1.0/24..."
|
placeholder="192.168.1.0/24"
|
||||||
@keyup.enter="searchBindings"
|
@keyup.enter="searchBindings"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-field field-status">
|
<div class="filter-field field-status">
|
||||||
<label class="filter-label" for="binding-status-filter">Status</label>
|
<label class="filter-label" for="binding-status-filter">状态</label>
|
||||||
<el-select
|
<el-select
|
||||||
id="binding-status-filter"
|
id="binding-status-filter"
|
||||||
v-model="filters.status"
|
v-model="filters.status"
|
||||||
aria-label="Filter by binding status"
|
aria-label="Filter by binding status"
|
||||||
clearable
|
clearable
|
||||||
placeholder="Status..."
|
placeholder="全部状态"
|
||||||
>
|
>
|
||||||
<el-option label="Active" :value="1" />
|
<el-option label="正常" :value="1" />
|
||||||
<el-option label="Banned" :value="2" />
|
<el-option label="封禁" :value="2" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="toolbar-right">
|
<div class="binding-actions">
|
||||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</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>
|
</div>
|
||||||
|
|
||||||
<div class="data-table table-block">
|
<div class="binding-table-toolbar">
|
||||||
<el-table :data="rows" v-loading="loading">
|
<div class="inline-meta">
|
||||||
<el-table-column prop="id" label="ID" width="90" />
|
<span class="status-chip">
|
||||||
<el-table-column prop="token_display" label="Token" min-width="170" />
|
<el-icon><SwitchButton /></el-icon>
|
||||||
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
|
当前匹配 {{ formatCompactNumber(total) }} 条绑定
|
||||||
<el-table-column label="Status" width="120">
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
|
<div class="binding-token-cell">
|
||||||
{{ row.status_label }}
|
<div class="binding-token-main">
|
||||||
</el-tag>
|
<strong>{{ row.token_display }}</strong>
|
||||||
|
<span class="binding-id">#{{ row.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="muted">首次使用:{{ formatDateTime(row.first_used_at) }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="first_used_at" label="First used" min-width="190">
|
|
||||||
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
|
<el-table-column label="绑定地址" min-width="220">
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="last_used_at" label="Last used" min-width="190">
|
|
||||||
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="Actions" min-width="280" fixed="right">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="toolbar-left">
|
<div class="binding-ip-cell">
|
||||||
<el-button @click="openEdit(row)">Edit CIDR</el-button>
|
<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
|
<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"
|
type="danger"
|
||||||
plain
|
plain
|
||||||
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
@click="confirmAction('确认解绑并允许下次重新首绑吗?', () => 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>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -366,62 +530,64 @@ 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="total, prev, pager, next"
|
||||||
:current-page="filters.page"
|
:current-page="filters.page"
|
||||||
:page-size="filters.page_size"
|
:page-size="filters.page_size"
|
||||||
:total="total"
|
:total="total"
|
||||||
@current-change="onPageChange"
|
@current-change="onPageChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
|
<el-dialog v-model="dialogVisible" title="更新绑定规则" width="520px">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="CIDR or single IP">
|
<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
|
<el-input
|
||||||
v-model="form.bound_ip"
|
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,例如 192.168.1.10 192.168.1.11"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-else
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
title="全部放行后,这个 Token 不再校验来源 IP。仅建议在确有必要的内部场景中使用。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="muted">
|
||||||
|
单地址模式支持单个 IP 或一个 CIDR;多 IP 模式按逐行 IP 精确放行;全部放行表示跳过来源地址校验。
|
||||||
|
</p>
|
||||||
</el-form>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
16
main.py
@@ -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__":
|
||||||
|
|||||||
@@ -24,19 +24,6 @@ http {
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
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;
|
||||||
@@ -65,7 +52,7 @@ http {
|
|||||||
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 $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +61,7 @@ http {
|
|||||||
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 $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -83,7 +70,7 @@ http {
|
|||||||
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 $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user