Fix HTTP deployment flow and redesign bindings workspace
This commit is contained in:
77
PRD.md
77
PRD.md
@@ -24,16 +24,16 @@
|
|||||||
### 2.1 流量链路
|
### 2.1 流量链路
|
||||||
|
|
||||||
```
|
```
|
||||||
调用方 (Client)
|
调用方 (Client)
|
||||||
│
|
│
|
||||||
│ HTTPS (443)
|
│ HTTP (80)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ Nginx │
|
│ Nginx │
|
||||||
│ 职责:TLS终止 / 路径路由 / │
|
│ 职责:路径路由 / │
|
||||||
│ 静态文件 / 内网鉴权 / 粗粒度限流 │
|
│ 静态文件 / 内网鉴权 / 粗粒度限流 │
|
||||||
└────────────────┬────────────────────────┘
|
└────────────────┬────────────────────────┘
|
||||||
│ HTTP 内网转发
|
│ HTTP 内网转发
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────┐
|
┌─────────────────────────────────────────┐
|
||||||
│ Key-IP Sentinel App │
|
│ Key-IP Sentinel App │
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
| 缓存层 | **Redis 7+** | Token-IP 绑定热数据,TTL 7 天 |
|
| 缓存层 | **Redis 7+** | Token-IP 绑定热数据,TTL 7 天 |
|
||||||
| 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 |
|
| 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 |
|
||||||
| 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA,打包后由 Nginx 直接托管 |
|
| 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA,打包后由 Nginx 直接托管 |
|
||||||
| 外层网关 | **Nginx** | TLS 终止、路径隔离、静态文件、`limit_req_zone` 限流 |
|
| 外层网关 | **Nginx** | 路径隔离、静态文件、`limit_req_zone` 限流 |
|
||||||
| 部署方式 | **Docker Compose** | 共 4 个容器:nginx / sentinel-app / redis / postgres |
|
| 部署方式 | **Docker Compose** | 共 4 个容器:nginx / sentinel-app / redis / postgres |
|
||||||
|
|
||||||
***
|
***
|
||||||
@@ -122,26 +122,25 @@
|
|||||||
在 `nginx.conf` 中需要实现以下配置:
|
在 `nginx.conf` 中需要实现以下配置:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# 1. TLS 终止(HTTPS → HTTP 转发给 sentinel-app)
|
# 1. 代理路径:/ 全部转发给 sentinel-app:7000
|
||||||
# 2. 代理路径:/ 全部转发给 sentinel-app:7000
|
# 2. 管理后台访问限制
|
||||||
# 3. 管理后台访问限制
|
location /admin/ {
|
||||||
location /admin/ {
|
allow 10.0.0.0/8; # 内网 IP 段
|
||||||
allow 10.0.0.0/8; # 内网 IP 段
|
allow 192.168.0.0/16;
|
||||||
allow 192.168.0.0/16;
|
deny all;
|
||||||
deny all;
|
proxy_pass http://sentinel-app:7000;
|
||||||
proxy_pass http://sentinel-app:7000;
|
}
|
||||||
}
|
# 3. 静态文件(前端 UI)
|
||||||
# 4. 静态文件(前端 UI)
|
location /admin/ui/ {
|
||||||
location /admin/ui/ {
|
root /etc/nginx/html;
|
||||||
root /etc/nginx/html;
|
try_files $uri $uri/ /admin/ui/index.html;
|
||||||
try_files $uri $uri/ /admin/ui/index.html;
|
}
|
||||||
}
|
# 4. 基础限流
|
||||||
# 5. 基础限流
|
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
|
# 5. 强制写入真实 IP(防客户端伪造)
|
||||||
# 6. 强制写入真实 IP(防客户端伪造)
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值
|
||||||
proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值
|
```
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Sentinel App 反向代理模块
|
### 4.2 Sentinel App 反向代理模块
|
||||||
- **受信 IP Header**:只读取 `X-Real-IP`(Nginx 写入的),忽略请求中原始的 `X-Forwarded-For`。
|
- **受信 IP Header**:只读取 `X-Real-IP`(Nginx 写入的),忽略请求中原始的 `X-Forwarded-For`。
|
||||||
@@ -337,15 +336,13 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: sentinel-nginx
|
container_name: sentinel-nginx
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
volumes:
|
||||||
volumes:
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
|
||||||
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- sentinel-app
|
- sentinel-app
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -69,6 +69,8 @@ npm run dev
|
|||||||
|
|
||||||
The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`.
|
The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`.
|
||||||
|
|
||||||
|
If you prefer the repository root entrypoint, `uv run main.py` now starts the same FastAPI app on `APP_PORT` (default `7000`).
|
||||||
|
|
||||||
## Dependency Management
|
## Dependency Management
|
||||||
|
|
||||||
- Local Python development uses `uv` via [`pyproject.toml`](/d:/project/sentinel/pyproject.toml).
|
- Local Python development uses `uv` via [`pyproject.toml`](/d:/project/sentinel/pyproject.toml).
|
||||||
@@ -94,12 +96,10 @@ cd ..
|
|||||||
|
|
||||||
This produces `frontend/dist`, which Nginx serves at `/admin/ui/`.
|
This produces `frontend/dist`, which Nginx serves at `/admin/ui/`.
|
||||||
|
|
||||||
### 3. Provide TLS assets
|
### 3. Build 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 `llm-shared-net` already exists if `DOWNSTREAM_URL=http://new-api:3000` should resolve across stacks.
|
||||||
- `nginx/ssl/server.crt`
|
|
||||||
- `nginx/ssl/server.key`
|
|
||||||
|
|
||||||
### 4. Start the stack
|
### 4. Start the stack
|
||||||
|
|
||||||
@@ -109,10 +109,10 @@ docker compose up --build -d
|
|||||||
|
|
||||||
Services:
|
Services:
|
||||||
|
|
||||||
- `https://<host>/` forwards model API traffic through Sentinel.
|
- `http://<host>/` forwards model API traffic through Sentinel.
|
||||||
- `https://<host>/admin/ui/` serves the admin console.
|
- `http://<host>/admin/ui/` serves the admin console.
|
||||||
- `https://<host>/admin/api/*` serves the admin API.
|
- `http://<host>/admin/api/*` serves the admin API.
|
||||||
- `https://<host>/health` exposes the app health check.
|
- `http://<host>/health` exposes the app health check.
|
||||||
|
|
||||||
## Admin API Summary
|
## Admin API Summary
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
|
|||||||
sentinel_hmac_secret: str = Field(alias="SENTINEL_HMAC_SECRET", min_length=32)
|
sentinel_hmac_secret: str = Field(alias="SENTINEL_HMAC_SECRET", min_length=32)
|
||||||
admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=8)
|
admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=8)
|
||||||
admin_jwt_secret: str = Field(alias="ADMIN_JWT_SECRET", min_length=16)
|
admin_jwt_secret: str = Field(alias="ADMIN_JWT_SECRET", min_length=16)
|
||||||
trusted_proxy_ips: tuple[str, ...] = Field(default_factory=tuple, alias="TRUSTED_PROXY_IPS")
|
trusted_proxy_ips_raw: str = Field(default="", alias="TRUSTED_PROXY_IPS")
|
||||||
sentinel_failsafe_mode: Literal["open", "closed"] = Field(
|
sentinel_failsafe_mode: Literal["open", "closed"] = Field(
|
||||||
default="closed",
|
default="closed",
|
||||||
alias="SENTINEL_FAILSAFE_MODE",
|
alias="SENTINEL_FAILSAFE_MODE",
|
||||||
@@ -62,17 +62,10 @@ class Settings(BaseSettings):
|
|||||||
def normalize_downstream_url(cls, value: str) -> str:
|
def normalize_downstream_url(cls, value: str) -> str:
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
@field_validator("trusted_proxy_ips", mode="before")
|
@property
|
||||||
@classmethod
|
def trusted_proxy_ips(self) -> tuple[str, ...]:
|
||||||
def split_proxy_ips(cls, value: object) -> tuple[str, ...]:
|
parts = [item.strip() for item in self.trusted_proxy_ips_raw.split(",")]
|
||||||
if value is None:
|
return tuple(item for item in parts if item)
|
||||||
return tuple()
|
|
||||||
if isinstance(value, str):
|
|
||||||
parts = [item.strip() for item in value.split(",")]
|
|
||||||
return tuple(item for item in parts if item)
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
return tuple(str(item).strip() for item in value if str(item).strip())
|
|
||||||
return (str(value).strip(),)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def trusted_proxy_networks(self):
|
def trusted_proxy_networks(self):
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ services:
|
|||||||
container_name: sentinel-nginx
|
container_name: sentinel-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8016:80"
|
||||||
- "443:443"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- sentinel-app
|
- sentinel-app
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
|
||||||
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
- ./frontend/dist:/etc/nginx/html/admin/ui:ro
|
||||||
networks:
|
networks:
|
||||||
- sentinel-net
|
- sentinel-net
|
||||||
@@ -29,7 +27,7 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
networks:
|
networks:
|
||||||
- sentinel-net
|
- sentinel-net
|
||||||
- llm-shared-net
|
- shared_network
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -49,7 +47,7 @@ services:
|
|||||||
- sentinel-net
|
- sentinel-net
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:16
|
||||||
container_name: sentinel-postgres
|
container_name: sentinel-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -69,5 +67,5 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
sentinel-net:
|
sentinel-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
llm-shared-net:
|
shared_network:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -630,6 +630,184 @@ body::before {
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.binding-workbench {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-head-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-head-copy p,
|
||||||
|
.binding-head-copy h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(242, 250, 247, 0.58));
|
||||||
|
border: 1px solid rgba(9, 22, 30, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary-card strong {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary-label {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--sentinel-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary-card--warn {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 246, 238, 0.86), rgba(255, 239, 225, 0.74));
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-summary-card--danger {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 240, 241, 0.88), rgba(255, 229, 231, 0.74));
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, 0.9fr) minmax(190px, 1.1fr) minmax(140px, 0.7fr) minmax(120px, 0.5fr) auto;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(9, 29, 41, 0.98), rgba(11, 32, 45, 0.88));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-page-size {
|
||||||
|
width: min(100%, 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-filter-grid .filter-label {
|
||||||
|
color: rgba(247, 255, 254, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-filter-grid .el-input__wrapper,
|
||||||
|
.binding-filter-grid .el-select__wrapper {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-table-note {
|
||||||
|
color: var(--sentinel-ink-soft);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-table .el-table {
|
||||||
|
--el-table-border-color: rgba(9, 22, 30, 0.08);
|
||||||
|
--el-table-header-bg-color: rgba(8, 31, 45, 0.95);
|
||||||
|
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05);
|
||||||
|
--el-table-header-text-color: rgba(247, 255, 254, 0.86);
|
||||||
|
border-radius: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-table .el-table th.el-table__cell {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-table .el-table td.el-table__cell {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-row--banned {
|
||||||
|
--el-table-tr-bg-color: rgba(220, 79, 83, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-row--dormant {
|
||||||
|
--el-table-tr-bg-color: rgba(239, 127, 65, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-token-cell,
|
||||||
|
.binding-health-cell,
|
||||||
|
.binding-activity-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-token-main,
|
||||||
|
.binding-ip-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-id {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(9, 22, 30, 0.06);
|
||||||
|
color: var(--sentinel-ink-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-ip-cell code {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(9, 22, 30, 0.07);
|
||||||
|
color: #08202d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-action-row .el-button {
|
||||||
|
min-width: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-feedback {
|
.form-feedback {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
color: var(--sentinel-danger);
|
color: var(--sentinel-danger);
|
||||||
@@ -670,6 +848,16 @@ body::before {
|
|||||||
.hero-actions {
|
.hero-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.binding-summary-strip,
|
||||||
|
.binding-filter-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
@@ -684,9 +872,24 @@ body::before {
|
|||||||
.chart-card,
|
.chart-card,
|
||||||
.table-card,
|
.table-card,
|
||||||
.form-card,
|
.form-card,
|
||||||
.hero-panel {
|
.hero-panel,
|
||||||
|
.binding-workbench {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.binding-summary-strip,
|
||||||
|
.binding-filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-table-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binding-ip-line,
|
||||||
|
.binding-action-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
CopyDocument,
|
||||||
|
EditPen,
|
||||||
|
Lock,
|
||||||
|
RefreshRight,
|
||||||
|
Search,
|
||||||
|
SwitchButton,
|
||||||
|
Unlock,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import MetricTile from '../components/MetricTile.vue'
|
|
||||||
import PageHero from '../components/PageHero.vue'
|
import PageHero from '../components/PageHero.vue'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction'
|
import { useAsyncAction } from '../composables/useAsyncAction'
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +22,10 @@ import {
|
|||||||
unbindBinding,
|
unbindBinding,
|
||||||
updateBindingIp,
|
updateBindingIp,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
import { formatCompactNumber, formatDateTime } from '../utils/formatters'
|
||||||
|
|
||||||
const defaultPageSize = 20
|
const defaultPageSize = 20
|
||||||
|
const staleWindowDays = 30
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const rows = ref([])
|
const rows = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -33,33 +43,20 @@ const filters = reactive({
|
|||||||
page_size: defaultPageSize,
|
page_size: defaultPageSize,
|
||||||
})
|
})
|
||||||
const { loading, run } = useAsyncAction()
|
const { loading, run } = useAsyncAction()
|
||||||
|
const pageSizeOptions = [20, 50, 100]
|
||||||
|
|
||||||
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
|
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
|
||||||
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
|
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
||||||
const visibleProtectedRate = computed(() => {
|
const attentionCount = computed(() => rows.value.filter((item) => item.status === 2 || isDormant(item)).length)
|
||||||
if (!rows.value.length) {
|
const currentWindowLabel = computed(() => {
|
||||||
return 0
|
if (!total.value) {
|
||||||
|
return '0-0'
|
||||||
}
|
}
|
||||||
return activeCount.value / rows.value.length
|
const start = (filters.page - 1) * filters.page_size + 1
|
||||||
|
const end = Math.min(filters.page * filters.page_size, total.value)
|
||||||
|
return `${start}-${end}`
|
||||||
})
|
})
|
||||||
const opsCards = [
|
|
||||||
{
|
|
||||||
eyebrow: 'Unbind',
|
|
||||||
title: 'Reset first-use bind',
|
|
||||||
note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eyebrow: 'Edit CIDR',
|
|
||||||
title: 'Handle endpoint changes',
|
|
||||||
note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eyebrow: 'Ban',
|
|
||||||
title: 'Freeze compromised tokens',
|
|
||||||
note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function parsePositiveInteger(value, fallbackValue) {
|
function parsePositiveInteger(value, fallbackValue) {
|
||||||
const parsed = Number.parseInt(value, 10)
|
const parsed = Number.parseInt(value, 10)
|
||||||
@@ -128,6 +125,80 @@ 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 'No activity'
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedHours = Math.floor((Date.now() - new Date(value).getTime()) / 3600000)
|
||||||
|
if (elapsedHours < 1) {
|
||||||
|
return 'Active within 1h'
|
||||||
|
}
|
||||||
|
if (elapsedHours < 24) {
|
||||||
|
return `Active ${elapsedHours}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(elapsedHours / 24)
|
||||||
|
if (days < staleWindowDays) {
|
||||||
|
return `Active ${days}d ago`
|
||||||
|
}
|
||||||
|
return `Dormant ${days}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(row) {
|
||||||
|
if (row.status === 2) {
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
return isDormant(row) ? 'warning' : 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(row) {
|
||||||
|
if (row.status === 2) {
|
||||||
|
return 'Banned'
|
||||||
|
}
|
||||||
|
return isDormant(row) ? 'Dormant' : 'Healthy'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipTypeLabel(boundIp) {
|
||||||
|
if (!boundIp) {
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
if (!boundIp.includes('/')) {
|
||||||
|
return 'Single IP'
|
||||||
|
}
|
||||||
|
return boundIp.endsWith('/32') || boundIp.endsWith('/128') ? 'Single IP' : 'CIDR'
|
||||||
|
}
|
||||||
|
|
||||||
|
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} copied.`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(`Failed to copy ${label.toLowerCase()}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBindings() {
|
async function loadBindings() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const data = await fetchBindings(requestParams())
|
const data = await fetchBindings(requestParams())
|
||||||
@@ -205,6 +276,12 @@ async function onPageChange(value) {
|
|||||||
await syncBindings()
|
await syncBindings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onPageSizeChange(value) {
|
||||||
|
filters.page_size = value
|
||||||
|
filters.page = 1
|
||||||
|
await syncBindings()
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
(query) => {
|
(query) => {
|
||||||
@@ -219,191 +296,227 @@ watch(
|
|||||||
<div class="page-grid">
|
<div class="page-grid">
|
||||||
<PageHero
|
<PageHero
|
||||||
eyebrow="Binding control"
|
eyebrow="Binding control"
|
||||||
title="Inspect first-use bindings and intervene without touching proxy workers"
|
title="Operate token-to-IP bindings from one dense, searchable workbench"
|
||||||
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state."
|
description="Search quickly, verify the last seen address, then edit CIDRs or remove stale registrations without hunting through secondary cards."
|
||||||
>
|
>
|
||||||
<template #aside>
|
<template #aside>
|
||||||
<div class="hero-stat-pair">
|
<div class="hero-stat-pair">
|
||||||
<div class="hero-stat">
|
<div class="hero-stat">
|
||||||
<span class="eyebrow">Visible active share</span>
|
<span class="eyebrow">Matching total</span>
|
||||||
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
<strong>{{ formatCompactNumber(total) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-stat">
|
<div class="hero-stat">
|
||||||
<span class="eyebrow">Page volume</span>
|
<span class="eyebrow">Needs review</span>
|
||||||
<strong>{{ formatCompactNumber(rows.length) }}</strong>
|
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<el-button :icon="RefreshRight" plain @click="refreshBindings">Refresh</el-button>
|
||||||
|
</template>
|
||||||
</PageHero>
|
</PageHero>
|
||||||
|
|
||||||
<section class="metric-grid">
|
<section class="binding-workbench panel">
|
||||||
<MetricTile
|
<div class="binding-head">
|
||||||
eyebrow="Visible rows"
|
<div class="binding-head-copy">
|
||||||
:value="formatCompactNumber(rows.length)"
|
<p class="eyebrow">Binding registry</p>
|
||||||
note="Records loaded on the current page."
|
<h3 class="section-title">Search, compare, and intervene fast</h3>
|
||||||
accent="slate"
|
<p class="muted">
|
||||||
/>
|
Focus stays on the table: search by token suffix or IP, inspect last activity, then change CIDR, unbind, or ban in place.
|
||||||
<MetricTile
|
</p>
|
||||||
eyebrow="Matching total"
|
</div>
|
||||||
:value="formatCompactNumber(total)"
|
<div class="binding-summary-strip" aria-label="Binding summary">
|
||||||
note="Bindings matching current filters."
|
<article class="binding-summary-card">
|
||||||
accent="mint"
|
<span class="binding-summary-label">Visible window</span>
|
||||||
/>
|
<strong>{{ currentWindowLabel }}</strong>
|
||||||
<MetricTile
|
<span class="muted">of {{ formatCompactNumber(total) }}</span>
|
||||||
eyebrow="Active rows"
|
</article>
|
||||||
:value="formatCompactNumber(activeCount)"
|
<article class="binding-summary-card">
|
||||||
note="Active items visible in the current slice."
|
<span class="binding-summary-label">Active on page</span>
|
||||||
accent="mint"
|
<strong>{{ formatCompactNumber(activeCount) }}</strong>
|
||||||
/>
|
<span class="muted">healthy rows</span>
|
||||||
<MetricTile
|
</article>
|
||||||
eyebrow="Banned rows"
|
<article class="binding-summary-card binding-summary-card--warn">
|
||||||
:value="formatCompactNumber(bannedCount)"
|
<span class="binding-summary-label">Attention</span>
|
||||||
note="Banned items currently visible in the table."
|
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||||
accent="amber"
|
<span class="muted">banned or dormant</span>
|
||||||
/>
|
</article>
|
||||||
</section>
|
<article class="binding-summary-card binding-summary-card--danger">
|
||||||
|
<span class="binding-summary-label">Banned</span>
|
||||||
|
<strong>{{ formatCompactNumber(bannedCount) }}</strong>
|
||||||
|
<span class="muted">blocked rows</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="content-grid content-grid--balanced">
|
<div class="binding-filter-grid">
|
||||||
<article class="table-card panel">
|
<div class="filter-field field-sm">
|
||||||
<div class="toolbar">
|
<label class="filter-label" for="binding-token-suffix">Token suffix</label>
|
||||||
<div class="toolbar-left">
|
<el-input
|
||||||
<div class="filter-field field-sm">
|
id="binding-token-suffix"
|
||||||
<label class="filter-label" for="binding-token-suffix">Token Suffix</label>
|
v-model="filters.token_suffix"
|
||||||
<el-input
|
aria-label="Filter by token suffix"
|
||||||
id="binding-token-suffix"
|
autocomplete="off"
|
||||||
v-model="filters.token_suffix"
|
clearable
|
||||||
aria-label="Filter by token suffix"
|
name="binding_token_suffix"
|
||||||
autocomplete="off"
|
placeholder="sk...tail"
|
||||||
clearable
|
@keyup.enter="searchBindings"
|
||||||
name="binding_token_suffix"
|
>
|
||||||
placeholder="Token suffix..."
|
<template #prefix>
|
||||||
@keyup.enter="searchBindings"
|
<el-icon><Search /></el-icon>
|
||||||
/>
|
</template>
|
||||||
</div>
|
</el-input>
|
||||||
<div class="filter-field field-md">
|
|
||||||
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label>
|
|
||||||
<el-input
|
|
||||||
id="binding-ip-filter"
|
|
||||||
v-model="filters.ip"
|
|
||||||
aria-label="Filter by bound CIDR"
|
|
||||||
autocomplete="off"
|
|
||||||
clearable
|
|
||||||
name="binding_ip_filter"
|
|
||||||
placeholder="192.168.1.0/24..."
|
|
||||||
@keyup.enter="searchBindings"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="filter-field field-status">
|
|
||||||
<label class="filter-label" for="binding-status-filter">Status</label>
|
|
||||||
<el-select
|
|
||||||
id="binding-status-filter"
|
|
||||||
v-model="filters.status"
|
|
||||||
aria-label="Filter by binding status"
|
|
||||||
clearable
|
|
||||||
placeholder="Status..."
|
|
||||||
>
|
|
||||||
<el-option label="Active" :value="1" />
|
|
||||||
<el-option label="Banned" :value="2" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-right">
|
|
||||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
|
||||||
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="data-table table-block">
|
<div class="filter-field field-md">
|
||||||
<el-table :data="rows" v-loading="loading">
|
<label class="filter-label" for="binding-ip-filter">Bound IP or CIDR</label>
|
||||||
<el-table-column prop="id" label="ID" width="90" />
|
<el-input
|
||||||
<el-table-column prop="token_display" label="Token" min-width="170" />
|
id="binding-ip-filter"
|
||||||
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
|
v-model="filters.ip"
|
||||||
<el-table-column label="Status" width="120">
|
aria-label="Filter by bound CIDR"
|
||||||
<template #default="{ row }">
|
autocomplete="off"
|
||||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
|
clearable
|
||||||
{{ row.status_label }}
|
name="binding_ip_filter"
|
||||||
</el-tag>
|
placeholder="192.168.1.0/24"
|
||||||
</template>
|
@keyup.enter="searchBindings"
|
||||||
</el-table-column>
|
>
|
||||||
<el-table-column prop="first_used_at" label="First used" min-width="190">
|
<template #prefix>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
|
<el-icon><Connection /></el-icon>
|
||||||
</el-table-column>
|
</template>
|
||||||
<el-table-column prop="last_used_at" label="Last used" min-width="190">
|
</el-input>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
|
</div>
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="Actions" min-width="280" fixed="right">
|
<div class="filter-field field-status">
|
||||||
<template #default="{ row }">
|
<label class="filter-label" for="binding-status-filter">Status</label>
|
||||||
<div class="toolbar-left">
|
<el-select
|
||||||
<el-button @click="openEdit(row)">Edit CIDR</el-button>
|
id="binding-status-filter"
|
||||||
<el-button
|
v-model="filters.status"
|
||||||
type="danger"
|
aria-label="Filter by binding status"
|
||||||
plain
|
clearable
|
||||||
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
placeholder="Any status"
|
||||||
>
|
>
|
||||||
Remove Binding
|
<el-option label="Active" :value="1" />
|
||||||
</el-button>
|
<el-option label="Banned" :value="2" />
|
||||||
<el-button
|
</el-select>
|
||||||
v-if="row.status === 1"
|
</div>
|
||||||
type="warning"
|
|
||||||
plain
|
<div class="filter-field field-page-size">
|
||||||
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
|
<label class="filter-label" for="binding-page-size">Page size</label>
|
||||||
>
|
<el-select
|
||||||
Ban Token
|
id="binding-page-size"
|
||||||
</el-button>
|
v-model="filters.page_size"
|
||||||
<el-button
|
aria-label="Bindings page size"
|
||||||
v-else
|
@change="onPageSizeChange"
|
||||||
type="success"
|
>
|
||||||
plain
|
<el-option v-for="size in pageSizeOptions" :key="size" :label="`${size} rows`" :value="size" />
|
||||||
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
|
</el-select>
|
||||||
>
|
</div>
|
||||||
Restore Token
|
|
||||||
</el-button>
|
<div class="binding-actions">
|
||||||
|
<el-button @click="resetFilters">Reset</el-button>
|
||||||
|
<el-button :icon="RefreshRight" plain :loading="loading" @click="refreshBindings">Reload</el-button>
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading" @click="searchBindings">Apply filters</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="binding-table-toolbar">
|
||||||
|
<div class="inline-meta">
|
||||||
|
<span class="status-chip">
|
||||||
|
<el-icon><SwitchButton /></el-icon>
|
||||||
|
{{ formatCompactNumber(total) }} matched bindings
|
||||||
|
</span>
|
||||||
|
<span class="binding-table-note">Dormant means no activity for {{ staleWindowDays }} days or more.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table binding-table">
|
||||||
|
<el-table :data="rows" :row-class-name="rowClassName" v-loading="loading">
|
||||||
|
<el-table-column label="Binding" 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>
|
</div>
|
||||||
</template>
|
<span class="muted">First seen {{ formatDateTime(row.first_used_at) }}</span>
|
||||||
</el-table-column>
|
</div>
|
||||||
</el-table>
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
|
|
||||||
<div class="toolbar pagination-toolbar">
|
<el-table-column label="Bound address" min-width="220">
|
||||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
<template #default="{ row }">
|
||||||
<el-pagination
|
<div class="binding-ip-cell">
|
||||||
background
|
<div class="binding-ip-line">
|
||||||
layout="prev, pager, next"
|
<code>{{ row.bound_ip }}</code>
|
||||||
:current-page="filters.page"
|
<el-button text :icon="CopyDocument" @click="copyValue(row.bound_ip, 'Bound IP')">Copy</el-button>
|
||||||
:page-size="filters.page_size"
|
</div>
|
||||||
:total="total"
|
<span class="muted">{{ ipTypeLabel(row.bound_ip) }}</span>
|
||||||
@current-change="onPageChange"
|
</div>
|
||||||
/>
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside class="soft-grid">
|
<el-table-column label="Health" min-width="160">
|
||||||
<article class="support-card">
|
<template #default="{ row }">
|
||||||
<p class="eyebrow">Operator guide</p>
|
<div class="binding-health-cell">
|
||||||
<h4>Choose the least disruptive action first</h4>
|
<el-tag :type="statusTone(row)" round effect="dark">
|
||||||
<p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
|
{{ statusText(row) }}
|
||||||
</article>
|
</el-tag>
|
||||||
|
<span class="muted">{{ row.status_label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<article class="support-card">
|
<el-table-column label="Last activity" min-width="210">
|
||||||
<p class="eyebrow">Quick reference</p>
|
<template #default="{ row }">
|
||||||
<ul class="support-list" role="list">
|
<div class="binding-activity-cell">
|
||||||
<li v-for="item in opsCards" :key="item.title" class="support-list-item">
|
<strong>{{ formatLastSeen(row.last_used_at) }}</strong>
|
||||||
<span class="eyebrow">{{ item.eyebrow }}</span>
|
<span class="muted">{{ formatDateTime(row.last_used_at) }}</span>
|
||||||
<strong>{{ item.title }}</strong>
|
</div>
|
||||||
<span class="muted">{{ item.note }}</span>
|
</template>
|
||||||
</li>
|
</el-table-column>
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="support-card">
|
<el-table-column label="Actions" min-width="360" fixed="right">
|
||||||
<p class="eyebrow">Visible ratio</p>
|
<template #default="{ row }">
|
||||||
<div class="support-kpi">
|
<div class="binding-action-row">
|
||||||
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
<el-button :icon="EditPen" @click="openEdit(row)">Edit CIDR</el-button>
|
||||||
<p>Active records across the current page view.</p>
|
<el-button
|
||||||
</div>
|
:icon="row.status === 1 ? Lock : Unlock"
|
||||||
</article>
|
:type="row.status === 1 ? 'warning' : 'success'"
|
||||||
</aside>
|
plain
|
||||||
|
@click="
|
||||||
|
confirmAction(
|
||||||
|
row.status === 1 ? 'Ban this token?' : 'Restore this token to active state?',
|
||||||
|
() => (row.status === 1 ? banBinding(row.id) : unbanBinding(row.id)),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ row.status === 1 ? 'Ban' : 'Restore' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:icon="SwitchButton"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
||||||
|
>
|
||||||
|
Unbind
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar pagination-toolbar">
|
||||||
|
<span class="muted">Page {{ filters.page }} of {{ 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>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
|
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
|
||||||
|
|||||||
16
main.py
16
main.py
@@ -1,5 +1,17 @@
|
|||||||
def main():
|
from __future__ import annotations
|
||||||
print("Hello from sentinel!")
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=int(os.getenv("APP_PORT", "7000")),
|
||||||
|
reload=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -24,19 +24,6 @@ http {
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
|
||||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
|
||||||
ssl_session_timeout 1d;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_prefer_server_ciphers off;
|
|
||||||
|
|
||||||
client_max_body_size 32m;
|
client_max_body_size 32m;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -65,7 +52,7 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +61,7 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -83,7 +70,7 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto http;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user