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

19
PRD.md
View File

@@ -26,11 +26,11 @@
``` ```
调用方 (Client) 调用方 (Client)
│ HTTPS (443) │ HTTP (80)
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ Nginx │ │ Nginx │
│ 职责:TLS终止 / 路径路由 / │ │ 职责:路径路由 /
│ 静态文件 / 内网鉴权 / 粗粒度限流 │ │ 静态文件 / 内网鉴权 / 粗粒度限流 │
└────────────────┬────────────────────────┘ └────────────────┬────────────────────────┘
│ HTTP 内网转发 │ HTTP 内网转发
@@ -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,23 +122,22 @@
`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;
} }
# 4. 静态文件(前端 UI # 3. 静态文件(前端 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;
} }
# 5. 基础限流 # 4. 基础限流
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
# 6. 强制写入真实 IP防客户端伪造 # 5. 强制写入真实 IP防客户端伪造
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值 proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值
``` ```
@@ -341,10 +340,8 @@ services:
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
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./frontend/dist:/etc/nginx/html/admin/ui:ro - ./frontend/dist:/etc/nginx/html/admin/ui:ro
depends_on: depends_on:
- sentinel-app - sentinel-app

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()
if isinstance(value, str):
parts = [item.strip() for item in value.split(",")]
return tuple(item for item in parts if item) 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,56 +296,62 @@ 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="toolbar">
<div class="toolbar-left">
<div class="filter-field field-sm"> <div class="filter-field field-sm">
<label class="filter-label" for="binding-token-suffix">Token Suffix</label> <label class="filter-label" for="binding-token-suffix">Token suffix</label>
<el-input <el-input
id="binding-token-suffix" id="binding-token-suffix"
v-model="filters.token_suffix" v-model="filters.token_suffix"
@@ -276,12 +359,17 @@ watch(
autocomplete="off" autocomplete="off"
clearable clearable
name="binding_token_suffix" name="binding_token_suffix"
placeholder="Token suffix..." placeholder="sk...tail"
@keyup.enter="searchBindings" @keyup.enter="searchBindings"
/> >
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div> </div>
<div class="filter-field field-md"> <div class="filter-field field-md">
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label> <label class="filter-label" for="binding-ip-filter">Bound IP or CIDR</label>
<el-input <el-input
id="binding-ip-filter" id="binding-ip-filter"
v-model="filters.ip" v-model="filters.ip"
@@ -289,10 +377,15 @@ watch(
autocomplete="off" autocomplete="off"
clearable clearable
name="binding_ip_filter" name="binding_ip_filter"
placeholder="192.168.1.0/24..." placeholder="192.168.1.0/24"
@keyup.enter="searchBindings" @keyup.enter="searchBindings"
/> >
<template #prefix>
<el-icon><Connection /></el-icon>
</template>
</el-input>
</div> </div>
<div class="filter-field field-status"> <div class="filter-field field-status">
<label class="filter-label" for="binding-status-filter">Status</label> <label class="filter-label" for="binding-status-filter">Status</label>
<el-select <el-select
@@ -300,64 +393,112 @@ watch(
v-model="filters.status" v-model="filters.status"
aria-label="Filter by binding status" aria-label="Filter by binding status"
clearable clearable
placeholder="Status..." placeholder="Any status"
> >
<el-option label="Active" :value="1" /> <el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" /> <el-option label="Banned" :value="2" />
</el-select> </el-select>
</div> </div>
<div class="filter-field field-page-size">
<label class="filter-label" for="binding-page-size">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} rows`" :value="size" />
</el-select>
</div> </div>
<div class="toolbar-right"> <div class="binding-actions">
<el-button @click="resetFilters">Reset Filters</el-button> <el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</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> </div>
<div class="data-table table-block"> <div class="binding-table-toolbar">
<el-table :data="rows" v-loading="loading"> <div class="inline-meta">
<el-table-column prop="id" label="ID" width="90" /> <span class="status-chip">
<el-table-column prop="token_display" label="Token" min-width="170" /> <el-icon><SwitchButton /></el-icon>
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" /> {{ formatCompactNumber(total) }} matched bindings
<el-table-column label="Status" width="120"> </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 }"> <template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round> <div class="binding-token-cell">
{{ row.status_label }} <div class="binding-token-main">
</el-tag> <strong>{{ row.token_display }}</strong>
<span class="binding-id">#{{ row.id }}</span>
</div>
<span class="muted">First seen {{ formatDateTime(row.first_used_at) }}</span>
</div>
</template> </template>
</el-table-column> </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 label="Bound address" min-width="220">
</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 }"> <template #default="{ row }">
<div class="toolbar-left"> <div class="binding-ip-cell">
<el-button @click="openEdit(row)">Edit CIDR</el-button> <div class="binding-ip-line">
<code>{{ row.bound_ip }}</code>
<el-button text :icon="CopyDocument" @click="copyValue(row.bound_ip, 'Bound IP')">Copy</el-button>
</div>
<span class="muted">{{ ipTypeLabel(row.bound_ip) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Health" 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_label }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Last activity" 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="Actions" min-width="360" fixed="right">
<template #default="{ row }">
<div class="binding-action-row">
<el-button :icon="EditPen" @click="openEdit(row)">Edit CIDR</el-button>
<el-button <el-button
:icon="row.status === 1 ? Lock : Unlock"
:type="row.status === 1 ? 'warning' : 'success'"
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" type="danger"
plain plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))" @click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
> >
Remove Binding Unbind
</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> </el-button>
</div> </div>
</template> </template>
@@ -369,41 +510,13 @@ watch(
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span> <span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
<el-pagination <el-pagination
background background
layout="prev, pager, next" layout="total, prev, pager, next"
:current-page="filters.page" :current-page="filters.page"
:page-size="filters.page_size" :page-size="filters.page_size"
:total="total" :total="total"
@current-change="onPageChange" @current-change="onPageChange"
/> />
</div> </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> </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 "";
} }
} }