Fix HTTP deployment flow and redesign bindings workspace

This commit is contained in:
2026-03-04 13:59:38 +08:00
parent 380a78283e
commit f212b68c2c
8 changed files with 578 additions and 275 deletions

77
PRD.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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