Compare commits

...
15 Commits
Author SHA1 Message Date
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
DenozordecandCursor 4d83b8d673 feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 23:23:52 +07:00
DenozordecandCursor 2820cff988 refactor(web): streamline /network page with ReUI kit
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / go (push) Skipped
CI / bird2 (push) Skipped
CI / web (push) Successful in 1m14s
CI / release (push) Successful in 4m23s
Сжать экран «Сеть»: KPI (stats-12) и вкладки Пиры/Спикеры через ResourcePage; убраны дубли charts и заглушка control-plane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 01:12:42 +07:00
DenozordecandCursor 54b7ea3bd5 feat(lookup): resolve domain to IPs for membership check
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 28s
CI / web (push) Successful in 1m6s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m6s
Для FQDN после проверки DOMAINS выполняется live DNS (A/AAAA), каждый IP проверяется по IP_RANGES и snapshots; в ответе resolved_ips / resolved_ip, UI KPI и OpenAPI обновлены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 01:01:54 +07:00
Denozordec 57bcfcd9e1 fix: improve error message for invalid input in lookup functionality
CI / changes (push) Successful in 5s
CI / openapi (push) Skipped
CI / web (push) Skipped
CI / commitlint (push) Skipped
CI / go (push) Successful in 54s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m51s
Updated the error response for invalid input in the handleLookup function to provide a clearer message indicating that the query must be an IP address or FQDN, enhancing user understanding of the input requirements.
2026-07-18 00:05:49 +07:00
Denozordec 1317a73e9a refactor: update AppShell and sidebar components for improved design consistency
CI / changes (push) Successful in 4s
CI / openapi (push) Skipped
CI / web (push) Successful in 1m4s
CI / commitlint (push) Skipped
CI / go (push) Failing after 14s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Removed unused utility functions from the AppShell component and updated the sidebar width to a fixed value for better layout control. Revised the UI design documentation to clarify the shared App Shell chrome specifications and ensure alignment with design standards. Enhanced the sidebar configuration to reflect the new width and styling guidelines.
2026-07-17 23:56:35 +07:00
Denozordec 039d2f3dd9 refactor: update dashboard quick links and app shell for improved layout and functionality
CI / changes (push) Successful in 7s
CI / openapi (push) Skipped
CI / commitlint (push) Skipped
CI / web (push) Successful in 1m4s
CI / go (push) Failing after 17s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Refactored the DashboardQuickLinks component to simplify icon classes for better semantic clarity. Updated the AppShell component to integrate the AppSwitcher and AppsMenu, enhancing navigation and user experience. Adjusted the layout of the app shell header and main content for improved consistency and alignment. Updated UI design documentation to reflect these changes and ensure adherence to shared design standards.
2026-07-17 23:15:00 +07:00
Denozordec 54a0b5b966 feat: add lookup functionality for IP/domain verification and enhance dashboard links
CI / changes (push) Successful in 5s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 22s
CI / web (push) Successful in 49s
CI / go (push) Failing after 16s
CI / bird2 (push) Skipped
CI / release (push) Skipped
Introduced a new lookup feature allowing users to quickly verify IP addresses or domains against community lists. Updated the DashboardQuickLinks component to include a new action for IP/domain checks, enhancing user navigation. Expanded API documentation to include the new lookup endpoint and its response structure, ensuring comprehensive coverage of the feature. Updated UI design documentation to reflect the integration of the lookup functionality.
2026-07-17 20:53:11 +07:00
Denozordec 1639ba40f3 refactor: update dashboard components to integrate QuickActionGrid and enhance settings
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 33s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m14s
Removed the deprecated DashboardQuickLinkCard and replaced it with QuickActionGrid in the DashboardQuickLinks component for improved organization and user experience. Updated the AppearanceSettingsTab to include a toggle for displaying quick actions on the dashboard, enhancing user customization options. Adjusted the SystemMonitorPopover layout for better alignment and spacing. Updated documentation to reflect the new UI preferences for quick actions.
2026-07-17 20:34:25 +07:00
Denozordec 26a96bc824 refactor: update KPI components for improved layout and clarity
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m21s
Revised the KpiStatGrid and KpiStatCardBody components to enhance the layout and presentation of KPI tiles. Updated the documentation to reflect the new horizontal compact hybrid design, ensuring better visual organization with the icon on the left and improved label and value alignment. Adjusted the skeleton loading state for a more cohesive user experience.
2026-07-17 18:37:00 +07:00
108 changed files with 4755 additions and 2775 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>"
}
}
}
+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` (EvoBGP hybrid SoT) |
| 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`
+28
View File
@@ -0,0 +1,28 @@
# EvoBGP web (Vite) — переменные окружения.
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
# ---------------------------------------------------------------------------
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
# ---------------------------------------------------------------------------
# REUI_LICENSE_KEY=
# ---------------------------------------------------------------------------
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
# недоступен либо VITE_AUTH_ENABLED=false).
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
# ---------------------------------------------------------------------------
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
# ---------------------------------------------------------------------------
# Auth-portal SSO
# ---------------------------------------------------------------------------
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
# Пример:
# VITE_AUTH_ENABLED=true
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
#
# Backend опционально может отдавать GET /v1/auth/config
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
# VITE_AUTH_ENABLED=false
# VITE_AUTH_PORTAL_URL=http://localhost:5175
+97
View File
@@ -0,0 +1,97 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Sidebar app switcher — shared chrome etalon EvoBGP. @see https://reui.io/preview/base/app-shell-12 */
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem key={app.id} disabled>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -1,69 +0,0 @@
import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import type { ReactNode } from 'react'
import { CardDotField } from '@/components/dashboard/card-dot-field'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
import { Item, ItemMedia } from '@evobgp/ui/components/item'
export type QuickLinkCardProps = {
icon: ReactNode
label: string
description: string
to: string
search?: Record<string, string>
iconClass: string
}
export function DashboardQuickLinkCard({
icon,
label,
description,
to,
search,
iconClass,
}: QuickLinkCardProps) {
return (
<Link
to={to}
search={search}
className="group block h-full rounded-[inherit] focus-visible:outline-none"
aria-label={`${label}: ${description}`}
>
<Card
size="sm"
className={cn(
'relative isolate h-full overflow-hidden transition-colors',
'hover:border-foreground/20',
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
)}
>
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
<Item
className={cn(
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
iconClass,
)}
>
<ItemMedia variant="icon" className="size-auto">
{icon}
</ItemMedia>
</Item>
<div className="mt-auto flex flex-col gap-3">
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
Перейти
<ChevronRight
aria-hidden
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
/>
</span>
</div>
</CardContent>
</Card>
</Link>
)
}
@@ -1,78 +1,76 @@
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import type { ReactNode } from 'react'
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
import { PanelCard } from '@/components/panel-card'
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
type QuickLink = {
icon: ReactNode
label: string
description: string
to: string
search?: Record<string, string>
iconClass: string
}
const LINKS: QuickLink[] = [
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
const ACTIONS: QuickActionItem[] = [
{
icon: <Plus aria-hidden />,
label: 'Создать модуль',
id: 'lookup',
title: 'Проверка IP/домена',
description: 'Membership в списках и community (entry + snapshot).',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
{
icon: <Tags aria-hidden />,
label: 'BGP-сообщества',
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
to: '/directories',
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
icon: <Tags aria-hidden />,
iconClassName: 'text-info',
},
{
icon: <Network aria-hidden />,
label: 'Сеть',
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
to: '/network',
search: { tab: 'overview' },
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
icon: <Network aria-hidden />,
iconClassName: 'text-success',
},
{
icon: <Share2 aria-hidden />,
label: 'Добавить пира',
id: 'add-peer',
title: 'Добавить пира',
description: 'Настройка BGP-соседа и шаблонов сессии.',
to: '/network',
search: { tab: 'peers' },
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
icon: <Share2 aria-hidden />,
iconClassName: 'text-warning',
},
{
icon: <Play aria-hidden />,
label: 'Деплой',
id: 'deploy',
title: 'Деплой',
description: 'Ревизии конфигурации и применение на нодах.',
to: '/operations',
search: { tab: 'revisions' },
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
icon: <Play aria-hidden />,
iconClassName: 'text-muted-foreground',
},
{
icon: <Gauge aria-hidden />,
label: 'Мониторинг',
id: 'monitoring',
title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
icon: <Gauge aria-hidden />,
iconClassName: 'text-destructive',
},
]
export function DashboardQuickLinks() {
return (
<PanelCard
title="Быстрые действия"
<QuickActionGrid
actions={ACTIONS}
description="Частые переходы к настройке и деплою"
className="@container w-full"
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
>
{LINKS.map((link) => (
<DashboardQuickLinkCard key={link.label} {...link} />
))}
</PanelCard>
/>
)
}
@@ -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="Поиск правил…"
/>
)
}
+40 -25
View File
@@ -9,7 +9,7 @@ import {
BookText,
KeyRound,
ServerCog,
Shield,
Search,
} from 'lucide-react'
import {
@@ -41,9 +41,12 @@ import { TooltipProvider } from '@evobgp/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ComponentType, CSSProperties, ReactNode } from 'react'
import { AppSwitcher } from '@/components/app-switcher'
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 {
to: string
@@ -51,6 +54,8 @@ interface NavItem {
icon: ComponentType<{ className?: string }>
description?: string
search?: Record<string, string>
/** Explicit permission override; when omitted derived from `to`. */
permission?: string
}
interface NavGroup {
@@ -74,6 +79,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'Маршрутизация',
items: [
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
],
@@ -82,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' } },
],
@@ -99,6 +104,18 @@ const NAV_GROUPS: NavGroup[] = [
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
/** Nav visible for the current claim (or all items when portal auth is off). */
function useVisibleNavGroups(): NavGroup[] {
if (!isAuthEnabled()) return NAV_GROUPS
return NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => {
const perm = item.permission ?? permissionForPath(item.to)
return !perm || can(perm)
}),
})).filter((group) => group.items.length > 0)
}
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
)
@@ -116,8 +133,14 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
keywords: [item.to.replace(/^\//, '')],
}))
/**
* Shared ops chrome etalon for CFDM / vps-tracker.
* @see https://reui.io/preview/base/app-shell-12
* @see docs/ui-design-contract.md — Shared App Shell chrome
*/
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const visibleGroups = useVisibleNavGroups()
const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
ALL_NAV_ITEMS[0]
@@ -134,29 +157,16 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarProvider
style={
{
'--sidebar-width': '260px',
'--sidebar-width-icon': '62px',
'--header-height': '56px',
'--sidebar-width': '240px',
} as CSSProperties
}
>
<Sidebar collapsible="icon">
<SidebarHeader className="gap-2">
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div>
</div>
<div className="px-2 group-data-[collapsible=icon]:px-0">
<CommandPalette items={COMMAND_ITEMS} />
</div>
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
{visibleGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
@@ -182,11 +192,13 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter />
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
@@ -204,12 +216,15 @@ export function AppShell({ children }: { children: ReactNode }) {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
@@ -0,0 +1,104 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon]
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -29,9 +29,11 @@ export type CommandPaletteItem = {
interface CommandPaletteProps {
items: CommandPaletteItem[]
className?: string
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
hotkeyOnly?: boolean
}
export function CommandPalette({ items }: CommandPaletteProps) {
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
@@ -68,25 +70,27 @@ export function CommandPalette({ items }: CommandPaletteProps) {
return (
<>
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
{!hotkeyOnly ? (
<SidebarGroup className="p-0">
<SidebarGroupContent className="relative">
<Button
type="button"
variant="outline"
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
onClick={() => setOpen(true)}
>
Поиск
</Button>
<Search
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
/>
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
K
</Kbd>
</SidebarGroupContent>
</SidebarGroup>
) : null}
<Dialog
open={open}
+229
View File
@@ -0,0 +1,229 @@
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,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
authPortalUrl,
clearPortalToken,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
resetPortalHandoff,
} from '@/lib/auth'
/** 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 '?'
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const authOn = isAuthEnabled()
const claims = getClaims()
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
const email =
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
const fallback = initials(name || email)
function handleSignOut() {
if (authOn) {
clearPortalToken()
resetPortalHandoff()
redirectToPortalLogout()
return
}
setApiToken(null)
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.assign('/settings?tab=connection&reason=token-required')
}
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<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>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg text-xs">
{fallback}
</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs text-muted-foreground">
{email || '—'}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
>
<SettingsIcon aria-hidden />
Настройки UI
</DropdownMenuItem>
{authOn ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={authPortalUrl()} />}
>
<ExternalLinkIcon aria-hidden />
Открыть Auth Portal
</DropdownMenuItem>
) : null}
<DropdownMenuItem className="cursor-default focus:bg-transparent">
<PaletteIcon aria-hidden />
Тема
<div className="ml-auto">
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon aria-hidden />
{authOn ? 'Выйти' : 'Сбросить токен'}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
@@ -0,0 +1,133 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridCard, DataGridSection } from '@/components/data-grid-shell'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import type { LookupMatch } from '@/types/api'
/**
* Lookup matches grid — data-grid-filtering-2 pattern.
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/docs/components/base/badge
*/
export function LookupMatchesGrid({
items,
isLoading = false,
}: {
items: LookupMatch[]
isLoading?: boolean
}) {
const navigate = useNavigate()
const columns = useMemo<ColumnDef<LookupMatch>[]>(
() => [
{
accessorKey: 'layer',
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
cell: ({ row }) => (
<Badge
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
size="sm"
>
{row.original.layer}
</Badge>
),
meta: { headerTitle: 'Слой' },
},
{
accessorKey: 'module_name',
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.module_name}
subtitle={row.original.module_type}
accent="primary"
/>
),
meta: { headerTitle: 'Модуль' },
},
{
accessorKey: 'matched_value',
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.matched_value}
subtitle={
row.original.resolved_ip
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
: row.original.match_kind
}
accent="mono"
/>
),
meta: { headerTitle: 'Совпадение' },
},
{
id: 'community',
accessorFn: (row) => row.community_title || row.community || '',
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
cell: ({ row }) => {
const title = row.original.community_title?.trim()
const value = row.original.community?.trim()
if (!title && !value) {
return <span className="text-muted-foreground text-sm"></span>
}
return (
<DataGridPrimaryCell
title={title || value || '—'}
subtitle={title && value && title !== value ? value : undefined}
/>
)
},
meta: { headerTitle: 'Community' },
},
{
id: 'source',
enableSorting: false,
header: 'Источник',
cell: ({ row }) =>
row.original.source ? (
<CategoryBadge>{row.original.source}</CategoryBadge>
) : (
<span className="text-muted-foreground text-sm"></span>
),
meta: { headerTitle: 'Источник' },
},
],
[],
)
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
getRowId: (row) =>
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
})
return (
<DataGridCard
title="Совпадения"
description="Entries и snapshots · клик по строке открывает модуль"
>
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет совпадений"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Фильтр совпадений…"
onRowClick={(row) =>
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
}
/>
</DataGridCard>
)
}
@@ -0,0 +1,76 @@
import { useState, type FormEvent } from 'react'
import { Search } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Field, FieldLabel } from '@evobgp/ui/components/field'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@evobgp/ui/components/input-group'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
/**
* Lookup search form — Frame + InputGroup (form-7 pattern).
* @see https://reui.io/preview/base/form-7
* @see https://reui.io/docs/components/base/frame
*/
export function LookupSearchForm({
initialQuery = '',
isPending = false,
onSubmit,
}: {
initialQuery?: string
isPending?: boolean
onSubmit: (q: string) => void
}) {
const [value, setValue] = useState(initialQuery)
function handleSubmit(e: FormEvent) {
e.preventDefault()
const q = value.trim()
if (!q) return
onSubmit(q)
}
return (
<Frame spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Проверка списка</FrameTitle>
<FrameDescription>
IP или FQDN поиск в entries и материализованных snapshots с community.
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
id="lookup-q"
name="q"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="8.8.8.8 или example.com"
autoComplete="off"
autoFocus
/>
</InputGroup>
</Field>
<Button type="submit" disabled={isPending || !value.trim()}>
Проверить
</Button>
</form>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,62 @@
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import type { LookupResponse } from '@/types/api'
/**
* Lookup summary KPI — stats-12 via KpiStatGrid.
* @see https://reui.io/preview/base/stats-12
*/
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
const resolvedCount = data.resolved_ips?.length ?? 0
const items: KpiStatItem[] = [
{
id: 'matched',
label: 'Результат',
value: data.matched ? 'Найдено' : 'Не найдено',
hint: data.normalized,
icon: <Radar aria-hidden />,
iconClassName: data.matched
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
variant: data.matched ? 'default' : 'warning',
},
{
id: 'entry',
label: 'Слой entry',
value: entryCount,
hint: 'сырые списки',
icon: <ListChecks aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
id: 'snapshot',
label: 'Слой snapshot',
value: snapshotCount,
hint: 'материализация',
icon: <Layers aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
]
if (data.query_kind === 'domain') {
items.push({
id: 'resolved',
label: 'DNS IP',
value: resolvedCount,
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
icon: <Globe aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
})
}
return (
<KpiStatGrid
items={items}
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
/>
)
}
-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,115 @@
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
import { aggregateNetworkMetrics } from '@/queries/overview'
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
/**
* Network page KPI strip.
* @see https://reui.io/preview/base/stats-12
*/
export function NetworkKpi({
peers,
speakers,
bird,
loading,
}: {
peers: PeerRow[]
speakers: SpeakerRow[]
bird?: BirdStatus
loading?: boolean
}) {
const net = aggregateNetworkMetrics(peers, speakers)
const birdHealthy = bird?.healthy
const birdSessions =
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
const cards: KpiStatCardData[] = [
{
id: 'peers-established',
icon: <Share2 aria-hidden />,
iconClassName: 'text-success',
label: 'Пиры Established',
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
footer: (
<Badge variant="success-light" size="sm">
{loading ? '…' : `${net.peersTotal} в каталоге`}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'speakers-online',
icon: <ServerCog aria-hidden />,
iconClassName: 'text-info',
label: 'Спикеры online',
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
footer: (
<Badge
variant={
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
? 'success-light'
: 'warning-light'
}
size="sm"
>
{loading ? '…' : 'live'}
</Badge>
),
to: '/network',
search: { tab: 'speakers' },
},
{
id: 'mismatches',
icon: <AlertTriangle aria-hidden />,
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
label: 'Расхождения',
value: loading ? '—' : String(net.peersMismatch),
variant: net.peersMismatch > 0 ? 'warning' : 'default',
footer: (
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
</Badge>
),
to: '/network',
search: { tab: 'peers' },
},
{
id: 'bird',
icon: <Network aria-hidden />,
iconClassName:
birdHealthy === false ? 'text-destructive' : 'text-primary',
label: 'BIRD',
value: loading ? '—' : birdSessions,
variant: birdHealthy === false ? 'destructive' : 'default',
footer: (
<Badge
variant={
birdHealthy === true
? 'success-light'
: birdHealthy === false
? 'destructive-light'
: 'outline'
}
size="sm"
>
{loading
? '…'
: birdHealthy === true
? 'в норме'
: birdHealthy === false
? 'проблема'
: 'н/д'}
</Badge>
),
},
]
return (
<section aria-label="Ключевые метрики сети">
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
</section>
)
}
@@ -1,17 +1,76 @@
import { useMemo, useState } from 'react'
import { Plus } from 'lucide-react'
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getPeerFilterFieldValue,
peerColumns,
peerTabFilter,
} from '@/components/network/network-peers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { PeerRow, SpeakerRow } from '@/types/api'
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
/**
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const PEER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'established', label: 'Established' },
{ id: 'pending', label: 'Ожидание' },
{ id: 'disabled', label: 'Выключены' },
]
const SESSION_STATE_OPTIONS = [
{ value: 'Established', label: 'Established' },
{ value: 'Idle', label: 'Idle' },
{ value: 'Active', label: 'Active' },
{ value: 'Connect', label: 'Connect' },
{ value: 'OpenSent', label: 'OpenSent' },
{ value: 'OpenConfirm', label: 'OpenConfirm' },
]
function createDefaultPeerFilters(): Filter[] {
return [createFilter('name', 'contains', [''])]
}
const peerFilterFields: FilterFieldConfig[] = [
{
key: 'name',
label: 'Имя',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Поиск по имени…',
},
{
key: 'neighbor',
label: 'Сосед',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-48',
placeholder: 'Адрес соседа…',
},
{
key: 'session_state',
label: 'Состояние',
icon: <ActivityIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[160px]',
options: SESSION_STATE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
},
]
interface NetworkPeersCardProps {
items: PeerRow[]
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
onRetry: () => void
}
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
if (tab === 'all') return items
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
const enabled = items.filter((p) => p.enabled !== false)
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
return enabled.filter((p) => p.session_state !== 'Established')
}
function tabCounts(items: PeerRow[]) {
const enabled = items.filter((p) => p.enabled !== false)
return {
all: items.length,
established: enabled.filter((p) => p.session_state === 'Established').length,
pending: enabled.filter((p) => p.session_state !== 'Established').length,
disabled: items.length - enabled.length,
}
}
export function NetworkPeersCard({
items,
speakers,
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
onRetry,
}: NetworkPeersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [tab, setTab] = useState<PeerTab>('all')
const counts = useMemo(() => tabCounts(items), [items])
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Пиры"
description="BGP-соседи и привязка к спикерам"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить пира
</Button>
}
>
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
</TabsList>
</Tabs>
</div>
<QueryState
data={filtered}
isLoading={isLoading}
isError={isError}
error={error}
empty={filtered.length === 0}
emptyTitle="Нет пиров в выборке"
emptyDescription="Измените фильтр или добавьте BGP-соседа."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={PEER_TABS}
tabFilter={peerTabFilter}
filterFields={peerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultPeerFilters())}
getFilterFieldValue={getPeerFilterFieldValue}
columns={peerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет пиров',
description: 'Добавьте первого BGP-соседа.',
action: addButton,
}}
/>
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
</>
@@ -1,90 +1,77 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
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 { bgpSessionStateRu } from '@/lib/ui-labels'
import type { PeerRow } from '@/types/api'
export function NetworkPeersGrid({
items,
isLoading = false,
}: {
items: PeerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<PeerRow>[]>(
() => [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? '—'}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
],
[],
)
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
{
id: 'name',
accessorFn: (row) => row.name ?? row.neighbor,
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
cell: ({ row }) => (
<DataGridPrimaryCell
title={row.original.name ?? row.original.neighbor}
subtitle={row.original.name ? row.original.neighbor : undefined}
accent="primary"
/>
),
meta: { headerTitle: 'Имя' },
},
{
accessorKey: 'neighbor',
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
),
meta: { headerTitle: 'Адрес соседа' },
},
{
accessorKey: 'remote_asn',
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
),
meta: { headerTitle: 'ASN' },
},
{
accessorKey: 'session_state',
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
cell: ({ row }) => (
<div className="flex items-center gap-1">
<StatusBadge
status={row.original.session_state ?? ''}
label={bgpSessionStateRu(row.original.session_state)}
/>
{row.original.session_mismatch ? (
<CategoryBadge tone="warning">расхождение</CategoryBadge>
) : null}
</div>
),
meta: { headerTitle: 'Состояние' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
getRowId: (row) => row.id,
})
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
switch (field) {
case 'name':
return item.name ?? item.neighbor
case 'neighbor':
return item.neighbor
case 'session_state':
return item.session_state
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет пиров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск пиров…"
/>
)
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
if (tabId === 'disabled') return item.enabled === false
const enabled = item.enabled !== false
if (tabId === 'established') return enabled && item.session_state === 'Established'
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
return true
}
@@ -1,15 +1,65 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { DataGridCard } from '@/components/data-grid-shell'
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import {
getSpeakerFilterFieldValue,
speakerColumns,
speakerTabFilter,
} from '@/components/network/network-speakers-grid'
import {
createFilter,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
import type { SpeakerRow } from '@/types/api'
/**
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
* @see https://reui.io/preview/base/data-grid-filtering-2
*/
const SPEAKER_TABS = [
{ id: 'all', label: 'Все' },
{ id: 'online', label: 'Online' },
{ id: 'offline', label: 'Offline' },
]
const ROLE_OPTIONS = [
{ value: 'primary', label: 'primary' },
{ value: 'secondary', label: 'secondary' },
{ value: 'speaker', label: 'speaker' },
]
function createDefaultSpeakerFilters(): Filter[] {
return [createFilter('endpoint', 'contains', [''])]
}
const speakerFilterFields: FilterFieldConfig[] = [
{
key: 'endpoint',
label: 'Конечная точка',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'endpoint…',
},
{
key: 'role',
label: 'Роль',
icon: <TagIcon className="size-3.5" aria-hidden />,
type: 'select',
searchable: false,
className: 'w-[140px]',
options: ROLE_OPTIONS,
customValueRenderer: (values) =>
renderSingleSelectedLabel(values, ROLE_OPTIONS),
},
]
interface NetworkSpeakersCardProps {
items: SpeakerRow[]
isLoading: boolean
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
onRetry,
}: NetworkSpeakersCardProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
const addButton = useMemo(
() => (
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
),
[],
)
return (
<>
<DataGridCard
<ResourcePage
title="Спикеры"
description="BIRD-агенты на нодах tenant"
actions={
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
<Plus />
Добавить спикера
</Button>
}
>
<QueryState
data={items}
isLoading={isLoading}
isError={isError}
error={error}
empty={items.length === 0}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте первого BIRD-агента на ноде."
skeleton={<TableSkeleton rows={6} cols={4} />}
onRetry={onRetry}
>
{(data) => (
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
)}
</QueryState>
</DataGridCard>
tabs={SPEAKER_TABS}
tabFilter={speakerTabFilter}
filterFields={speakerFilterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
getFilterFieldValue={getSpeakerFilterFieldValue}
columns={speakerColumns}
data={items}
getRowId={(row) => row.id}
isLoading={isLoading}
isError={isError}
error={error instanceof Error ? error : null}
onRetry={onRetry}
primaryAction={addButton}
emptyState={{
title: 'Нет спикеров',
description: 'Добавьте первого BIRD-агента на ноде.',
action: addButton,
}}
/>
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
@@ -1,87 +1,78 @@
import { ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { CategoryBadge } from '@/components/category-badge'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGridSection } from '@/components/data-grid-shell'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
import { speakerOnlineLabel } from '@/lib/ui-labels'
import type { SpeakerRow } from '@/types/api'
export function NetworkSpeakersGrid({
items,
isLoading = false,
}: {
items: SpeakerRow[]
isLoading?: boolean
}) {
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
() => [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
return <Badge variant="outline" size="sm" radius="full"></Badge>
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
],
[],
)
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
{
accessorKey: 'endpoint',
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
cell: ({ row }) => (
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
),
meta: { headerTitle: 'Конечная точка' },
},
{
accessorKey: 'role',
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
meta: { headerTitle: 'Роль' },
},
{
id: 'agent',
enableSorting: false,
header: 'Агент',
cell: ({ row }) => {
const live = row.original.live
if (live?.agent_ok === true) {
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
}
if (live?.agent_ok === false) {
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
}
return (
<Badge variant="outline" size="sm" radius="full">
</Badge>
)
},
meta: { headerTitle: 'Агент' },
},
{
id: 'bgp',
enableSorting: false,
header: 'BGP',
cell: ({ row }) => {
const live = row.original.live
if (!live) return '—'
return (
<span className="text-xs">
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
</span>
)
},
meta: { headerTitle: 'BGP' },
},
]
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
data: items,
columns,
getSearchText: (row) =>
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
getRowId: (row) => row.id,
})
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
switch (field) {
case 'endpoint':
return item.endpoint
case 'role':
return item.role
default:
return undefined
}
}
return (
<DataGridSection
table={table}
recordCount={filteredCount}
isLoading={isLoading}
emptyMessage="Нет спикеров"
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск спикеров…"
/>
)
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
if (tabId === 'online') return item.live?.agent_ok === true
if (tabId === 'offline') return item.live?.agent_ok !== true
return true
}
@@ -20,6 +20,7 @@ export {
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
@@ -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,13 +4,14 @@ 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'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* KPI tile data — hybrid compact stats-12 (icon + value + label + Badge footer).
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
@@ -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()
@@ -90,11 +81,11 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full flex-col items-start gap-3">
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
@@ -104,7 +95,11 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
</Item>
) : null}
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
@@ -113,10 +108,7 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
>
{item.value}
</div>
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
</div>
{footer ? <div className="mt-auto w-full">{footer}</div> : null}
</div>
)
}
@@ -203,11 +195,15 @@ function KpiStatGridSkeleton({ count }: { count: number }) {
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex flex-col gap-3">
<Skeleton className="size-10.5 rounded-lg" />
<Skeleton className="h-7 w-14" />
<Skeleton className="h-4 w-20" />
<Skeleton className="mt-auto h-4.5 w-16 rounded-full" />
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
@@ -266,7 +262,7 @@ function KpiStatCardItem({ item }: { item: KpiStatItem }) {
}
/**
* Hybrid KPI grid — compact Frame strip.
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
@@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
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
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -1,5 +1,7 @@
import { Moon, Sun, SunMoon } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
@@ -9,6 +11,9 @@ import {
ToggleGroup,
ToggleGroupItem,
} from '@evobgp/ui/components/toggle-group'
import { Switch } from '@evobgp/ui/components/switch'
import { apiMutate } from '@/lib/api-client'
import { settingsKeys, settingsQueryOptions } from '@/queries/settings'
const THEME_OPTIONS = [
{ value: 'light', label: 'Светлая', icon: Sun },
@@ -16,8 +21,27 @@ const THEME_OPTIONS = [
{ value: 'system', label: 'Система', icon: SunMoon },
] as const
function parseShowQuickActions(value: unknown): boolean {
if (value === false || value === 0 || value === 'false' || value === '0') return false
return true
}
export function AppearanceSettingsTab() {
const { theme, setTheme } = useTheme()
const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
const patchMut = useMutation({
mutationFn: (payload: Record<string, boolean>) =>
apiMutate('/v1/settings', 'PATCH', payload),
onSuccess: () => {
toast.success('Настройки интерфейса сохранены')
void qc.invalidateQueries({ queryKey: settingsKeys.all })
},
onError: (e) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
return (
<div className="space-y-6">
@@ -63,6 +87,31 @@ export function AppearanceSettingsTab() {
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
<SettingsCard
title="Дашборд"
description="Блоки на экране «Обзор»"
>
<SettingsFieldGroup
legend="Быстрые действия"
description="Показывать KPI-like плитки быстрых переходов под метриками."
>
<SettingRow
title="Быстрые действия"
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
last
>
<Switch
checked={showQuickActions}
disabled={settingsQ.isLoading || patchMut.isPending}
onCheckedChange={(checked) =>
patchMut.mutate({ ui_show_quick_actions: checked })
}
aria-label="Показывать быстрые действия"
/>
</SettingRow>
</SettingsFieldGroup>
</SettingsCard>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppUrl as getAppUrlFromConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
import { getClaims, isAuthEnabled } from '@/lib/auth'
/**
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
* when auth-portal is disabled or unavailable.
*/
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
const authOn = isAuthEnabled()
const { data, isLoading } = useQuery({
...appSwitcherQueryOptions(),
enabled: authOn,
})
const claims = getClaims()
const config = useMemo<AppSwitcherConfig>(() => {
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
if (!authOn) return raw
const allowed = claims?.apps
if (!allowed?.length) return raw
const set = new Set(allowed)
const apps = raw.apps.filter((a) => set.has(a.id))
if (!apps.length) return raw
return { ...raw, apps }
}, [data, authOn, claims?.apps])
return { config, isLoading: authOn && isLoading }
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
}
+11
View File
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
return t
}
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
/**
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
* is disabled or hasn't issued a token yet we fall back to the local API-key
* (`evobgp_api_token`) so curl-style tooling keeps working.
*/
function getToken(): string | null {
if (typeof window === 'undefined') return null
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
if (portal && portal.trim()) return portal.trim()
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
if (!raw) return null
const normalized = normalizeApiToken(raw)
+104
View File
@@ -0,0 +1,104 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { z } from 'zod'
import { CURRENT_APP_ID } from '@/lib/auth'
export { CURRENT_APP_ID }
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string(),
icon: appSwitcherIconSchema.default('server'),
shortcut: z.string().optional(),
})
const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
/** Shared defaults across ops apps — chrome app switcher. */
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'http://192.168.100.67:3001',
icon: 'server',
shortcut: '⌘1',
},
{
id: 'cfdm',
name: 'CF Domain Manager',
subtitle: 'Управление доменами',
url: 'http://192.168.100.67:6363',
icon: 'cloud',
shortcut: '⌘2',
},
{
id: 'bgp',
name: 'EvoBGP',
subtitle: 'BGP маршрутизация',
url: 'http://192.168.100.67:3000',
icon: 'globe',
shortcut: '⌘3',
},
],
}
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
if (!raw?.trim()) {
return DEFAULT_APP_SWITCHER_CONFIG
}
try {
const parsed = JSON.parse(raw) as unknown
return appSwitcherConfigSchema.parse(parsed)
} catch (error) {
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
return DEFAULT_APP_SWITCHER_CONFIG
}
}
export function getAppSwitcherConfig(): AppSwitcherConfig {
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
}
export function getAppUrl(
appId: string,
config: AppSwitcherConfig = getAppSwitcherConfig(),
): string | undefined {
return config.apps.find((app) => app.id === appId)?.url
}
export function getCurrentApp(
config: AppSwitcherConfig = getAppSwitcherConfig(),
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
}
+302
View File
@@ -0,0 +1,302 @@
/**
* Portal JWT SSO integration for EvoBGP UI.
*
* Two independent auth channels:
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
* to EvoBGP API when the API accepts portal-issued tokens.
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
* in localStorage; used when auth-portal is disabled or as a fallback.
*
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
* (curl/dev) and as backup.
*/
const TOKEN_KEY = 'evobgp_portal_token'
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
const HANDOFF_COOLDOWN_MS = 12_000
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
export const CURRENT_APP_ID = 'bgp'
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
/**
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
* when the endpoint isn't implemented (404) or the API is unreachable.
*
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
*/
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch('/v1/auth/config', {
headers: { Accept: 'application/json' },
})
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* ignore — fall through to vite defaults */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getPortalToken(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(TOKEN_KEY)
}
export function setPortalToken(token: string): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(TOKEN_KEY, token)
}
export function clearPortalToken(): void {
if (typeof window === 'undefined') return
window.localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
if (runtimeConfig) return runtimeConfig.required
return viteAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return vitePortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
if (typeof window === 'undefined') return false
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
if (typeof window === 'undefined') return
window.sessionStorage.setItem(HANDOFF_KEY, '1')
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(HANDOFF_KEY)
}
/** Clear cooldown too — use on intentional logout so next login is allowed. */
export function resetPortalHandoff(): void {
if (typeof window === 'undefined') return
window.sessionStorage.removeItem(HANDOFF_KEY)
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function hasPortalHandoffFlag(): boolean {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
}
/**
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
* (clears local token) — prevents infinite SSO when API rejects JWT.
*/
export function redirectToPortalLogin(returnTo?: string): boolean {
if (typeof window === 'undefined') return false
if (isPortalHandoffCoolingDown()) {
clearPortalToken()
return false
}
markPortalHandoff()
const callback = returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback)
window.location.assign(url.toString())
return true
}
/** End portal SSO session (refresh cookie + portal token). */
export function redirectToPortalLogout(): void {
clearPortalToken()
resetPortalHandoff()
if (typeof window === 'undefined') return
window.location.assign(`${authPortalUrl()}/logout`)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get('access_token'),
expiresAt: params.get('expires_at'),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split('.')
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ''),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getPortalToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearPortalToken()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
/** Access-check: pass when portal auth is disabled or claim grants required. */
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
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)
}
/** 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')) {
return 'bgp:dashboard:read'
}
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
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('/schedule')) return 'bgp:schedule:read'
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
if (pathname.startsWith('/access')) return 'bgp:access:admin'
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
return null
}
const FALLBACK_PATH = '/dashboard'
/** First path in the sidebar the current user may open. */
export function firstAllowedPath(): string {
const candidates: readonly string[] = [
'/dashboard',
'/modules',
'/lookup',
'/network',
'/directories',
'/operations',
'/schedule',
'/monitoring',
'/access',
'/tenant-settings',
'/settings',
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return FALLBACK_PATH
}
+32
View File
@@ -0,0 +1,32 @@
import { queryOptions } from '@tanstack/react-query'
import { ensureAuthConfig } from '@/lib/auth'
import {
DEFAULT_APP_SWITCHER_CONFIG,
parseAppSwitcherConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
const { portalUrl } = await ensureAuthConfig()
const base = portalUrl.replace(/\/$/, '')
const res = await fetch(`${base}/api/v1/app-switcher`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
const raw = (await res.json()) as unknown
return parseAppSwitcherConfig(JSON.stringify(raw))
}
export function appSwitcherQueryOptions() {
return queryOptions({
queryKey: appSwitcherQueryKey,
queryFn: fetchPortalAppSwitcher,
staleTime: 60_000,
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
retry: 1,
})
}
-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 })
},
})
}
+21
View File
@@ -0,0 +1,21 @@
import { queryOptions } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client'
import type { LookupResponse } from '@/types/api'
export const lookupKeys = {
all: ['lookup'] as const,
query: (q: string) => [...lookupKeys.all, q] as const,
}
/** GET /v1/lookup?q= — dual-layer membership (entry + snapshot). */
export function lookupQueryOptions(q: string) {
const trimmed = q.trim()
return queryOptions<LookupResponse>({
queryKey: lookupKeys.query(trimmed),
queryFn: () =>
apiJSON<LookupResponse>(`/v1/lookup?q=${encodeURIComponent(trimmed)}`),
enabled: trimmed.length > 0,
staleTime: 15_000,
})
}
+58 -4
View File
@@ -1,15 +1,69 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import {
can,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getPortalToken,
permissionForPath,
redirectToPortalLogin,
} from '@/lib/auth'
/**
* Two-mode gate:
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
* → require auth-portal JWT (SSO) + section permission via `can()`.
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
*/
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => {
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
beforeLoad: async ({ location }) => {
const cfg = await ensureAuthConfig()
if (cfg.required) {
const token = getPortalToken()
const claims = getClaims()
if (!token || !claims) {
const ok = redirectToPortalLogin(
`${window.location.origin}/auth/callback`,
)
if (!ok) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {})
return
}
if (!claims.apps.includes('bgp')) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
const perm = permissionForPath(location.pathname)
if (perm && !can(perm)) {
const fallback = firstAllowedPath()
if (fallback !== location.pathname) {
throw redirect({ to: fallback as '/dashboard' })
}
}
return
}
// Settings available without token — first-run onboarding (incl. `dev`).
if (location.pathname === '/settings') return
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
typeof window !== 'undefined'
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
: null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings', search: { tab: 'connection', reason: 'token-required' } })
throw redirect({
to: '/settings',
search: { tab: 'connection', reason: 'token-required' },
})
}
},
component: AuthLayout,
+11 -3
View File
@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import { useQueries, useQuery } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useState } from 'react'
@@ -27,13 +27,21 @@ import {
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
} from '@/queries/overview'
import { settingsQueryOptions } from '@/queries/settings'
export const Route = createFileRoute('/_auth/dashboard')({
component: DashboardComponent,
})
function parseShowQuickActions(value: unknown): boolean {
if (value === false || value === 0 || value === 'false' || value === '0') return false
return true
}
function DashboardComponent() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
const results = useQueries({
queries: [
@@ -86,8 +94,6 @@ function DashboardComponent() {
}
/>
<DashboardQuickLinks />
{initialLoading ? (
<AnalyticsDashboardSkeleton />
) : (
@@ -99,6 +105,8 @@ function DashboardComponent() {
jobs={jobs}
/>
{showQuickActions ? <DashboardQuickLinks /> : null}
<div className={dashboardMainSidebarClassName}>
<div className="xl:col-span-8">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
-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>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { lookupQueryOptions } from '@/queries/lookup'
/**
* Quick membership lookup page.
* Surface: frame · KPI: stats-12 · form: form-7 · grid: data-grid-filtering-2 · empty: empty-state-2
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/form-7
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/empty-state-2
*/
export const Route = createFileRoute('/_auth/lookup')({
component: LookupComponent,
validateSearch: (search: Record<string, unknown>) => ({
q: typeof search.q === 'string' ? search.q : '',
}),
})
function LookupComponent() {
const { q } = useSearch({ from: '/_auth/lookup' })
const navigate = Route.useNavigate()
const lookupQ = useQuery(lookupQueryOptions(q))
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Проверка"
description="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
/>
<LookupSearchForm
key={q}
initialQuery={q}
isPending={lookupQ.isFetching}
onSubmit={(next) => void navigate({ search: { q: next } })}
/>
{!q.trim() ? (
<EmptyState
icon={<Search className="size-8" />}
title="Введите IP или домен"
description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам."
/>
) : (
<QueryState
data={lookupQ.data}
isLoading={lookupQ.isLoading}
isError={lookupQ.isError}
error={lookupQ.error}
onRetry={() => void lookupQ.refetch()}
skeleton={
<div className="flex flex-col gap-4 md:gap-6">
<SectionCardsSkeleton />
<TableSkeleton rows={5} />
</div>
}
>
{(data) => (
<div className="flex flex-col gap-4 md:gap-6">
<LookupSummaryKpi data={data} />
{data.matched ? (
<LookupMatchesGrid items={data.matches} isLoading={lookupQ.isFetching} />
) : (
<EmptyState
icon={<Search className="size-8" />}
title="Не найдено в списках"
description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`}
/>
)}
</div>
)}
</QueryState>
)}
</div>
)
}
+44 -101
View File
@@ -1,28 +1,38 @@
import { createFileRoute, useSearch } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@evobgp/ui/components/button'
import { PanelCard } from '@/components/panel-card'
import { TabsContent } from '@evobgp/ui/components/tabs'
import { RefreshCw } from 'lucide-react'
import {
DashboardNetworkCapacityCard,
NetworkOverviewAnalyticsCard,
} from '@/components/analytics'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { NetworkKpi } from '@/components/network/network-kpi'
import { NetworkPeersCard } from '@/components/network/network-peers-card'
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
import { overviewJobsQueryOptions } from '@/queries/overview'
import {
networkBirdQueryOptions,
networkPeersQueryOptions,
networkSpeakersQueryOptions,
} from '@/queries/network'
type NetworkTab = 'peers' | 'speakers'
function parseNetworkTab(value: unknown): NetworkTab {
if (value === 'speakers') return 'speakers'
// legacy: overview | control-plane → peers
return 'peers'
}
/**
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/data-grid-filtering-2
* @see https://reui.io/preview/base/empty-state-12
*/
export const Route = createFileRoute('/_auth/network')({
component: NetworkComponent,
validateSearch: (search: Record<string, unknown>) => ({
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
? search.tab
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
tab: parseNetworkTab(search.tab),
}),
})
@@ -32,12 +42,10 @@ function NetworkComponent() {
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
const jobsQ = useQuery(overviewJobsQueryOptions())
const refreshing = peersQ.isFetching || speakersQ.isFetching
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
const peers = peersQ.data?.items ?? []
const speakers = speakersQ.data?.items ?? []
const jobs = jobsQ.data?.items ?? []
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
function refetchAll() {
@@ -47,121 +55,56 @@ function NetworkComponent() {
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры, спикеры и live-метрики нод"
description="BGP-пиры, спикеры и статус BIRD"
actions={
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
Обновить
</Button>
}
/>
<BadgeTabs
<NetworkKpi
peers={peers}
speakers={speakers}
bird={birdQ.data}
loading={overviewLoading || birdQ.isLoading}
/>
<CountedLineTabs
value={search.tab}
onValueChange={(tab) =>
navigate({
search: {
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
},
})
navigate({ search: { tab: tab as NetworkTab } })
}
items={[
{ value: 'overview', label: 'Обзор' },
{ value: 'peers', label: 'Пиры', count: peers.length },
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
{ value: 'control-plane', label: 'Плоскость управления' },
tabs={[
{ id: 'peers', label: 'Пиры', count: peers.length },
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
]}
>
<TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-2">
<NetworkOverviewAnalyticsCard
peers={peers}
speakers={speakers}
loading={overviewLoading}
/>
<DashboardNetworkCapacityCard
peers={peers}
speakers={speakers}
jobs={jobs}
loading={overviewLoading}
/>
</div>
<PanelCard
className="mt-4"
title="BIRD (control plane)"
description="Статус birdc на хосте API"
contentClassName="py-4"
>
<QueryState
data={birdQ.data}
isLoading={birdQ.isLoading}
isError={birdQ.isError}
error={birdQ.error}
skeleton={<TableSkeleton rows={3} cols={2} />}
onRetry={() => birdQ.refetch()}
>
{(bird) => <BirdSummary bird={bird} />}
</QueryState>
</PanelCard>
</TabsContent>
<TabsContent value="peers" className="mt-0">
<TabsContent value="peers" className="mt-4">
<NetworkPeersCard
items={peers}
speakers={speakers}
isLoading={peersQ.isLoading}
isError={peersQ.isError}
error={peersQ.error}
onRetry={() => peersQ.refetch()}
onRetry={() => void peersQ.refetch()}
/>
</TabsContent>
<TabsContent value="speakers" className="mt-0">
<TabsContent value="speakers" className="mt-4">
<NetworkSpeakersCard
items={speakers}
isLoading={speakersQ.isLoading}
isError={speakersQ.isError}
error={speakersQ.error}
onRetry={() => speakersQ.refetch()}
onRetry={() => void speakersQ.refetch()}
/>
</TabsContent>
<TabsContent value="control-plane" className="mt-0">
<PanelCard
title="Настройки Control Plane (BIRD)"
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
contentClassName="py-4 text-sm text-muted-foreground"
>
См. раздел «Настройки BIRD».
</PanelCard>
</TabsContent>
</BadgeTabs>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
return (
<div className="flex flex-col gap-2 text-sm">
<Field
label="Состояние"
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
/>
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
</CountedLineTabs>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
authPortalUrl,
clearPortalHandoffFlag,
clearPortalToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getPortalToken,
parseHashToken,
redirectToPortalLogin,
setPortalToken,
} from '@/lib/auth'
/**
* SSO callback reads `#access_token=…&expires_at=…` returned by auth-portal,
* stores the JWT, and drops the user on the first allowed page.
*
* If the hash is empty (direct visit / already logged in) the route re-runs the
* portal handshake; the cool-down guard prevents redirect storms.
*/
export const Route = createFileRoute('/auth/callback')({
validateSearch: (search: Record<string, unknown>) => ({
error: typeof search.error === 'string' ? search.error : undefined,
}),
beforeLoad: async ({ search }) => {
await ensureAuthConfig()
if (search.error === 'sso_loop') return
if (typeof window === 'undefined') return
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setPortalToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearPortalToken()
window.location.assign(authPortalUrl())
await new Promise(() => {})
return
}
throw redirect({ to: firstAllowedPath() as '/dashboard' })
}
if (getPortalToken() && getClaims()) {
clearPortalHandoffFlag()
throw redirect({ to: firstAllowedPath() as '/dashboard' })
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) {
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {})
},
component: AuthCallbackPage,
})
function AuthCallbackPage() {
const { error } = Route.useSearch()
if (error === 'sso_loop') {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Сессия не принята</h1>
<p className="text-muted-foreground max-w-md text-sm">
Повторный вход через auth-portal остановлен (защита от цикла редиректов).
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
Войдите заново на portal, затем откройте EvoBGP.
</p>
<a className="text-primary text-sm underline" href={authPortalUrl()}>
Открыть Auth Portal
</a>
</div>
)
}
return null
}
+33 -38
View File
@@ -151,6 +151,37 @@ export type BgpCommunityCreate = {
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
export type CommunitiesResponse = Page<BgpCommunity>
// ---- Lookup (GET /v1/lookup) ----
/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */
export type LookupQueryKind = 'ip' | 'domain'
export type LookupLayer = 'entry' | 'snapshot'
export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix'
export type LookupMatch = {
layer: LookupLayer
module_id: string
module_name: string
module_type: ModuleType
match_kind: LookupMatchKind
matched_value: string
entry_id?: string
source?: string
community_id?: string | null
community?: string
community_title?: string
resolved_ip?: string
}
export type LookupResponse = {
query: string
query_kind: LookupQueryKind
normalized: string
matched: boolean
match_count: number
matches: LookupMatch[]
resolved_ips?: string[]
}
// ---- Peers ----
export type PeerSessionOnSpeaker = {
speaker_id: string
@@ -344,41 +375,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.
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_SWITCHER?: string
/** '1' | 'true' → require auth-portal JWT; иначе — локальный API-токен. */
readonly VITE_AUTH_ENABLED?: string
/** URL auth-portal (SSO). Пример: http://192.168.100.67:5175 или https://auth.shnt.top. */
readonly VITE_AUTH_PORTAL_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
File diff suppressed because one or more lines are too long
+30
View File
@@ -37,6 +37,11 @@ func main() {
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
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"),
}
srv, err := httpapi.New(opts)
if err != nil {
@@ -86,6 +91,31 @@ func main() {
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if v := strings.TrimSpace(c); v != "" {
return v
}
}
return ""
}
func boolFromEnv(keys ...string) bool {
for _, k := range keys {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
continue
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return false
}
func startBirdMetricsPoller(ctx context.Context) {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
+30
View File
@@ -32,6 +32,11 @@ func main() {
SeedDemo: seedDemo,
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
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"),
}
srv, err := httpapi.New(opts)
if err != nil {
@@ -78,6 +83,31 @@ func main() {
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
}
func firstNonEmpty(candidates ...string) string {
for _, c := range candidates {
if v := strings.TrimSpace(c); v != "" {
return v
}
}
return ""
}
func boolFromEnv(keys ...string) bool {
for _, k := range keys {
v := strings.TrimSpace(os.Getenv(k))
if v == "" {
continue
}
switch strings.ToLower(v) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
}
return false
}
func startBirdMetricsPoller(ctx context.Context) {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
+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:
+34
View File
@@ -2,6 +2,40 @@
Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git.
## Portal SSO (JWT)
Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal.
| Переменная | Назначение |
|------------|------------|
| `AUTH_REQUIRED` / `EVOBGP_AUTH_REQUIRED` | Включить проверку portal JWT для UI |
| `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` | 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`.
**Ownership:** modules, peers, firewall clients/rules с `created_by_user_id` видны создателю и portal `is_admin` (API keys — весь tenant).
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
## API-ключи (`EVOBGP_API_KEYS`)
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
+9
View File
@@ -33,6 +33,15 @@
- `POST /v1/modules`, `PATCH /v1/modules/{module_id}`, `DELETE /v1/modules/{module_id}`
- `GET|POST|PATCH|DELETE` для `.../cdn-sources`, `.../as-entries`, `.../domain-entries`, `.../ip-range-entries`
- `POST /v1/modules/{module_id}/refresh`
- `GET /v1/router-lists/catalog` — агрегированный каталог модулей/entries/communities
### Lookup
- `GET /v1/lookup?q=` — быстрая проверка IP или FQDN в списках (viewer+).
- Слой `entry`: `IP_RANGES` (`CIDR.Contains`) / `DOMAINS` (нормализованный FQDN).
- Слой `snapshot`: материализованные `module_prefix_snapshot` (для IP — Contains по всем модулям; для домена — `source=domain` у matched DOMAINS-модулей).
- В каждом матче — community (`community_id` / значение / title).
- Live DoH не выполняется. Контракт: OpenAPI `lookupMembership`.
### DoH profiles
+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.
+158 -2
View File
@@ -11,9 +11,15 @@ info:
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
**Роли** (матрица доступа): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
**Аутентификация (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 из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа.
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
JWT permissions мапятся на ту же лестницу (`:read`→viewer, `:write`→editor, `:admin`→operator).
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа / portal tenant env.
license:
name: Proprietary
identifier: LicenseRef-Proprietary
@@ -27,6 +33,8 @@ tags:
description: Liveness, readiness и метаданные сборки. Обычно без чувствительных данных; доступ может быть шире.
- name: Modules
description: Экземпляры модулей префиксов (AS, CDN, домены, статические IP-диапазоны) и вложенные записи. Чтение - viewer+; изменение - editor+.
- name: Lookup
description: Быстрая проверка membership IP/FQDN в списках (entries + module prefix snapshots) и community. Чтение - viewer+.
- name: DoH profiles
description: Профили DNS-over-HTTPS для модулей типа домены. Секрет в ответах не возвращается.
- name: Communities
@@ -221,6 +229,12 @@ components:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
BadRequest:
description: Некорректный запрос (пустой или невалидный параметр).
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
Forbidden:
description: Недостаточно прав для операции.
content:
@@ -751,6 +765,100 @@ components:
description: Человекочитаемое название для UI и фильтров.
additionalProperties: true
LookupQueryKind:
type: string
enum: [ip, domain]
description: Определённый тип запроса после нормализации.
LookupLayer:
type: string
enum: [entry, snapshot]
description: |
`entry` — сырые IP_RANGES / DOMAINS entries;
`snapshot` — материализованные префиксы `module_prefix_snapshot`.
LookupMatchKind:
type: string
enum: [ip_range, domain, prefix]
description: Вид совпадения (entry CIDR, entry FQDN или snapshot prefix).
LookupMatch:
type: object
required:
- layer
- module_id
- module_name
- module_type
- match_kind
- matched_value
properties:
layer:
$ref: "#/components/schemas/LookupLayer"
module_id:
$ref: "#/components/schemas/ResourceId"
module_name:
type: string
module_type:
$ref: "#/components/schemas/ModuleType"
match_kind:
$ref: "#/components/schemas/LookupMatchKind"
matched_value:
type: string
description: CIDR, FQDN или prefix, с которым совпал запрос.
entry_id:
type: string
description: ID entry (только для layer=entry).
source:
type: string
description: Источник строки snapshot (ip_range, domain, as, cdn, …).
community_id:
type: ["string", "null"]
community:
type: string
description: Техническое значение BGP community.
community_title:
type: string
description: Человекочитаемое название community.
resolved_ip:
type: string
description: |
IP, полученный DNS-resolve domain-запроса, из-за которого появился этот матч.
Пусто для прямого IP-запроса и для FQDN entry/snapshot без resolve.
LookupResponse:
type: object
required:
- query
- query_kind
- normalized
- matched
- match_count
- matches
properties:
query:
type: string
description: Исходная строка запроса.
query_kind:
$ref: "#/components/schemas/LookupQueryKind"
normalized:
type: string
description: Нормализованный IP или FQDN.
matched:
type: boolean
description: true, если есть хотя бы одно совпадение.
match_count:
type: integer
minimum: 0
matches:
type: array
items:
$ref: "#/components/schemas/LookupMatch"
resolved_ips:
type: array
description: IP-адреса после live DNS resolve (только для query_kind=domain; A/AAAA).
items:
type: string
BgpPeer:
type: object
required:
@@ -1455,6 +1563,10 @@ components:
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
runtime_logs_auto_mode:
$ref: "#/components/schemas/RuntimeLogCleanupMode"
ui_show_quick_actions:
type: boolean
description: Показывать блок «Быстрые действия» на дашборде (UI preference).
default: true
additionalProperties: true
RevisionDiff:
@@ -1776,6 +1888,50 @@ paths:
default:
$ref: "#/components/responses/DefaultProblem"
/v1/lookup:
get:
tags: [Lookup]
summary: Проверка IP или домена в списках
description: |
Быстрая membership-проверка по tenant:
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
(все module prefix snapshots, `Prefix.Contains`);
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
каждого полученного IP так же, как для IP-запроса (ranges + все snapshots).
Community на матче: `entry.community_id || module.default_community_id` (entry)
или `PrefixRow.community_id` (snapshot), с join к справочнику communities.
Поля `resolved_ips` / `resolved_ip` заполняются только для domain-запросов
(после успешного DNS). Ошибка DNS не даёт 5xx: FQDN-слой всё равно возвращается.
operationId: lookupMembership
parameters:
- $ref: "#/components/parameters/TenantId"
- name: q
in: query
required: true
schema:
type: string
minLength: 1
maxLength: 253
description: IP-адрес или FQDN для проверки.
responses:
"200":
description: Результат проверки (в т.ч. matched=false при отсутствии совпадений).
content:
application/json:
schema:
$ref: "#/components/schemas/LookupResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
default:
$ref: "#/components/responses/DefaultProblem"
/v1/router-lists/catalog:
get:
tags: [Modules]
+72 -15
View File
@@ -1,27 +1,32 @@
# 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 | hybrid `stats-12` (compact Frame strip: colored icon → value ± variant labelBadge footer) | 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 |
@@ -31,32 +36,84 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
| Component | Role |
|-----------|------|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
| `KpiStatGrid` | hybrid compact stats-12 KPI tiles (`variant`, Badge footer) |
| `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 / EvoFirewall | KPI → **QuickActionGrid** → charts / rest |
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
| auth-portal | portal-specific; Quick Actions при наличии dashboard |
Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
## Shared App Shell 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` |
| 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** (тема — в 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`, sync-row footer, Search pill в header, issues Badge в header, `ModeToggle` в header (тема только в NavUser), Provider `color-mix` для `--sidebar*`.
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
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 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
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
- 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
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+147 -7
View File
@@ -6,18 +6,32 @@ import (
"strings"
"evobgp/internal/authkey"
"github.com/golang-jwt/jwt/v5"
)
type ctxKey int
const authCtxKey ctxKey = 1
// Auth kinds distinguish API key sessions from portal JWT sessions.
const (
AuthKindAPIKey = "apikey"
AuthKindJWT = "jwt"
)
// Auth holds resolved API identity for a request.
type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Role string // viewer, editor, operator, node, firewall (apikeys only)
Token string
APIKeyID string // non-empty for DB-managed keys
// Portal / dual-auth fields (empty for API keys unless noted).
Kind string // "apikey" | "jwt"
UserID string // JWT sub
Email string // JWT email claim
Permissions []string // JWT permissions claim (bgp:*)
IsAdmin bool // JWT is_admin claim
}
func authFromContext(ctx context.Context) (Auth, bool) {
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
return out
}
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
func looksLikeJWT(raw string) bool {
if raw == "" {
return false
}
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return false
}
for _, p := range parts {
if strings.TrimSpace(p) == "" {
return false
}
}
return true
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
a, status, msg, ok := s.resolveJWT(raw)
if !ok {
writeProblem(w, status, http.StatusText(status), msg)
return
}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
a, ok := s.resolveAuth(raw)
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
return Auth{
Kind: AuthKindAPIKey,
TenantID: rec.tenantID,
Role: rec.role,
Token: raw,
APIKeyID: rec.keyID,
}
}
// resolveAuth maps a bearer token to tenant identity.
// resolveAuth maps a bearer token to tenant identity (API key path).
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
// is available; env/DB mapping is used only when demo tenant is absent.
func (s *Server) resolveAuth(raw string) (Auth, bool) {
@@ -99,23 +146,115 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
if !ok {
if s.firewallResolver != nil {
if fw, ok := s.firewallResolver.Lookup(raw); ok {
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
}
}
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
}
return Auth{}, false
}
return authFromKeyRecord(raw, rec), true
}
// 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) {
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(s.jwtSecret), nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil || tok == nil || !tok.Valid {
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
}
claims, ok := tok.Claims.(jwt.MapClaims)
if !ok {
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
}
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
got, _ := claims["iss"].(string)
if strings.TrimSpace(got) != iss {
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
}
}
apps := coerceStringSlice(claims["apps"])
if !containsFold(apps, "bgp") {
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
}
sub, _ := claims["sub"].(string)
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: tenantID,
UserID: strings.TrimSpace(sub),
Email: strings.TrimSpace(email),
Permissions: perms,
IsAdmin: isAdmin,
Token: raw,
}, 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:
return t
case []any:
out := make([]string, 0, len(t))
for _, x := range t {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}
func containsFold(items []string, needle string) bool {
for _, x := range items {
if strings.EqualFold(strings.TrimSpace(x), needle) {
return true
}
}
return false
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
return Auth{}, false
}
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
}
func roleLevel(role string) int {
@@ -131,7 +270,8 @@ func roleLevel(role string) int {
}
}
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
if strings.ToLower(a.Role) == "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
+302
View File
@@ -0,0 +1,302 @@
package httpapi
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
testJWTSecret = "test-secret-32-bytes-long-abcdef"
testIssuer = "https://auth.test.local"
)
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
t.Helper()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString([]byte(testJWTSecret))
if err != nil {
t.Fatalf("sign jwt: %v", err)
}
return s
}
func newJWTTestServer(t *testing.T) (*Server, string) {
t.Helper()
srv, err := New(Options{
SeedDemo: true,
BundleSeedHex: testBundleSeed,
JWTSecret: testJWTSecret,
AuthIssuer: testIssuer,
AuthPortalURL: "https://portal.test.local",
AuthRequired: true,
PortalTenantID: "", // filled after DemoIDs
})
if err != nil {
t.Fatal(err)
}
tenant, _, _, _, _ := srv.Store().DemoIDs()
// Override tenant to match seed.
srv.portalTenantID = tenant
return srv, tenant
}
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"email": "user@test.local",
"apps": []string{"bgp"},
"permissions": []string{"bgp:modules:read"},
"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 TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": "https://other.example.com",
"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.StatusUnauthorized {
t.Fatalf("status=%d want 401", resp.StatusCode)
}
}
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "user-1",
"apps": []string{"cfdm", "portal"},
"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.StatusForbidden {
t.Fatalf("status=%d want 403", resp.StatusCode)
}
}
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
token := signTestJWT(t, jwt.MapClaims{
"iss": testIssuer,
"sub": "admin-1",
"apps": []string{"bgp"},
"is_admin": true,
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", 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 TestAuthJWTMissingPermissionRejected(t *testing.T) {
srv, _ := newJWTTestServer(t)
defer srv.Close()
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"},
"exp": time.Now().Add(time.Hour).Unix(),
})
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", 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.StatusForbidden {
t.Fatalf("status=%d want 403", resp.StatusCode)
}
}
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()
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
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 TestHasPermissionSupersets(t *testing.T) {
cases := []struct {
name string
granted []string
want string
ok bool
}{
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
{"empty-granted", nil, "bgp:modules:read", false},
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
}
})
}
}
-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
}
+107
View File
@@ -0,0 +1,107 @@
package httpapi
import (
"net/http"
"strings"
)
// Portal permission strings. Format: <app>:<section>:<level> (bgp:modules:read).
// Superset order: admin ⊃ write ⊃ read for the same <app>:<section>.
const (
permLevelRead = "read"
permLevelWrite = "write"
permLevelAdmin = "admin"
)
// permLevelRank returns 0 for unknown, 1 for read, 2 for write, 3 for admin.
func permLevelRank(level string) int {
switch strings.ToLower(strings.TrimSpace(level)) {
case permLevelRead:
return 1
case permLevelWrite:
return 2
case permLevelAdmin:
return 3
default:
return 0
}
}
// splitPerm splits a permission string into (app, section, level).
func splitPerm(perm string) (app, section, level string, ok bool) {
parts := strings.Split(strings.TrimSpace(perm), ":")
if len(parts) != 3 {
return "", "", "", false
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
}
// HasPermission reports whether the granted list satisfies required, applying the
// admin ⊃ write ⊃ read superset within the same app+section.
func HasPermission(granted []string, required string) bool {
rApp, rSection, rLevel, ok := splitPerm(required)
if !ok {
return false
}
needRank := permLevelRank(rLevel)
if needRank == 0 {
return false
}
for _, g := range granted {
gApp, gSection, gLevel, ok := splitPerm(g)
if !ok {
continue
}
if !strings.EqualFold(gApp, rApp) || !strings.EqualFold(gSection, rSection) {
continue
}
if permLevelRank(gLevel) >= needRank {
return true
}
}
return false
}
// permAPIKeyRoleFor maps a permission level to the API-key role required.
func permAPIKeyRoleFor(perm string) string {
_, _, level, ok := splitPerm(perm)
if !ok {
return "operator"
}
switch strings.ToLower(level) {
case permLevelRead:
return "viewer"
case permLevelWrite:
return "editor"
case permLevelAdmin:
return "operator"
default:
return "operator"
}
}
// requirePerm enforces a permission for a portal JWT or falls back to the API-key role ladder.
// node/firewall roles are always rejected (they use requireNode / requireFirewall).
func (s *Server) requirePerm(w http.ResponseWriter, a Auth, perm string) bool {
switch strings.ToLower(a.Role) {
case "node":
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
return false
case "firewall":
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall role cannot access this resource")
return false
}
if a.Kind == AuthKindJWT || len(a.Permissions) > 0 {
if a.IsAdmin || HasPermission(a.Permissions, perm) {
return true
}
writeProblem(w, http.StatusForbidden, "Forbidden", "missing permission: "+perm)
return false
}
need := permAPIKeyRoleFor(perm)
if roleLevel(a.Role) < roleLevel(need) {
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
return false
}
return true
}
+43 -45
View File
@@ -35,9 +35,10 @@ func (s *Server) Handler() http.Handler {
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
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)
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
// 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))
}
@@ -54,6 +55,7 @@ func (s *Server) registerRoutes() {
func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules", s.handleListModules)
m.HandleFunc("GET /lookup", s.handleLookup)
m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog)
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers)
@@ -85,13 +87,21 @@ func (s *Server) registerV1(m *http.ServeMux) {
s.registerPostgresMaintenanceRoutes(m)
s.registerMaintenanceRoutes(m)
s.registerRuntimeLogsRoutes(m)
s.registerFirewallRoutes(m)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleAuthConfigPublic exposes portal-auth wiring so the UI can decide whether to redirect to the login portal.
// Registered on the public mux (no auth middleware): safe to call without a bearer token.
func (s *Server) handleAuthConfigPublic(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"required": s.authRequired,
"portal_url": s.authPortalURL,
})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
checks := map[string]string{"store": "ok", "jobs": "memory"}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
@@ -187,7 +197,7 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:modules:read") {
return
}
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
@@ -203,23 +213,9 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
}
filtered := make([]*store.Module, 0)
limit := parseListLimit(r)
cursor := r.URL.Query().Get("cursor")
if typeFilter == "" && enabledFilter == nil {
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
for _, mod := range page {
filtered = append(filtered, mod)
}
items := make([]map[string]any, 0, len(filtered))
for _, mod := range filtered {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
return
}
for _, mod := range s.store.ListModules(a.TenantID) {
all := s.store.ListModules(a.TenantID)
all = store.FilterOwned(all, func(m *store.Module) string { return m.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
for _, mod := range all {
if typeFilter != "" && mod.Type != typeFilter {
continue
}
@@ -244,7 +240,7 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:directories:read") {
return
}
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
@@ -267,10 +263,14 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:modules:read") {
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err == nil && !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, mod.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
@@ -288,10 +288,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:network:read") {
return
}
allPeers := s.store.ListPeers(a.TenantID)
allPeers = store.FilterOwned(allPeers, func(p *store.BGPPeer) string { return p.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
@@ -391,7 +392,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:network:read") {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
@@ -423,7 +424,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:modules:write") {
return
}
moduleID := r.PathValue("module_id")
@@ -459,7 +460,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -513,7 +514,7 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -586,7 +587,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
@@ -603,7 +604,7 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
@@ -642,7 +643,7 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
revID := r.PathValue("revision_id")
@@ -668,7 +669,7 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
@@ -685,7 +686,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "operator") {
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
revID := r.PathValue("revision_id")
@@ -716,8 +717,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
var body struct {
@@ -763,8 +763,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
spkID := r.PathValue("id")
@@ -814,8 +813,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
@@ -838,7 +836,7 @@ func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
@@ -853,7 +851,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -876,7 +874,7 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
@@ -893,7 +891,7 @@ func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
if !s.requirePerm(w, a, "bgp:operations:read") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
@@ -931,7 +929,7 @@ func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
if !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
+18 -9
View File
@@ -21,13 +21,22 @@ func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
writeJSON(w, http.StatusOK, map[string]any{
resp := map[string]any{
"tenant_id": a.TenantID,
"role": a.Role,
})
"kind": a.Kind,
}
if a.Kind == AuthKindJWT {
resp["user_id"] = a.UserID
resp["email"] = a.Email
resp["permissions"] = a.Permissions
resp["is_admin"] = a.IsAdmin
}
writeJSON(w, http.StatusOK, resp)
}
func apiKeyJSON(k *store.APIKey) map[string]any {
@@ -59,7 +68,7 @@ func apiKeyJSON(k *store.APIKey) map[string]any {
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
list, err := s.store.ListAPIKeys(a.TenantID)
@@ -74,7 +83,7 @@ func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
@@ -87,7 +96,7 @@ func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
var body struct {
@@ -127,7 +136,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
var raw map[string]json.RawMessage
@@ -183,7 +192,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
@@ -199,7 +208,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
return
}
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
+88 -49
View File
@@ -80,7 +80,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -99,12 +99,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
newModule := &store.Module{
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
})
}
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
newModule.CreatedByUserID = a.UserID
}
mod, err := s.store.CreateModule(a.TenantID, newModule)
if err != nil {
writeStoreErr(w, err)
return
@@ -114,7 +118,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
rawBody, err := io.ReadAll(r.Body)
@@ -158,7 +162,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
body.RefreshIntervalSec = &zero
}
}
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
moduleID := r.PathValue("module_id")
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
}
mod, err := s.store.UpdateModule(a.TenantID, moduleID, &body)
if err != nil {
writeStoreErr(w, err)
return
@@ -168,10 +179,17 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
moduleID := r.PathValue("module_id")
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
}
if err := s.store.SoftDeleteModule(a.TenantID, moduleID); err != nil {
writeStoreErr(w, err)
return
}
@@ -215,7 +233,7 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
@@ -248,7 +266,7 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body struct {
@@ -327,7 +345,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.CDNSource
@@ -357,7 +375,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.CDNSourcePatch
@@ -387,7 +405,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -401,7 +419,7 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
@@ -439,7 +457,7 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.ASEntry
@@ -459,7 +477,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.ASEntryPatch
@@ -479,7 +497,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -493,7 +511,7 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
@@ -516,7 +534,7 @@ func domainEntryJSON(x *store.DomainEntry) map[string]any {
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.DomainEntry
@@ -536,7 +554,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.DomainEntryPatch
@@ -556,7 +574,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -570,7 +588,7 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
@@ -593,7 +611,7 @@ func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.IPRangeEntry
@@ -613,7 +631,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
var body store.IPRangePatch
@@ -633,7 +651,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
mid := r.PathValue("module_id")
@@ -647,7 +665,7 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
return
}
moduleID := r.PathValue("module_id")
@@ -730,7 +748,7 @@ func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
return
}
moduleID := r.PathValue("module_id")
@@ -774,7 +792,7 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
list, err := s.store.ListDohProfiles(a.TenantID)
@@ -806,7 +824,7 @@ func dohJSON(x *store.DohProfile) map[string]any {
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
@@ -819,7 +837,7 @@ func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.DohProfile
@@ -837,7 +855,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.DohProfilePatch
@@ -855,7 +873,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
@@ -867,7 +885,7 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
list, err := s.store.ListCommunities(a.TenantID)
@@ -892,7 +910,7 @@ func commJSON(x *store.Community) map[string]any {
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
return
}
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
@@ -905,7 +923,7 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.Community
@@ -923,7 +941,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
var body store.CommunityPatch
@@ -941,7 +959,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
return
}
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
@@ -953,7 +971,7 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.BGPPeer
@@ -962,6 +980,9 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
return
}
body.TenantID = a.TenantID
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
body.CreatedByUserID = a.UserID
}
x, err := s.store.CreatePeer(a.TenantID, &body)
if err != nil {
writeStoreErr(w, err)
@@ -973,7 +994,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
@@ -981,12 +1002,16 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, x.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
writeJSON(w, http.StatusOK, peerJSON(x))
}
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.PeerPatch
@@ -994,7 +1019,14 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
return
}
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
peerID := r.PathValue("id")
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
}
x, err := s.store.UpdatePeer(a.TenantID, peerID, &body)
if err != nil {
writeStoreErr(w, err)
return
@@ -1005,10 +1037,17 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
peerID := r.PathValue("id")
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
return
}
}
if err := s.store.DeletePeer(a.TenantID, peerID); err != nil {
writeStoreErr(w, err)
return
}
@@ -1018,7 +1057,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.Speaker
@@ -1044,7 +1083,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
@@ -1057,7 +1096,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
var body store.SpeakerPatch
@@ -1075,7 +1114,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "editor") {
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
return
}
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
@@ -1087,7 +1126,7 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
@@ -1111,7 +1150,7 @@ func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request)
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
return
}
m, err := s.store.ListGlobalSettings(a.TenantID)
@@ -1124,7 +1163,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
return
}
var body map[string]any
+12 -659
View File
@@ -1,663 +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.requireAtLeast(w, a, "operator") {
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.requireAtLeast(w, a, "viewer") {
return
}
items, err := s.store.ListFirewallClients(a.TenantID)
if err != nil {
writeInternalError(w, "internal", err)
return
}
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.requireAtLeast(w, a, "viewer") {
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
}
writeJSON(w, http.StatusOK, client)
}
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
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.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
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.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
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.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
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.requireAtLeast(w, a, "viewer") {
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
}
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.requireAtLeast(w, a, "operator") {
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
}
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
Priority: body.Priority,
Action: body.Action,
CommunityID: body.CommunityID,
Comment: body.Comment,
})
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.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
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.requireAtLeast(w, a, "operator") {
return
}
id := strings.TrimSpace(r.PathValue("id"))
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.requireAtLeast(w, a, "operator") {
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.requireAtLeast(w, a, "viewer") {
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)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"evobgp/internal/lookup"
"evobgp/internal/store"
)
// handleLookup implements GET /v1/lookup?q= (operationId: lookupMembership).
func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requirePerm(w, a, "bgp:lookup:read") {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query parameter q is required")
return
}
res, err := lookup.Lookup(r.Context(), s.store, a.TenantID, q)
if err != nil {
if errors.Is(err, store.ErrInvalidInput) {
writeProblem(w, http.StatusBadRequest, "Bad Request", "query must be an IP address or FQDN")
return
}
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
+79
View File
@@ -0,0 +1,79 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"evobgp/internal/store"
)
func TestLookupMembershipHTTP(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true, BundleSeedHex: testBundleSeed})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, modIP, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "edkey|"+tenant+"|editor")
comms, err := srv.Store().ListCommunities(tenant)
if err != nil || len(comms) == 0 {
t.Fatal("demo community")
}
cid := comms[0].ID
if _, err := srv.Store().CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
Prefix: "198.51.100.0/24",
CommunityID: &cid,
}); err != nil {
t.Fatal(err)
}
if err := srv.Store().SetModulePrefixSnapshot(tenant, modIP, "t", []store.PrefixRow{
{Prefix: "198.51.100.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q="+url.QueryEscape("198.51.100.7"), nil)
req.Header.Set("Authorization", "Bearer edkey")
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: %s", resp.StatusCode, b)
}
var body struct {
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
QueryKind string `json:"query_kind"`
Matches []struct {
Layer string `json:"layer"`
} `json:"matches"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if !body.Matched || body.QueryKind != "ip" || body.MatchCount < 2 {
t.Fatalf("unexpected body: %+v", body)
}
reqBad, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/lookup?q=", nil)
reqBad.Header.Set("Authorization", "Bearer edkey")
respBad, err := ts.Client().Do(reqBad)
if err != nil {
t.Fatal(err)
}
defer func() { _ = respBad.Body.Close() }()
if respBad.StatusCode != http.StatusBadRequest {
t.Fatalf("empty q: status %d", respBad.StatusCode)
}
}
+4 -4
View File
@@ -62,7 +62,7 @@ func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -81,7 +81,7 @@ func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Re
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
@@ -163,7 +163,7 @@ func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Re
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
if s.maintStats == nil {
@@ -185,7 +185,7 @@ func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Req
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -27,12 +27,10 @@ func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
}
// requireOperatorStrict is a compatibility shim mapping the legacy "operator"
// API-key role to the tenant-settings admin permission for JWT/API-key clients.
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return false
}
return true
return s.requirePerm(w, a, "bgp:tenant_settings:admin")
}
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
@@ -201,7 +199,7 @@ func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
cursor := r.URL.Query().Get("cursor")
@@ -35,7 +35,7 @@ func parseLimitQuery(r *http.Request, def, max int) int {
func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -50,7 +50,7 @@ func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request)
func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -65,7 +65,7 @@ func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -80,7 +80,7 @@ func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
@@ -95,7 +95,7 @@ func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
@@ -110,7 +110,7 @@ func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Re
func (s *Server) handleMonitoringCorrelation(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
return
}
window := 60
+2 -2
View File
@@ -52,7 +52,7 @@ func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID s
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
@@ -78,7 +78,7 @@ func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Requ
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
if !ok || !s.requirePerm(w, a, "bgp:operations:admin") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
+6 -6
View File
@@ -70,7 +70,7 @@ func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]an
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
return
}
items, err := s.runtimeLogs.ListFiles()
@@ -87,7 +87,7 @@ func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Reques
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
@@ -111,7 +111,7 @@ func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
filename := r.PathValue("filename")
@@ -157,7 +157,7 @@ func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, te
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
@@ -199,7 +199,7 @@ func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Req
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
return
}
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
@@ -221,7 +221,7 @@ func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request)
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
return
}
cursor := r.URL.Query().Get("cursor")
+22
View File
@@ -36,6 +36,13 @@ type Server struct {
runtimeLogs *runtimelogs.Service
runtimeLogsPolicyTenant string
mux *http.ServeMux
// Portal / dual-auth (JWT) configuration.
jwtSecret string
authIssuer string
authPortalURL string
portalTenantID string
authRequired bool
}
// Options configures the API server.
@@ -49,6 +56,13 @@ type Options struct {
CORSAllowedOrigins string
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
RuntimeLogsPolicyTenant string
// Portal / dual-auth (JWT) — leave empty to disable JWT path.
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 // fallback when JWT has no bgp_tenant_id / tenants.bgp
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
}
// New constructs Server and wiring for async jobs.
@@ -104,6 +118,14 @@ func New(opts Options) (*Server, error) {
cdnHTTP: NewCDNHTTPClient(),
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
jwtSecret: strings.TrimSpace(opts.JWTSecret),
authIssuer: strings.TrimSpace(opts.AuthIssuer),
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
authRequired: opts.AuthRequired,
}
if s.authIssuer == "" {
s.authIssuer = "https://auth.shnt.top"
}
s.mux = http.NewServeMux()
s.registerRoutes()
+1 -1
View File
@@ -71,7 +71,7 @@ func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
return
}
writeJSON(w, http.StatusOK, map[string]any{
+369
View File
@@ -0,0 +1,369 @@
// Package lookup implements dual-layer membership checks for IP addresses and FQDNs
// against module entries and materialized prefix snapshots.
package lookup
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
"unicode"
"evobgp/internal/store"
)
// QueryKind is the normalized kind of a lookup query.
type QueryKind string
const (
KindIP QueryKind = "ip"
KindDomain QueryKind = "domain"
)
// Layer identifies which data source produced a match.
type Layer string
const (
LayerEntry Layer = "entry"
LayerSnapshot Layer = "snapshot"
)
// MatchKind is the concrete match type within a layer.
type MatchKind string
const (
MatchIPRange MatchKind = "ip_range"
MatchDomain MatchKind = "domain"
MatchPrefix MatchKind = "prefix"
)
// Match is one membership hit (entry or snapshot) with resolved community fields.
type Match struct {
Layer Layer `json:"layer"`
ModuleID string `json:"module_id"`
ModuleName string `json:"module_name"`
ModuleType string `json:"module_type"`
MatchKind MatchKind `json:"match_kind"`
MatchedValue string `json:"matched_value"`
EntryID string `json:"entry_id,omitempty"`
Source string `json:"source,omitempty"`
CommunityID *string `json:"community_id,omitempty"`
Community string `json:"community,omitempty"`
CommunityTitle string `json:"community_title,omitempty"`
// ResolvedIP is set when the hit came from a DNS-resolved address of a domain query.
ResolvedIP string `json:"resolved_ip,omitempty"`
}
// Result is the full lookup response payload.
type Result struct {
Query string `json:"query"`
QueryKind QueryKind `json:"query_kind"`
Normalized string `json:"normalized"`
Matched bool `json:"matched"`
MatchCount int `json:"match_count"`
Matches []Match `json:"matches"`
ResolvedIPs []string `json:"resolved_ips,omitempty"`
}
// DomainResolver resolves a hostname to IP addresses (A/AAAA).
type DomainResolver func(ctx context.Context, host string) ([]netip.Addr, error)
// Lookup checks whether q (IP or FQDN) is present in tenant lists (entries + snapshots).
// For domains, FQDN membership is checked first, then live DNS resolve and IP membership.
func Lookup(ctx context.Context, st store.Backend, tenantID, q string) (*Result, error) {
return LookupWithResolver(ctx, st, tenantID, q, systemDNSResolver)
}
// LookupWithResolver is like Lookup but uses resolve for domain→IP (tests / alternate DNS).
func LookupWithResolver(
ctx context.Context,
st store.Backend,
tenantID, q string,
resolve DomainResolver,
) (*Result, error) {
raw := strings.TrimSpace(q)
if raw == "" {
return nil, fmt.Errorf("%w: empty query", store.ErrInvalidInput)
}
comms, err := st.ListCommunities(tenantID)
if err != nil {
return nil, err
}
commByID := make(map[string]*store.Community, len(comms))
for _, c := range comms {
if c != nil {
commByID[c.ID] = c
}
}
out := &Result{
Query: raw,
Matches: make([]Match, 0),
}
if addr, err := netip.ParseAddr(raw); err == nil {
out.QueryKind = KindIP
out.Normalized = addr.String()
if err := lookupIP(st, tenantID, addr, out, commByID, ""); err != nil {
return nil, err
}
} else {
fqdn, ok := normalizeFQDN(raw)
if !ok {
return nil, fmt.Errorf("%w: query must be an IP address or FQDN", store.ErrInvalidInput)
}
out.QueryKind = KindDomain
out.Normalized = fqdn
if err := lookupDomain(st, tenantID, fqdn, out, commByID); err != nil {
return nil, err
}
if resolve == nil {
resolve = systemDNSResolver
}
if err := lookupResolvedIPs(ctx, st, tenantID, fqdn, out, commByID, resolve); err != nil {
return nil, err
}
}
out.MatchCount = len(out.Matches)
out.Matched = out.MatchCount > 0
return out, nil
}
func systemDNSResolver(ctx context.Context, host string) ([]netip.Addr, error) {
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
return uniqAddrs(ips), nil
}
func uniqAddrs(in []netip.Addr) []netip.Addr {
seen := make(map[netip.Addr]struct{}, len(in))
out := make([]netip.Addr, 0, len(in))
for _, a := range in {
a = a.Unmap()
if _, ok := seen[a]; ok {
continue
}
seen[a] = struct{}{}
out = append(out, a)
}
return out
}
func lookupResolvedIPs(
ctx context.Context,
st store.Backend,
tenantID, fqdn string,
out *Result,
commByID map[string]*store.Community,
resolve DomainResolver,
) error {
ips, err := resolve(ctx, fqdn)
if err != nil {
// DNS failure must not hide FQDN-layer matches already collected.
return nil
}
out.ResolvedIPs = make([]string, 0, len(ips))
for _, ip := range ips {
out.ResolvedIPs = append(out.ResolvedIPs, ip.String())
if err := lookupIP(st, tenantID, ip, out, commByID, ip.String()); err != nil {
return err
}
}
return nil
}
func lookupIP(
st store.Backend,
tenantID string,
addr netip.Addr,
out *Result,
commByID map[string]*store.Community,
resolvedIP string,
) error {
for _, mod := range st.ListModules(tenantID) {
if mod == nil {
continue
}
if mod.Type == "IP_RANGES" {
entries, err := st.ListIPRangeEntries(tenantID, mod.ID)
if err != nil {
return err
}
for _, e := range entries {
if e == nil {
continue
}
pfx, err := netip.ParsePrefix(strings.TrimSpace(e.Prefix))
if err != nil {
continue
}
if !pfx.Contains(addr) {
continue
}
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerEntry,
ModuleID: mod.ID,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchIPRange,
MatchedValue: e.Prefix,
EntryID: e.ID,
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
ResolvedIP: resolvedIP,
}, commByID))
}
}
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mod.ID)
if err != nil {
return err
}
if !ok || snap == nil {
continue
}
for _, row := range snap.Prefixes {
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix))
if err != nil {
continue
}
if !pfx.Contains(addr) {
continue
}
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerSnapshot,
ModuleID: mod.ID,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchPrefix,
MatchedValue: row.Prefix,
Source: row.Source,
CommunityID: row.CommunityID,
ResolvedIP: resolvedIP,
}, commByID))
}
}
return nil
}
func lookupDomain(st store.Backend, tenantID, fqdn string, out *Result, commByID map[string]*store.Community) error {
matchedModuleIDs := make(map[string]*store.Module)
for _, mod := range st.ListModules(tenantID) {
if mod == nil || mod.Type != "DOMAINS" {
continue
}
entries, err := st.ListDomainEntries(tenantID, mod.ID)
if err != nil {
return err
}
for _, e := range entries {
if e == nil {
continue
}
norm, ok := normalizeFQDN(e.FQDN)
if !ok || norm != fqdn {
continue
}
matchedModuleIDs[mod.ID] = mod
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerEntry,
ModuleID: mod.ID,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchDomain,
MatchedValue: e.FQDN,
EntryID: e.ID,
CommunityID: resolveCommunityID(e.CommunityID, mod.DefaultCommunityID),
}, commByID))
}
}
for mid, mod := range matchedModuleIDs {
snap, ok, err := st.GetModulePrefixSnapshot(tenantID, mid)
if err != nil {
return err
}
if !ok || snap == nil {
continue
}
for _, row := range snap.Prefixes {
if !strings.EqualFold(strings.TrimSpace(row.Source), "domain") {
continue
}
out.Matches = append(out.Matches, decorateMatch(Match{
Layer: LayerSnapshot,
ModuleID: mid,
ModuleName: mod.Name,
ModuleType: mod.Type,
MatchKind: MatchPrefix,
MatchedValue: row.Prefix,
Source: row.Source,
CommunityID: row.CommunityID,
}, commByID))
}
}
return nil
}
func resolveCommunityID(entryID, defaultID *string) *string {
if entryID != nil && strings.TrimSpace(*entryID) != "" {
return entryID
}
if defaultID != nil && strings.TrimSpace(*defaultID) != "" {
return defaultID
}
return nil
}
func decorateMatch(m Match, commByID map[string]*store.Community) Match {
if m.CommunityID == nil {
return m
}
c, ok := commByID[*m.CommunityID]
if !ok || c == nil {
return m
}
m.Community = c.Community
m.CommunityTitle = c.Title
return m
}
// normalizeFQDN lowercases, trims trailing dots, and validates a simple hostname shape.
func normalizeFQDN(s string) (string, bool) {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ".")
s = strings.ToLower(s)
if s == "" || len(s) > 253 {
return "", false
}
if strings.ContainsAny(s, " /\\\t\n") {
return "", false
}
if _, err := netip.ParseAddr(s); err == nil {
return "", false
}
labels := strings.Split(s, ".")
if len(labels) < 2 {
return "", false
}
for _, label := range labels {
if label == "" || len(label) > 63 {
return "", false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return "", false
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return "", false
}
}
return s, true
}
+262
View File
@@ -0,0 +1,262 @@
package lookup
import (
"context"
"errors"
"net/netip"
"testing"
"evobgp/internal/store"
)
func TestLookupIPEntryAndSnapshot(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, modIP, _, _ := m.DemoIDs()
cid := ""
comms, err := m.ListCommunities(tenant)
if err != nil || len(comms) == 0 {
t.Fatal("expected demo community")
}
cid = comms[0].ID
def := cid
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &def}); err != nil {
t.Fatal(err)
}
entryComm := cid
e, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
Prefix: "203.0.113.0/24",
CommunityID: &entryComm,
})
if err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash1", []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
res, err := Lookup(context.Background(), m, tenant, "203.0.113.10")
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindIP || res.Normalized != "203.0.113.10" {
t.Fatalf("kind/normalized: %+v", res)
}
if !res.Matched || res.MatchCount < 2 {
t.Fatalf("expected entry+snapshot matches, got %+v", res)
}
var entryHit, snapHit bool
for _, hit := range res.Matches {
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
entryHit = true
if hit.Community != "demo-comm" || hit.CommunityTitle != "Demo" {
t.Fatalf("entry community: %+v", hit)
}
}
if hit.Layer == LayerSnapshot && hit.MatchedValue == "203.0.113.0/24" {
snapHit = true
}
}
if !entryHit || !snapHit {
t.Fatalf("missing layers entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
}
}
func TestLookupIPCommunityFallback(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, modIP, _, _ := m.DemoIDs()
comms, _ := m.ListCommunities(tenant)
cid := comms[0].ID
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil {
t.Fatal(err)
}
if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{Prefix: "10.0.0.0/8"}); err != nil {
t.Fatal(err)
}
res, err := Lookup(context.Background(), m, tenant, "10.1.2.3")
if err != nil {
t.Fatal(err)
}
if !res.Matched {
t.Fatal("expected match")
}
found := false
for _, hit := range res.Matches {
if hit.Layer == LayerEntry {
found = true
if hit.CommunityID == nil || *hit.CommunityID != cid {
t.Fatalf("expected default community, got %+v", hit)
}
if hit.Community != "demo-comm" {
t.Fatalf("community value: %+v", hit)
}
}
}
if !found {
t.Fatal("no entry match")
}
}
func TestLookupDomainEntryAndSnapshot(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
mod, err := m.CreateModule(tenant, &store.Module{
Type: "DOMAINS",
Name: "demo-domains",
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
comms, _ := m.ListCommunities(tenant)
cid := comms[0].ID
e, err := m.CreateDomainEntry(tenant, mod.ID, &store.DomainEntry{
FQDN: "Example.COM.",
CommunityID: &cid,
})
if err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, mod.ID, "hash-d", []store.PrefixRow{
{Prefix: "198.51.100.1/32", CommunityID: &cid, Source: "domain"},
{Prefix: "203.0.113.9/32", CommunityID: &cid, Source: "other"},
}); err != nil {
t.Fatal(err)
}
noDNS := func(context.Context, string) ([]netip.Addr, error) { return nil, nil }
res, err := LookupWithResolver(context.Background(), m, tenant, "example.com", noDNS)
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindDomain || res.Normalized != "example.com" {
t.Fatalf("kind/normalized: %+v", res)
}
if !res.Matched {
t.Fatal("expected match")
}
var entryHit, snapHit, otherSnap bool
for _, hit := range res.Matches {
if hit.Layer == LayerEntry && hit.EntryID == e.ID {
entryHit = true
}
if hit.Layer == LayerSnapshot && hit.MatchedValue == "198.51.100.1/32" {
snapHit = true
}
if hit.MatchedValue == "203.0.113.9/32" {
otherSnap = true
}
}
if !entryHit || !snapHit {
t.Fatalf("entry=%v snap=%v matches=%+v", entryHit, snapHit, res.Matches)
}
if otherSnap {
t.Fatal("non-domain snapshot source should be excluded")
}
}
func TestLookupDomainResolvedIPAgainstRanges(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, modIP, _, _ := m.DemoIDs()
comms, _ := m.ListCommunities(tenant)
cid := comms[0].ID
if _, err := m.UpdateModule(tenant, modIP, &store.ModulePatch{DefaultCommunityID: &cid}); err != nil {
t.Fatal(err)
}
if _, err := m.CreateIPRangeEntry(tenant, modIP, &store.IPRangeEntry{
Prefix: "203.0.113.0/24",
CommunityID: &cid,
}); err != nil {
t.Fatal(err)
}
if err := m.SetModulePrefixSnapshot(tenant, modIP, "hash-r", []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "ip_range"},
}); err != nil {
t.Fatal(err)
}
fake := func(_ context.Context, host string) ([]netip.Addr, error) {
if host != "google.com" {
t.Fatalf("unexpected host %q", host)
}
return []netip.Addr{netip.MustParseAddr("203.0.113.50")}, nil
}
res, err := LookupWithResolver(context.Background(), m, tenant, "google.com", fake)
if err != nil {
t.Fatal(err)
}
if res.QueryKind != KindDomain {
t.Fatalf("kind: %+v", res)
}
if len(res.ResolvedIPs) != 1 || res.ResolvedIPs[0] != "203.0.113.50" {
t.Fatalf("resolved_ips: %+v", res.ResolvedIPs)
}
if !res.Matched {
t.Fatalf("expected IP membership via resolve, got %+v", res)
}
var viaResolve bool
for _, hit := range res.Matches {
if hit.ResolvedIP == "203.0.113.50" && hit.MatchedValue == "203.0.113.0/24" {
viaResolve = true
}
}
if !viaResolve {
t.Fatalf("missing resolved-ip match: %+v", res.Matches)
}
}
func TestLookupNoMatch(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
res, err := Lookup(context.Background(), m, tenant, "192.0.2.1")
if err != nil {
t.Fatal(err)
}
if res.Matched || res.MatchCount != 0 || len(res.Matches) != 0 {
t.Fatalf("expected empty: %+v", res)
}
}
func TestLookupInvalid(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
ctx := context.Background()
_, err := Lookup(ctx, m, tenant, "")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("empty: %v", err)
}
_, err = Lookup(ctx, m, tenant, "not a host")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("spaces: %v", err)
}
_, err = Lookup(ctx, m, tenant, "localhost")
if !errors.Is(err, store.ErrInvalidInput) {
t.Fatalf("single label: %v", err)
}
}
func TestNormalizeFQDN(t *testing.T) {
got, ok := normalizeFQDN(" Example.COM. ")
if !ok || got != "example.com" {
t.Fatalf("got %q ok=%v", got, ok)
}
}
+46 -16
View File
@@ -119,7 +119,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
if err != nil {
return nil
@@ -133,7 +133,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
var createdBy *string
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
@@ -153,6 +154,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
@@ -175,7 +179,7 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
ORDER BY priority, name
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
@@ -191,7 +195,8 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
var doh, dc, cron *string
var refresh *int32
var last *time.Time
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
var createdBy *string
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
continue
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
@@ -211,6 +216,9 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &m)
moduleByID[m.ID] = &m
}
@@ -238,11 +246,12 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
var doh, dc, cron *string
var refresh *int32
var last *time.Time
var createdBy *string
err := p.pool.QueryRow(ctx, `
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -265,6 +274,9 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
t := last.UTC()
m.LastRefreshedAt = &t
}
if createdBy != nil {
m.CreatedByUserID = strings.TrimSpace(*createdBy)
}
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
if err := p.fillModuleDohFields(ctx, &m); err != nil {
return nil, err
@@ -299,10 +311,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
lastArg = in.LastRefreshedAt.UTC()
}
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg, createdBy)
if err != nil {
return nil, err
}
@@ -402,7 +418,8 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
created_by_user_id
FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID)
if err != nil {
return nil
@@ -412,10 +429,14 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
for rows.Next() {
var peer store.BGPPeer
var sp *string
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil {
var createdBy *string
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy); err != nil {
continue
}
peer.SpeakerID = sp
if createdBy != nil {
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
}
out = append(out, &peer)
}
return out
@@ -425,11 +446,13 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
ctx := context.Background()
var peer store.BGPPeer
var sp *string
var createdBy *string
err := p.pool.QueryRow(ctx, `
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
created_by_user_id
FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON)
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, store.ErrNotFound
@@ -437,6 +460,9 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
return nil, err
}
peer.SpeakerID = sp
if createdBy != nil {
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
}
return &peer, nil
}
@@ -461,10 +487,14 @@ func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPee
sp = strings.TrimSpace(*in.SpeakerID)
}
enabled := store.EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState)
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`,
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb))
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json, created_by_user_id)
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb, $9)`,
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb), createdBy)
if err != nil {
return nil, err
}
+35 -21
View File
@@ -20,7 +20,7 @@ const firewallClientSelectCols = `
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
COALESCE(last_apply_source, ''),
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at, created_by_user_id`
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
ctx := context.Background()
@@ -63,11 +63,15 @@ func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClien
}
id := uuid.NewString()
ctx := context.Background()
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion), createdBy)
if err != nil {
return nil, err
}
@@ -232,11 +236,11 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
var err error
if clientID == nil {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
} else {
rows, err = p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
}
if err != nil {
@@ -249,7 +253,7 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
@@ -263,7 +267,7 @@ func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
ctx := context.Background()
rows, err := p.pool.Query(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE tenant_id=$1
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
if err != nil {
@@ -294,10 +298,14 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
}
id := uuid.NewString()
ctx := context.Background()
var createdBy any
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
createdBy = v
}
_, err := p.pool.Exec(ctx, `
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment, created_by_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment), createdBy)
if err != nil {
return nil, err
}
@@ -307,7 +315,7 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
ctx := context.Background()
row := p.pool.QueryRow(ctx, `
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
r, err := scanFirewallRuleRow(row.Scan, tenantID)
if err != nil {
@@ -423,19 +431,22 @@ func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, e
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
var r store.FirewallRule
r.TenantID = tenantID
var clientID, communityID *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
var clientID, communityID, createdBy *string
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt, &createdBy); err != nil {
return nil, err
}
r.ClientID = clientID
r.CommunityID = communityID
if createdBy != nil {
r.CreatedByUserID = strings.TrimSpace(*createdBy)
}
return &r, nil
}
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
var c store.FirewallClient
c.TenantID = tenantID
var approvedBy *string
var approvedBy, createdBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
@@ -444,16 +455,16 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
var c store.FirewallClient
var approvedBy *string
var approvedBy, createdBy *string
var lastSeen, lastApply, approved, revoked *time.Time
var prefixCount, ipCount *int
var packetsDropped, packetsAccepted *int64
@@ -462,14 +473,14 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
); err != nil {
return nil, err
}
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
}
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy, createdBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
c.LastSeenAt = lastSeen
c.LastApplyAt = lastApply
c.ApprovedAt = approved
@@ -477,6 +488,9 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
if approvedBy != nil {
c.ApprovedByAPIKeyID = *approvedBy
}
if createdBy != nil {
c.CreatedByUserID = strings.TrimSpace(*createdBy)
}
if prefixCount != nil {
c.LastApplyPrefixCount = *prefixCount
}
+1
View File
@@ -150,6 +150,7 @@ type Backend interface {
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error)
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
DeleteFirewallRule(tenantID, ruleID string) error
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
+22 -18
View File
@@ -29,15 +29,17 @@ type FirewallClient struct {
ApprovedAt *time.Time `json:"approved_at,omitempty"`
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
// FirewallClientCreate is input for enroll (token hash supplied by caller).
type FirewallClientCreate struct {
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
Name string
Hostname string
TokenPrefix string
TokenHash []byte
ClientVersion string
CreatedByUserID string
}
// FirewallClientPatch is a partial update for operator edits.
@@ -62,23 +64,25 @@ type FirewallClientReplicationRow struct {
// FirewallRule is one block/accept policy rule.
type FirewallRule struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Priority int `json:"priority"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
// FirewallRuleCreate is input for creating a rule.
type FirewallRuleCreate struct {
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
Priority *int `json:"priority,omitempty"`
Action string `json:"action"`
CommunityID *string `json:"community_id,omitempty"`
Comment string `json:"comment,omitempty"`
CreatedByUserID string `json:"-"`
}
// FirewallRulePatch is a partial rule update.
+11 -9
View File
@@ -95,6 +95,7 @@ type Module struct {
DohResolverPolicy string
LastRefreshedAt *time.Time
DeletedAt *time.Time
CreatedByUserID string // portal JWT sub; empty = system / API key
}
type Revision struct {
@@ -112,15 +113,16 @@ type Revision struct {
// BGPPeer maps to bgp_peer (+ display fields in meta).
type BGPPeer struct {
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID *string `json:"bgp_speaker_id"`
Name string `json:"name"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
Enabled bool `json:"enabled"`
SessionState string `json:"session_state"`
PoliciesJSON string `json:"policies_json"`
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
SpeakerID *string `json:"bgp_speaker_id"`
Name string `json:"name"`
Neighbor string `json:"neighbor"`
RemoteASN int64 `json:"remote_asn"`
Enabled bool `json:"enabled"`
SessionState string `json:"session_state"`
PoliciesJSON string `json:"policies_json"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
}
type Speaker struct {
+2
View File
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
DohResolverPolicy: in.DohResolverPolicy,
LastRefreshedAt: in.LastRefreshedAt,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
NormalizeModuleDoh(mod)
m.modules[id] = mod
@@ -680,6 +681,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
Neighbor: neighbor, RemoteASN: in.RemoteASN,
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
}
m.peers[id] = p
return p, nil

Some files were not shown because too many files have changed in this diff Show More