Compare commits

...
9 Commits
Author SHA1 Message Date
DenozordecandCursor 738d2e2256 feat(httpapi): add local audit log with portal dual-write
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / web (push) Skipped
CI / openapi (push) Successful in 32s
CI / go (push) Successful in 1m23s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m56s
Локальный audit_log (миграции pg/sqlite), GET /v1/audit, запись на CRUD и async push в auth-portal (source_app=bgp).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:24:54 +07:00
Denozordec 2e3e1493f5 feat(auth): add sessionCanManageApiKeys function and update access control logic
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 53s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m2s
Introduced the sessionCanManageApiKeys function to determine if a session can manage API keys based on role, permissions, and admin status. Updated the AccessComponent to utilize this new function for enabling/disabling API key management features. Enhanced documentation to reflect changes in API key management roles and permissions, including updates to the OpenAPI specification.
2026-07-21 03:18:59 +07:00
DenozordecandCursor fd2fd8298d chore(rules): add kpi-hybrid rule for stats-12 DNA
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 18s
Закрепить только hybrid KPI из kit (KpiStatGrid / row tiles); ссылка из reui-mcp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 02:36:10 +07:00
DenozordecandCursor df7cd99060 chore(security): ignore local mcp.json configs
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m0s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m9s
.cursor/mcp.json и .mcp.json в gitignore; убраны из индекса; example без секрета.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:30:06 +07:00
Denozordec c273cea067 fix: update daemon PID and timestamps in configuration files
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m9s
CI / go (push) Successful in 1m6s
CI / bird2 (push) Successful in 14s
CI / release (push) Successful in 23s
Adjusted the daemon PID and updated the startedAt timestamp in the .codegraph/daemon.pid file. Modified the mcp.json configuration to change the API URL and update the authorization header for improved security. This ensures the application uses the correct process ID and API endpoint for functionality.
2026-07-21 00:25:52 +07:00
DenozordecandCursor 1f969e6cac feat(web): align NavUser chrome and ReUI PRO kit
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 55s
CI / bird2 (push) Canceled after 0s
CI / release (push) Canceled after 0s
Тема в NavUser вместо ModeToggle в header; общие kpi-cols для KPI/Quick Actions; WEB-01 → user-reui; skill 42d70dcc3d и design contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:23:35 +07:00
DenozordecandCursor c1132cbe19 feat(httpapi): remove firewall HTTP/UI after EvoFirewall cutover
CI / changes (push) Successful in 4s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 51s
CI / go (push) Successful in 59s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m11s
Все /v1/firewall/* отвечают 410 Gone; UI и docs указывают на EvoFirewall.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 19:51:08 +07:00
Denozordec b28ad88b22 feat(auth): enhance tenant resolution in JWT handling
CI / changes (push) Successful in 4s
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 58s
CI / commitlint (push) Skipped
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m15s
Updated the authentication logic to prioritize tenant resolution from JWT claims, specifically using `tenants.bgp` or `bgp_tenant_id` as the primary source. If these claims are absent, the system will fallback to the configured `EVOBGP_PORTAL_TENANT_ID`. This change improves the flexibility of tenant management in the authentication process. Additionally, updated related documentation to reflect these changes and added tests to ensure proper functionality.
2026-07-19 01:03:33 +07:00
Denozordec b871d62de6 feat(auth): add environment variables for portal SSO integration
Introduced new environment variables for the authentication portal in the production and microvps configurations. Updated the documentation to clarify the necessity of passing these variables to the evobgp-all service. This change enhances the authentication flow by enabling single sign-on (SSO) capabilities through JWT, ensuring a more secure and streamlined user experience.
2026-07-19 00:55:35 +07:00
69 changed files with 1863 additions and 2187 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
-6
View File
@@ -1,6 +0,0 @@
{
"pid": 51184,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1784215907952
}
@@ -11,9 +11,9 @@
]
},
"reui": {
"url": "https://mcp.reui.io/api/mcp?style=base-nova",
"url": "https://mcp.reui.io",
"headers": {
"X-Reui-Style": "base-nova"
"Authorization": "Bearer <REUI_LICENSE_KEY>"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc).
## MUST
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
Импорты UI: `@evobgp/ui/components/*`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
+111
View File
@@ -0,0 +1,111 @@
---
description: ReUI PRO (@reui) — MCP user-reui, Frame surface, kit, license, матрица выбора
alwaysApply: true
---
# ReUI MCP — обязательно (PRO + free)
Проект: **Base UI** (`style: base-nova`), surface lock **`frame`**.
Связанные: [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`web-shadcn.mdc`](web-shadcn.mdc), [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
| Документ | URL |
|----------|-----|
| **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started |
| **Styling** | https://reui.io/docs/styling |
| **MCP** | https://reui.io/docs/mcp |
| **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup |
| **Base UI components** | https://reui.io/docs/components/base/<name> |
## Primary MCP
1. **`user-reui`** — `search` / `compose_page` / `get_block` / `get_component` / `get_install_command` / `validate_usage` / `get_audit_checklist`
2. **`plugin-shadcn-shadcn`** — primitives `@shadcn`; для `@reui` — вторично
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
## Когда ReUI vs shadcn
| Задача | Registry | Импорт |
|--------|----------|--------|
| Button, Sheet, Field, Sidebar, Tabs | `@shadcn` | `@evobgp/ui/components/*` |
| PRO pages/sections (settings, stats, auth, dashboard) | `@reui` blocks | adapt → `apps/web/src/components/` / `reui-kit/` |
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
| Filters | `@reui` | `@/components/reui/filters` |
| Frame surface | `@reui` | `@/components/reui/frame` |
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
## MCP workflow
0. Codegraph / поиск существующих `reui-kit/*`, `@/components/reui/*`
1. `user-reui` `search` (`surface: "frame"`, `category` при известном)
2. Страница целиком → `compose_page`; секция → `get_block`
3. `get_component` для API primitives из `componentsUsed`
4. CLI из `apps/web`: `pnpm dlx shadcn@latest add @reui/<name> --yes`
5. Post-add: shadcn imports → `@evobgp/ui/components/*`
6. Adapt by reuse → kit / route
7. `validate_usage` + `get_audit_checklist`
## Размещение
| Слой | Путь | Импорт |
|------|------|--------|
| shadcn | `packages/ui/src/components/` | `@evobgp/ui/components/*` |
| ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` |
| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes |
| Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` |
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
## License
```env
# .env.local (gitignored)
REUI_LICENSE_KEY=
```
`apps/web/components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Эталоны preview
| Зона | Preview |
|------|---------|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| List | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | https://reui.io/preview/base/settings-16 |
| Auth | https://reui.io/preview/base/auth-13 |
| Shell | https://reui.io/preview/base/app-shell-12 |
| Empty | https://reui.io/preview/base/empty-state-12 |
## Запрещено
- Копипаст с reui.io без CLI
- ReUI в `packages/ui` / импорт как `@evobgp/ui`
- Radix-варианты docs — только Base UI
- Raw `bg-emerald-*` вместо ReUI `variant`
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
- Смешивать Card и Frame на ops-экране
- Ставить shadcn/ui blocks выше ReUI PRO
## Чеклист
- [ ] `user-reui` search/get_block + previewUrl
- [ ] `surface: frame`
- [ ] CLI add из `apps/web` при новом item
- [ ] Kit / `@/components/reui` / `@evobgp/ui` — правильный слой
- [ ] `pnpm --filter @evobgp/web run build`
+1 -1
View File
@@ -11,7 +11,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+10 -10
View File
@@ -9,12 +9,12 @@ alwaysApply: false
# Web UI — React + shadcn/ui + ReUI
**Источники правды:**
- **ReUI PRO first:** MCP `user-reui` ([`reui-mcp.mdc`](reui-mcp.mdc)) — pages / KPI / lists / settings / shell
- shadcn primitives: MCP `plugin-shadcn-shadcn` (secondary)
- ReUI Base UI: https://reui.io/docs/components/base/<name> · [llms.txt](https://reui.io/llms.txt)
- shadcn/ui React: https://ui.shadcn.com/docs/components
- ReUI Base UI: https://reui.io/docs/components/base/<name>
- ReUI llms.txt: https://reui.io/llms.txt
- MCP `plugin-shadcn-shadcn` (registries: `@shadcn`, `@reui`) — перед любой UI-задачей
Общие правила Go/API: `.cursor/rules/engineering.mdc`. Стек ID: `.cursor/rules/context7-stack.mdc`.
Иерархия: **ReUI PRO > shadcn**. Общие: `.cursor/rules/engineering.mdc`, `context7-stack.mdc`.
## Слои UI
@@ -23,18 +23,18 @@ alwaysApply: false
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell |
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, QuickActionGrid, OpsDashboard, SettingsShell |
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI hybrid SoT: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2). Quick Actions: `QuickActionGrid`.
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
## Правила
**WEB-01** | MUST | Перед новым UI — MCP `plugin-shadcn-shadcn`: `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items`. Только после — JSX.
*Rationale:* единый источник правды и API.
*Проверка:* review; нет самописных примитивов, если есть registry item.
**WEB-01** | MUST | Перед новым UI — сначала MCP **`user-reui`** (`search` → `get_block` / `compose_page`, `surface: "frame"`) + cite `previewUrl`/`docsUrl`. Primitives — MCP `plugin-shadcn-shadcn`. Только после — JSX.
*Rationale:* ReUI PRO выше shadcn; единый Frame surface.
*Проверка:* review; [`reui-mcp.mdc`](reui-mcp.mdc).
**WEB-02** | MUST | Отсутствующий shadcn-примитив — `pnpm dlx shadcn@latest add <component>` (из `apps/web`). ReUI — `pnpm dlx shadcn@latest add @reui/<name>`.
*Проверка:* файлы в `packages/ui/src/components/` (для shadcn) или `apps/web/src/components/reui/` (для ReUI).
@@ -73,7 +73,7 @@ alwaysApply: false
**WEB-14** | SHOULD | Нетривиальный UI — прочитать страницу компонента shadcn/ReUI (props, a11y).
**WEB-15** | MUST | Сомнения — MCP `plugin-shadcn-shadcn` + shadcn CLI docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-15** | MUST | Сомнения — MCP `user-reui` + `plugin-shadcn-shadcn` + docs + `pnpm --filter @evobgp/web run typecheck`.
**WEB-16** | MUST | Подтверждение удаления — `ConfirmDialog` из `@/components/confirm-dialog`, не `window.confirm`.
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
+4
View File
@@ -28,3 +28,7 @@ Thumbs.db
# Compose runtime log sidecar output (deploy/compose/runtime-logs)
deploy/compose/runtime-logs/
# Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json
.mcp.json
.codegraph/daemon.pid
-16
View File
@@ -1,16 +0,0 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
},
"reui": {
"type": "http",
"url": "https://mcp.reui.io/api/mcp"
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `0e224b0281`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+2
View File
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
## Installing
+29 -1
View File
@@ -1,6 +1,6 @@
# ReUI components
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -32,6 +32,34 @@ Common mistakes:
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
@@ -1,195 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { FirewallClient } from '@/types/api'
function formatPacketCount(value?: number | null): string | null {
if (value == null || value <= 0) return null
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
return String(value)
}
export interface FirewallClientsGridProps {
clients: FirewallClient[]
isLoading?: boolean
onApprove: (id: string) => void
onReject: (id: string) => void
approvePending?: boolean
rejectPending?: boolean
emptyTitle?: string
}
export function FirewallClientsGrid({
clients,
isLoading = false,
onApprove,
onReject,
approvePending = false,
rejectPending = false,
emptyTitle = 'Нет клиентов',
}: FirewallClientsGridProps) {
const columns = useMemo<ColumnDef<FirewallClient>[]>(
() => [
{
accessorKey: 'name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name}
subtitle={row.original.hostname || row.original.token_prefix}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'status',
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
meta: { headerTitle: 'Статус' },
},
{
id: 'last_seen_at',
accessorFn: (row) => row.last_seen_at ?? '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
cell: ({ row }) => (
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
),
sortingFn: (a, b) => {
const av = a.original.last_seen_at ?? ''
const bv = b.original.last_seen_at ?? ''
return av.localeCompare(bv)
},
meta: { headerTitle: 'Последняя активность' },
},
{
id: 'apply',
enableSorting: false,
header: 'Применение',
cell: ({ row }) => {
const c = row.original
return (
<span className="text-xs">
{c.last_apply_status ?? '—'}
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
</span>
)
},
meta: { headerTitle: 'Применение' },
},
{
id: 'packets',
enableSorting: false,
header: 'Пакеты',
cell: ({ row }) => {
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
if (!dropped && !accepted) {
return <span className="text-muted-foreground text-xs"></span>
}
return (
<span className="text-muted-foreground text-xs">
{dropped ? <span className="text-destructive">{dropped}</span> : null}
{dropped && accepted ? ' · ' : null}
{accepted ? <span className="text-success">{accepted}</span> : null}
</span>
)
},
meta: { headerTitle: 'Пакеты' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const c = row.original
return (
<div className="flex justify-end gap-2">
{c.status === 'pending' ? (
<>
<Button
size="sm"
variant="outline"
type="button"
disabled={approvePending}
onClick={() => onApprove(c.id)}
>
Одобрить
</Button>
<ConfirmDialog
trigger={
<Button
size="sm"
variant="outline"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Отклонить
</Button>
}
title="Отклонить запрос?"
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
confirmLabel="Отклонить"
destructive
onConfirm={() => onReject(c.id)}
/>
</>
) : null}
{c.status === 'approved' ? (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={rejectPending}
>
Удалить
</Button>
}
title="Удалить клиент?"
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
confirmLabel="Удалить"
destructive
onConfirm={() => onReject(c.id)}
/>
) : null}
</div>
)
},
},
],
[approvePending, onApprove, onReject, rejectPending],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: clients,
columns,
getSearchText: (row) =>
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск клиентов…"
/>
)
}
@@ -1,101 +0,0 @@
import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { useCreateFirewallRule } from '@/queries/firewall'
import type { BgpCommunity } from '@/types/api'
const FIREWALL_ACTION_ITEMS = [
{ value: 'block', label: 'block' },
{ value: 'accept', label: 'accept' },
] as const
interface FirewallRuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
}
export function FirewallRuleCreateDialog({
open,
onOpenChange,
communities,
}: FirewallRuleCreateDialogProps) {
const createMutation = useCreateFirewallRule()
const [action, setAction] = useState<'block' | 'accept'>('block')
const [communityId, setCommunityId] = useState<string | null>(null)
const [comment, setComment] = useState('')
useEffect(() => {
if (!open) return
setAction('block')
setCommunityId(null)
setComment('')
}, [open])
async function save() {
try {
await createMutation.mutateAsync({
scope: 'tenant',
action,
community_id: communityId,
comment: comment.trim(),
})
onOpenChange(false)
} catch {
// toast handled in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новое правило"
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
Добавить
</LoadingButton>
</>
}
>
<SelectField
id="fw-rule-action"
label="Действие"
items={[...FIREWALL_ACTION_ITEMS]}
value={action}
placeholder="Выберите действие"
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
/>
<CommunitySelect
id="fw-rule-community"
label="Community"
value={communityId}
onValueChange={setCommunityId}
communities={communities}
nullable
placeholder="Все communities"
/>
<div className="flex flex-col gap-2">
<Label htmlFor="fw-rule-comment">Комментарий</Label>
<Input
id="fw-rule-comment"
placeholder="Комментарий"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
</FormDrawer>
)
}
@@ -1,122 +0,0 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridSection } from '@/components/data-grid-shell'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { communityLabel } from '@/lib/modules/helpers'
import type { BgpCommunity, FirewallRule } from '@/types/api'
export interface FirewallRulesGridProps {
rules: FirewallRule[]
communities: BgpCommunity[]
isLoading?: boolean
onDelete: (id: string) => void
deletePending?: boolean
emptyTitle?: string
}
export function FirewallRulesGrid({
rules,
communities,
isLoading = false,
onDelete,
deletePending = false,
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
}: FirewallRulesGridProps) {
const columns = useMemo<ColumnDef<FirewallRule>[]>(
() => [
{
accessorKey: 'priority',
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
cell: ({ row }) => row.original.priority,
meta: { headerTitle: '#' },
},
{
accessorKey: 'action',
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
cell: ({ row }) => (
<StatusBadge status={row.original.action} label={row.original.action} />
),
meta: { headerTitle: 'Действие' },
},
{
id: 'community',
enableSorting: false,
header: 'Community',
cell: ({ row }) => (
<span className="text-sm">
{row.original.community_id
? communityLabel(row.original.community_id, communities)
: 'Все'}
</span>
),
meta: { headerTitle: 'Community' },
},
{
accessorKey: 'comment',
enableSorting: false,
header: 'Комментарий',
cell: ({ row }) => row.original.comment || '—',
meta: { headerTitle: 'Комментарий' },
},
{
id: 'actions',
enableSorting: false,
header: () => null,
cell: ({ row }) => {
const r = row.original
return (
<ConfirmDialog
trigger={
<Button
size="sm"
variant="ghost"
type="button"
className="text-destructive"
disabled={deletePending}
>
Удалить
</Button>
}
title="Удалить правило?"
description={
r.comment
? `Правило #${r.priority} (${r.action}): ${r.comment}`
: `Правило #${r.priority} (${r.action}) будет удалено.`
}
confirmLabel="Удалить"
destructive
onConfirm={() => onDelete(r.id)}
/>
)
},
},
],
[communities, deletePending, onDelete],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: rules,
columns,
getSearchText: (row) =>
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
getRowId: (row) => row.id,
})
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage={emptyTitle}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск правил…"
/>
)
}
@@ -9,7 +9,6 @@ import {
BookText,
KeyRound,
ServerCog,
Shield,
Search,
} from 'lucide-react'
@@ -47,7 +46,6 @@ import { AppsMenu } from '@/components/layout/apps-menu'
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
import { NavUser } from '@/components/layout/nav-user'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { ModeToggle } from '@/components/mode-toggle'
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
interface NavItem {
@@ -90,7 +88,6 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Операции',
items: [
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
],
@@ -221,7 +218,6 @@ export function AppShell({ children }: { children: ReactNode }) {
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
+82 -11
View File
@@ -1,12 +1,20 @@
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
ExternalLinkIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,12 +41,71 @@ import {
resetPortalHandoff,
} from '@/lib/auth'
/**
* Sidebar footer account menu.
* Portal mode → shows JWT email + logout via auth-portal.
* Local mode → shows the API-key hint + clears the local token.
* @see https://reui.io/preview/base/app-shell-12
*/
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{THEMES.map(({ value, label, icon }) => {
const isActive = currentTheme === value
return (
<Button
key={value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={label}
variant="ghost"
size="icon-xs"
onClick={() => setTheme(value)}
className={cn(
'rounded-full',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</Button>
)
})}
</div>
)
}
function initials(source: string): string {
const base = source.trim()
if (!base) return '?'
@@ -56,8 +123,7 @@ export function NavUser() {
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() ||
(authOn ? '' : 'локальный API-токен')
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
@@ -87,9 +153,7 @@ export function NavUser() {
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
@@ -140,6 +204,13 @@ export function NavUser() {
Открыть Auth Portal
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
-29
View File
@@ -1,29 +0,0 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" />}>
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Сменить тему</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme('light')}>Светлая</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>Тёмная</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>Системная</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { Skeleton } from '@evobgp/ui/components/skeleton'
@@ -47,16 +48,6 @@ const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
destructive: 'text-destructive',
}
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
@@ -10,6 +10,7 @@ import {
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { cn } from '@evobgp/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
@@ -30,16 +31,6 @@ interface QuickActionGridProps {
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
+25 -2
View File
@@ -255,9 +255,34 @@ export function can(required: string): boolean {
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes(CURRENT_APP_ID)) return false
if (claims.is_admin) return true
return hasPermission(claims.permissions, required)
}
/**
* Whether /v1/auth/session may manage API keys (`bgp:access:admin`).
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key operator.
*/
export function sessionCanManageApiKeys(session: {
role?: string
kind?: string
is_admin?: boolean
permissions?: readonly string[]
} | null | undefined): boolean {
if (!session) return false
const jwtPath =
session.kind === 'jwt' ||
session.is_admin === true ||
(session.permissions?.length ?? 0) > 0
if (jwtPath) {
return (
session.is_admin === true ||
hasPermission(session.permissions ?? [], 'bgp:access:admin')
)
}
return session.role === 'operator'
}
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
export function permissionForPath(pathname: string): string | null {
if (pathname === '/' || pathname.startsWith('/dashboard')) {
@@ -268,7 +293,6 @@ export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/network')) return 'bgp:network:read'
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
if (pathname.startsWith('/firewall')) return 'bgp:firewall:read'
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
@@ -288,7 +312,6 @@ export function firstAllowedPath(): string {
'/network',
'/directories',
'/operations',
'/firewall',
'/schedule',
'/monitoring',
'/access',
-102
View File
@@ -1,102 +0,0 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { apiJSON } from '@/lib/api-client'
import type {
FirewallClient,
FirewallClientsResponse,
FirewallInstallContext,
FirewallRule,
FirewallRulesResponse,
} from '@/types/api'
export const firewallKeys = {
all: ['firewall'] as const,
clients: () => [...firewallKeys.all, 'clients'] as const,
installContext: () => [...firewallKeys.all, 'install-context'] as const,
rules: (scope: string, clientId?: string) =>
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
}
export function firewallInstallContextQueryOptions() {
return queryOptions<FirewallInstallContext>({
queryKey: firewallKeys.installContext(),
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
staleTime: 60_000,
retry: false,
})
}
export function firewallClientsQueryOptions() {
return queryOptions<FirewallClientsResponse>({
queryKey: firewallKeys.clients(),
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
staleTime: 15_000,
})
}
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
const qs =
scope === 'client' && clientId
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
: '?scope=tenant'
return queryOptions<FirewallRulesResponse>({
queryKey: firewallKeys.rules(scope, clientId),
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
staleTime: 15_000,
})
}
export function useApproveFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Клиент одобрен')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
})
}
export function useDeleteFirewallClient() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Клиент удалён')
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
})
}
export function useCreateFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiJSON<FirewallRule>('/v1/firewall/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success('Правило добавлено')
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
})
}
export function useDeleteFirewallRule() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: firewallKeys.all })
},
})
}
+53 -26
View File
@@ -11,6 +11,7 @@ import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { sessionCanManageApiKeys } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import { apiKeysQueryOptions } from '@/queries/api-keys'
@@ -21,11 +22,11 @@ export const Route = createFileRoute('/_auth/access')({
function AccessComponent() {
const sessionQuery = useQuery(authSessionQueryOptions())
const session = sessionQuery.data ?? null
const isOperator = session?.role === 'operator'
const canManageKeys = sessionCanManageApiKeys(session)
const keysQuery = useQuery({
...apiKeysQueryOptions(),
enabled: isOperator,
enabled: canManageKeys,
})
const keys = keysQuery.data ?? []
@@ -73,16 +74,31 @@ function AccessComponent() {
function refetchAll() {
void sessionQuery.refetch()
if (isOperator) void keysQuery.refetch()
if (canManageKeys) void keysQuery.refetch()
}
const sessionKindLabel =
session?.kind === 'jwt' ? 'Portal JWT' : session?.kind === 'apikey' ? 'API-ключ' : null
const sessionAccessLabel = (() => {
if (!session) return null
if (session.kind === 'jwt' || session.is_admin || (session.permissions?.length ?? 0) > 0) {
if (session.is_admin) return 'admin (portal)'
if (sessionCanManageApiKeys(session)) return 'bgp:access:admin'
return session.permissions?.length
? session.permissions.slice(0, 3).join(', ')
: 'без access:admin'
}
return session.role || '—'
})()
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Права доступа"
description="API-ключи control plane и текущая сессия Bearer-токена."
actions={
isOperator ? (
canManageKeys ? (
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
Обновить
@@ -94,36 +110,48 @@ function AccessComponent() {
{session ? (
<PanelCard
title="Текущая сессия"
description="Tenant и роль ключа, с которым открыта панель."
description="Tenant и права текущего Bearer (API-ключ или portal JWT)."
contentClassName="grid gap-3 py-4 text-sm sm:grid-cols-2"
>
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
</div>
<div>
<p className="text-muted-foreground">Доступ</p>
<p className="font-mono text-xs">{sessionAccessLabel}</p>
</div>
{sessionKindLabel ? (
<div>
<p className="text-muted-foreground">Tenant</p>
<p className="break-all font-mono text-xs">{session.tenant_id}</p>
<p className="text-muted-foreground">Тип</p>
<p className="font-mono text-xs">{sessionKindLabel}</p>
</div>
) : null}
{session.email ? (
<div>
<p className="text-muted-foreground">Роль</p>
<p className="font-mono">{session.role}</p>
<p className="text-muted-foreground">Email</p>
<p className="break-all text-xs">{session.email}</p>
</div>
) : null}
</PanelCard>
) : (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
Не удалось определить сессию. Укажите токен в{' '}
<Link
to="/settings"
search={{ tab: 'connection' }}
className="text-primary underline-offset-4 hover:underline"
>
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
Не удалось определить сессию. Укажите токен в{' '}
<Link
to="/settings"
search={{ tab: 'connection' }}
className="text-primary underline-offset-4 hover:underline"
>
настройках
</Link>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
{sessionQuery.isError && sessionQuery.error instanceof Error ? (
<span className="mt-2 block text-destructive">{sessionQuery.error.message}</span>
) : null}
</PanelCard>
)}
{isOperator ? (
{canManageKeys ? (
<>
{keysQuery.isLoading ? (
<SectionCardsSkeleton count={3} />
@@ -140,10 +168,9 @@ function AccessComponent() {
</>
) : session ? (
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:{' '}
<span className="font-mono">{session.role}</span>. Для выдачи ключей войдите с
operator-ключом или создайте ключ через API / переменную{' '}
<code className="text-xs">EVOBGP_API_KEYS</code>.
Управление API-ключами доступно роли <strong>operator</strong> (API-ключ) или portal JWT
с <strong>is_admin</strong> / правом <code className="text-xs">bgp:access:admin</code>.
Текущий доступ: <span className="font-mono">{sessionAccessLabel}</span>.
</PanelCard>
) : null}
</div>
-276
View File
@@ -1,276 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
import {
firewallClientsQueryOptions,
firewallInstallContextQueryOptions,
firewallRulesQueryOptions,
useApproveFirewallClient,
useDeleteFirewallClient,
useDeleteFirewallRule,
} from '@/queries/firewall'
function httpsOrigin(origin: string): string {
try {
const u = new URL(origin)
u.protocol = 'https:'
return u.origin
} catch {
return origin.replace(/^http:/i, 'https:')
}
}
export const Route = createFileRoute('/_auth/firewall')({
component: FirewallPage,
})
function FirewallPage() {
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient()
const deleteClient = useDeleteFirewallClient()
const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data
const [clientName, setClientName] = useState('web-01')
const [cpUrl, setCpUrl] = useState(() =>
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
)
const [seed, setSeed] = useState('')
const [createRuleOpen, setCreateRuleOpen] = useState(false)
useEffect(() => {
if (installCtx?.suggested_cp_url) {
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
}
if (installCtx?.bundle_seed) {
setSeed(installCtx.bundle_seed)
}
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
const communities = communitiesQ.data?.items ?? []
const { activeClients, pending } = useMemo(() => {
const all = clientsQ.data?.items ?? []
return {
activeClients: all.filter((c) => c.status !== 'revoked'),
pending: all.filter((c) => c.status === 'pending'),
}
}, [clientsQ.data?.items])
const rules = rulesQ.data?.items ?? []
const installCmd = useMemo(() => {
const s = seed.trim() || '<bundle_seed_hex>'
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
EVOBGP_SEED=${s} \\
EVOBGP_CLIENT_NAME="${clientName}" \\
bash`
}, [clientName, cpUrl, seed])
async function copyInstall() {
if (!seed.trim()) {
toast.error(
installCtx?.bundle_seed_configured === false
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
: 'Seed бандла недоступен (нужна роль оператора)',
)
return
}
try {
await navigator.clipboard.writeText(installCmd)
toast.success('Команда скопирована')
} catch {
toast.error('Не удалось скопировать')
}
}
return (
<div className="flex flex-col gap-6">
<PageHeader
title="Файрвол: blocklist"
description="Linux-серверы: синхронизация CIDR по policy block/accept"
actions={
<Button
variant="outline"
size="sm"
onClick={() => {
void clientsQ.refetch()
void rulesQ.refetch()
}}
disabled={clientsQ.isFetching}
>
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
Обновить
</Button>
}
/>
<PanelCard
title={
<span className="flex items-center gap-2">
<Shield className="size-4" />
Установка на сервер
</span>
}
description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
contentClassName="flex flex-col gap-4 py-4"
>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="fw-name">Имя сервера</Label>
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-url">URL API</Label>
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="fw-seed">Seed бандла</Label>
<Input
id="fw-seed"
type="password"
readOnly
placeholder="EVOBGP_BUNDLE_SEED_HEX"
value={seed}
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
{installCtxQ.isLoading
? 'Загрузка с плоскости управления…'
: installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
</p>
</div>
</div>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
<Copy />
Копировать команду
</Button>
</PanelCard>
<BadgeTabs
defaultValue="clients"
items={[
{ value: 'clients', label: 'Клиенты', count: activeClients.length },
{ value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
{
value: 'requests',
label: 'Запросы',
count: pending.length,
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
},
]}
>
<TabsContent value="clients" className="mt-0">
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={activeClients}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
<TabsContent value="rules" className="mt-0">
<DataGridCard
title="Правила"
actions={
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
<Plus />
Добавить правило
</Button>
}
>
<QueryState
data={rulesQ.data}
isLoading={rulesQ.isLoading}
isError={rulesQ.isError}
error={rulesQ.error}
onRetry={() => void rulesQ.refetch()}
skeleton={<TableSkeleton rows={5} cols={5} />}
>
{() => (
<FirewallRulesGrid
rules={rules}
communities={communities}
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
onDelete={(id) => deleteRule.mutate(id)}
deletePending={deleteRule.isPending}
/>
)}
</QueryState>
</DataGridCard>
<FirewallRuleCreateDialog
open={createRuleOpen}
onOpenChange={setCreateRuleOpen}
communities={communities}
/>
</TabsContent>
<TabsContent value="requests" className="mt-0">
<DataGridCard
title="Запросы"
description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
>
<QueryState
data={clientsQ.data}
isLoading={clientsQ.isLoading}
isError={clientsQ.isError}
error={clientsQ.error}
onRetry={() => void clientsQ.refetch()}
skeleton={<TableSkeleton rows={3} cols={6} />}
>
{() => (
<FirewallClientsGrid
clients={pending}
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => deleteClient.mutate(id)}
approvePending={approve.isPending}
rejectPending={deleteClient.isPending}
emptyTitle="Нет ожидающих запросов"
/>
)}
</QueryState>
</DataGridCard>
</TabsContent>
</BadgeTabs>
</div>
)
}
+12 -41
View File
@@ -341,13 +341,20 @@ export type JobsResponse = Page<JobRow>
export type AppSettings = Record<string, unknown>
// ---- Auth / API keys ----
export type ApiKeyRole = 'viewer' | 'editor' | 'operator' | 'node'
/** GET /v1/auth/session — API key has role; portal JWT uses kind/permissions/is_admin. */
export type AuthSession = {
tenant_id: string
role: 'viewer' | 'editor' | 'operator' | 'node'
/** API-key role; empty for portal JWT sessions. */
role: ApiKeyRole | ''
kind?: 'apikey' | 'jwt'
user_id?: string
email?: string
permissions?: string[]
is_admin?: boolean
}
export type ApiKeyRole = AuthSession['role']
export type ApiKey = {
id: string
name: string
@@ -375,41 +382,5 @@ export type AsyncJobAccepted = {
job_id: string
}
// ---- Firewall blocklist ----
export type FirewallClient = {
id: string
name: string
hostname?: string
token_prefix: string
status: 'pending' | 'approved' | 'revoked'
last_seen_at?: string | null
last_seen_at_source?: string
last_apply_at?: string | null
last_apply_status?: string
last_apply_prefix_count?: number
last_apply_packets_dropped?: number
last_apply_packets_accepted?: number
last_apply_source?: string
client_version?: string
created_at: string
}
export type FirewallClientsResponse = { items: FirewallClient[] }
export type FirewallRule = {
id: string
client_id?: string | null
priority: number
action: 'block' | 'accept'
community_id?: string | null
comment?: string
}
export type FirewallRulesResponse = { items: FirewallRule[] }
export type FirewallInstallContext = {
bundle_seed: string
bundle_seed_configured: boolean
suggested_cp_url: string
install_sh_url: string
}
// Firewall blocklist types removed — the firewall subsystem moved to the standalone
// EvoFirewall service. See docs/firewall.md.
File diff suppressed because one or more lines are too long
+1
View File
@@ -42,6 +42,7 @@ func main() {
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
AuditIngestSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_AUDIT_INGEST_SECRET"), os.Getenv("AUTH_AUDIT_INGEST_SECRET")),
}
srv, err := httpapi.New(opts)
if err != nil {
+1
View File
@@ -37,6 +37,7 @@ func main() {
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
AuditIngestSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_AUDIT_INGEST_SECRET"), os.Getenv("AUTH_AUDIT_INGEST_SECRET")),
}
srv, err := httpapi.New(opts)
if err != nil {
+7
View File
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
WEBUI_IP_WHITELIST=203.0.113.10/32
LETSENCRYPT_EMAIL=admin@example.com
CF_DNS_API_TOKEN=
# Auth-portal SSO → контейнер evobgp-all (не VITE_* — они только для build web)
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
EVOBGP_PORTAL_TENANT_ID=
@@ -27,3 +27,16 @@ AUTO_UPDATE_INTERVAL_SEC=300
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
# Защищенные сервисы, которые updater никогда не перезапускает
AUTO_UPDATE_PROTECTED_SERVICES=bird2
# ---------------------------------------------------------------------------
# Auth-portal SSO (прокидывается в контейнер evobgp-all)
# VITE_* в runtime .env НЕ влияют на уже собранный web-образ —
# UI читает GET /v1/auth/config с API (AUTH_REQUIRED / AUTH_PORTAL_URL).
# ---------------------------------------------------------------------------
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# UUID tenant из БД (обязателен для JWT). При EVOBGP_SEED_DEMO=1 смотрите лог
# старта evobgp-all / SELECT id FROM tenant LIMIT 1;
EVOBGP_PORTAL_TENANT_ID=
@@ -136,6 +136,11 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
EVOBGP_DEV_INSECURE: "1"
volumes:
- bird_etc:/etc/bird
+6
View File
@@ -138,6 +138,12 @@ services:
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_SERVICE: evobgp-all
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
# Portal SSO (JWT) — см. docs/access.md / auth-portal integrate-evobgp.md
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
# DEV ONLY — не для production (см. docs/access.md).
EVOBGP_DEV_INSECURE: "1"
volumes:
+33 -12
View File
@@ -12,7 +12,24 @@
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
| `EVOBGP_PORTAL_TENANT_ID` | Tenant для всех portal JWT (обязателен при JWT) |
| `AUTH_AUDIT_INGEST_SECRET` / `EVOBGP_AUTH_AUDIT_INGEST_SECRET` | Shared secret для push CRUD audit в auth-portal (`POST /api/v1/ingest/audit`, `source_app=bgp`) |
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
Источник tenant (по приоритету):
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
2. Env `EVOBGP_PORTAL_TENANT_ID`
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required``AUTH_REQUIRED`, `portal_url``AUTH_PORTAL_URL`).
Проверка после рестарта:
```bash
curl -sS https://bgp.shnt.top/v1/auth/config
# {"required":true,"portal_url":"https://auth.shnt.top"}
```
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
@@ -44,14 +61,18 @@ opkey|01ARZ3NDEKTSV4RRFFQ69G5FAV|operator,nodekey|01ARZ3NDEKTSV4RRFFQ69G5FAV|nod
### Управление через API и UI
При подключённой БД operator может:
При подключённой БД управлять ключами может:
- `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
- В веб-панели: **Права доступа** (`/access`) → блок «API-ключи» (только для роли `operator`). Токен для браузера — в **Настройки** (`/settings`).
- API-ключ с ролью **`operator`**, или
- portal JWT с **`is_admin`** / правом **`bgp:access:admin`** (админ auth-portal).
Эндпоинты: `GET|POST /v1/api-keys`, `GET|PATCH|DELETE /v1/api-keys/{id}`, `POST /v1/api-keys/{id}/rotate` — см. OpenAPI, тег **API keys**.
В веб-панели: **Права доступа** (`/access`) → блок «API-ключи». Токен для браузера (API-key gate) — в **Настройки** (`/settings`).
Полный токен возвращается **один раз** в ответе `201` (создание) и `200` (ротация). В списках — только `prefix` (первые 8 символов). В БД хранится SHA-256 токена, не plaintext.
`GET /v1/auth/session` текущие `tenant_id` и `role` (для UI).
`GET /v1/auth/session``tenant_id`, `kind` (`apikey`|`jwt`), для API-ключа — `role`; для JWT — `user_id`, `email`, `permissions`, `is_admin`.
### Роли
@@ -162,13 +183,13 @@ http://localhost:5173,http://127.0.0.1:5173,https://ui.example.com
## Краткая матрица (ориентир)
| Действие | viewer | editor | operator | node |
|----------|--------|--------|----------|------|
| GET модули, ревизии, peers, speakers | да | да | да | нет |
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет |
| apply, rollback, PATCH settings | нет | нет | да | нет |
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет |
| bundle, latest revision, enroll | нет | нет | нет | да |
| Действие | viewer | editor | operator | node | portal admin / `bgp:access:admin` |
|----------|--------|--------|----------|------|-----------------------------------|
| GET модули, ревизии, peers, speakers | да | да | да | нет | по permissions |
| POST/PATCH/DELETE CRUD сущностей | нет | да | да | нет | по permissions |
| apply, rollback, PATCH settings | нет | нет | да | нет | `bgp:operations:admin` |
| Управление API-ключами (`/v1/api-keys`) | нет | нет | да | нет | да |
| bundle, latest revision, enroll | нет | нет | нет | да | нет |
Точные проверки по каждому маршруту — в коде `internal/httpapi` и в схеме безопасности операций в OpenAPI.
+10
View File
@@ -116,6 +116,16 @@
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
## CRUD audit (`/v1/audit`)
Локальный журнал изменений CRUD (modules, peers, settings, API keys, …). Миграция `000030_audit_log`. Чтение — `bgp:monitoring:read` (viewer+).
| Метод | Путь | Роль | Назначение |
|-------|------|------|------------|
| `GET` | `/v1/audit` | viewer+ | Пагинированный audit (`cursor`, `limit`, опционально `action`, `severity`) |
При `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` каждая запись дополнительно отправляется в auth-portal (`POST /api/v1/ingest/audit`, `source_app=bgp`).
## Соглашения из OpenAPI
- Ошибки в стиле **RFC 9457** (`application/problem+json`): `type`, `title`, `status`, `detail`, и т.д.
+9 -46
View File
@@ -1,52 +1,15 @@
# Firewall blocklist
# Firewall (deprecated in EvoBGP)
Подсистема синхронизации blocklist на произвольные Linux-серверы через bash-скрипт и HTTP API.
**Hard cutover:** подсистема firewall перенесена в отдельный продукт **[EvoFirewall](https://git.shts.su/denozord/EvoFirewall)**.
## Авторизация
Все HTTP-эндпоинты `/v1/firewall/*` в EvoBGP отвечают **410 Gone**.
1. **Enroll**`POST /v1/firewall/enroll` с заголовком `X-EvoBGP-Seed` (значение `EVOBGP_BUNDLE_SEED_HEX` на CP). Клиент генерирует токен `evobgp_fw_*` локально.
2. **Approve** — operator в Web UI (`/firewall` → Запросы).
3. **Sync**`GET /v1/firewall/blocklist` с `Authorization: Bearer <client_token>`.
Таблицы `firewall_client` / `firewall_rule` в БД оставлены (не удаляются миграциями) для истории; API/UI/scripts больше не обслуживают их.
## Политика block/accept
## Миграция клиентов
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
- **`accept`** — не блокировать префиксы этого community.
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
- **Default** — accept (пустой blocklist без явных `block`).
1. Разверните EvoFirewall (auth-portal app id `fw`).
2. Переустановите агенты one-liner'ом EvoFirewall (`/v1/agent/install.sh`).
3. Для списков по community создайте IP list type `evobgp_community` и укажите `EVOBGP_API_URL` + token в настройках EvoFirewall.
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
Справочник communities: Web UI → Справочники, или модули с привязкой community к префиксам.
## Установка на сервер
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
Требуется миграция **`000027_firewall`** в PostgreSQL (применяется при старте API с актуальным бинарём). Если enroll отвечает `503` / `database schema outdated` — перезапустите `evobgp-api` / `evobgp-all` после деплоя новой версии.
```bash
curl -fsSL https://<api>/v1/firewall/install.sh | \
EVOBGP_CP_URL=https://<api> \
EVOBGP_SEED=<bundle_seed_hex> \
EVOBGP_CLIENT_NAME="web-01" \
bash
```
Файлы: `/etc/evobgp/firewall.conf`, `/usr/local/sbin/evobgp-firewall.sh`, systemd timer `evobgp-firewall.timer`.
После **approve** в UI выполните на сервере (или дождитесь timer):
```bash
sudo rm -f /var/lib/evobgp-firewall/last_hash
sudo /usr/local/sbin/evobgp-firewall.sh
sudo nft list table inet evobgp_blocklist
```
Для парсинга JSON нужен `jq` или `python3` (install.sh ставит `jq` на Debian/Ubuntu при отсутствии).
## Failover через speaker
При `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` на speaker-agent CP реплицирует состояние через `POST /v1/agent/firewall-replicate`. Клиенты используют тот же DNS-домен.
См. также [access.md](access.md), [remote-speakers.md](remote-speakers.md).
Старые токены `evobgp_fw_*` **не** переносятся — только re-enroll.
+122 -3
View File
@@ -13,7 +13,7 @@ info:
**Аутентификация (dual):**
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `EVOBGP_PORTAL_TENANT_ID`.
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
@@ -59,6 +59,8 @@ tags:
description: Сессия текущего API-ключа (tenant и роль).
- name: Monitoring
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
- name: Audit
description: Журнал CRUD-изменений tenant (локально + опциональный push в auth-portal). Чтение — bgp:monitoring:read.
- name: Maintenance
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
- name: RuntimeLogs
@@ -676,13 +678,32 @@ components:
AuthSession:
type: object
required: [tenant_id, role]
required: [tenant_id, kind]
properties:
tenant_id:
$ref: "#/components/schemas/ResourceId"
kind:
type: string
enum: [apikey, jwt]
description: apikey — Bearer API key; jwt — portal SSO token.
role:
type: string
enum: [viewer, editor, operator, node]
description: >
API-key role (viewer|editor|operator|node). Empty string for portal JWT sessions.
user_id:
type: string
description: JWT sub (portal sessions only).
email:
type: string
description: JWT email claim (portal sessions only).
permissions:
type: array
items:
type: string
description: JWT permissions (bgp:*); portal sessions only.
is_admin:
type: boolean
description: Portal is_admin claim; grants all bgp permissions.
ApiKey:
type: object
@@ -1278,6 +1299,70 @@ components:
has_more:
type: boolean
AuditSeverity:
type: string
enum: [info, warning, critical]
AuditLogEntry:
type: object
required:
[id, tenant_id, event_id, source_app, action, severity, summary, created_at]
properties:
id:
$ref: "#/components/schemas/ResourceId"
tenant_id:
$ref: "#/components/schemas/ResourceId"
event_id:
type: string
description: Stable id for portal ingest deduplication (prefix bgp-).
source_app:
type: string
enum: [bgp]
action:
type: string
description: Machine action key (e.g. bgp.module.create).
severity:
$ref: "#/components/schemas/AuditSeverity"
actor_user_id:
type: ["string", "null"]
actor_email:
type: ["string", "null"]
actor_name:
type: ["string", "null"]
actor_api_key_prefix:
type: ["string", "null"]
target_type:
type: ["string", "null"]
enum: [app_resource, null]
target_id:
type: ["string", "null"]
summary:
type: string
details:
type: ["object", "null"]
additionalProperties: true
ip:
type: ["string", "null"]
created_at:
type: string
format: date-time
portal_pushed_at:
type: ["string", "null"]
format: date-time
AuditLogList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/AuditLogEntry"
next_cursor:
type: string
has_more:
type: boolean
RuntimeLogAutoPolicy:
type: object
properties:
@@ -4501,6 +4586,40 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/audit:
get:
tags: [Audit]
summary: Журнал CRUD audit tenant
description: |
Локальный журнал изменений (modules, peers, settings, API keys и т.д.).
При настроенных `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` события также
отправляются в auth-portal ingest (`source_app=bgp`).
operationId: listAuditLog
parameters:
- $ref: "#/components/parameters/TenantId"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
- name: action
in: query
schema:
type: string
description: Filter by action prefix/key (exact match).
- name: severity
in: query
schema:
$ref: "#/components/schemas/AuditSeverity"
responses:
"200":
description: Успешно.
content:
application/json:
schema:
$ref: "#/components/schemas/AuditLogList"
"400":
$ref: "#/components/responses/BadRequest"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/settings:
get:
tags: [Settings]
+49 -30
View File
@@ -1,100 +1,119 @@
# UI Design Contract (ops apps)
Единый контракт для vps-tracker, CFDM и EvoBGP. Surface: **ReUI Frame**. Kit API: `apps/web/src/components/reui-kit/`.
Единый контракт для **CFDM · vps-tracker · EvoBGP · EvoFirewall · auth-portal**.
Surface: **ReUI Frame**. Kit: `apps/web/src/components/reui-kit/`.
Иерархия: **ReUI PRO > shadcn primitives**.
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
## Surface
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
```ts
// apps/web/src/lib/ui-surface.ts
// apps/web/src/lib/ui-surface.ts (где есть)
export const UI_SURFACE = 'frame' as const
```
Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
Settings: секции через Frame + `gap` (без hairline `Separator` под PageHeader); `SettingRow` без `FieldSeparator` по умолчанию (`separated` opt-in). Preview: [settings-3](https://reui.io/preview/base/settings-3) · [settings-16](https://reui.io/preview/base/settings-16).
## Canonical PRO references
| Зона | Block | Preview |
|------|-------|---------|
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
| KPI | horizontal compact hybrid (EvoBGP SoT: icon left + label/Badge + value ± variant) | https://reui.io/preview/base/stats-12 |
| Quick Actions | Frame tiles (sibling KPI) + Badge «Перейти» | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
| Lookup | `/lookup` — Frame form + `KpiStatGrid` + DataGrid | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/empty-state-2 |
## Kit API (`reui-kit/`)
| Component | Role |
|-----------|------|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `ui_show_quick_actions`) |
| `OpsDashboard` | KPI + charts + attention queue |
| `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) |
| `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) |
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
| `SettingsShell` | settings nav + Outlet |
| `DetailPanel` | detail Frame sections |
| `filter-utils` | apply/clear ReUI Filters |
`KpiStatGrid` / `QuickActionGrid` markup — SoT **EvoBGP**; в остальных apps diff только `@scope/ui` imports.
## Dashboard layout
| App | Section order |
|-----|---------------|
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
| EvoBGP / CFDM / EvoFirewall | KPI → **QuickActionGrid** → charts / rest |
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
| auth-portal | portal-specific; Quick Actions при наличии dashboard |
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
## Shared App Shell chrome
Эталон: **EvoBGP** production [`apps/web/src/components/layout/app-shell.tsx`](../apps/web/src/components/layout/app-shell.tsx) + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav labels/hrefs и `main` content. Разметка, ширина, фон и hover chrome идентичны.
Эталон разметки: production apps + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
При переключении между apps меняются **только** sidebar nav labels/hrefs и `main` content.
| Токен / зона | Значение |
|--------------|----------|
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px``packages/ui` sidebar + Provider style) |
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css`**без** AppShell `color-mix` override |
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` |
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent`**без** AppShell `color-mix` override |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu****SystemMonitorPopover** **ModeToggle** (без Search в chrome) |
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4`**пустой** `SidebarFooter` |
| Header right | **AppsMenu****SystemMonitorPopover** (тема — в NavUser) |
| Sidebar | AppSwitcher → groups → icons `size-4`**NavUser** в `SidebarFooter` |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
Запрещено в chrome: `SidebarRail`, sync-row footer, Search pill в header, issues Badge в header, `ModeToggle` в header (тема только в NavUser), Provider `color-mix` для `--sidebar*`.
App Switcher: source of truth — auth-portal `GET /api/v1/app-switcher`. Id: `bgp`. Admin: portal `/admin/apps`.
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
## System monitor
`SystemMonitorPopover` in app-shell header next to `ModeToggle` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
`SystemMonitorPopover` in header after AppsMenu. Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
## MCP workflow
1. MCP `user-reui``search` / `get_block` / `get_component` with `surface: "frame"`
1. MCP `user-reui``search` / `get_block` / `compose_page` / `get_component` with `surface: "frame"`
2. Cite `previewUrl` + `docsUrl`
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
4. Adapt into kit — do not hand-roll KPI/grid/settings rows
4. Adapt into kit — do not hand-roll KPI / Quick Actions / grid / settings rows
5. `validate_usage` / `get_audit_checklist`
Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
Primitives: MCP `plugin-shadcn-shadcn` + project `@scope/ui` (`@cfdm/ui` / `@evobgp/ui` / `@evofw/ui` / `@authportal/ui`).
## License
```env
# apps/web/.env.local (gitignored)
REUI_LICENSE_KEY=
```
`apps/web/components.json``@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Spacing
- AppShell main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` (shared chrome)
- AppShell main / PageShell: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5`
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
- Max 1 primary CTA per screen
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`
- Semantic tokens only — no raw `bg-emerald-*`
## Forbidden
- Card as ops list/dashboard shell
- Hand-rolled data tables when ReUI DataGrid exists
- Hand-rolled KPI grids when `KpiStatGrid` exists
- Hand-rolled data tables when ReUI DataGrid / `ResourcePage` exists
- Hand-rolled KPI when `KpiStatGrid` exists
- Hand-rolled Quick Actions when `QuickActionGrid` exists
- SectionCards / DataGridCard as design эталон
- Mixing Card and Frame surfaces on one ops screen
+118
View File
@@ -0,0 +1,118 @@
// Package audit pushes local audit events to auth-portal ingest API.
package audit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"evobgp/internal/httpclient"
"evobgp/internal/store"
)
const ingestPath = "/api/v1/ingest/audit"
// PortalPusher sends audit rows to auth-portal (best-effort, async-friendly).
type PortalPusher struct {
BaseURL string
Secret string
HTTPClient *http.Client
MarkPushed func(id string) error
}
// PushEvent posts one audit entry to portal ingest.
func (p *PortalPusher) PushEvent(ctx context.Context, entry *store.AuditEntry) error {
if p == nil || entry == nil {
return nil
}
base := strings.TrimRight(strings.TrimSpace(p.BaseURL), "/")
secret := strings.TrimSpace(p.Secret)
if base == "" || secret == "" {
return nil
}
hc := p.HTTPClient
if hc == nil {
hc = httpclient.New(15 * time.Second)
}
body := map[string]any{
"events": []map[string]any{p.eventPayload(entry)},
}
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("audit: marshal ingest: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+ingestPath, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+secret)
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("audit: portal ingest: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("audit: portal ingest %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
if p.MarkPushed != nil {
if err := p.MarkPushed(entry.ID); err != nil {
log.Printf("audit: mark portal pushed id=%s: %v", entry.ID, err)
}
}
return nil
}
func (p *PortalPusher) eventPayload(entry *store.AuditEntry) map[string]any {
ev := map[string]any{
"event_id": entry.EventID,
"source_app": store.AuditSourceAppBGP,
"action": entry.Action,
"severity": entry.Severity,
"summary": entry.Summary,
"created_at": entry.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if entry.ActorUserID != "" {
ev["actor_user_id"] = entry.ActorUserID
} else {
ev["actor_user_id"] = nil
}
if entry.ActorEmail != "" {
ev["actor_email"] = entry.ActorEmail
} else {
ev["actor_email"] = nil
}
if entry.ActorName != "" {
ev["actor_name"] = entry.ActorName
} else {
ev["actor_name"] = nil
}
if entry.TargetType != "" {
ev["target_type"] = entry.TargetType
} else {
ev["target_type"] = nil
}
if entry.TargetID != "" {
ev["target_id"] = entry.TargetID
} else {
ev["target_id"] = nil
}
if entry.Details != nil {
ev["details"] = entry.Details
} else {
ev["details"] = nil
}
if entry.IP != "" {
ev["ip"] = entry.IP
} else {
ev["ip"] = nil
}
return ev
}
+63
View File
@@ -0,0 +1,63 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"evobgp/internal/store"
)
func TestPortalPusherPushEvent(t *testing.T) {
var got struct {
Events []map[string]any `json:"events"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != ingestPath {
t.Fatalf("path=%s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-secret" {
t.Fatalf("auth=%q", r.Header.Get("Authorization"))
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]int{"accepted": 1, "duplicates": 0})
}))
defer srv.Close()
marked := false
p := &PortalPusher{
BaseURL: srv.URL,
Secret: "test-secret",
MarkPushed: func(id string) error {
marked = id == "local-id"
return nil
},
}
entry := &store.AuditEntry{
ID: "local-id",
EventID: "bgp-test-event",
Action: "bgp.module.create",
Severity: store.AuditSeverityInfo,
Summary: "Created module",
SourceApp: store.AuditSourceAppBGP,
CreatedAt: time.Now().UTC(),
TargetType: store.AuditTargetAppResource,
TargetID: "mod-1",
}
if err := p.PushEvent(context.Background(), entry); err != nil {
t.Fatal(err)
}
if len(got.Events) != 1 {
t.Fatalf("events=%d", len(got.Events))
}
if got.Events[0]["source_app"] != "bgp" {
t.Fatalf("source_app=%v", got.Events[0]["source_app"])
}
if !marked {
t.Fatal("expected mark pushed")
}
}
+23 -4
View File
@@ -160,9 +160,6 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
if strings.TrimSpace(s.portalTenantID) == "" {
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
}
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
@@ -190,12 +187,19 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
if strings.TrimSpace(sub) == "" {
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
}
tenantID := tenantIDFromClaims(claims)
if tenantID == "" {
tenantID = strings.TrimSpace(s.portalTenantID)
}
if tenantID == "" {
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (set bgp tenant in auth-portal App Switcher or EVOBGP_PORTAL_TENANT_ID)", false
}
email, _ := claims["email"].(string)
perms := coerceStringSlice(claims["permissions"])
isAdmin, _ := claims["is_admin"].(bool)
return Auth{
Kind: AuthKindJWT,
TenantID: s.portalTenantID,
TenantID: tenantID,
UserID: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Permissions: perms,
@@ -204,6 +208,21 @@ func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
}, 0, "", true
}
// tenantIDFromClaims prefers tenants.bgp, then bgp_tenant_id.
func tenantIDFromClaims(claims jwt.MapClaims) string {
if m, ok := claims["tenants"].(map[string]any); ok {
if v, ok := m["bgp"].(string); ok {
if tid := strings.TrimSpace(v); tid != "" {
return tid
}
}
}
if v, ok := claims["bgp_tenant_id"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
func coerceStringSlice(v any) []string {
switch t := v.(type) {
case []string:
+77
View File
@@ -181,6 +181,83 @@ func TestAuthJWTMissingPermissionRejected(t *testing.T) {
}
}
func TestAuthJWTTenantFromClaimWithoutEnv(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
// No PortalTenantID — must come from JWT claim.
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"bgp_tenant_id": tenant,
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
}
func TestAuthJWTRejectedWhenTenantMissing(t *testing.T) {
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"bgp"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status=%d want 503", resp.StatusCode)
}
}
func TestAuthConfigPublic(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
-118
View File
@@ -1,118 +0,0 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
"evobgp/internal/nodedispatch"
"evobgp/internal/store"
)
func (s *Server) replicateFirewallStateToSpeakers(tenantID string) {
if !nodedispatch.Enabled() {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
clients, err := s.store.ListApprovedFirewallClientsForReplication(tenantID)
if err != nil {
log.Printf("httpapi: firewall replicate clients: %v", err)
return
}
rules, err := s.store.ListAllFirewallRulesForReplication(tenantID)
if err != nil {
log.Printf("httpapi: firewall replicate rules: %v", err)
return
}
revs, _, _ := s.store.ListRevisions(tenantID, "", "", 1)
if len(revs) == 0 {
return
}
revID := revs[0].ID
prefixesByCommunity, _, err := s.loadPrefixesByCommunity(tenantID, revID)
if err != nil {
log.Printf("httpapi: firewall replicate prefixes: %v", err)
return
}
payloadRules := make([]map[string]any, 0, len(rules))
for _, r := range rules {
payloadRules = append(payloadRules, map[string]any{
"client_id": r.ClientID,
"priority": r.Priority,
"action": r.Action,
"community_id": r.CommunityID,
})
}
payloadClients := make([]map[string]any, 0, len(clients))
for _, c := range clients {
payloadClients = append(payloadClients, map[string]any{
"token_hash_hex": c.TokenHashHex,
"client_id": c.ClientID,
"name": c.Name,
})
}
body := map[string]any{
"tenant_id": tenantID,
"revision_id": revID,
"generated_at": time.Now().UTC().Format(time.RFC3339),
"clients": payloadClients,
"rules": payloadRules,
"prefixes_by_community": prefixesByCommunity,
}
speakers := s.store.ListSpeakersForTenant(tenantID)
for _, sp := range speakers {
meta := store.ParseSpeakerMeta(sp.MetaJSON)
if !store.SpeakerNeedsRemoteDispatch(sp.Role, meta) || !meta.FirewallFailover {
continue
}
domain := strings.TrimSpace(meta.AgentDomain)
if domain == "" {
continue
}
url := "https://" + strings.TrimSuffix(domain, "/") + "/v1/agent/firewall-replicate"
status, errMsg := postFirewallReplicate(ctx, url, meta.AgentSecret, body)
patch := store.SpeakerMeta{
LastFirewallReplicateAt: time.Now().UTC().Format(time.RFC3339Nano),
LastFirewallReplicateStatus: status,
LastFirewallReplicateError: errMsg,
}
merged := store.MergeSpeakerMetaJSON(sp.MetaJSON, patch)
mp := merged
if _, err := s.store.UpdateSpeaker(tenantID, sp.ID, &store.SpeakerPatch{MetaJSON: &mp}); err != nil {
log.Printf("httpapi: firewall replicate meta update %s: %v", sp.ID, err)
}
}
}
func postFirewallReplicate(ctx context.Context, url, secret string, body map[string]any) (status, errMsg string) {
b, err := json.Marshal(body)
if err != nil {
return "error", err.Error()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return "error", err.Error()
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(secret))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "error", err.Error()
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return "ok", ""
}
return "error", resp.Status
}
+4 -4
View File
@@ -36,9 +36,9 @@ func (s *Server) Handler() http.Handler {
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
// Firewall subsystem moved to the standalone EvoFirewall service; see docs/firewall.md.
// Registered on the public mux so it wins over the "/v1/" subtree below regardless of auth.
s.mux.HandleFunc("/v1/firewall/", s.handleFirewallGone)
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
return s.withCORS(observability.HTTPMiddleware(s.mux))
}
@@ -83,11 +83,11 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
s.registerCRUDRoutes(m)
s.registerAuditRoutes(m)
s.registerPostgresMonitoringRoutes(m)
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
s.registerRuntimeLogsRoutes(m)
s.registerFirewallRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+6 -1
View File
@@ -131,6 +131,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
}
out := apiKeyJSON(&created.APIKey)
out["token"] = created.Token
s.recordCRUDAudit(r, a, "bgp.api_key.create", "Created API key "+created.Name, created.ID, map[string]any{"api_key_id": created.ID, "role": created.Role})
writeJSON(w, http.StatusCreated, out)
}
@@ -187,6 +188,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
s.recordCRUDAudit(r, a, "bgp.api_key.update", "Updated API key "+k.Name, k.ID, map[string]any{"api_key_id": k.ID, "role": k.Role})
writeJSON(w, http.StatusOK, apiKeyJSON(k))
}
@@ -195,7 +197,8 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
keyID := r.PathValue("id")
if err := s.store.RevokeAPIKey(a.TenantID, keyID); err != nil {
writeStoreErr(w, err)
return
}
@@ -203,6 +206,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "failed to reload api keys")
return
}
s.recordCRUDAudit(r, a, "bgp.api_key.revoke", "Revoked API key", keyID, map[string]any{"api_key_id": keyID})
w.WriteHeader(http.StatusNoContent)
}
@@ -222,5 +226,6 @@ func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
}
out := apiKeyJSON(&rotated.APIKey)
out["token"] = rotated.Token
s.recordCRUDAudit(r, a, "bgp.api_key.rotate", "Rotated API key "+rotated.Name, rotated.ID, map[string]any{"api_key_id": rotated.ID})
writeJSON(w, http.StatusOK, out)
}
+171
View File
@@ -0,0 +1,171 @@
package httpapi
import (
"context"
"log"
"net"
"net/http"
"strings"
"time"
"evobgp/internal/audit"
"evobgp/internal/store"
)
func (s *Server) registerAuditRoutes(m *http.ServeMux) {
m.HandleFunc("GET /audit", s.handleListAudit)
}
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
cursor := r.URL.Query().Get("cursor")
limit := parseLimitQuery(r, 20, 200)
filter := store.AuditListFilter{
Action: strings.TrimSpace(r.URL.Query().Get("action")),
Severity: strings.TrimSpace(r.URL.Query().Get("severity")),
}
if filter.Severity != "" && !store.ValidAuditSeverity(filter.Severity) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid severity")
return
}
items, next, hasMore, err := s.store.ListAudit(a.TenantID, cursor, limit, filter)
if err != nil {
writeInternalError(w, "audit_list", err)
return
}
out := make([]map[string]any, 0, len(items))
for _, row := range items {
out = append(out, auditEntryJSON(row))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore})
}
func auditEntryJSON(row *store.AuditEntry) map[string]any {
if row == nil {
return map[string]any{}
}
m := map[string]any{
"id": row.ID,
"tenant_id": row.TenantID,
"event_id": row.EventID,
"source_app": row.SourceApp,
"action": row.Action,
"severity": row.Severity,
"actor_user_id": strPtrOrNull(row.ActorUserID),
"actor_email": strPtrOrNull(row.ActorEmail),
"actor_name": strPtrOrNull(row.ActorName),
"actor_api_key_prefix": strPtrOrNull(row.ActorAPIKeyPrefix),
"target_type": strPtrOrNull(row.TargetType),
"target_id": strPtrOrNull(row.TargetID),
"summary": row.Summary,
"details": row.Details,
"ip": strPtrOrNull(row.IP),
"created_at": row.CreatedAt.UTC().Format(time.RFC3339Nano),
"portal_pushed_at": nil,
}
if row.PortalPushedAt != nil {
m["portal_pushed_at"] = row.PortalPushedAt.UTC().Format(time.RFC3339Nano)
}
if m["details"] == nil {
m["details"] = nil
}
return m
}
func (s *Server) recordCRUDAudit(r *http.Request, a Auth, action, summary, targetID string, details map[string]any) {
if s == nil || s.store == nil {
return
}
in := store.AuditAppendInput{
TenantID: a.TenantID,
Action: action,
Severity: store.AuditSeverityInfo,
TargetType: store.AuditTargetAppResource,
TargetID: targetID,
Summary: summary,
Details: details,
IP: clientIP(r),
}
fillAuditActor(&in, a)
entry, err := s.store.AppendAudit(in)
if err != nil {
log.Printf("httpapi: audit append action=%s: %v", action, err)
return
}
s.pushAuditToPortal(entry)
}
func fillAuditActor(in *store.AuditAppendInput, a Auth) {
if in == nil {
return
}
if a.Kind == AuthKindJWT {
in.ActorUserID = strings.TrimSpace(a.UserID)
in.ActorEmail = strings.TrimSpace(a.Email)
if in.ActorEmail != "" {
in.ActorName = in.ActorEmail
}
return
}
prefix := actorPrefix(a)
in.ActorAPIKeyPrefix = prefix
if prefix != "" {
in.ActorName = "apikey:" + prefix
}
}
func (s *Server) pushAuditToPortal(entry *store.AuditEntry) {
if s == nil || s.auditPusher == nil || entry == nil {
return
}
pusher := s.auditPusher
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := pusher.PushEvent(ctx, entry); err != nil {
log.Printf("httpapi: audit portal push event_id=%s: %v", entry.EventID, err)
}
}()
}
func clientIP(r *http.Request) string {
if r == nil {
return ""
}
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
parts := strings.Split(xff, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
return xrip
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
// initAuditPusher wires portal push when URL and secret are configured.
func (s *Server) initAuditPusher(portalURL, ingestSecret string) {
base := strings.TrimSpace(portalURL)
secret := strings.TrimSpace(ingestSecret)
if base == "" || secret == "" {
return
}
s.auditPusher = &audit.PortalPusher{
BaseURL: base,
Secret: secret,
MarkPushed: func(id string) error {
if s.store == nil {
return nil
}
return s.store.MarkAuditPortalPushed(id)
},
}
}
+52
View File
@@ -0,0 +1,52 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"evobgp/internal/store"
)
func TestHandleListAudit(t *testing.T) {
mem := store.NewMemory()
mem.SeedDemo()
tenant, _, _, _, _ := mem.DemoIDs()
srv, err := New(Options{SeedDemo: false, InsecureDev: true})
if err != nil {
t.Fatal(err)
}
srv.store = mem
_, err = mem.AppendAudit(store.AuditAppendInput{
TenantID: tenant,
Action: "bgp.module.create",
Summary: "Created module demo",
TargetID: "mod-x",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/v1/audit", nil)
req.Header.Set("Authorization", "Bearer dev")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Items) != 1 {
t.Fatalf("items=%d", len(body.Items))
}
if body.Items[0]["action"] != "bgp.module.create" {
t.Fatalf("action=%v", body.Items[0]["action"])
}
}
+55 -7
View File
@@ -8,6 +8,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -113,6 +114,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.module.create", "Created module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "type": mod.Type, "name": mod.Name})
writeJSON(w, http.StatusCreated, moduleJSON(mod))
}
@@ -174,6 +176,7 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.module.update", "Updated module "+mod.Name, mod.ID, map[string]any{"module_id": mod.ID, "name": mod.Name})
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
@@ -193,6 +196,7 @@ func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.module.delete", "Deleted module", moduleID, map[string]any{"module_id": moduleID})
w.WriteHeader(http.StatusNoContent)
}
@@ -369,6 +373,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.cdn_source.create", "Created CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID, "url": x.URL})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_create")
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
}
@@ -399,6 +404,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.cdn_source.update", "Updated CDN source", x.ID, map[string]any{"module_id": mid, "source_id": x.ID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_patch")
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
}
@@ -409,10 +415,12 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteCDNSource(a.TenantID, mid, r.PathValue("source_id")); err != nil {
sourceID := r.PathValue("source_id")
if err := s.store.DeleteCDNSource(a.TenantID, mid, sourceID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.cdn_source.delete", "Deleted CDN source", sourceID, map[string]any{"module_id": mid, "source_id": sourceID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "cdn_source_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -471,6 +479,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.as_entry.create", "Created AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_create")
writeJSON(w, http.StatusCreated, asEntryJSON(x))
}
@@ -491,6 +500,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.as_entry.update", "Updated AS entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "asn": x.ASN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_patch")
writeJSON(w, http.StatusOK, asEntryJSON(x))
}
@@ -501,10 +511,12 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteASEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
entryID := r.PathValue("entry_id")
if err := s.store.DeleteASEntry(a.TenantID, mid, entryID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.as_entry.delete", "Deleted AS entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "as_entry_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -548,6 +560,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.domain_entry.create", "Created domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_create")
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
}
@@ -568,6 +581,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.domain_entry.update", "Updated domain entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "fqdn": x.FQDN})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_patch")
writeJSON(w, http.StatusOK, domainEntryJSON(x))
}
@@ -578,10 +592,12 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteDomainEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
entryID := r.PathValue("entry_id")
if err := s.store.DeleteDomainEntry(a.TenantID, mid, entryID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.domain_entry.delete", "Deleted domain entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "domain_entry_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -625,6 +641,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.ip_range.create", "Created IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create")
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
}
@@ -645,6 +662,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.ip_range.update", "Updated IP range entry", x.ID, map[string]any{"module_id": mid, "entry_id": x.ID, "prefix": x.Prefix})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch")
writeJSON(w, http.StatusOK, ipRangeJSON(x))
}
@@ -655,10 +673,12 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
return
}
mid := r.PathValue("module_id")
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil {
entryID := r.PathValue("entry_id")
if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, entryID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.ip_range.delete", "Deleted IP range entry", entryID, map[string]any{"module_id": mid, "entry_id": entryID})
s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -850,6 +870,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.doh_profile.create", "Created DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
writeJSON(w, http.StatusCreated, dohJSON(x))
}
@@ -868,6 +889,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.doh_profile.update", "Updated DoH profile "+x.Name, x.ID, map[string]any{"profile_id": x.ID, "name": x.Name})
writeJSON(w, http.StatusOK, dohJSON(x))
}
@@ -876,10 +898,12 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
profileID := r.PathValue("id")
if err := s.store.DeleteDohProfile(a.TenantID, profileID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.doh_profile.delete", "Deleted DoH profile", profileID, map[string]any{"profile_id": profileID})
w.WriteHeader(http.StatusNoContent)
}
@@ -936,6 +960,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.community.create", "Created community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
writeJSON(w, http.StatusCreated, commJSON(x))
}
@@ -954,6 +979,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.community.update", "Updated community "+x.Community, x.ID, map[string]any{"community_id": x.ID, "community": x.Community})
writeJSON(w, http.StatusOK, commJSON(x))
}
@@ -962,10 +988,12 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
commID := r.PathValue("id")
if err := s.store.DeleteCommunity(a.TenantID, commID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.community.delete", "Deleted community", commID, map[string]any{"community_id": commID})
w.WriteHeader(http.StatusNoContent)
}
@@ -988,6 +1016,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.peer.create", "Created BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
s.enqueuePeerReconcile(a.TenantID, "peer_create")
writeJSON(w, http.StatusCreated, peerJSON(x))
}
@@ -1031,6 +1060,7 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.peer.update", "Updated BGP peer "+x.Name, x.ID, map[string]any{"peer_id": x.ID, "neighbor": x.Neighbor})
s.enqueuePeerReconcile(a.TenantID, "peer_patch")
writeJSON(w, http.StatusOK, peerJSON(x))
}
@@ -1051,6 +1081,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.peer.delete", "Deleted BGP peer", peerID, map[string]any{"peer_id": peerID})
s.enqueuePeerReconcile(a.TenantID, "peer_delete")
w.WriteHeader(http.StatusNoContent)
}
@@ -1074,6 +1105,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.speaker.create", "Created speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
resp := speakerJSONFromStore(s.store, x)
if meta := store.ParseSpeakerMeta(x.MetaJSON); meta.AgentSecret != "" {
resp["agent_secret"] = meta.AgentSecret
@@ -1109,6 +1141,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.speaker.update", "Updated speaker "+x.ID, x.ID, map[string]any{"speaker_id": x.ID, "role": x.Role})
writeJSON(w, http.StatusOK, speakerJSONFromStore(s.store, x))
}
@@ -1117,10 +1150,12 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
speakerID := r.PathValue("speaker_id")
if err := s.store.DeleteSpeaker(a.TenantID, speakerID); err != nil {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.speaker.delete", "Deleted speaker", speakerID, map[string]any{"speaker_id": speakerID})
w.WriteHeader(http.StatusNoContent)
}
@@ -1187,9 +1222,22 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
s.recordCRUDAudit(r, a, "bgp.settings.update", "Updated tenant settings", a.TenantID, map[string]any{"keys": settingsAuditKeys(body)})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func settingsAuditKeys(body map[string]any) []string {
if len(body) == 0 {
return nil
}
keys := make([]string, 0, len(body))
for k := range body {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func parseRevisionRetentionMinutes(v any) (int, bool) {
const minMinutes = 15
const maxMinutes = 30 * 24 * 60
+12 -705
View File
@@ -1,709 +1,16 @@
package httpapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
import "net/http"
"evobgp/internal/authkey"
"evobgp/internal/firewall"
"evobgp/internal/firewallscripts"
"evobgp/internal/store"
)
func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext)
m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients)
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview)
m.HandleFunc("PATCH /firewall/clients/{id}", s.handlePatchFirewallClient)
m.HandleFunc("POST /firewall/clients/{id}/approve", s.handleApproveFirewallClient)
m.HandleFunc("POST /firewall/clients/{id}/revoke", s.handleRevokeFirewallClient)
m.HandleFunc("DELETE /firewall/clients/{id}", s.handleDeleteFirewallClient)
m.HandleFunc("GET /firewall/rules", s.handleListFirewallRules)
m.HandleFunc("POST /firewall/rules", s.handleCreateFirewallRule)
m.HandleFunc("PATCH /firewall/rules/{id}", s.handlePatchFirewallRule)
m.HandleFunc("DELETE /firewall/rules/{id}", s.handleDeleteFirewallRule)
m.HandleFunc("POST /firewall/rules:reorder", s.handleReorderFirewallRules)
m.HandleFunc("GET /firewall/blocklist", s.handleFirewallBlocklist)
m.HandleFunc("POST /firewall/apply-report", s.handleFirewallApplyReport)
m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat)
}
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
seed := strings.TrimSpace(s.bundleSeedHex)
writeJSON(w, http.StatusOK, map[string]any{
"bundle_seed": seed,
"bundle_seed_configured": seed != "",
"suggested_cp_url": publicHTTPSBaseURL(r),
"install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh",
})
}
// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links.
func publicHTTPSBaseURL(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" {
host = strings.TrimSpace(strings.Split(xf, ",")[0])
}
if host == "" {
return ""
}
return "https://" + host
}
func requestBaseURL(r *http.Request) string {
return publicHTTPSBaseURL(r)
}
func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
return
}
seed := strings.TrimSpace(r.Header.Get("X-EvoBGP-Seed"))
if seed == "" || s.bundleSeedHex == "" || !strings.EqualFold(seed, s.bundleSeedHex) {
writeProblem(w, http.StatusForbidden, "Forbidden", "invalid or missing X-EvoBGP-Seed")
return
}
var body struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
ClientToken string `json:"client_token"`
ClientVersion string `json:"client_version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
name := strings.TrimSpace(body.Name)
tok := strings.TrimSpace(body.ClientToken)
if name == "" || tok == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "name and client_token are required")
return
}
if !strings.HasPrefix(tok, "evobgp_fw_") {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_token must use evobgp_fw_ prefix")
return
}
tenantID, err := s.firewallEnrollTenantID()
if err != nil {
writeInternalError(w, "internal", err)
return
}
hash := authkey.HashToken(tok)
prefix := tok
if len(prefix) > 12 {
prefix = prefix[:12]
}
client, err := s.store.CreateFirewallClient(tenantID, &store.FirewallClientCreate{
Name: name,
Hostname: strings.TrimSpace(body.Hostname),
TokenPrefix: prefix,
TokenHash: hash,
ClientVersion: strings.TrimSpace(body.ClientVersion),
})
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusConflict, "Conflict", "client token already enrolled")
return
}
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"client_id": client.ID,
"status": client.Status,
"message": "pending operator approval in EvoBGP UI",
})
}
func (s *Server) firewallEnrollTenantID() (string, error) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid != "" {
return tid, nil
}
ids, err := s.store.ListTenantIDs()
if err != nil {
return "", err
}
if len(ids) == 0 {
return "", errors.New("httpapi: no tenant for firewall enroll")
}
return ids[0], nil
}
func (s *Server) handleFirewallInstallScript(w http.ResponseWriter, r *http.Request) {
s.serveFirewallScript(w, "install.sh")
}
func (s *Server) handleFirewallSyncScript(w http.ResponseWriter, r *http.Request) {
s.serveFirewallScript(w, "evobgp-firewall.sh")
}
func (s *Server) serveFirewallScript(w http.ResponseWriter, name string) {
b, err := readFirewallScript(name)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "script not found")
return
}
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func readFirewallScript(name string) ([]byte, error) {
if b, err := firewallscripts.FS.ReadFile(name); err == nil {
return b, nil
}
candidates := []string{}
if dir := strings.TrimSpace(os.Getenv("EVOBGP_FIREWALL_SCRIPTS")); dir != "" {
candidates = append(candidates, filepath.Join(dir, name))
}
candidates = append(candidates, filepath.Join("scripts", "firewall", name))
for _, p := range candidates {
b, err := os.ReadFile(p)
if err == nil {
return b, nil
}
}
return nil, os.ErrNotExist
}
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.GetFirewallClient(a.TenantID, id)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
var patch store.FirewallClientPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
client, err := s.store.UpdateFirewallClient(a.TenantID, id, &patch)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
}
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
_ = s.firewallResolver.Reload(s.store)
go s.replicateFirewallStateToSpeakers(a.TenantID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
var clientID *string
if scope == "client" {
cid := strings.TrimSpace(r.URL.Query().Get("client_id"))
if cid == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
return
}
clientID = &cid
}
items, err := s.store.ListFirewallRules(a.TenantID, clientID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
var body struct {
Scope string `json:"scope"`
ClientID *string `json:"client_id"`
Action string `json:"action"`
CommunityID *string `json:"community_id"`
Comment string `json:"comment"`
Priority *int `json:"priority"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
var clientID *string
if strings.TrimSpace(body.Scope) == "client" {
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required for scope=client")
return
}
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
fwRule := &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
fwRule.CreatedByUserID = a.UserID
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
if err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusCreated, rule)
}
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
var patch store.FirewallRulePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
rule, err := s.store.UpdateFirewallRule(a.TenantID, id, &patch)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, rule)
}
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
}
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
return
}
var body struct {
Scope string `json:"scope"`
ClientID *string `json:"client_id"`
OrderedIDs []string `json:"ordered_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
var clientID *string
if strings.TrimSpace(body.Scope) == "client" {
if body.ClientID == nil || strings.TrimSpace(*body.ClientID) == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "client_id required")
return
}
cid := strings.TrimSpace(*body.ClientID)
clientID = &cid
}
if err := s.store.ReorderFirewallRules(a.TenantID, clientID, body.OrderedIDs); err != nil {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid reorder")
return
}
go s.replicateFirewallStateToSpeakers(a.TenantID)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallBlocklist(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
client, err := s.store.GetFirewallClient(a.TenantID, a.APIKeyID)
if err != nil {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown firewall client")
return
}
if client.Status != "approved" {
w.Header().Set("Retry-After", "60")
writeProblem(w, http.StatusForbidden, "Forbidden", "client pending approval")
return
}
_ = s.store.TouchFirewallClientLastSeen(client.ID, "cp", clientIP(r), r.UserAgent())
resp, err := s.buildFirewallBlocklist(r.Context(), client)
if err != nil {
if errors.Is(err, errNoFirewallRevision) {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeInternalError(w, "internal", err)
return
}
w.Header().Set("X-EvoBGP-Source", "cp")
w.Header().Set("X-EvoBGP-Revision-ID", resp.RevisionID)
w.Header().Set("X-EvoBGP-Generated-At", resp.GeneratedAt)
w.Header().Set("X-EvoBGP-Rules-Version", resp.RulesVersion)
if strings.Contains(r.Header.Get("Accept"), "text/plain") {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
for _, p := range resp.Prefixes {
_, _ = w.Write([]byte(p + "\n"))
}
return
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleFirewallApplyReport(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
var body struct {
Status string `json:"status"`
Error string `json:"error"`
PrefixCount int `json:"prefix_count"`
IPCount int `json:"ip_count"`
PacketsDropped int64 `json:"packets_dropped"`
PacketsAccepted int64 `json:"packets_accepted"`
Version string `json:"version"`
KernelMethod string `json:"kernel_method"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
return
}
src := strings.TrimSpace(body.Source)
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastApply(
a.APIKeyID, src, body.Status, body.Error,
body.PrefixCount, body.IPCount, body.PacketsDropped, body.PacketsAccepted,
)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireFirewall(w, a) {
return
}
var body struct {
Source string `json:"source"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
src := strings.TrimSpace(body.Source)
if src == "" {
src = "cp"
}
_ = s.store.TouchFirewallClientLastSeen(a.APIKeyID, src, clientIP(r), r.UserAgent())
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
client, err := s.store.GetFirewallClient(a.TenantID, id)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
return
}
resp, err := s.buildFirewallBlocklist(r.Context(), client)
if err != nil {
if errors.Is(err, errNoFirewallRevision) {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeInternalError(w, "internal", err)
return
}
writeJSON(w, http.StatusOK, resp)
}
var errNoFirewallRevision = errors.New("httpapi: no firewall revision")
type firewallBlocklistResponse struct {
ClientID string `json:"client_id"`
RevisionID string `json:"revision_id"`
GeneratedAt string `json:"generated_at"`
Source string `json:"source"`
RulesApplied int `json:"rules_applied"`
CommunitiesEvaluated int `json:"communities_evaluated"`
CommunitiesBlocked int `json:"communities_blocked"`
Prefixes []string `json:"prefixes"`
Total int `json:"total"`
Hash string `json:"hash"`
RulesVersion string `json:"-"`
}
func (s *Server) buildFirewallBlocklist(ctx context.Context, client *store.FirewallClient) (*firewallBlocklistResponse, error) {
_ = ctx
revs, _, _ := s.store.ListRevisions(client.TenantID, "", "", 1)
if len(revs) == 0 {
return nil, errNoFirewallRevision
}
rev := revs[0]
prefixesByCommunity, commCount, err := s.loadPrefixesByCommunity(client.TenantID, rev.ID)
if err != nil {
return nil, err
}
rules, err := s.store.ListAllFirewallRulesForClient(client.TenantID, client.ID)
if err != nil {
return nil, err
}
fwRules := storeRulesToFirewall(rules)
blocked := firewall.Evaluate(client.ID, fwRules, prefixesByCommunity)
blockedComm := countBlockedCommunities(client.ID, fwRules, prefixesByCommunity)
hash := prefixListHash(blocked)
return &firewallBlocklistResponse{
ClientID: client.ID,
RevisionID: rev.ID,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Source: "cp",
RulesApplied: len(rules),
CommunitiesEvaluated: commCount,
CommunitiesBlocked: blockedComm,
Prefixes: blocked,
Total: len(blocked),
Hash: hash,
RulesVersion: firewall.RulesVersionHash(fwRules),
}, nil
}
func (s *Server) loadPrefixesByCommunity(tenantID, revisionID string) (map[string][]string, int, error) {
out := make(map[string][]string)
communities := make(map[string]struct{})
cursor := ""
for {
rows, next, more := s.store.ListRevisionPrefixes(tenantID, revisionID, cursor, 5000)
for _, row := range rows {
key := ""
if row.CommunityID != nil {
key = strings.TrimSpace(*row.CommunityID)
}
communities[key] = struct{}{}
out[key] = append(out[key], strings.TrimSpace(row.Prefix))
}
if !more {
break
}
cursor = next
}
return out, len(communities), nil
}
func storeRulesToFirewall(rules []*store.FirewallRule) []firewall.Rule {
out := make([]firewall.Rule, 0, len(rules))
for _, r := range rules {
var cid *string
if r.CommunityID != nil {
v := *r.CommunityID
cid = &v
}
var cl *string
if r.ClientID != nil {
v := *r.ClientID
cl = &v
}
out = append(out, firewall.Rule{
ClientID: cl,
Priority: r.Priority,
Action: r.Action,
CommunityID: cid,
})
}
return out
}
func countBlockedCommunities(clientID string, rules []firewall.Rule, prefixesByCommunity map[string][]string) int {
n := 0
for k := range prefixesByCommunity {
ordered := mergeRulesForCount(clientID, rules)
for _, r := range ordered {
if r.CommunityID == nil || strings.TrimSpace(*r.CommunityID) == k {
if strings.EqualFold(r.Action, "block") {
n++
}
break
}
}
}
return n
}
func mergeRulesForCount(clientID string, rules []firewall.Rule) []firewall.Rule {
var clientRules, tenantRules []firewall.Rule
for _, r := range rules {
if r.ClientID != nil && *r.ClientID == clientID {
clientRules = append(clientRules, r)
continue
}
if r.ClientID == nil {
tenantRules = append(tenantRules, r)
}
}
sort.Slice(clientRules, func(i, j int) bool { return clientRules[i].Priority < clientRules[j].Priority })
sort.Slice(tenantRules, func(i, j int) bool { return tenantRules[i].Priority < tenantRules[j].Priority })
out := append([]firewall.Rule{}, clientRules...)
return append(out, tenantRules...)
}
func prefixListHash(prefixes []string) string {
cp := append([]string(nil), prefixes...)
sort.Strings(cp)
sum := sha256.Sum256([]byte(strings.Join(cp, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
func clientIP(r *http.Request) string {
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i >= 0 {
return host[:i]
}
return host
// handleFirewallGone responds 410 Gone for every legacy /v1/firewall/* endpoint
// (enroll, install.sh, sync-script, clients, rules, blocklist, apply-report,
// heartbeat, install-context). The firewall subsystem (client enrollment,
// block/accept rules, blocklist distribution) has moved to the standalone
// EvoFirewall service; see docs/firewall.md.
//
// Existing firewall_client / firewall_rule tables and store code (memory_firewall.go,
// postgres_firewall.go, firewall_types.go) are intentionally left in place — only the
// HTTP surface is decommissioned here.
func (s *Server) handleFirewallGone(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusGone, "Gone", "firewall feature moved to EvoFirewall — see docs/firewall.md")
}
+32 -278
View File
@@ -1,19 +1,15 @@
package httpapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"evobgp/internal/authkey"
"evobgp/internal/store"
)
func TestFirewallEnrollAndBlocklist(t *testing.T) {
// TestFirewallRoutesGone verifies the firewall subsystem HTTP surface has been
// decommissioned in favor of the standalone EvoFirewall service (see docs/firewall.md).
// Every legacy /v1/firewall/* path — public and authenticated — must answer 410 Gone.
func TestFirewallRoutesGone(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
@@ -26,279 +22,37 @@ func TestFirewallEnrollAndBlocklist(t *testing.T) {
defer ts.Close()
client := ts.Client()
tok := "evobgp_fw_testtoken123456789012345678901234"
enrollBody := `{"name":"web-01","hostname":"web-01.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
cases := []struct {
method string
path string
auth bool // send a valid operator bearer token
}{
{http.MethodPost, "/v1/firewall/enroll", false},
{http.MethodGet, "/v1/firewall/install.sh", false},
{http.MethodGet, "/v1/firewall/sync-script", false},
{http.MethodGet, "/v1/firewall/install-context", true},
{http.MethodGet, "/v1/firewall/clients", true},
{http.MethodGet, "/v1/firewall/clients/any-id", true},
{http.MethodGet, "/v1/firewall/rules", true},
{http.MethodGet, "/v1/firewall/blocklist", false},
{http.MethodPost, "/v1/firewall/apply-report", false},
{http.MethodPost, "/v1/firewall/heartbeat", false},
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusForbidden {
t.Fatalf("pending blocklist want 403 got %d", respBlock.StatusCode)
}
reqApprove, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/clients/"+clientID+"/approve", nil)
reqApprove.Header.Set("Authorization", "Bearer opkey")
respApprove, err := client.Do(reqApprove)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respApprove.Body.Close() }()
if respApprove.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respApprove.Body)
t.Fatalf("approve status=%d body=%s", respApprove.StatusCode, b)
}
_, err = srv.Store().CreateFirewallRule(tenant, nil, &store.FirewallRuleCreate{Action: "accept", Comment: "default"})
if err != nil {
t.Fatal(err)
}
reqBlock2, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock2.Header.Set("Authorization", "Bearer "+tok)
respBlock2, err := client.Do(reqBlock2)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock2.Body.Close() }()
if respBlock2.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respBlock2.Body)
t.Fatalf("blocklist status=%d body=%s", respBlock2.StatusCode, b)
}
var bl map[string]any
if err := json.NewDecoder(respBlock2.Body).Decode(&bl); err != nil {
t.Fatal(err)
}
if total, _ := bl["total"].(float64); total != 0 {
t.Fatalf("accept-only want empty blocklist, total=%v", total)
}
reportBody := `{"status":"ok","prefix_count":0,"ip_count":0,"packets_dropped":42,"packets_accepted":1000,"source":"cp","kernel_method":"nft"}`
reqReport, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/apply-report", strings.NewReader(reportBody))
reqReport.Header.Set("Authorization", "Bearer "+tok)
reqReport.Header.Set("Content-Type", "application/json")
respReport, err := client.Do(reqReport)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respReport.Body.Close() }()
if respReport.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respReport.Body)
t.Fatalf("apply-report status=%d body=%s", respReport.StatusCode, b)
}
gotClient, err := srv.Store().GetFirewallClient(tenant, clientID)
if err != nil {
t.Fatal(err)
}
if gotClient.LastApplyPacketsDropped != 42 || gotClient.LastApplyPacketsAccepted != 1000 {
t.Fatalf("packet stats dropped=%d accepted=%d", gotClient.LastApplyPacketsDropped, gotClient.LastApplyPacketsAccepted)
}
}
func TestFirewallEnrollBadSeed(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
body := `{"name":"x","client_token":"evobgp_fw_` + strings.Repeat("a", 40) + `"}`
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-EvoBGP-Seed", "deadbeef")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("want 403 got %d", resp.StatusCode)
}
}
func TestFirewallInstallScriptPublic(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
for _, path := range []string{"/v1/firewall/install.sh", "/v1/firewall/sync-script"} {
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
resp, err := ts.Client().Do(req)
for _, tc := range cases {
req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil)
if err != nil {
t.Fatal(err)
}
func() {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("%s status=%d body=%s", path, resp.StatusCode, b)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "shellscript") {
t.Fatalf("%s content-type=%q", path, ct)
}
b, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(b), "#!/") {
t.Fatalf("%s missing shebang", path)
}
}()
}
}
func TestFirewallInstallContext(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator,vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
reqOp, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
reqOp.Header.Set("Authorization", "Bearer opkey")
respOp, err := client.Do(reqOp)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respOp.Body.Close() }()
if respOp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(respOp.Body)
t.Fatalf("operator install-context status=%d body=%s", respOp.StatusCode, b)
}
var ctx map[string]any
if err := json.NewDecoder(respOp.Body).Decode(&ctx); err != nil {
t.Fatal(err)
}
if seed, _ := ctx["bundle_seed"].(string); seed != testBundleSeed {
t.Fatalf("bundle_seed=%q want %q", seed, testBundleSeed)
}
if configured, _ := ctx["bundle_seed_configured"].(bool); !configured {
t.Fatal("bundle_seed_configured want true")
}
if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") {
t.Fatalf("suggested_cp_url=%q want https", url)
}
if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") {
t.Fatalf("install_sh_url=%q", url)
}
reqVw, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil)
reqVw.Header.Set("Authorization", "Bearer vwkey")
respVw, err := client.Do(reqVw)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respVw.Body.Close() }()
if respVw.StatusCode != http.StatusForbidden {
t.Fatalf("viewer install-context want 403 got %d", respVw.StatusCode)
}
}
func TestFirewallDeletePendingClient(t *testing.T) {
srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
tok := "evobgp_fw_revoketest123456789012345678901"
enrollBody := `{"name":"reject-me","hostname":"test.local","client_token":"` + tok + `","client_version":"test/1"}`
reqEnroll, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/firewall/enroll", strings.NewReader(enrollBody))
reqEnroll.Header.Set("Content-Type", "application/json")
reqEnroll.Header.Set("X-EvoBGP-Seed", testBundleSeed)
respEnroll, err := client.Do(reqEnroll)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respEnroll.Body.Close() }()
if respEnroll.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(respEnroll.Body)
t.Fatalf("enroll status=%d body=%s", respEnroll.StatusCode, b)
}
var enroll map[string]any
if err := json.NewDecoder(respEnroll.Body).Decode(&enroll); err != nil {
t.Fatal(err)
}
clientID, _ := enroll["client_id"].(string)
if clientID == "" {
t.Fatal("missing client_id")
}
reqDelete, _ := http.NewRequest(http.MethodDelete, ts.URL+"/v1/firewall/clients/"+clientID, nil)
reqDelete.Header.Set("Authorization", "Bearer opkey")
respDelete, err := client.Do(reqDelete)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respDelete.Body.Close() }()
if respDelete.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(respDelete.Body)
t.Fatalf("delete status=%d body=%s", respDelete.StatusCode, b)
}
_, err = srv.Store().GetFirewallClient(tenant, clientID)
if err == nil {
t.Fatal("client should be deleted")
}
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("delete err=%v", err)
}
reqBlock, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/blocklist", nil)
reqBlock.Header.Set("Authorization", "Bearer "+tok)
respBlock, err := client.Do(reqBlock)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBlock.Body.Close() }()
if respBlock.StatusCode != http.StatusUnauthorized {
t.Fatalf("deleted blocklist want 401 got %d", respBlock.StatusCode)
}
}
func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
tok := "evobgp_fw_sample"
h := authkey.HashToken(tok)
if len(h) != 32 {
t.Fatalf("hash len %d", len(h))
if tc.auth {
req.Header.Set("Authorization", "Bearer opkey")
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusGone {
t.Fatalf("%s %s: want 410 got %d", tc.method, tc.path, resp.StatusCode)
}
}
}
+13 -6
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"evobgp/internal/audit"
"evobgp/internal/jobs"
"evobgp/internal/maintenance"
"evobgp/internal/pgmonitor"
@@ -38,11 +39,13 @@ type Server struct {
mux *http.ServeMux
// Portal / dual-auth (JWT) configuration.
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
auditIngestSecret string
auditPusher *audit.PortalPusher
}
// Options configures the API server.
@@ -61,8 +64,10 @@ type Options struct {
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users)
PortalTenantID string // fallback when JWT has no bgp_tenant_id / tenants.bgp
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
// AuditIngestSecret — AUTH_AUDIT_INGEST_SECRET for portal push (optional).
AuditIngestSecret string
}
// New constructs Server and wiring for async jobs.
@@ -123,10 +128,12 @@ func New(opts Options) (*Server, error) {
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
authRequired: opts.AuthRequired,
auditIngestSecret: strings.TrimSpace(opts.AuditIngestSecret),
}
if s.authIssuer == "" {
s.authIssuer = "https://auth.shnt.top"
}
s.initAuditPusher(s.authPortalURL, s.auditIngestSecret)
s.mux = http.NewServeMux()
s.registerRoutes()
return s, nil
+186
View File
@@ -0,0 +1,186 @@
package repository
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"evobgp/internal/store"
)
// AppendAudit inserts a tenant-scoped audit row.
func (p *Postgres) AppendAudit(in store.AuditAppendInput) (*store.AuditEntry, error) {
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
return nil, store.ErrInvalidInput
}
sev := strings.TrimSpace(in.Severity)
if sev == "" {
sev = store.AuditSeverityInfo
}
if !store.ValidAuditSeverity(sev) {
return nil, store.ErrInvalidInput
}
ctx := context.Background()
id := uuid.NewString()
eventID := "bgp-" + uuid.NewString()
var detailJSON []byte
if in.Details != nil {
detailJSON, _ = json.Marshal(in.Details)
}
var createdAt time.Time
err := p.pool.QueryRow(ctx, `
INSERT INTO audit_log
(id, tenant_id, event_id, source_app, action, severity,
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
target_type, target_id, summary, details_json, ip, created_at)
VALUES ($1, $2, $3, 'bgp', $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, now())
RETURNING created_at`,
id, strings.TrimSpace(in.TenantID), eventID, strings.TrimSpace(in.Action), sev,
nullIfEmpty(in.ActorUserID), nullIfEmpty(in.ActorEmail), nullIfEmpty(in.ActorName),
nullIfEmpty(in.ActorAPIKeyPrefix), nullIfEmpty(in.TargetType), nullIfEmpty(in.TargetID),
strings.TrimSpace(in.Summary), nullJSONBytes(detailJSON), nullIfEmpty(in.IP),
).Scan(&createdAt)
if err != nil {
return nil, err
}
return &store.AuditEntry{
ID: id,
TenantID: strings.TrimSpace(in.TenantID),
EventID: eventID,
SourceApp: store.AuditSourceAppBGP,
Action: strings.TrimSpace(in.Action),
Severity: sev,
ActorUserID: strings.TrimSpace(in.ActorUserID),
ActorEmail: strings.TrimSpace(in.ActorEmail),
ActorName: strings.TrimSpace(in.ActorName),
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
TargetType: strings.TrimSpace(in.TargetType),
TargetID: strings.TrimSpace(in.TargetID),
Summary: strings.TrimSpace(in.Summary),
Details: in.Details,
IP: strings.TrimSpace(in.IP),
CreatedAt: createdAt.UTC(),
}, nil
}
// ListAudit returns paginated audit rows for a tenant.
func (p *Postgres) ListAudit(tenantID, cursor string, limit int, filter store.AuditListFilter) ([]*store.AuditEntry, string, bool, error) {
if limit <= 0 {
limit = 50
}
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
ctx := context.Background()
args := []any{tenantID}
where := "tenant_id = $1"
argN := 2
if a := strings.TrimSpace(filter.Action); a != "" {
where += " AND action = $" + strconv.Itoa(argN)
args = append(args, a)
argN++
}
if s := strings.TrimSpace(filter.Severity); s != "" {
where += " AND severity = $" + strconv.Itoa(argN)
args = append(args, s)
argN++
}
args = append(args, limit+1, off)
q := `
SELECT id, tenant_id, event_id, source_app, action, severity,
actor_user_id, actor_email, actor_name, actor_api_key_prefix,
target_type, target_id, summary, details_json, ip, created_at, portal_pushed_at
FROM audit_log
WHERE ` + where + `
ORDER BY created_at DESC, id DESC
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
rows, err := p.pool.Query(ctx, q, args...)
if err != nil {
return nil, "", false, err
}
defer rows.Close()
var out []*store.AuditEntry
for rows.Next() {
row, err := scanAuditEntry(rows.Scan)
if err != nil {
return nil, "", false, err
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, "", false, err
}
more := len(out) > limit
if more {
out = out[:limit]
}
next := ""
if more {
next = strconv.Itoa(off + limit)
}
return out, next, more, nil
}
// MarkAuditPortalPushed sets portal_pushed_at for a row.
func (p *Postgres) MarkAuditPortalPushed(id string) error {
ctx := context.Background()
tag, err := p.pool.Exec(ctx, `UPDATE audit_log SET portal_pushed_at = now() WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return store.ErrNotFound
}
return nil
}
func scanAuditEntry(scan func(dest ...any) error) (*store.AuditEntry, error) {
var row store.AuditEntry
var actorUserID, actorEmail, actorName, actorPrefix, targetType, targetID, ip *string
var detailRaw []byte
var portalPushed *time.Time
if err := scan(
&row.ID, &row.TenantID, &row.EventID, &row.SourceApp, &row.Action, &row.Severity,
&actorUserID, &actorEmail, &actorName, &actorPrefix,
&targetType, &targetID, &row.Summary, &detailRaw, &ip, &row.CreatedAt, &portalPushed,
); err != nil {
return nil, err
}
row.CreatedAt = row.CreatedAt.UTC()
if actorUserID != nil {
row.ActorUserID = *actorUserID
}
if actorEmail != nil {
row.ActorEmail = *actorEmail
}
if actorName != nil {
row.ActorName = *actorName
}
if actorPrefix != nil {
row.ActorAPIKeyPrefix = *actorPrefix
}
if targetType != nil {
row.TargetType = *targetType
}
if targetID != nil {
row.TargetID = *targetID
}
if ip != nil {
row.IP = *ip
}
if len(detailRaw) > 0 {
_ = json.Unmarshal(detailRaw, &row.Details)
}
if portalPushed != nil {
t := portalPushed.UTC()
row.PortalPushedAt = &t
}
return &row, nil
}
+67
View File
@@ -0,0 +1,67 @@
package store
import (
"strings"
"time"
)
const (
AuditSourceAppBGP = "bgp"
AuditTargetAppResource = "app_resource"
AuditSeverityInfo = "info"
AuditSeverityWarning = "warning"
AuditSeverityCritical = "critical"
)
// AuditEntry is a persisted CRUD / settings audit row (local + portal ingest).
type AuditEntry struct {
ID string
TenantID string
EventID string
SourceApp string
Action string
Severity string
ActorUserID string
ActorEmail string
ActorName string
ActorAPIKeyPrefix string
TargetType string
TargetID string
Summary string
Details map[string]any
IP string
CreatedAt time.Time
PortalPushedAt *time.Time
}
// AuditAppendInput is input for AppendAudit.
type AuditAppendInput struct {
TenantID string
Action string
Severity string
ActorUserID string
ActorEmail string
ActorName string
ActorAPIKeyPrefix string
TargetType string
TargetID string
Summary string
Details map[string]any
IP string
}
// AuditListFilter optional query filters for ListAudit.
type AuditListFilter struct {
Action string
Severity string
}
// ValidAuditSeverity reports whether s is an allowed severity.
func ValidAuditSeverity(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case AuditSeverityInfo, AuditSeverityWarning, AuditSeverityCritical:
return true
default:
return false
}
}
+5
View File
@@ -132,6 +132,11 @@ type Backend interface {
AppendRuntimeLogCleanupAudit(tenantID, actor, filename, action string, sizeBefore int64, sizeAfter *int64, detail map[string]any) (string, error)
ListRuntimeLogCleanupAudit(tenantID, cursor string, limit int) ([]*RuntimeLogCleanupAudit, string, bool, error)
// CRUD audit log (tenant-scoped; optional portal ingest push from httpapi).
AppendAudit(in AuditAppendInput) (*AuditEntry, error)
ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error)
MarkAuditPortalPushed(id string) error
// Firewall blocklist clients and policy rules.
ListFirewallClients(tenantID string) ([]*FirewallClient, error)
GetFirewallClient(tenantID, id string) (*FirewallClient, error)
+1
View File
@@ -50,6 +50,7 @@ type Memory struct {
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
auditLog []*AuditEntry
// DemoIDs valid after SeedDemo()
demoTenantID string
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"sort"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) AppendAudit(in AuditAppendInput) (*AuditEntry, error) {
if strings.TrimSpace(in.TenantID) == "" || strings.TrimSpace(in.Action) == "" || strings.TrimSpace(in.Summary) == "" {
return nil, ErrInvalidInput
}
sev := strings.TrimSpace(in.Severity)
if sev == "" {
sev = AuditSeverityInfo
}
if !ValidAuditSeverity(sev) {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
row := &AuditEntry{
ID: uuid.NewString(),
TenantID: strings.TrimSpace(in.TenantID),
EventID: "bgp-" + uuid.NewString(),
SourceApp: AuditSourceAppBGP,
Action: strings.TrimSpace(in.Action),
Severity: sev,
ActorUserID: strings.TrimSpace(in.ActorUserID),
ActorEmail: strings.TrimSpace(in.ActorEmail),
ActorName: strings.TrimSpace(in.ActorName),
ActorAPIKeyPrefix: strings.TrimSpace(in.ActorAPIKeyPrefix),
TargetType: strings.TrimSpace(in.TargetType),
TargetID: strings.TrimSpace(in.TargetID),
Summary: strings.TrimSpace(in.Summary),
Details: in.Details,
IP: strings.TrimSpace(in.IP),
CreatedAt: now,
}
m.mu.Lock()
defer m.mu.Unlock()
m.auditLog = append(m.auditLog, row)
return cloneAuditEntry(row), nil
}
func (m *Memory) ListAudit(tenantID, cursor string, limit int, filter AuditListFilter) ([]*AuditEntry, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
var filtered []*AuditEntry
for _, row := range m.auditLog {
if row.TenantID != tenantID {
continue
}
if a := strings.TrimSpace(filter.Action); a != "" && row.Action != a {
continue
}
if s := strings.TrimSpace(filter.Severity); s != "" && row.Severity != s {
continue
}
filtered = append(filtered, row)
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
return filtered[i].ID > filtered[j].ID
}
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(filtered) {
end = len(filtered)
} else if end < len(filtered) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(filtered) {
return nil, "", false, nil
}
out := make([]*AuditEntry, end-off)
for i := off; i < end; i++ {
out[i-off] = cloneAuditEntry(filtered[i])
}
return out, next, hasMore, nil
}
func (m *Memory) MarkAuditPortalPushed(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, row := range m.auditLog {
if row.ID == id {
now := time.Now().UTC()
row.PortalPushedAt = &now
return nil
}
}
return ErrNotFound
}
func cloneAuditEntry(row *AuditEntry) *AuditEntry {
if row == nil {
return nil
}
cp := *row
if row.Details != nil {
cp.Details = make(map[string]any, len(row.Details))
for k, v := range row.Details {
cp.Details[k] = v
}
}
return &cp
}
+70
View File
@@ -0,0 +1,70 @@
package store
import "testing"
func TestMemoryAppendAndListAudit(t *testing.T) {
m := NewMemory()
tenantA := "tenant-a"
tenantB := "tenant-b"
entry, err := m.AppendAudit(AuditAppendInput{
TenantID: tenantA,
Action: "bgp.module.create",
Summary: "Created module test",
TargetID: "mod-1",
})
if err != nil {
t.Fatal(err)
}
if entry == nil || entry.EventID == "" || entry.SourceApp != AuditSourceAppBGP {
t.Fatalf("unexpected entry: %+v", entry)
}
if _, err := m.AppendAudit(AuditAppendInput{
TenantID: tenantB,
Action: "bgp.peer.delete",
Summary: "Deleted peer",
}); err != nil {
t.Fatal(err)
}
items, _, hasMore, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || hasMore {
t.Fatalf("items=%d hasMore=%v", len(items), hasMore)
}
if items[0].Action != "bgp.module.create" {
t.Fatalf("action=%s", items[0].Action)
}
filtered, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{Action: "bgp.peer.delete"})
if err != nil {
t.Fatal(err)
}
if len(filtered) != 0 {
t.Fatalf("expected empty filter result, got %d", len(filtered))
}
if err := m.MarkAuditPortalPushed(entry.ID); err != nil {
t.Fatal(err)
}
items2, _, _, err := m.ListAudit(tenantA, "", 10, AuditListFilter{})
if err != nil {
t.Fatal(err)
}
if items2[0].PortalPushedAt == nil {
t.Fatal("expected portal_pushed_at")
}
}
func TestMemoryAppendAuditValidation(t *testing.T) {
m := NewMemory()
if _, err := m.AppendAudit(AuditAppendInput{}); err != ErrInvalidInput {
t.Fatalf("err=%v", err)
}
if _, err := m.AppendAudit(AuditAppendInput{TenantID: "t", Action: "x", Summary: "s", Severity: "bad"}); err != ErrInvalidInput {
t.Fatalf("err=%v", err)
}
}
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_audit_log_tenant_action;
DROP INDEX IF EXISTS idx_audit_log_tenant_created;
DROP INDEX IF EXISTS idx_audit_log_event_id;
DROP TABLE IF EXISTS audit_log;
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
event_id TEXT NOT NULL,
source_app TEXT NOT NULL DEFAULT 'bgp',
action TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
actor_user_id TEXT,
actor_email TEXT,
actor_name TEXT,
actor_api_key_prefix TEXT,
target_type TEXT,
target_id TEXT,
summary TEXT NOT NULL,
details_json JSONB,
ip TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
portal_pushed_at TIMESTAMPTZ,
CONSTRAINT audit_log_severity_chk CHECK (severity IN ('info', 'warning', 'critical')),
CONSTRAINT audit_log_source_app_chk CHECK (source_app = 'bgp'),
CONSTRAINT audit_log_summary_chk CHECK (length(trim(summary)) > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log (event_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_created
ON audit_log (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_action
ON audit_log (tenant_id, action, created_at DESC);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_audit_log_tenant_action;
DROP INDEX IF EXISTS idx_audit_log_tenant_created;
DROP INDEX IF EXISTS idx_audit_log_event_id;
DROP TABLE IF EXISTS audit_log;
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
event_id TEXT NOT NULL,
source_app TEXT NOT NULL DEFAULT 'bgp',
action TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
actor_user_id TEXT,
actor_email TEXT,
actor_name TEXT,
actor_api_key_prefix TEXT,
target_type TEXT,
target_id TEXT,
summary TEXT NOT NULL,
details_json TEXT,
ip TEXT,
created_at TEXT NOT NULL,
portal_pushed_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_log_event_id ON audit_log (event_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_created
ON audit_log (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_action
ON audit_log (tenant_id, action, created_at DESC);