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