Compare commits

...

4 Commits

27 changed files with 3339 additions and 844 deletions

3
.gitignore vendored
View File

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

View File

@@ -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/

77
PRD.md
View File

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

257
README.md
View File

@@ -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`.

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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(",")]
return tuple(item for item in parts if item)
if isinstance(value, (list, tuple, set)):
return tuple(str(item).strip() for item in value if str(item).strip())
return (str(value).strip(),)
@property
def trusted_proxy_ips(self) -> tuple[str, ...]:
parts = [item.strip() for item in self.trusted_proxy_ips_raw.split(",")]
return tuple(item for item in parts if item)
@cached_property
def trusted_proxy_networks(self):

View File

@@ -14,7 +14,7 @@ from redis.asyncio import from_url as redis_from_url
from app.api import auth, bindings, dashboard, logs, settings as settings_api
from app.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(

View File

@@ -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:

View File

@@ -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(

View File

@@ -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,

View File

@@ -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.")

View File

@@ -5,18 +5,25 @@ import json
import logging
import time
from dataclasses import dataclass
from datetime import UTC, date, timedelta
from datetime import date, timedelta
from typing import Callable
from redis.asyncio import Redis
from sqlalchemy import func, select, text, update
from sqlalchemy import func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import RuntimeSettings, Settings
from app.core.ip_utils import is_ip_in_network
from app.core.security import hash_token, mask_token
from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding
from app.models.token_binding import (
BINDING_MODE_ALL,
BINDING_MODE_MULTIPLE,
BINDING_MODE_SINGLE,
STATUS_ACTIVE,
STATUS_BANNED,
TokenBinding,
)
logger = logging.getLogger(__name__)
@@ -27,6 +34,8 @@ class BindingRecord:
token_hash: str
token_display: str
bound_ip: str
binding_mode: str
allowed_ips: list[str]
status: int
ip_matched: bool
@@ -104,42 +113,101 @@ class BindingService:
def metrics_key(self, target_date: date) -> str:
return f"sentinel:metrics:{target_date.isoformat()}"
def build_bound_ip_display(self, binding_mode: str, allowed_ips: list[str]) -> str:
if binding_mode == BINDING_MODE_ALL:
return "ALL"
if not allowed_ips:
return "-"
if binding_mode == BINDING_MODE_MULTIPLE:
return ", ".join(allowed_ips)
return allowed_ips[0]
def is_client_allowed(self, client_ip: str, binding_mode: str, allowed_ips: list[str]) -> bool:
if binding_mode == BINDING_MODE_ALL:
return True
return any(is_ip_in_network(client_ip, item) for item in allowed_ips)
def to_binding_record(self, binding: TokenBinding, client_ip: str) -> BindingRecord:
allowed_ips = [str(item) for item in binding.allowed_ips]
binding_mode = binding.binding_mode or BINDING_MODE_SINGLE
return BindingRecord(
id=binding.id,
token_hash=binding.token_hash,
token_display=binding.token_display,
bound_ip=binding.bound_ip,
binding_mode=binding_mode,
allowed_ips=allowed_ips,
status=binding.status,
ip_matched=self.is_client_allowed(client_ip, binding_mode, allowed_ips),
)
def denied_result(
self,
token_hash: str,
token_display: str,
bound_ip: str,
detail: str,
*,
should_alert: bool = True,
status_code: int = 403,
) -> BindingCheckResult:
return BindingCheckResult(
allowed=False,
status_code=status_code,
detail=detail,
token_hash=token_hash,
token_display=token_display,
bound_ip=bound_ip,
should_alert=should_alert,
)
def allowed_result(
self,
token_hash: str,
token_display: str,
bound_ip: str,
detail: str,
*,
newly_bound: bool = False,
) -> BindingCheckResult:
return BindingCheckResult(
allowed=True,
status_code=200,
detail=detail,
token_hash=token_hash,
token_display=token_display,
bound_ip=bound_ip,
newly_bound=newly_bound,
)
def evaluate_existing_record(
self,
record: BindingRecord,
token_hash: str,
token_display: str,
detail: str,
) -> BindingCheckResult:
if record.status == STATUS_BANNED:
return self.denied_result(token_hash, token_display, record.bound_ip, "Token is banned.")
if record.ip_matched:
self.record_last_used(token_hash)
return self.allowed_result(token_hash, token_display, record.bound_ip, detail)
return self.denied_result(
token_hash,
token_display,
record.bound_ip,
"Client IP does not match the allowed binding rule.",
)
async def evaluate_token_binding(self, token: str, client_ip: str) -> BindingCheckResult:
token_hash = hash_token(token, self.settings.sentinel_hmac_secret)
token_display = mask_token(token)
cache_hit, cache_available = await self._load_binding_from_cache(token_hash)
cache_hit, cache_available = await self._load_binding_from_cache(token_hash, client_ip)
if cache_hit is not None:
if cache_hit.status == STATUS_BANNED:
return BindingCheckResult(
allowed=False,
status_code=403,
detail="Token is banned.",
token_hash=token_hash,
token_display=token_display,
bound_ip=cache_hit.bound_ip,
should_alert=True,
)
if is_ip_in_network(client_ip, cache_hit.bound_ip):
if cache_hit.ip_matched:
await self._touch_cache(token_hash)
self.record_last_used(token_hash)
return BindingCheckResult(
allowed=True,
status_code=200,
detail="Allowed from cache.",
token_hash=token_hash,
token_display=token_display,
bound_ip=cache_hit.bound_ip,
)
return BindingCheckResult(
allowed=False,
status_code=403,
detail="Client IP does not match the bound CIDR.",
token_hash=token_hash,
token_display=token_display,
bound_ip=cache_hit.bound_ip,
should_alert=True,
)
return self.evaluate_existing_record(cache_hit, token_hash, token_display, "Allowed from cache.")
if not cache_available:
logger.warning("Redis is unavailable. Falling back to PostgreSQL for token binding.")
@@ -159,36 +227,8 @@ class BindingService:
return self._handle_backend_failure(token_hash, token_display)
if record is not None:
await self.sync_binding_cache(record.token_hash, record.bound_ip, record.status)
if record.status == STATUS_BANNED:
return BindingCheckResult(
allowed=False,
status_code=403,
detail="Token is banned.",
token_hash=token_hash,
token_display=token_display,
bound_ip=record.bound_ip,
should_alert=True,
)
if record.ip_matched:
self.record_last_used(token_hash)
return BindingCheckResult(
allowed=True,
status_code=200,
detail="Allowed from PostgreSQL.",
token_hash=token_hash,
token_display=token_display,
bound_ip=record.bound_ip,
)
return BindingCheckResult(
allowed=False,
status_code=403,
detail="Client IP does not match the bound CIDR.",
token_hash=token_hash,
token_display=token_display,
bound_ip=record.bound_ip,
should_alert=True,
)
await self.sync_binding_cache(record.token_hash, record.bound_ip, record.binding_mode, record.allowed_ips, record.status)
return self.evaluate_existing_record(record, token_hash, token_display, "Allowed from PostgreSQL.")
try:
created = await self._create_binding(token_hash, token_display, client_ip)
@@ -202,52 +242,42 @@ class BindingService:
return self._handle_backend_failure(token_hash, token_display)
if existing is None:
return self._handle_backend_failure(token_hash, token_display)
await self.sync_binding_cache(existing.token_hash, existing.bound_ip, existing.status)
if existing.status == STATUS_BANNED:
return BindingCheckResult(
allowed=False,
status_code=403,
detail="Token is banned.",
token_hash=token_hash,
token_display=token_display,
bound_ip=existing.bound_ip,
should_alert=True,
)
if existing.ip_matched:
self.record_last_used(token_hash)
return BindingCheckResult(
allowed=True,
status_code=200,
detail="Allowed after concurrent bind resolution.",
token_hash=token_hash,
token_display=token_display,
bound_ip=existing.bound_ip,
)
return BindingCheckResult(
allowed=False,
status_code=403,
detail="Client IP does not match the bound CIDR.",
token_hash=token_hash,
token_display=token_display,
bound_ip=existing.bound_ip,
should_alert=True,
await self.sync_binding_cache(
existing.token_hash,
existing.bound_ip,
existing.binding_mode,
existing.allowed_ips,
existing.status,
)
return self.evaluate_existing_record(existing, token_hash, token_display, "Allowed after concurrent bind resolution.")
await self.sync_binding_cache(created.token_hash, created.bound_ip, created.status)
return BindingCheckResult(
allowed=True,
status_code=200,
detail="First-use bind created.",
token_hash=token_hash,
token_display=token_display,
bound_ip=created.bound_ip,
newly_bound=True,
await self.sync_binding_cache(
created.token_hash,
created.bound_ip,
created.binding_mode,
created.allowed_ips,
created.status,
)
return self.allowed_result(token_hash, token_display, created.bound_ip, "First-use bind created.", newly_bound=True)
async def sync_binding_cache(self, token_hash: str, bound_ip: str, status_code: int) -> None:
async def sync_binding_cache(
self,
token_hash: str,
bound_ip: str,
binding_mode: str,
allowed_ips: list[str],
status_code: int,
) -> None:
if self.redis is None:
return
payload = json.dumps({"bound_ip": bound_ip, "status": status_code})
payload = json.dumps(
{
"bound_ip": bound_ip,
"binding_mode": binding_mode,
"allowed_ips": allowed_ips,
"status": status_code,
}
)
try:
await self.redis.set(self.cache_key(token_hash), payload, ex=self.settings.redis_binding_ttl_seconds)
except Exception:
@@ -336,7 +366,7 @@ class BindingService:
)
return series
async def _load_binding_from_cache(self, token_hash: str) -> tuple[BindingRecord | None, bool]:
async def _load_binding_from_cache(self, token_hash: str, client_ip: str) -> tuple[BindingRecord | None, bool]:
if self.redis is None:
return None, False
try:
@@ -348,14 +378,18 @@ class BindingService:
return None, True
data = json.loads(raw)
allowed_ips = [str(item) for item in data.get("allowed_ips", [])]
binding_mode = str(data.get("binding_mode", BINDING_MODE_SINGLE))
return (
BindingRecord(
id=0,
token_hash=token_hash,
token_display="",
bound_ip=data["bound_ip"],
bound_ip=str(data.get("bound_ip", self.build_bound_ip_display(binding_mode, allowed_ips))),
binding_mode=binding_mode,
allowed_ips=allowed_ips,
status=int(data["status"]),
ip_matched=False,
ip_matched=self.is_client_allowed(client_ip, binding_mode, allowed_ips),
),
True,
)
@@ -369,69 +403,33 @@ class BindingService:
logger.warning("Failed to extend binding cache TTL.", extra={"token_hash": token_hash})
async def _load_binding_from_db(self, token_hash: str, client_ip: str) -> BindingRecord | None:
query = text(
"""
SELECT
id,
token_hash,
token_display,
bound_ip::text AS bound_ip,
status,
CAST(:client_ip AS inet) << bound_ip AS ip_matched
FROM token_bindings
WHERE token_hash = :token_hash
LIMIT 1
"""
)
async with self.session_factory() as session:
result = await session.execute(query, {"token_hash": token_hash, "client_ip": client_ip})
row = result.mappings().first()
if row is None:
binding = await session.scalar(select(TokenBinding).where(TokenBinding.token_hash == token_hash).limit(1))
if binding is None:
return None
return BindingRecord(
id=int(row["id"]),
token_hash=str(row["token_hash"]),
token_display=str(row["token_display"]),
bound_ip=str(row["bound_ip"]),
status=int(row["status"]),
ip_matched=bool(row["ip_matched"]),
)
return self.to_binding_record(binding, client_ip)
async def _create_binding(self, token_hash: str, token_display: str, client_ip: str) -> BindingRecord | None:
statement = text(
"""
INSERT INTO token_bindings (token_hash, token_display, bound_ip, status)
VALUES (:token_hash, :token_display, CAST(:bound_ip AS cidr), :status)
ON CONFLICT (token_hash) DO NOTHING
RETURNING id, token_hash, token_display, bound_ip::text AS bound_ip, status
"""
)
async with self.session_factory() as session:
try:
result = await session.execute(
statement,
{
"token_hash": token_hash,
"token_display": token_display,
"bound_ip": client_ip,
"status": STATUS_ACTIVE,
},
binding = TokenBinding(
token_hash=token_hash,
token_display=token_display,
bound_ip=client_ip,
binding_mode=BINDING_MODE_SINGLE,
allowed_ips=[client_ip],
status=STATUS_ACTIVE,
)
row = result.mappings().first()
session.add(binding)
await session.flush()
await session.commit()
except SQLAlchemyError:
await session.refresh(binding)
except SQLAlchemyError as exc:
await session.rollback()
if "duplicate key" in str(exc).lower() or "unique" in str(exc).lower():
return None
raise
if row is None:
return None
return BindingRecord(
id=int(row["id"]),
token_hash=str(row["token_hash"]),
token_display=str(row["token_display"]),
bound_ip=str(row["bound_ip"]),
status=int(row["status"]),
ip_matched=True,
)
return self.to_binding_record(binding, client_ip)
def _handle_backend_failure(self, token_hash: str, token_display: str) -> BindingCheckResult:
runtime_settings = self.runtime_settings_getter()

View File

@@ -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()

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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) {

View File

@@ -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: '运行配置',
},
},
],

View File

@@ -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) {

View File

@@ -1,9 +1,18 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import {
Connection,
CopyDocument,
EditPen,
Lock,
RefreshRight,
Search,
SwitchButton,
Unlock,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction'
import {
@@ -13,9 +22,10 @@ import {
unbindBinding,
updateBindingIp,
} from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
import { formatCompactNumber, formatDateTime } from '../utils/formatters'
const defaultPageSize = 20
const staleWindowDays = 30
const dialogVisible = ref(false)
const rows = ref([])
const total = ref(0)
@@ -23,7 +33,8 @@ const route = useRoute()
const router = useRouter()
const form = reactive({
id: null,
bound_ip: '',
binding_mode: 'single',
allowed_ips_text: '',
})
const filters = reactive({
token_suffix: '',
@@ -33,33 +44,20 @@ const filters = reactive({
page_size: defaultPageSize,
})
const { loading, run } = useAsyncAction()
const pageSizeOptions = [20, 50, 100]
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
const visibleProtectedRate = computed(() => {
if (!rows.value.length) {
return 0
const attentionCount = computed(() => rows.value.filter((item) => item.status === 2 || isDormant(item)).length)
const currentWindowLabel = computed(() => {
if (!total.value) {
return '0-0'
}
return activeCount.value / rows.value.length
const start = (filters.page - 1) * filters.page_size + 1
const end = Math.min(filters.page * filters.page_size, total.value)
return `${start}-${end}`
})
const opsCards = [
{
eyebrow: 'Unbind',
title: 'Reset first-use bind',
note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.',
},
{
eyebrow: 'Edit CIDR',
title: 'Handle endpoint changes',
note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.',
},
{
eyebrow: 'Ban',
title: 'Freeze compromised tokens',
note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.',
},
]
function parsePositiveInteger(value, fallbackValue) {
const parsed = Number.parseInt(value, 10)
@@ -128,12 +126,76 @@ function requestParams() {
}
}
function elapsedDays(value) {
if (!value) {
return Number.POSITIVE_INFINITY
}
return Math.floor((Date.now() - new Date(value).getTime()) / 86400000)
}
function isDormant(row) {
return elapsedDays(row.last_used_at) >= staleWindowDays
}
function formatLastSeen(value) {
if (!value) {
return '暂无记录'
}
const elapsedHours = Math.floor((Date.now() - new Date(value).getTime()) / 3600000)
if (elapsedHours < 1) {
return '1 小时内活跃'
}
if (elapsedHours < 24) {
return `${elapsedHours} 小时前活跃`
}
const days = Math.floor(elapsedHours / 24)
if (days < staleWindowDays) {
return `${days} 天前活跃`
}
return `沉寂 ${days}`
}
function statusTone(row) {
if (row.status === 2) {
return 'danger'
}
return isDormant(row) ? 'warning' : 'success'
}
function statusText(row) {
if (row.status === 2) {
return '已封禁'
}
return isDormant(row) ? '沉寂' : '正常'
}
function rowClassName({ row }) {
if (row.status === 2) {
return 'binding-row--banned'
}
if (isDormant(row)) {
return 'binding-row--dormant'
}
return ''
}
async function copyValue(value, label) {
try {
await navigator.clipboard.writeText(String(value))
ElMessage.success(`${label}已复制。`)
} catch {
ElMessage.error(`复制${label}失败。`)
}
}
async function loadBindings() {
await run(async () => {
const data = await fetchBindings(requestParams())
rows.value = data.items
total.value = data.total
}, 'Failed to load bindings.')
}, '加载绑定列表失败。')
}
async function refreshBindings() {
@@ -167,18 +229,52 @@ async function searchBindings() {
function openEdit(row) {
form.id = row.id
form.bound_ip = row.bound_ip
form.binding_mode = row.binding_mode
form.allowed_ips_text = (row.allowed_ips || []).join('\n')
dialogVisible.value = true
}
function normalizeAllowedIpText(value) {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean)
}
function bindingModeLabel(mode) {
if (mode === 'all') {
return '全部放行'
}
if (mode === 'multiple') {
return '多 IP'
}
return '单地址'
}
function bindingRuleText(row) {
if (row.binding_mode === 'all') {
return '全部 IP 放行'
}
return row.bound_ip
}
async function submitEdit() {
if (!form.bound_ip) {
ElMessage.warning('Provide a CIDR or single IP.')
const allowedIps = normalizeAllowedIpText(form.allowed_ips_text)
if (form.binding_mode !== 'all' && !allowedIps.length) {
ElMessage.warning('请填写至少一个 IP 或 CIDR。')
return
}
try {
await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
ElMessage.success('Binding updated.')
await run(
() =>
updateBindingIp({
id: form.id,
binding_mode: form.binding_mode,
allowed_ips: allowedIps,
}),
'更新绑定失败。',
)
ElMessage.success('绑定规则已更新。')
dialogVisible.value = false
await refreshBindings()
} catch {}
@@ -186,12 +282,12 @@ async function submitEdit() {
async function confirmAction(title, action) {
try {
await ElMessageBox.confirm(title, 'Confirm action', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
await ElMessageBox.confirm(title, '确认操作', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
await run(action, 'Operation failed.')
await run(action, '操作失败。')
await refreshBindings()
} catch (error) {
if (error === 'cancel') {
@@ -205,6 +301,12 @@ async function onPageChange(value) {
await syncBindings()
}
async function onPageSizeChange(value) {
filters.page_size = value
filters.page = 1
await syncBindings()
}
watch(
() => route.query,
(query) => {
@@ -218,210 +320,274 @@ watch(
<template>
<div class="page-grid">
<PageHero
eyebrow="Binding control"
title="Inspect first-use bindings and intervene without touching proxy workers"
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state."
eyebrow="绑定控制"
title="围绕绑定表格完成查询、核对与处置"
description="按 Token 尾号或绑定地址快速检索,确认最近活跃时间后直接编辑规则、解绑或封禁。"
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Visible active share</span>
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
<span class="eyebrow">匹配总数</span>
<strong>{{ formatCompactNumber(total) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Page volume</span>
<strong>{{ formatCompactNumber(rows.length) }}</strong>
<span class="eyebrow">待关注</span>
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button :icon="RefreshRight" plain @click="refreshBindings">刷新</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Visible rows"
:value="formatCompactNumber(rows.length)"
note="Records loaded on the current page."
accent="slate"
/>
<MetricTile
eyebrow="Matching total"
:value="formatCompactNumber(total)"
note="Bindings matching current filters."
accent="mint"
/>
<MetricTile
eyebrow="Active rows"
:value="formatCompactNumber(activeCount)"
note="Active items visible in the current slice."
accent="mint"
/>
<MetricTile
eyebrow="Banned rows"
:value="formatCompactNumber(bannedCount)"
note="Banned items currently visible in the table."
accent="amber"
/>
</section>
<section class="content-grid content-grid--balanced">
<article class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<div class="filter-field field-sm">
<label class="filter-label" for="binding-token-suffix">Token Suffix</label>
<el-input
id="binding-token-suffix"
v-model="filters.token_suffix"
aria-label="Filter by token suffix"
autocomplete="off"
clearable
name="binding_token_suffix"
placeholder="Token suffix..."
@keyup.enter="searchBindings"
/>
</div>
<div class="filter-field field-md">
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label>
<el-input
id="binding-ip-filter"
v-model="filters.ip"
aria-label="Filter by bound CIDR"
autocomplete="off"
clearable
name="binding_ip_filter"
placeholder="192.168.1.0/24..."
@keyup.enter="searchBindings"
/>
</div>
<div class="filter-field field-status">
<label class="filter-label" for="binding-status-filter">Status</label>
<el-select
id="binding-status-filter"
v-model="filters.status"
aria-label="Filter by binding status"
clearable
placeholder="Status..."
>
<el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" />
</el-select>
</div>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset Filters</el-button>
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</el-button>
</div>
<section class="binding-workbench panel">
<div class="binding-head">
<div class="binding-head-copy">
<p class="eyebrow">绑定列表</p>
<h3 class="section-title">聚焦表格本身减少干扰信息</h3>
<p class="muted">支持单地址多个 IP 与全部放行三种规则页面只保留高频查询与处置动作</p>
</div>
<div class="data-table table-block">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
<el-table-column label="Status" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="first_used_at" label="First used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
</el-table-column>
<el-table-column prop="last_used_at" label="Last used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
</el-table-column>
<el-table-column label="Actions" min-width="280" fixed="right">
<template #default="{ row }">
<div class="toolbar-left">
<el-button @click="openEdit(row)">Edit CIDR</el-button>
<el-button
type="danger"
plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
>
Remove Binding
</el-button>
<el-button
v-if="row.status === 1"
type="warning"
plain
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
>
Ban Token
</el-button>
<el-button
v-else
type="success"
plain
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
>
Restore Token
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="binding-summary-strip" aria-label="Binding summary">
<article class="binding-summary-card">
<span class="binding-summary-label">当前范围</span>
<strong>{{ currentWindowLabel }}</strong>
<span class="muted"> {{ formatCompactNumber(total) }} </span>
</article>
<article class="binding-summary-card">
<span class="binding-summary-label">当前页正常</span>
<strong>{{ formatCompactNumber(activeCount) }}</strong>
<span class="muted">可继续放行</span>
</article>
<article class="binding-summary-card binding-summary-card--warn">
<span class="binding-summary-label">需要关注</span>
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
<span class="muted">封禁或长期不活跃</span>
</article>
<article class="binding-summary-card binding-summary-card--danger">
<span class="binding-summary-label">已封禁</span>
<strong>{{ formatCompactNumber(bannedCount) }}</strong>
<span class="muted">已阻断</span>
</article>
</div>
</div>
<div class="toolbar pagination-toolbar">
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
</article>
<aside class="soft-grid">
<article class="support-card">
<p class="eyebrow">Operator guide</p>
<h4>Choose the least disruptive action first</h4>
<p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
</article>
<article class="support-card">
<p class="eyebrow">Quick reference</p>
<ul class="support-list" role="list">
<li v-for="item in opsCards" :key="item.title" class="support-list-item">
<span class="eyebrow">{{ item.eyebrow }}</span>
<strong>{{ item.title }}</strong>
<span class="muted">{{ item.note }}</span>
</li>
</ul>
</article>
<article class="support-card">
<p class="eyebrow">Visible ratio</p>
<div class="support-kpi">
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
<p>Active records across the current page view.</p>
</div>
</article>
</aside>
</section>
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
<el-form label-position="top">
<el-form-item label="CIDR or single IP">
<div class="binding-filter-grid">
<div class="filter-field field-sm">
<label class="filter-label" for="binding-token-suffix">Token 尾号</label>
<el-input
v-model="form.bound_ip"
id="binding-token-suffix"
v-model="filters.token_suffix"
aria-label="Filter by token suffix"
autocomplete="off"
clearable
name="binding_token_suffix"
placeholder="输入 Token 尾号"
@keyup.enter="searchBindings"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="filter-field field-md">
<label class="filter-label" for="binding-ip-filter">绑定 IP / CIDR</label>
<el-input
id="binding-ip-filter"
v-model="filters.ip"
aria-label="Filter by bound CIDR"
autocomplete="off"
clearable
name="binding_ip_filter"
placeholder="192.168.1.0/24"
@keyup.enter="searchBindings"
>
<template #prefix>
<el-icon><Connection /></el-icon>
</template>
</el-input>
</div>
<div class="filter-field field-status">
<label class="filter-label" for="binding-status-filter">状态</label>
<el-select
id="binding-status-filter"
v-model="filters.status"
aria-label="Filter by binding status"
clearable
placeholder="全部状态"
>
<el-option label="正常" :value="1" />
<el-option label="封禁" :value="2" />
</el-select>
</div>
<div class="filter-field field-page-size">
<label class="filter-label" for="binding-page-size">每页条数</label>
<el-select
id="binding-page-size"
v-model="filters.page_size"
aria-label="Bindings page size"
@change="onPageSizeChange"
>
<el-option v-for="size in pageSizeOptions" :key="size" :label="`${size} 条`" :value="size" />
</el-select>
</div>
<div class="binding-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button :icon="RefreshRight" plain :loading="loading" @click="refreshBindings">重新加载</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="searchBindings">应用筛选</el-button>
</div>
</div>
<div class="binding-table-toolbar">
<div class="inline-meta">
<span class="status-chip">
<el-icon><SwitchButton /></el-icon>
当前匹配 {{ formatCompactNumber(total) }} 条绑定
</span>
<span class="binding-table-note">沉寂表示 {{ staleWindowDays }} 天及以上没有请求规则支持单地址 IP 与全部放行</span>
</div>
</div>
<div class="data-table binding-table">
<el-table :data="rows" :row-class-name="rowClassName" v-loading="loading">
<el-table-column label="绑定对象" min-width="220">
<template #default="{ row }">
<div class="binding-token-cell">
<div class="binding-token-main">
<strong>{{ row.token_display }}</strong>
<span class="binding-id">#{{ row.id }}</span>
</div>
<span class="muted">首次使用{{ formatDateTime(row.first_used_at) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="绑定地址" min-width="220">
<template #default="{ row }">
<div class="binding-ip-cell">
<div class="binding-ip-line">
<code>{{ bindingRuleText(row) }}</code>
<el-button text :icon="CopyDocument" @click="copyValue(bindingRuleText(row), '绑定规则')">复制</el-button>
</div>
<span class="muted">{{ bindingModeLabel(row.binding_mode) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="健康状态" min-width="160">
<template #default="{ row }">
<div class="binding-health-cell">
<el-tag :type="statusTone(row)" round effect="dark">
{{ statusText(row) }}
</el-tag>
<span class="muted">{{ row.status === 1 ? '活动绑定' : '禁止放行' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近活动" min-width="210">
<template #default="{ row }">
<div class="binding-activity-cell">
<strong>{{ formatLastSeen(row.last_used_at) }}</strong>
<span class="muted">{{ formatDateTime(row.last_used_at) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="360" fixed="right">
<template #default="{ row }">
<div class="binding-action-row">
<el-button :icon="EditPen" @click="openEdit(row)">编辑规则</el-button>
<el-button
:icon="row.status === 1 ? Lock : Unlock"
:type="row.status === 1 ? 'warning' : 'success'"
plain
@click="
confirmAction(
row.status === 1 ? '确认封禁这个 Token 吗?' : '确认恢复这个 Token 吗?',
() => (row.status === 1 ? banBinding(row.id) : unbanBinding(row.id)),
)
"
>
{{ row.status === 1 ? '封禁' : '恢复' }}
</el-button>
<el-button
:icon="SwitchButton"
type="danger"
plain
@click="confirmAction('确认解绑并允许下次重新首绑吗?', () => unbindBinding(row.id))"
>
解绑
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar pagination-toolbar">
<span class="muted"> {{ filters.page }} / {{ pageCount }} </span>
<el-pagination
background
layout="total, prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
</section>
<el-dialog v-model="dialogVisible" title="更新绑定规则" width="520px">
<el-form label-position="top">
<el-form-item label="规则模式">
<el-radio-group v-model="form.binding_mode" class="binding-mode-group">
<el-radio-button value="single">单地址</el-radio-button>
<el-radio-button value="multiple">多个 IP</el-radio-button>
<el-radio-button value="all">全部放行</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.binding_mode === 'single'" label="IP 或 CIDR">
<el-input
v-model="form.allowed_ips_text"
autocomplete="off"
name="bound_ip"
placeholder="192.168.1.0/24"
@keyup.enter="submitEdit"
/>
</el-form-item>
<el-form-item v-else-if="form.binding_mode === 'multiple'" label="多个 IP">
<el-input
v-model="form.allowed_ips_text"
type="textarea"
:rows="6"
autocomplete="off"
name="allowed_ips"
placeholder="每行一个 IP例如&#10;192.168.1.10&#10;192.168.1.11"
/>
</el-form-item>
<el-alert
v-else
type="warning"
:closable="false"
title="全部放行后,这个 Token 不再校验来源 IP。仅建议在确有必要的内部场景中使用。"
/>
<p class="muted">
单地址模式支持单个 IP 或一个 CIDR IP 模式按逐行 IP 精确放行全部放行表示跳过来源地址校验
</p>
</el-form>
<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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
View File

@@ -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__":

View File

@@ -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 "";
}
}