From f212b68c2c45ff6be517f321ef48a96e9f73702e Mon Sep 17 00:00:00 2001 From: chy ky Date: Wed, 4 Mar 2026 13:59:38 +0800 Subject: [PATCH] Fix HTTP deployment flow and redesign bindings workspace --- PRD.md | 77 +++-- README.md | 18 +- app/config.py | 17 +- docker-compose.yml | 10 +- frontend/src/styles.css | 205 ++++++++++++- frontend/src/views/Bindings.vue | 491 ++++++++++++++++++++------------ main.py | 16 +- nginx/nginx.conf | 19 +- 8 files changed, 578 insertions(+), 275 deletions(-) diff --git a/PRD.md b/PRD.md index 17c5cb0..23e07c7 100644 --- a/PRD.md +++ b/PRD.md @@ -24,16 +24,16 @@ ### 2.1 流量链路 ``` -调用方 (Client) - │ - │ HTTPS (443) - ▼ -┌─────────────────────────────────────────┐ -│ Nginx │ -│ 职责:TLS终止 / 路径路由 / │ -│ 静态文件 / 内网鉴权 / 粗粒度限流 │ -└────────────────┬────────────────────────┘ - │ HTTP 内网转发 +调用方 (Client) + │ + │ HTTP (80) + ▼ +┌─────────────────────────────────────────┐ +│ Nginx │ +│ 职责:路径路由 / │ +│ 静态文件 / 内网鉴权 / 粗粒度限流 │ +└────────────────┬────────────────────────┘ + │ HTTP 内网转发 ▼ ┌─────────────────────────────────────────┐ │ Key-IP Sentinel App │ @@ -65,7 +65,7 @@ | 缓存层 | **Redis 7+** | Token-IP 绑定热数据,TTL 7 天 | | 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 | | 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA,打包后由 Nginx 直接托管 | -| 外层网关 | **Nginx** | TLS 终止、路径隔离、静态文件、`limit_req_zone` 限流 | +| 外层网关 | **Nginx** | 路径隔离、静态文件、`limit_req_zone` 限流 | | 部署方式 | **Docker Compose** | 共 4 个容器:nginx / sentinel-app / redis / postgres | *** @@ -122,26 +122,25 @@ 在 `nginx.conf` 中需要实现以下配置: ```nginx -# 1. TLS 终止(HTTPS → HTTP 转发给 sentinel-app) -# 2. 代理路径:/ 全部转发给 sentinel-app:7000 -# 3. 管理后台访问限制 -location /admin/ { - allow 10.0.0.0/8; # 内网 IP 段 - allow 192.168.0.0/16; - deny all; - proxy_pass http://sentinel-app:7000; -} -# 4. 静态文件(前端 UI) -location /admin/ui/ { - root /etc/nginx/html; - try_files $uri $uri/ /admin/ui/index.html; -} -# 5. 基础限流 -limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; -# 6. 强制写入真实 IP(防客户端伪造) -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值 -``` +# 1. 代理路径:/ 全部转发给 sentinel-app:7000 +# 2. 管理后台访问限制 +location /admin/ { + allow 10.0.0.0/8; # 内网 IP 段 + allow 192.168.0.0/16; + deny all; + proxy_pass http://sentinel-app:7000; +} +# 3. 静态文件(前端 UI) +location /admin/ui/ { + root /etc/nginx/html; + try_files $uri $uri/ /admin/ui/index.html; +} +# 4. 基础限流 +limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; +# 5. 强制写入真实 IP(防客户端伪造) +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值 +``` ### 4.2 Sentinel App 反向代理模块 - **受信 IP Header**:只读取 `X-Real-IP`(Nginx 写入的),忽略请求中原始的 `X-Forwarded-For`。 @@ -337,15 +336,13 @@ version: '3.8' services: nginx: - image: nginx:alpine - container_name: sentinel-nginx - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - - ./nginx/ssl:/etc/nginx/ssl:ro - - ./frontend/dist:/etc/nginx/html/admin/ui:ro + image: nginx:alpine + container_name: sentinel-nginx + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./frontend/dist:/etc/nginx/html/admin/ui:ro depends_on: - sentinel-app networks: diff --git a/README.md b/README.md index 7362a7c..479548a 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,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). @@ -94,12 +96,10 @@ cd .. This produces `frontend/dist`, which Nginx serves at `/admin/ui/`. -### 3. Provide TLS assets +### 3. Build prerequisites -Place certificate files at: - -- `nginx/ssl/server.crt` -- `nginx/ssl/server.key` +- Build the frontend first. If `frontend/dist` is missing, `/admin/ui/` cannot be served by Nginx. +- Ensure the external Docker network `llm-shared-net` already exists if `DOWNSTREAM_URL=http://new-api:3000` should resolve across stacks. ### 4. Start the stack @@ -109,10 +109,10 @@ docker compose up --build -d Services: -- `https:///` forwards model API traffic through Sentinel. -- `https:///admin/ui/` serves the admin console. -- `https:///admin/api/*` serves the admin API. -- `https:///health` exposes the app health check. +- `http:///` forwards model API traffic through Sentinel. +- `http:///admin/ui/` serves the admin console. +- `http:///admin/api/*` serves the admin API. +- `http:///health` exposes the app health check. ## Admin API Summary diff --git a/app/config.py b/app/config.py index 5533c54..21f8f8a 100644 --- a/app/config.py +++ b/app/config.py @@ -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): diff --git a/docker-compose.yml b/docker-compose.yml index fc60a4c..79cb3c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 436b0b1..704b46c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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) { diff --git a/frontend/src/views/Bindings.vue b/frontend/src/views/Bindings.vue index 7450e42..656fcc2 100644 --- a/frontend/src/views/Bindings.vue +++ b/frontend/src/views/Bindings.vue @@ -1,9 +1,18 @@